• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

luttje / videobrew / 8171994845

06 Mar 2024 12:24PM UTC coverage: 25.12% (-19.2%) from 44.313%
8171994845

Pull #25

github

web-flow
Merge 792373961 into 00419a58c
Pull Request #25: Feature/install playwright

356 of 537 branches covered (66.29%)

Branch coverage included in aggregate %.

18 of 93 new or added lines in 7 files covered. (19.35%)

1730 existing lines in 23 files now uncovered.

2003 of 8854 relevant lines covered (22.62%)

461.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/packages/cli/src/cli.ts
UNCOV
1
#!/usr/bin/env node
×
UNCOV
2
import { buildVideoConfigFromFrames, getContainerFormats, renderVideo, VideoFormat } from './rendering/video-from-frames.js';
×
NEW
3
import { ensurePlaywrightInstalled, testPlaywrightInstallationWorking } from './utils/install-playwright.js';
×
UNCOV
4
import { startEditor, getEditorInstallPath, getEditorInstaller, EDITOR_PACKAGE_NAME } from './editor.js';
×
UNCOV
5
import { getExtensionByQuality, recordFrames } from './rendering/record-frames.js';
×
UNCOV
6
import { createLocalWebServer, LocalWebServerInstance } from './server.js';
×
UNCOV
7
import { inform, debug, panic, newlines } from './utils/logging.js';
×
UNCOV
8
import { ArgumentConfig, parse } from 'ts-command-line-args';
×
UNCOV
9
import { isVideoAppUrl } from './utils/is-video-url.js';
×
UNCOV
10
import { AsciiTable3 } from 'ascii-table3';
×
UNCOV
11
import { SingleBar } from 'cli-progress';
×
NEW
12
import pathToFfmpeg from 'ffmpeg-static';
×
UNCOV
13
import nodeCleanup from 'node-cleanup';
×
UNCOV
14
import { exec } from 'child_process';
×
UNCOV
15
import prompts from 'prompts';
×
UNCOV
16
import chalk from 'chalk';
×
UNCOV
17
import path from 'path';
×
UNCOV
18
import fs from 'fs';
×
UNCOV
19

×
UNCOV
20
export const CLI_PACKAGE_NAME = '@videobrew/cli';
×
UNCOV
21

×
UNCOV
22
const DEFAULT_VIDEO_APP_PATH = '.';
×
UNCOV
23
const DEFAULT_OUTPUT_PATH = 'out/my-video.mp4';
×
UNCOV
24
const DEFAULT_QUALITY = 90;
×
UNCOV
25

×
UNCOV
26
const EXAMPLE_VIDEO_APP_PATH = './video';
×
UNCOV
27
const EXAMPLE_VIDEO_APP_URL = 'https://example.test/video';
×
UNCOV
28
const EXAMPLE_OUTPUT_PATH = './rendered/video.mp4';
×
UNCOV
29

×
UNCOV
30
export interface IVideoBrewArguments {
×
UNCOV
31
  action: string;
×
UNCOV
32
  videoAppPathOrUrl?: string;
×
UNCOV
33
  output?: string;
×
UNCOV
34
  renderQuality?: number;
×
UNCOV
35
  help?: boolean;
×
UNCOV
36
}
×
UNCOV
37

×
UNCOV
38
export const argumentConfig: ArgumentConfig<IVideoBrewArguments> = {
×
UNCOV
39
  action: { type: String, defaultOption: true, description: 'Action to perform. Either "preview", "render", "render-formats" or "help"' },
×
UNCOV
40
  videoAppPathOrUrl: { type: String, alias: 'i', optional: true, description: `Relative path or absolute URL to the video app. Defaults to "${DEFAULT_VIDEO_APP_PATH}"` },
×
UNCOV
41
  output: { type: String, alias: 'o', optional: true, description: `Relative path to the output directory. Defaults to "${DEFAULT_OUTPUT_PATH}"` },
×
UNCOV
42
  renderQuality: { type: Number, alias: 'q', optional: true, description: `Quality of the rendered video. 0 is the lowest quality, 100 is the highest quality. Defaults to ${DEFAULT_QUALITY}` },
×
UNCOV
43
  help: { type: Boolean, optional: true, alias: 'h', description: 'Causes this usage guide to print' },
×
UNCOV
44
};
×
UNCOV
45

