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

luttje / videobrew / 8171852345

06 Mar 2024 12:13PM UTC coverage: 25.141% (-19.2%) from 44.313%
8171852345

Pull #25

github

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

356 of 537 branches covered (66.29%)

Branch coverage included in aggregate %.

17 of 89 new or added lines in 6 files covered. (19.1%)

1725 existing lines in 23 files now uncovered.

2003 of 8846 relevant lines covered (22.64%)

461.52 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';
×
UNCOV
12
import nodeCleanup from 'node-cleanup';
×
UNCOV
13
import { exec } from 'child_process';
×
UNCOV
14
import prompts from 'prompts';
×
UNCOV
15
import chalk from 'chalk';
×
UNCOV
16
import path from 'path';
×
UNCOV
17
import fs from 'fs';
×
UNCOV
18

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

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

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

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

×
UNCOV
37
export const argumentConfig: ArgumentConfig<IVideoBrewArguments> = {
×
UNCOV
38
  action: { type: String, defaultOption: true, description: 'Action to perform. Either "preview", "render", "render-formats" or "help"' },
×
UNCOV
39
  videoAppPathOrUrl: { type: String, alias: 'i', optional: true, description: `Relative path or absolute URL to the video app. Defaults to "${DEFAULT_VIDEO_APP_PATH}"` },
×
UNCOV
40
  output: { type: String, alias: 'o', optional: true, description: `Relative path to the output directory. Defaults to "${DEFAULT_OUTPUT_PATH}"` },
×
UNCOV
41
  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
42
  help: { type: Boolean, optional: true, alias: 'h', description: 'Causes this usage guide to print' },
×
UNCOV
43
};
×
UNCOV
44

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

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

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

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

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

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

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

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

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

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

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

×
UNCOV
179
  debug(output);
×
UNCOV
180

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

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

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

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

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

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

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

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

×
232
  return executePreview;
×
233
}
×
UNCOV
234

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

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

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

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

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

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

×
265

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

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

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

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

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

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

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

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

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

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

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

×
UNCOV
314
  if (!relativeVideoAppPath) {
×
315
    if (args._unknown?.length > 0) {
×
316
      const videoAppPathOrUrl = args._unknown.find(arg => {
×
317
        const extension = path.extname(arg);
×
318

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

×
322
      if (videoAppPathOrUrl) {
×
323
        relativeVideoAppPath = videoAppPathOrUrl;
×
324

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

×
UNCOV
339
  if (isVideoAppAtUrl) {
×
340
    const response = await fetch(videoAppPathOrUrl);
×
341

×
342
    if (!response.ok)
×
343
      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
344
  } else {
×
UNCOV
345
    videoAppPathOrUrl = path.resolve(relativeVideoAppPath);
×
UNCOV
346
    debug(`Video app full path: ${videoAppPathOrUrl}`);
×
UNCOV
347
    
×
UNCOV
348
    const videoAppFilePath = path.join(videoAppPathOrUrl, 'index.html');
×
UNCOV
349
    
×
UNCOV
350
    if (!fs.existsSync(videoAppPathOrUrl)) {
×
351
      panic(`Video app path ${videoAppPathOrUrl} does not exist! Please provide a valid path to where your video app is located.`);
×
352
    }
×
UNCOV
353

×
UNCOV
354
    if (!fs.existsSync(videoAppFilePath)) {
×
355
      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.`);
×
356
    }
×
UNCOV
357
  }
×
UNCOV
358

×
UNCOV
359
  let videoAppUrl = videoAppPathOrUrl;
×
UNCOV
360
  let serverInstance: LocalWebServerInstance | undefined;
×
UNCOV
361

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

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

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

×
UNCOV
380
  nodeCleanup(function (exitCode, signal) {
×
381
    stopLocalServer();
×
UNCOV
382
  });
×
UNCOV
383

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

×
UNCOV
421
    await render(videoAppUrl, output, quality);
×
UNCOV
422

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

×
433
    await startLocalServer();
×
434

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

×
UNCOV
441
if (!process.env.VIDEOBREW_UNIT_TESTING)
×
UNCOV
442
  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