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

luttje / videobrew / 8170952525

06 Mar 2024 10:59AM UTC coverage: 44.996% (+0.7%) from 44.313%
8170952525

Pull #25

github

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

500 of 717 branches covered (69.74%)

Branch coverage included in aggregate %.

56 of 88 new or added lines in 6 files covered. (63.64%)

180 existing lines in 3 files now uncovered.

3803 of 8846 relevant lines covered (42.99%)

463.66 hits per line

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

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

1✔
19
export const CLI_PACKAGE_NAME = '@videobrew/cli';
1✔
20

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

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

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

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

1✔
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
}
×
110

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

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

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

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

2✔
151
  progressBarFrames.update(totalFrames);
2✔
152
  progressBarFrames.stop();
2✔
153

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

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

2✔
176
  progressBarRender.update(100);
2✔
177
  progressBarRender.stop();
2✔
178

2✔
179
  debug(output);
2✔
180

2✔
181
  fs.rmSync(framesOutputPath, { recursive: true });
2✔
182

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

1✔
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
}
×
210

1✔
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
}
×
234

1✔
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
}
×
283

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

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

3✔
293
  if (result.installedOrUpdated) {
4!
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
  }
✔
305

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

2✔
312
  let relativeVideoAppPath = args.videoAppPathOrUrl;
2✔
313

2✔
314
  if (!relativeVideoAppPath) {
4!
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
  }
✔
335
  
2✔
336
  const isVideoAppAtUrl = isVideoAppUrl(relativeVideoAppPath);
2✔
337
  let videoAppPathOrUrl = relativeVideoAppPath;
2✔
338

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

2✔
354
    if (!fs.existsSync(videoAppFilePath)) {
2!
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
    }
×
357
  }
2✔
358

2✔
359
  let videoAppUrl = videoAppPathOrUrl;
2✔
360
  let serverInstance: LocalWebServerInstance | undefined;
2✔
361

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

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

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

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

2✔
384
  if (args.action === 'render') {
2✔
385
    let relativeOutputPath = args.output;
2✔
386
  
2✔
387
    if (!relativeOutputPath) {
2!
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
    }
×
408
  
2✔
409
    const quality = args.renderQuality ?? DEFAULT_QUALITY;
2✔
410
  
2✔
411
    if (quality < 0 || quality > 100)
2✔
412
      panic(`Render quality must be between 0 and 100! (Provided: ${quality})`);
2!
413
    
2✔
414
    inform(`Render quality chosen: ${quality}% ${(args.renderQuality === undefined ? '(default)' : '')}`);
2✔
415
  
2✔
416
    const output = path.resolve(relativeOutputPath);
2✔
417
    debug(`Output full path: ${output}`);
2✔
418
  
2✔
419
    await startLocalServer();
2✔
420

2✔
421
    await render(videoAppUrl, output, quality);
2✔
422

2✔
423
    await stopLocalServer();
2✔
424
  } else if (args.action === 'preview') {
4!
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
  }
×
439
}
4✔
440

1✔
441
if (!process.env.VIDEOBREW_UNIT_TESTING)
1✔
442
  main(parseArguments());
1!
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