×
46
function parseArguments() {
×
47
  const actionsTable =
×
48
  new AsciiTable3()
×
49
    .addRowMatrix([
×
50
      [chalk.bold('preview'), 'Preview the video app in the browser'],
×
51
      [chalk.bold('render'), 'Render the video app to a video file'],
×
52
      [chalk.bold('render-formats'), 'List all supported video render formats'],
×
53
    ])
×
NEW
54
      .setStyle('none');
×
NEW
55
  
×
NEW
56
    console.log(chalk.bold.bgRedBright('‌‌ ‌‌ ‌‌ ‌‌ ‌‌ ‌‌ ‌‌  ‌‌ ‌‌‌‌ ‌‌‌‌ ‌‌ ‌‌ ‌‌ \n  📼 Videobrew  \n‌‌ ‌‌ ‌‌  ‌‌ ‌‌ ‌‌‌‌ ‌‌‌ ‌‌ ‌‌ ‌‌ ‌‌ ‌‌ ‌‌ \n'));
×
57

×
58
  return parse(argumentConfig, {
×
59
    hideMissingArgMessages: true,
×
60
    stopAtFirstUnknown: true,
×
61
    showHelpWhenArgsMissing: true,
×
62

×
63
    headerContentSections: [
×
64
      {
×
65
        content: 'Create videos using web technologies.',
×
66
      },
×
67
      {
×
68
        header: 'Usage',
×
69
        content: [
×
70
          '$ videobrew <action> [options]',
×
71
        ],
×
72
      },
×
73
      {
×
74
        header: 'Actions',
×
75
        content: actionsTable.toString().split('\n'),
×
76
      },
×
77
    ],
×
78
    footerContentSections: [
×
79
      {
×
80
        header: 'Examples',
×
81
        content: [
×
82
          chalk.bold('Open a video app located in the current working directory in the browser:'),
×
83
          '$ videobrew preview',
×
84
          '',
×
85
          chalk.bold('Open a video app being served at an URL in the browser:'),
×
86
          `$ videobrew preview ${EXAMPLE_VIDEO_APP_URL}`,
×
87
          chalk.bgRed('Note:') + ' This will only work if the video app server has CORS enabled.',
×
88
          '',
×
89
          chalk.bold(`Render a video app in the current working directory to ${DEFAULT_OUTPUT_PATH}:`),
×
90
          '$ videobrew render',
×
91
          '',
×
92
          chalk.bold(`Render a low quality video app in a subdirectory to "${EXAMPLE_OUTPUT_PATH}":`),
×
93
          `$ videobrew render --renderQuality 0 ${EXAMPLE_VIDEO_APP_PATH} ${EXAMPLE_OUTPUT_PATH}`,
×
94
          chalk.bold('or:'),
×
95
          `$ videobrew render -i ${EXAMPLE_VIDEO_APP_PATH} -o ${EXAMPLE_OUTPUT_PATH}`,
×
96
          chalk.bold('or:'),
×
97
          `$ videobrew render --videoAppPathOrUrl ${EXAMPLE_VIDEO_APP_PATH} --output ${EXAMPLE_OUTPUT_PATH}`,
×
98
          '',
×
99
          chalk.bold('Render a video app, currently being served at a URL, to a video file:'),
×
100
          `$ videobrew render ${EXAMPLE_VIDEO_APP_URL}`,
×
101
        ],
×
102
      },
×
103
      {
×
104
        content: [
×
105
          'For more information, see https://github.com/luttje/videobrew/',
×
106
        ],
×
107
      },
×
108
    ],
×
109
  }, true, true);
×
110
}
×
UNCOV
111

