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

luttje / videobrew / 8172359402

06 Mar 2024 12:50PM UTC coverage: 45.119% (+0.8%) from 44.313%
8172359402

Pull #25

github

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

500 of 721 branches covered (69.35%)

Branch coverage included in aggregate %.

81 of 99 new or added lines in 7 files covered. (81.82%)

259 existing lines in 4 files now uncovered.

3831 of 8878 relevant lines covered (43.15%)

462.91 hits per line

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

46.65
/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 pathToFfmpeg from 'ffmpeg-static';
1✔
13
import nodeCleanup from 'node-cleanup';
1✔
14
import { exec } from 'child_process';
1✔
15
import prompts from 'prompts';
1✔
16
import chalk from 'chalk';
1✔
17
import path from 'path';
1✔
18
import fs from 'fs';
1✔
19

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

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

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

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

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

1✔
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(
×
NEW
57
    chalk.bold.white.bgRedBright(
×
NEW
58
      ' ╔═══════════════════╗ \n' +
×
NEW
59
      ' ║   📼 Videobrew    ║ \n' +
×
NEW
60
      ' ╚═══════════════════╝ '
×
NEW
61
    )
×
NEW
62
  );
×
63

×
64
  return parse(argumentConfig, {
×
65
    hideMissingArgMessages: true,
×
66
    stopAtFirstUnknown: true,
×
67
    showHelpWhenArgsMissing: true,
×
68

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

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

1✔
134
async function render(videoAppUrl: string, outputPath: string, renderQuality: number) {  
2✔
135
  const outputDirectory = path.resolve(path.dirname(outputPath));
2✔
136
  fs.mkdirSync(outputDirectory, { recursive: true });
2✔
137

2✔
138
  newlines();
2✔
139
  inform(`Step (1/2) Rendering frames:`);
2✔
140
  const progressBarFrames = new SingleBar({
2✔
141
    format: 'Rendering frames [{bar}] {percentage}% | ETA: {eta}s | {value}/{total} frames',
2✔
142
    hideCursor: true,
2✔
143
  });
2✔
144

2✔
145
  const framesOutputPath = fs.mkdtempSync(path.join(outputDirectory, '~tmp-'));
2✔
146
  let totalFrames = 0;
2✔
147
  const {
2✔
148
    framerate,
2✔
149
  } = await recordFrames(videoAppUrl, framesOutputPath, renderQuality, (currentFrame, max) => {
2✔
150
    if (currentFrame === 0) {
120✔
151
      totalFrames = max;
2✔
152
      progressBarFrames.start(max, currentFrame);
2✔
153
    }
2✔
154
    
120✔
155
    progressBarFrames.update(currentFrame);
120✔
156
  });
2✔
157

2✔
158
  progressBarFrames.update(totalFrames);
2✔
159
  progressBarFrames.stop();
2✔
160

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

2✔
179
  const output = await renderVideo(videoConfig, (percentage) => {
2✔
180
    progressBarRender.update(percentage);
4✔
181
  });
2✔
182

2✔
183
  progressBarRender.update(100);
2✔
184
  progressBarRender.stop();
2✔
185

2✔
186
  debug(output);
2✔
187

2✔
188
  fs.rmSync(framesOutputPath, { recursive: true });
2✔
189

2✔
190
  newlines();
2✔
191
  inform(
2✔
192
    `Video rendered successfully! 🎉
2✔
193
    \nYou can find your video here:\n> ` +
2✔
194
    chalk.underline(`${outputPath}`),
2✔
195
    chalk.green
2✔
196
  );
2✔
197
}
2✔
198

1✔
199
async function getCliInstalledGlobally() {
×
200
  return new Promise<boolean>((resolve, reject) => {
×
201
    exec(`npm list -g ${CLI_PACKAGE_NAME} --json`, (exception, stdout, stderr) => {
×
202
      if (exception) {
×
203
        resolve(false);
×
204
        return;
×
205
      }
×
206

×
207
      if (stderr) {
×
208
        resolve(false);
×
209
        return;
×
210
      }
×
211

×
212
      const installed = JSON.parse(stdout).dependencies[CLI_PACKAGE_NAME];
×
213
      resolve(installed !== undefined);
×
214
    });
×
215
  });
×
216
}
×
217

1✔
218
async function confirmPreview() {
×
219
  const cliInstalledGlobally = await getCliInstalledGlobally();
×
220
  const executePreview = async (videoAppUrl: string) => await preview(videoAppUrl, cliInstalledGlobally);
×
221

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

×
234
  if (!response.confirmed)
×
235
    return null;
×
236
  
×
237
  await installer.install();
×
238

×
239
  return executePreview;
×
240
}
×
241

1✔
242
async function preview(videoAppUrl: string, cliInstalledGlobally: boolean) {
×
243
  const { server, host, port } = await startEditor(videoAppUrl, cliInstalledGlobally);
×
NEW
244
  let interval: NodeJS.Timeout;
×
245
  let isRestarting = false;
×
246

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

×
251
    if (isRestarting) return;
×
252
    isRestarting = true;
×
253
    
×
254
    inform(`Restarting server...`, chalk.yellow);
×
255
    server.kill();
×
256

×
257
    await preview(videoAppUrl, cliInstalledGlobally);
×
258
  };
×
259

×
260
  // While the server is running, keep checking it to see if it crashed
×
261
  interval = setInterval(async () => {
×
262
    try {
×
263
      const result = await fetch(`http://${host}:${port}/health`);
×
264

×
265
      if (result.status !== 200)
×
266
        restart();
×
267
    } catch (e) {
×
268
      restart();
×
269
    }
×
270
  }, 1000);
×
271

×
272

×
273
  server.stdout!.on('data', (data) => {
×
274
    if (!data.includes('http://') && !data.includes('https://')) {
×
275
      data = data.toString().replace(`${host}:${port}`, `http://${host}:${port}`);
×
276
    }
×
277

×
278
    inform(`Editor Server: ${data}`, chalk.yellow);
×
279
  });
×
280

×
281
  server.stderr!.on('data', (data) => {
×
282
    inform(`Editor Server Error: ${data}`, chalk.red);
×
283
    restart();
×
284
  });
×
285

×
286
  server.on('close', (code) => {
×
287
    inform(`Editor Server exited with code ${code}`);
×
288
  });
×
289
}
×
290

1✔
291
export async function main(args: ReturnType<typeof parseArguments>) {
3✔
292
  if (args.action === 'help') {
3✔
293
    args._commandLineResults.printHelp();
1✔
294
    return;
1✔
295
  }
1✔
296

2✔
297
  inform('Checking Playwright browsers installation (this may take a minute)...');
2✔
298
  const result = await ensurePlaywrightInstalled();
2✔
299

2✔
300
  if (result.installedOrUpdated) {
3✔
301
    inform('Playwright browsers installation or update complete!', chalk.green);
1✔
302
    inform('Checking if Playwright installation is working...');
1✔
303

1✔
304
    const isWorking = await testPlaywrightInstallationWorking();
1✔
305

1✔
306
    if (!isWorking) {
1!
NEW
307
      panic('Playwright installation is not working! Please try running the command again.');
×
NEW
308
    }
×
309
    
1✔
310
    inform('Playwright installation is working!', chalk.green);
1✔
311
  }
1✔
312

2✔
313
  const containerFormats = await getContainerFormats();
2✔
314
  
2✔
315
  if (args.action === 'render-formats') {
3!
UNCOV
316
    return await showRenderFormats(containerFormats);
×
UNCOV
317
  }
✔
318

2✔
319
  let relativeVideoAppPath = args.videoAppPathOrUrl;
2✔
320

2✔
321
  if (!relativeVideoAppPath) {
3!
322
    if (args._unknown?.length > 0) {
×
NEW
323
      // TODO: Somehow warn if the output is in an unsupported format
×
324
      const videoAppPathOrUrl = args._unknown.find(arg => {
×
325
        const extension = path.extname(arg);
×
326

×
327
        return !containerFormats.some(format => `.${format.extension}` === extension);
×
328
      });
×
329

×
330
      if (videoAppPathOrUrl) {
×
331
        relativeVideoAppPath = videoAppPathOrUrl;
×
332

×
333
        inform(`Video app path chosen: ${relativeVideoAppPath}`);
×
334
      }
×
335
    }
×
336
    
×
337
    if (!relativeVideoAppPath) {
×
338
      relativeVideoAppPath = DEFAULT_VIDEO_APP_PATH;
×
339
      
×
340
      inform(`Video app path chosen: ${relativeVideoAppPath} (default)`);
×
341
    }
×
342
  }
✔
343
  
2✔
344
  const isVideoAppAtUrl = isVideoAppUrl(relativeVideoAppPath);
2✔
345
  let videoAppPathOrUrl = relativeVideoAppPath;
2✔
346

2✔
347
  if (isVideoAppAtUrl) {
3!
348
    const response = await fetch(videoAppPathOrUrl);
×
349

×
350
    if (!response.ok)
×
351
      panic(`Video app URL ${videoAppPathOrUrl} is not responding with 200 OK! Please provide a valid URL to where your video app is being served.`);
×
352
  } else {
3✔
353
    videoAppPathOrUrl = path.resolve(relativeVideoAppPath);
2✔
354
    debug(`Video app full path: ${videoAppPathOrUrl}`);
2✔
355
    
2✔
356
    const videoAppFilePath = path.join(videoAppPathOrUrl, 'index.html');
2✔
357
    
2✔
358
    if (!fs.existsSync(videoAppPathOrUrl)) {
2!
359
      panic(`Video app path ${videoAppPathOrUrl} does not exist! Please provide a valid path to where your video app is located.`);
×
360
    }
×
361

2✔
362
    if (!fs.existsSync(videoAppFilePath)) {
2!
363
      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.`);
×
364
    }
×
365
  }
2✔
366

2✔
367
  let videoAppUrl = videoAppPathOrUrl;
2✔
368
  let serverInstance: LocalWebServerInstance | undefined;
2✔
369

2✔
370
  const startLocalServer = async () => {
2✔
371
    if (isVideoAppAtUrl)
2✔
372
      return;
2!
373
    
2✔
374
    serverInstance = await createLocalWebServer(videoAppPathOrUrl);
2✔
375
    videoAppUrl = serverInstance.url;
2✔
376

2✔
377
    debug(`Serving video app at URL: ${videoAppUrl}`);
2✔
378
  }
2✔
379

2✔
380
  const stopLocalServer = async () => {
2✔
381
    if (!serverInstance)
2✔
382
      return;
2!
383
    
2✔
384
    serverInstance.close();
2✔
385
    debug(`Stopped local server`);
2✔
386
  }
2✔
387

2✔
388
  nodeCleanup(function (exitCode, signal) {
2✔
389
    stopLocalServer();
×
390
  });
2✔
391

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

2✔
429
    await render(videoAppUrl, output, quality);
2✔
430

2✔
431
    await stopLocalServer();
2✔
432
  } else if (args.action === 'preview') {
3!
433
    inform(`Preparing @videobrew/editor...`);
×
434
  
×
435
    const executePreview = await confirmPreview();
×
436
    
×
437
    if (!executePreview) {
×
438
      return panic('Aborting preview');
×
439
    }
×
440

×
441
    await startLocalServer();
×
442

×
443
    await executePreview(videoAppUrl);
×
444
  } else {
×
445
    panic(`Unknown action "${args.action}"! Use "preview", "render" or "render-formats`);
×
446
  }
×
447
}
3✔
448

1✔
449
if (!process.env.VIDEOBREW_UNIT_TESTING)
1✔
450
  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