×
UNCOV
112
async function showRenderFormats(containerFormats: VideoFormat[]) {
×
UNCOV
113
  const formatTable =
×
UNCOV
114
  new AsciiTable3()
×
UNCOV
115
      .setStyle('none');
×
UNCOV
116
  
×
UNCOV
117
  containerFormats
×
UNCOV
118
    .forEach(format => {
×
UNCOV
119
      formatTable.addRow(format.extension, format.name);
×
UNCOV
120
    });
×
UNCOV
121
  
×
NEW
122
  inform(`Supported render formats by ${pathToFfmpeg}:`);
×
UNCOV
123
  inform(formatTable.toString(), undefined, true);
×
UNCOV
124
  inform('To render as one of these formats suffix the output path with the desired format extension.', undefined, true);
×
UNCOV
125
  inform('\n(These are the container formats as reported by ffmpeg)', chalk.italic.gray, true);
×
UNCOV
126
}
×
UNCOV
127

×
UNCOV
128
async function render(videoAppUrl: string, outputPath: string, renderQuality: number) {  
×
UNCOV
129
  const outputDirectory = path.resolve(path.dirname(outputPath));
×
UNCOV
130
  fs.mkdirSync(outputDirectory, { recursive: true });
×
UNCOV
131

×
UNCOV
132
  newlines();
×
UNCOV
133
  inform(`Step (1/2) Rendering frames:`);
×
UNCOV
134
  const progressBarFrames = new SingleBar({
×
UNCOV
135
    format: 'Rendering frames [{bar}] {percentage}% | ETA: {eta}s | {value}/{total} frames',
×
UNCOV
136
    hideCursor: true,
×
UNCOV
137
  });
×
UNCOV
138

×
UNCOV
139
  const framesOutputPath = fs.mkdtempSync(path.join(outputDirectory, '~tmp-'));
×
UNCOV
140
  let totalFrames = 0;
×
UNCOV
141
  const {
×
UNCOV
142
    framerate,
×
UNCOV
143
  } = await recordFrames(videoAppUrl, framesOutputPath, renderQuality, (currentFrame, max) => {
×
UNCOV
144
    if (currentFrame === 0) {
×
UNCOV
145
      totalFrames = max;
×
UNCOV
146
      progressBarFrames.start(max, currentFrame);
×
UNCOV
147
    }
×
UNCOV
148
    
×
UNCOV
149
    progressBarFrames.update(currentFrame);
×
UNCOV
150
  });
×
UNCOV
151

×
UNCOV
152
  progressBarFrames.update(totalFrames);
×
UNCOV
153
  progressBarFrames.stop();
×
UNCOV
154

×
UNCOV
155
  const videoConfig = await buildVideoConfigFromFrames(
×
UNCOV
156
    framesOutputPath,
×
UNCOV
157
    framerate,
×
UNCOV
158
    outputPath,
×
UNCOV
159
    getExtensionByQuality(renderQuality),
×
UNCOV
160
  );
×
UNCOV
161
  
×
UNCOV
162
  newlines();
×
UNCOV
163
  inform(`Step (2/2) Rendering video from frames:`);
×
UNCOV
164
  const progressBarRender = new SingleBar({
×
UNCOV
165
    format: 'Rendering video [{bar}] {percentage}% | ETA: {eta}s',
×
UNCOV
166
    hideCursor: true,
×
UNCOV
167
  });
×
UNCOV
168
  
×
UNCOV
169
  debug(`Rendering with command: ${videoConfig.command}`);
×
UNCOV
170
  
×
UNCOV
171
  progressBarRender.start(100, 0);
×
UNCOV
172

×
UNCOV
173
  const output = await renderVideo(videoConfig, (percentage) => {
×
UNCOV
174
    progressBarRender.update(percentage);
×
UNCOV
175
  });
×
UNCOV
176

×
UNCOV
177
  progressBarRender.update(100);
×
UNCOV
178
  progressBarRender.stop();
×
UNCOV
179

×
UNCOV
180
  debug(output);
×
UNCOV
181

×
UNCOV
182
  fs.rmSync(framesOutputPath, { recursive: true });
×
UNCOV
183

×
UNCOV
184
  newlines();
×
UNCOV
185
  inform(
×
UNCOV
186
    `Video rendered successfully! 🎉
×
UNCOV
187
    \nYou can find your video here:\n> ` +
×
UNCOV
188
    chalk.underline(`${outputPath}`),
×
UNCOV
189
    chalk.green
×
UNCOV
190
  );
×
UNCOV
191
}
×
UNCOV
192

×
193
async function getCliInstalledGlobally() {
×
194
  return new Promise<boolean>((resolve, reject) => {
×
195
    exec(`npm list -g ${CLI_PACKAGE_NAME} --json`, (exception, stdout, stderr) => {
×
196
      if (exception) {
×
197
        resolve(false);
×
198
        return;
×
199
      }
×
200

×
201
      if (stderr) {
×
202
        resolve(false);
×
203
        return;
×
204
      }
×
205

×
206
      const installed = JSON.parse(stdout).dependencies[CLI_PACKAGE_NAME];
×
207
      resolve(installed !== undefined);
×
208
    });
×
209
  });
×
210
}
×
UNCOV
211

×
212
async function confirmPreview() {
×
213
  const cliInstalledGlobally = await getCliInstalledGlobally();
×
214
  const executePreview = async (videoAppUrl: string) => await preview(videoAppUrl, cliInstalledGlobally);
×
215

×
216
  if (await getEditorInstallPath(cliInstalledGlobally))
×
217
    return executePreview;
×
218
  
×
219
  inform(`To preview your video app, you need to install the '${chalk.green(EDITOR_PACKAGE_NAME)}' package`, chalk.red);
×
220
  const installer = getEditorInstaller(cliInstalledGlobally);
×
221
  const response = await prompts({
×
222
    type: 'confirm',
×
223
    name: 'confirmed',
×
224
    message: `Would you like to install the '${chalk.green(EDITOR_PACKAGE_NAME)}' package now? (Runs '${chalk.green(installer.command)}')`,
×
225
    initial: true,
×
226
  });
×
227

×
228
  if (!response.confirmed)
×
229
    return null;
×
230
  
×
231
  await installer.install();
×
232

×
233
  return executePreview;
×
234
}
×
UNCOV
235

×
236
async function preview(videoAppUrl: string, cliInstalledGlobally: boolean) {
×
237
  const { server, host, port } = await startEditor(videoAppUrl, cliInstalledGlobally);
×
NEW
238
  let interval: NodeJS.Timeout;
×
239
  let isRestarting = false;
×
240

×
241
  // When the server fails, restart it (this is a workaround for errors caused by `watch` rebuilding the editor with different filenames)
×
242
  const restart = async () => {
×
243
    clearInterval(interval);
×
244

×
245
    if (isRestarting) return;
×
246
    isRestarting = true;
×
247
    
×
248
    inform(`Restarting server...`, chalk.yellow);
×
249
    server.kill();
×
250

×
251
    await preview(videoAppUrl, cliInstalledGlobally);
×
252
  };
×
253

×
254
  // While the server is running, keep checking it to see if it crashed
×
255
  interval = setInterval(async () => {
×
256
    try {
×
257
      const result = await fetch(`http://${host}:${port}/health`);
×
258

×
259
      if (result.status !== 200)
×
260
        restart();
×
261
    } catch (e) {
×
262
      restart();
×
263
    }
×
264
  }, 1000);
×
265

×
266

×
267
  server.stdout!.on('data', (data) => {
×
268
    if (!data.includes('http://') && !data.includes('https://')) {
×
269
      data = data.toString().replace(`${host}:${port}`, `http://${host}:${port}`);
×
270
    }
×
271

×
272
    inform(`Editor Server: ${data}`, chalk.yellow);
×
273
  });
×
274

×
275
  server.stderr!.on('data', (data) => {
×
276
    inform(`Editor Server Error: ${data}`, chalk.red);
×
277
    restart();
×
278
  });
×
279

×
280
  server.on('close', (code) => {
×
281
    inform(`Editor Server exited with code ${code}`);
×
282
  });
×
283
}
×
UNCOV
284

×
UNCOV
285
export async function main(args: ReturnType<typeof parseArguments>) {
×
UNCOV
286
  if (args.action === 'help') {
×
UNCOV
287
    args._commandLineResults.printHelp();
×
UNCOV
288
    return;
×
UNCOV
289
  }
×
UNCOV
290

×
NEW
291
  inform('Checking Playwright browsers installation (this may take a minute)...');
×
NEW
292
  const result = await ensurePlaywrightInstalled();
×
NEW
293

×
NEW
294
  if (result.installedOrUpdated) {
×
NEW
295
    inform('Playwright browsers installation or update complete!', chalk.green);
×
NEW
296
    inform('Checking if Playwright installation is working...');
×
NEW
297

×
NEW
298
    const isWorking = await testPlaywrightInstallationWorking();
×
NEW
299

×
NEW
300
    if (!isWorking) {
×
NEW
301
      panic('Playwright installation is not working! Please try running the command again.');
×
NEW
302
    }
×
NEW
303
    
×
NEW
304
    inform('Playwright installation is working!', chalk.green);
×
NEW
305
  }
×
NEW
306

×
UNCOV
307
  const containerFormats = await getContainerFormats();
×
UNCOV
308
  
×
UNCOV
309
  if (args.action === 'render-formats') {
×
UNCOV
310
    return await showRenderFormats(containerFormats);
×
UNCOV
311
  }
×
UNCOV
312

×
UNCOV
313
  let relativeVideoAppPath = args.videoAppPathOrUrl;
×
UNCOV
314

×
UNCOV
315
  if (!relativeVideoAppPath) {
×
316
    if (args._unknown?.length > 0) {
×
NEW
317
      // TODO: Somehow warn if the output is in an unsupported format
×
318
      const videoAppPathOrUrl = args._unknown.find(arg => {
×
319
        const extension = path.extname(arg);
×
320

×
321
        return !containerFormats.some(format => `.${format.extension}` === extension);
×
322
      });
×
323

×
324
      if (videoAppPathOrUrl) {
×
325
        relativeVideoAppPath = videoAppPathOrUrl;
×
326

×
327
        inform(`Video app path chosen: ${relativeVideoAppPath}`);
×
328
      }
×
329
    }
×
330
    
×
331
    if (!relativeVideoAppPath) {
×
332
      relativeVideoAppPath = DEFAULT_VIDEO_APP_PATH;
×
333
      
×
334
      inform(`Video app path chosen: ${relativeVideoAppPath} (default)`);
×
335
    }
×
336
  }
×
UNCOV
337
  
×
UNCOV
338
  const isVideoAppAtUrl = isVideoAppUrl(relativeVideoAppPath);
×
UNCOV
339
  let videoAppPathOrUrl = relativeVideoAppPath;
×
UNCOV
340

×
UNCOV
341
  if (isVideoAppAtUrl) {
×
342
    const response = await fetch(videoAppPathOrUrl);
×
343

×
344
    if (!response.ok)
×
345
      panic(`Video app URL ${videoAppPathOrUrl} is not responding with 200 OK! Please provide a valid URL to where your video app is being served.`);
×
UNCOV
346
  } else {
×
UNCOV
347
    videoAppPathOrUrl = path.resolve(relativeVideoAppPath);
×
UNCOV
348
    debug(`Video app full path: ${videoAppPathOrUrl}`);
×
UNCOV
349
    
×
UNCOV
350
    const videoAppFilePath = path.join(videoAppPathOrUrl, 'index.html');
×
UNCOV
351
    
×
UNCOV
352
    if (!fs.existsSync(videoAppPathOrUrl)) {
×
353
      panic(`Video app path ${videoAppPathOrUrl} does not exist! Please provide a valid path to where your video app is located.`);
×
354
    }
×
UNCOV
355

×
UNCOV
356
    if (!fs.existsSync(videoAppFilePath)) {
×
357
      panic(`Video app path does not contain index.html (${videoAppFilePath} does not exist!) Please provide a valid path to where your video app is located.`);
×
358
    }
×
UNCOV
359
  }
×
UNCOV
360

×
UNCOV
361
  let videoAppUrl = videoAppPathOrUrl;
×
UNCOV
362
  let serverInstance: LocalWebServerInstance | undefined;
×
UNCOV
363

×
UNCOV
364
  const startLocalServer = async () => {
×
UNCOV
365
    if (isVideoAppAtUrl)
×
UNCOV
366
      return;
×
UNCOV
367
    
×
UNCOV
368
    serverInstance = await createLocalWebServer(videoAppPathOrUrl);
×
UNCOV
369
    videoAppUrl = serverInstance.url;
×
UNCOV
370

×
UNCOV
371
    debug(`Serving video app at URL: ${videoAppUrl}`);
×
UNCOV
372
  }
×
UNCOV
373

×
UNCOV
374
  const stopLocalServer = async () => {
×
UNCOV
375
    if (!serverInstance)
×
UNCOV
376
      return;
×
UNCOV
377
    
×
UNCOV
378
    serverInstance.close();
×
UNCOV
379
    debug(`Stopped local server`);
×
UNCOV
380
  }
×
UNCOV
381

×
UNCOV
382
  nodeCleanup(function (exitCode, signal) {
×
383
    stopLocalServer();
×
UNCOV
384
  });
×
UNCOV
385

×
UNCOV
386
  if (args.action === 'render') {
×
UNCOV
387
    let relativeOutputPath = args.output;
×
UNCOV
388
  
×
UNCOV
389
    if (!relativeOutputPath) {
×
390
      if (args._unknown?.length > 0) {
×
391
        const outputPath = args._unknown.find(arg => {
×
392
          const extension = path.extname(arg);
×
393
  
×
394
          return containerFormats.some(format => `.${format.extension}` === extension);
×
395
        });
×
396
  
×
397
        if (outputPath) {
×
398
          relativeOutputPath = outputPath;
×
399
  
×
400
          inform(`Output path chosen: ${relativeOutputPath}`);
×
401
        }
×
402
      }
×
403
      
×
404
      if (!relativeOutputPath){
×
405
        relativeOutputPath = DEFAULT_OUTPUT_PATH;
×
406
  
×
407
        inform(`Output path chosen: ${relativeOutputPath} (default)`);
×
408
      }
×
409
    }
×
UNCOV
410
  
×
UNCOV
411
    const quality = args.renderQuality ?? DEFAULT_QUALITY;
×
UNCOV
412
  
×
UNCOV
413
    if (quality < 0 || quality > 100)
×
UNCOV
414
      panic(`Render quality must be between 0 and 100! (Provided: ${quality})`);
×
UNCOV
415
    
×
UNCOV
416
    inform(`Render quality chosen: ${quality}% ${(args.renderQuality === undefined ? '(default)' : '')}`);
×
UNCOV
417
  
×
UNCOV
418
    const output = path.resolve(relativeOutputPath);
×
UNCOV
419
    debug(`Output full path: ${output}`);
×
UNCOV
420
  
×
UNCOV
421
    await startLocalServer();
×
UNCOV
422

×
UNCOV
423
    await render(videoAppUrl, output, quality);
×
UNCOV
424

×
UNCOV
425
    await stopLocalServer();
×
UNCOV
426
  } else if (args.action === 'preview') {
×
427
    inform(`Preparing @videobrew/editor...`);
×
428
  
×
429
    const executePreview = await confirmPreview();
×
430
    
×
431
    if (!executePreview) {
×
432
      return panic('Aborting preview');
×
433
    }
×
434

×
435
    await startLocalServer();
×
436

×
437
    await executePreview(videoAppUrl);
×
438
  } else {
×
439
    panic(`Unknown action "${args.action}"! Use "preview", "render" or "render-formats`);
×
440
  }
×
UNCOV
441
}
×
UNCOV
442

×
UNCOV
443
if (!process.env.VIDEOBREW_UNIT_TESTING)
×
UNCOV
444
  main(parseArguments());
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc