route-graphics 1.12.3 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,919 @@
1
+ import fs from "node:fs";
2
+ import fsPromises from "node:fs/promises";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { chromium } from "playwright";
9
+
10
+ import {
11
+ collectAssetDefinitions,
12
+ loadRenderDefinition,
13
+ parseBackgroundColor,
14
+ } from "./renderConfig.js";
15
+ import { getRendererBrowserLaunchOptions } from "./browserLaunch.js";
16
+ import { renderMp4 } from "./renderVideo.js";
17
+ import { parseStateSelection } from "./stateSelection.js";
18
+
19
+ const cliModuleDir = path.dirname(fileURLToPath(import.meta.url));
20
+ const projectRoot = path.resolve(cliModuleDir, "..", "..");
21
+ const bundlePath = path.join(projectRoot, "dist", "RouteGraphics.js");
22
+
23
+ const SUPPORTED_FORMATS = new Set(["png", "mp4"]);
24
+
25
+ const usage = `Usage:
26
+ route-graphics render <input.yaml> -o <output> [options]
27
+
28
+ Options:
29
+ -o, --output <path> Output path. Supports .png and .mp4
30
+ --format <png|mp4> Override format inference
31
+ --width <pixels> Override render width
32
+ --height <pixels> Override render height
33
+ --background-color <value> 0xRRGGBB, #RRGGBB, or decimal
34
+
35
+ PNG options:
36
+ --state <index> State index when YAML contains multiple states
37
+ --time <ms> Sample animations at a manual time
38
+ --wait-for-render-complete Wait for renderComplete before capture
39
+
40
+ MP4 options:
41
+ --states <list> State indexes/ranges, default all. Example: 0,2-5
42
+ --fps <number> Output frame rate (default: 30)
43
+ --hold <ms> Hold after each intermediate state (default: 0)
44
+ --initial-hold <ms> Hold after the first state before advancing (default: 0)
45
+ --final-hold <ms> Hold after the final state (default: 1000)
46
+ --max-state-duration <ms> Timeout per state (default: 15000)
47
+
48
+ Runtime options:
49
+ --browser-executable <path> Use a system Chrome/Chromium executable
50
+ --ffmpeg <path> ffmpeg path for MP4 output (default: ffmpeg)
51
+ --timeout <ms> Browser-side render timeout (default: 15000)
52
+ -h, --help Show this help
53
+ `;
54
+
55
+ const renderCommandUsage = usage;
56
+
57
+ const PNG_ONLY_OPTIONS = ["stateIndex", "timeMS", "waitForRenderComplete"];
58
+
59
+ const MP4_ONLY_OPTIONS = [
60
+ "stateSelection",
61
+ "fps",
62
+ "holdMS",
63
+ "initialHoldMS",
64
+ "finalHoldMS",
65
+ "maxStateDurationMS",
66
+ "ffmpegPath",
67
+ ];
68
+
69
+ const toOptionName = (propertyName) =>
70
+ ({
71
+ stateIndex: "--state",
72
+ timeMS: "--time",
73
+ waitForRenderComplete: "--wait-for-render-complete",
74
+ stateSelection: "--states",
75
+ fps: "--fps",
76
+ holdMS: "--hold",
77
+ initialHoldMS: "--initial-hold",
78
+ finalHoldMS: "--final-hold",
79
+ maxStateDurationMS: "--max-state-duration",
80
+ ffmpegPath: "--ffmpeg",
81
+ })[propertyName] ?? propertyName;
82
+
83
+ const formatDuration = (durationMS) => {
84
+ if (!Number.isFinite(durationMS)) {
85
+ return "unknown";
86
+ }
87
+
88
+ if (durationMS < 1000) {
89
+ return `${Math.round(durationMS)}ms`;
90
+ }
91
+
92
+ return `${(durationMS / 1000).toFixed(2)}s`;
93
+ };
94
+
95
+ const normalizeFormatName = (value) => {
96
+ if (typeof value !== "string" || value.length === 0) {
97
+ return undefined;
98
+ }
99
+
100
+ return value.trim().replace(/^\./, "").toLowerCase();
101
+ };
102
+
103
+ const formatFromExtension = (outputPath) => {
104
+ const extension = path.extname(outputPath).toLowerCase().replace(/^\./, "");
105
+
106
+ return SUPPORTED_FORMATS.has(extension) ? extension : undefined;
107
+ };
108
+
109
+ export const resolveOutputFormat = ({ outputPath, format }) => {
110
+ const explicitFormat = normalizeFormatName(format);
111
+ const extensionFormat = formatFromExtension(outputPath ?? "");
112
+
113
+ if (explicitFormat && !SUPPORTED_FORMATS.has(explicitFormat)) {
114
+ throw new Error(
115
+ `Unsupported format "${format}". Expected one of: png, mp4.`,
116
+ );
117
+ }
118
+
119
+ if (explicitFormat && extensionFormat && explicitFormat !== extensionFormat) {
120
+ throw new Error(
121
+ `Output extension ".${extensionFormat}" does not match --format ${explicitFormat}.`,
122
+ );
123
+ }
124
+
125
+ const resolved = explicitFormat ?? extensionFormat;
126
+
127
+ if (!resolved) {
128
+ throw new Error(
129
+ "Could not infer output format. Use a .png/.mp4 output path or pass --format.",
130
+ );
131
+ }
132
+
133
+ return resolved;
134
+ };
135
+
136
+ export const resolveOutputPath = ({ cwd, outputPath, format }) => {
137
+ const absoluteOutputPath = path.resolve(cwd, outputPath);
138
+
139
+ if (path.extname(absoluteOutputPath)) {
140
+ return absoluteOutputPath;
141
+ }
142
+
143
+ return `${absoluteOutputPath}.${format}`;
144
+ };
145
+
146
+ const parseIntegerOption = (label, value, { min = undefined } = {}) => {
147
+ if (value === undefined || value === null || String(value).length === 0) {
148
+ throw new Error(`${label} requires a value.`);
149
+ }
150
+
151
+ if (!/^-?\d+$/.test(String(value))) {
152
+ throw new Error(`${label} must be an integer.`);
153
+ }
154
+
155
+ const parsed = Number.parseInt(String(value), 10);
156
+ if (!Number.isSafeInteger(parsed)) {
157
+ throw new Error(`${label} must be a safe integer.`);
158
+ }
159
+
160
+ if (min !== undefined && parsed < min) {
161
+ throw new Error(`${label} must be at least ${min}.`);
162
+ }
163
+
164
+ return parsed;
165
+ };
166
+
167
+ const parseNumberOption = (label, value, { min = undefined } = {}) => {
168
+ if (value === undefined || value === null || String(value).length === 0) {
169
+ throw new Error(`${label} requires a value.`);
170
+ }
171
+
172
+ const parsed = Number(value);
173
+ if (!Number.isFinite(parsed)) {
174
+ throw new Error(`${label} must be a number.`);
175
+ }
176
+
177
+ if (min !== undefined && parsed < min) {
178
+ throw new Error(`${label} must be at least ${min}.`);
179
+ }
180
+
181
+ return parsed;
182
+ };
183
+
184
+ const readOptionValue = (argv, index, optionName) => {
185
+ const value = argv[index + 1];
186
+ if (value === undefined || value.startsWith("-")) {
187
+ throw new Error(`${optionName} requires a value.`);
188
+ }
189
+
190
+ return value;
191
+ };
192
+
193
+ export const parseRouteGraphicsCliArgs = (argv) => {
194
+ const options = {
195
+ command: undefined,
196
+ timeoutMS: 15000,
197
+ stateIndex: undefined,
198
+ waitForRenderComplete: false,
199
+ fps: undefined,
200
+ holdMS: undefined,
201
+ initialHoldMS: undefined,
202
+ finalHoldMS: undefined,
203
+ maxStateDurationMS: undefined,
204
+ ffmpegPath: undefined,
205
+ };
206
+ const positionals = [];
207
+
208
+ if (argv.length === 0) {
209
+ return {
210
+ ...options,
211
+ help: true,
212
+ };
213
+ }
214
+
215
+ const [command, ...rest] = argv;
216
+ options.command = command;
217
+
218
+ if (command === "-h" || command === "--help") {
219
+ return {
220
+ ...options,
221
+ command: undefined,
222
+ help: true,
223
+ };
224
+ }
225
+
226
+ if (command !== "render") {
227
+ throw new Error(`Unknown command: ${command}`);
228
+ }
229
+
230
+ for (let index = 0; index < rest.length; index += 1) {
231
+ const token = rest[index];
232
+
233
+ switch (token) {
234
+ case "-h":
235
+ case "--help":
236
+ options.help = true;
237
+ break;
238
+ case "-o":
239
+ case "--output":
240
+ options.outputPath = readOptionValue(rest, index, token);
241
+ index += 1;
242
+ break;
243
+ case "--format":
244
+ options.format = readOptionValue(rest, index, token);
245
+ index += 1;
246
+ break;
247
+ case "--width":
248
+ options.width = parseIntegerOption(
249
+ "Width",
250
+ readOptionValue(rest, index, token),
251
+ { min: 1 },
252
+ );
253
+ index += 1;
254
+ break;
255
+ case "--height":
256
+ options.height = parseIntegerOption(
257
+ "Height",
258
+ readOptionValue(rest, index, token),
259
+ { min: 1 },
260
+ );
261
+ index += 1;
262
+ break;
263
+ case "--state":
264
+ options.stateIndex = parseIntegerOption(
265
+ "State index",
266
+ readOptionValue(rest, index, token),
267
+ { min: 0 },
268
+ );
269
+ index += 1;
270
+ break;
271
+ case "--states":
272
+ options.stateSelection = readOptionValue(rest, index, token);
273
+ index += 1;
274
+ break;
275
+ case "--time":
276
+ options.timeMS = parseIntegerOption(
277
+ "Animation time",
278
+ readOptionValue(rest, index, token),
279
+ { min: 0 },
280
+ );
281
+ index += 1;
282
+ break;
283
+ case "--fps":
284
+ options.fps = parseNumberOption(
285
+ "FPS",
286
+ readOptionValue(rest, index, token),
287
+ {
288
+ min: 1,
289
+ },
290
+ );
291
+ index += 1;
292
+ break;
293
+ case "--hold":
294
+ options.holdMS = parseIntegerOption(
295
+ "Hold",
296
+ readOptionValue(rest, index, token),
297
+ { min: 0 },
298
+ );
299
+ index += 1;
300
+ break;
301
+ case "--initial-hold":
302
+ options.initialHoldMS = parseIntegerOption(
303
+ "Initial hold",
304
+ readOptionValue(rest, index, token),
305
+ { min: 0 },
306
+ );
307
+ index += 1;
308
+ break;
309
+ case "--final-hold":
310
+ options.finalHoldMS = parseIntegerOption(
311
+ "Final hold",
312
+ readOptionValue(rest, index, token),
313
+ { min: 0 },
314
+ );
315
+ index += 1;
316
+ break;
317
+ case "--max-state-duration":
318
+ options.maxStateDurationMS = parseIntegerOption(
319
+ "Max state duration",
320
+ readOptionValue(rest, index, token),
321
+ { min: 1 },
322
+ );
323
+ index += 1;
324
+ break;
325
+ case "--background-color":
326
+ options.backgroundColor = readOptionValue(rest, index, token);
327
+ index += 1;
328
+ break;
329
+ case "--browser-executable":
330
+ options.browserExecutablePath = readOptionValue(rest, index, token);
331
+ index += 1;
332
+ break;
333
+ case "--timeout":
334
+ options.timeoutMS = parseIntegerOption(
335
+ "Timeout",
336
+ readOptionValue(rest, index, token),
337
+ { min: 1 },
338
+ );
339
+ index += 1;
340
+ break;
341
+ case "--ffmpeg":
342
+ options.ffmpegPath = readOptionValue(rest, index, token);
343
+ index += 1;
344
+ break;
345
+ case "--wait-for-render-complete":
346
+ options.waitForRenderComplete = true;
347
+ break;
348
+ default:
349
+ if (token.startsWith("-")) {
350
+ throw new Error(`Unknown option: ${token}`);
351
+ }
352
+ positionals.push(token);
353
+ break;
354
+ }
355
+ }
356
+
357
+ if (positionals.length > 1) {
358
+ throw new Error("Only one input YAML file can be provided.");
359
+ }
360
+
361
+ return {
362
+ ...options,
363
+ inputPath: positionals[0],
364
+ };
365
+ };
366
+
367
+ const validateOptionsForFormat = (options, format) => {
368
+ if (format === "png") {
369
+ for (const option of MP4_ONLY_OPTIONS) {
370
+ if (options[option] !== undefined) {
371
+ throw new Error(
372
+ `${toOptionName(option)} is only supported for MP4 output.`,
373
+ );
374
+ }
375
+ }
376
+ return;
377
+ }
378
+
379
+ for (const option of PNG_ONLY_OPTIONS) {
380
+ if (option === "waitForRenderComplete") {
381
+ if (options[option] === true) {
382
+ throw new Error(
383
+ `${toOptionName(option)} is only supported for PNG output.`,
384
+ );
385
+ }
386
+ continue;
387
+ }
388
+
389
+ if (options[option] !== undefined) {
390
+ throw new Error(
391
+ `${toOptionName(option)} is only supported for PNG output.`,
392
+ );
393
+ }
394
+ }
395
+ };
396
+
397
+ const toBrowserPath = (assetId) => {
398
+ return `/__asset/${encodeURIComponent(assetId)}`;
399
+ };
400
+
401
+ const getServedAssetPath = (assetId, assetPath) => {
402
+ const extension = path.extname(assetPath) || "";
403
+
404
+ return `${toBrowserPath(assetId)}${extension}`;
405
+ };
406
+
407
+ const createRequestHandler = ({ assetRoutes }) => {
408
+ return async (request, response) => {
409
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
410
+
411
+ if (url.pathname === "/") {
412
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
413
+ response.end("<!doctype html><html><body></body></html>");
414
+ return;
415
+ }
416
+
417
+ if (url.pathname === "/dist/RouteGraphics.js") {
418
+ response.writeHead(200, {
419
+ "content-type": "text/javascript; charset=utf-8",
420
+ });
421
+ fs.createReadStream(bundlePath).pipe(response);
422
+ return;
423
+ }
424
+
425
+ if (!url.pathname.startsWith("/__asset/")) {
426
+ response.writeHead(404);
427
+ response.end("Not Found");
428
+ return;
429
+ }
430
+
431
+ const assetRecord = assetRoutes.get(url.pathname);
432
+
433
+ if (!assetRecord) {
434
+ response.writeHead(404);
435
+ response.end("Unknown asset");
436
+ return;
437
+ }
438
+
439
+ const stat = await fsPromises.stat(assetRecord.path);
440
+ const rangeHeader = request.headers.range;
441
+ const headers = {
442
+ "accept-ranges": "bytes",
443
+ "content-type": assetRecord.type,
444
+ };
445
+
446
+ if (!rangeHeader) {
447
+ headers["content-length"] = stat.size;
448
+ response.writeHead(200, headers);
449
+ if (request.method === "HEAD") {
450
+ response.end();
451
+ return;
452
+ }
453
+ fs.createReadStream(assetRecord.path).pipe(response);
454
+ return;
455
+ }
456
+
457
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
458
+ if (!match) {
459
+ response.writeHead(416);
460
+ response.end();
461
+ return;
462
+ }
463
+
464
+ const start = match[1] ? Number.parseInt(match[1], 10) : 0;
465
+ const end = match[2] ? Number.parseInt(match[2], 10) : stat.size - 1;
466
+
467
+ headers["content-length"] = end - start + 1;
468
+ headers["content-range"] = `bytes ${start}-${end}/${stat.size}`;
469
+ response.writeHead(206, headers);
470
+
471
+ if (request.method === "HEAD") {
472
+ response.end();
473
+ return;
474
+ }
475
+
476
+ fs.createReadStream(assetRecord.path, { start, end }).pipe(response);
477
+ };
478
+ };
479
+
480
+ const startAssetServer = async ({ assetRoutes }) => {
481
+ const server = http.createServer((request, response) => {
482
+ createRequestHandler({ assetRoutes })(request, response).catch((error) => {
483
+ response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
484
+ response.end(error.stack ?? error.message);
485
+ });
486
+ });
487
+
488
+ await new Promise((resolve, reject) => {
489
+ server.once("error", reject);
490
+ server.listen(0, "127.0.0.1", resolve);
491
+ });
492
+
493
+ const address = server.address();
494
+ if (!address || typeof address === "string") {
495
+ throw new Error("Could not determine local render server address.");
496
+ }
497
+
498
+ return {
499
+ origin: `http://127.0.0.1:${address.port}`,
500
+ close: () =>
501
+ new Promise((resolve, reject) => {
502
+ server.close((error) => {
503
+ if (error) {
504
+ reject(error);
505
+ return;
506
+ }
507
+ resolve();
508
+ });
509
+ }),
510
+ };
511
+ };
512
+
513
+ const normalizeBrowserAssets = async ({ assetDefinitions }) => {
514
+ const browserAssets = {};
515
+ const assetRoutes = new Map();
516
+
517
+ for (const [key, definition] of Object.entries(assetDefinitions)) {
518
+ if (definition.kind === "remote") {
519
+ browserAssets[key] = {
520
+ type: definition.type,
521
+ url: definition.url,
522
+ };
523
+ continue;
524
+ }
525
+
526
+ await fsPromises.access(definition.path, fs.constants.R_OK);
527
+ const servedPath = getServedAssetPath(key, definition.path);
528
+
529
+ assetRoutes.set(servedPath, {
530
+ path: definition.path,
531
+ type: definition.type,
532
+ });
533
+ browserAssets[key] = {
534
+ type: definition.type,
535
+ url: servedPath,
536
+ };
537
+ }
538
+
539
+ return {
540
+ assetRoutes,
541
+ browserAssets,
542
+ };
543
+ };
544
+
545
+ const ensureBundleExists = async () => {
546
+ try {
547
+ await fsPromises.access(bundlePath, fs.constants.R_OK);
548
+ } catch {
549
+ throw new Error(
550
+ "dist/RouteGraphics.js is missing. Run `bun run build` before using the renderer CLI.",
551
+ );
552
+ }
553
+ };
554
+
555
+ const normalizeRenderSettings = ({ cliOptions, definition }) => ({
556
+ width: cliOptions.width ?? definition.width ?? 1280,
557
+ height: cliOptions.height ?? definition.height ?? 720,
558
+ backgroundColor: parseBackgroundColor(
559
+ cliOptions.backgroundColor ?? definition.backgroundColor,
560
+ ),
561
+ });
562
+
563
+ const writePngOutput = async (outputPath, base64Png) => {
564
+ const commaIndex = base64Png.indexOf(",");
565
+ const payload = commaIndex >= 0 ? base64Png.slice(commaIndex + 1) : base64Png;
566
+ const buffer = Buffer.from(payload, "base64");
567
+
568
+ await fsPromises.mkdir(path.dirname(outputPath), { recursive: true });
569
+ await fsPromises.writeFile(outputPath, buffer);
570
+ };
571
+
572
+ const capturePng = async ({
573
+ origin,
574
+ width,
575
+ height,
576
+ backgroundColor,
577
+ state,
578
+ browserAssets,
579
+ timeMS,
580
+ waitForRenderComplete,
581
+ timeoutMS,
582
+ browserExecutablePath,
583
+ }) => {
584
+ const browser = await chromium.launch(
585
+ getRendererBrowserLaunchOptions(browserExecutablePath),
586
+ );
587
+
588
+ try {
589
+ const page = await browser.newPage({
590
+ viewport: {
591
+ width,
592
+ height,
593
+ },
594
+ });
595
+ const pageErrors = [];
596
+
597
+ page.on("pageerror", (error) => {
598
+ pageErrors.push(error.stack ?? error.message);
599
+ });
600
+
601
+ await page.goto(origin, {
602
+ waitUntil: "domcontentloaded",
603
+ });
604
+
605
+ const base64 = await page.evaluate(
606
+ async ({ moduleUrl, renderPayload }) => {
607
+ const nextFrame = async (count = 2) => {
608
+ await new Promise((resolve) => {
609
+ let remaining = count;
610
+ const tick = () => {
611
+ if (remaining <= 0) {
612
+ resolve();
613
+ return;
614
+ }
615
+ remaining -= 1;
616
+ requestAnimationFrame(tick);
617
+ };
618
+ requestAnimationFrame(tick);
619
+ });
620
+ };
621
+
622
+ const routeGraphicsModule = await import(moduleUrl);
623
+ const {
624
+ default: createRouteGraphics,
625
+ animatedSpritePlugin,
626
+ containerPlugin,
627
+ createAssetBufferManager,
628
+ inputPlugin,
629
+ particlesPlugin,
630
+ rectPlugin,
631
+ sliderPlugin,
632
+ soundPlugin,
633
+ spritePlugin,
634
+ textPlugin,
635
+ textRevealingPlugin,
636
+ tweenPlugin,
637
+ videoPlugin,
638
+ } = routeGraphicsModule;
639
+
640
+ const app = createRouteGraphics();
641
+ const assetBufferManager = createAssetBufferManager();
642
+ let renderCompleteResolve = () => {};
643
+ let renderTimeoutId = null;
644
+ let renderCompletePromise = Promise.resolve(null);
645
+
646
+ if (renderPayload.waitForRenderComplete) {
647
+ renderCompletePromise = new Promise((resolve, reject) => {
648
+ renderCompleteResolve = resolve;
649
+ renderTimeoutId = window.setTimeout(() => {
650
+ reject(new Error("Timed out waiting for renderComplete."));
651
+ }, renderPayload.timeoutMS);
652
+ });
653
+ }
654
+
655
+ try {
656
+ await app.init({
657
+ width: renderPayload.width,
658
+ height: renderPayload.height,
659
+ backgroundColor: renderPayload.backgroundColor,
660
+ animationPlaybackMode:
661
+ renderPayload.timeMS === null ? "auto" : "manual",
662
+ plugins: {
663
+ elements: [
664
+ textPlugin,
665
+ rectPlugin,
666
+ spritePlugin,
667
+ videoPlugin,
668
+ sliderPlugin,
669
+ inputPlugin,
670
+ containerPlugin,
671
+ textRevealingPlugin,
672
+ animatedSpritePlugin,
673
+ particlesPlugin,
674
+ ].filter(Boolean),
675
+ animations: [tweenPlugin].filter(Boolean),
676
+ audio: [soundPlugin].filter(Boolean),
677
+ },
678
+ eventHandler: (eventName, payload) => {
679
+ if (eventName === "renderComplete" && payload?.aborted !== true) {
680
+ renderCompleteResolve(payload);
681
+ }
682
+ },
683
+ debug: false,
684
+ });
685
+
686
+ if (Object.keys(renderPayload.assets).length > 0) {
687
+ await assetBufferManager.load(renderPayload.assets);
688
+ await app.loadAssets(assetBufferManager.getBufferMap());
689
+ }
690
+
691
+ document.body.replaceChildren(app.canvas);
692
+ app.render(renderPayload.state);
693
+ app.render(renderPayload.state);
694
+
695
+ if (renderPayload.timeMS !== null) {
696
+ app.setAnimationTime(renderPayload.timeMS);
697
+ } else if (renderPayload.waitForRenderComplete) {
698
+ await renderCompletePromise;
699
+ }
700
+
701
+ await nextFrame(2);
702
+
703
+ try {
704
+ await app.extractBase64();
705
+ } catch {}
706
+
707
+ await nextFrame(2);
708
+
709
+ return await app.extractBase64();
710
+ } finally {
711
+ if (renderTimeoutId !== null) {
712
+ window.clearTimeout(renderTimeoutId);
713
+ }
714
+ app.destroy();
715
+ }
716
+ },
717
+ {
718
+ moduleUrl: `${origin}/dist/RouteGraphics.js`,
719
+ renderPayload: {
720
+ width,
721
+ height,
722
+ backgroundColor,
723
+ state,
724
+ assets: browserAssets,
725
+ timeMS: timeMS ?? null,
726
+ waitForRenderComplete,
727
+ timeoutMS,
728
+ },
729
+ },
730
+ );
731
+
732
+ if (pageErrors.length > 0) {
733
+ throw new Error(pageErrors.join("\n"));
734
+ }
735
+
736
+ return base64;
737
+ } finally {
738
+ await browser.close();
739
+ }
740
+ };
741
+
742
+ const renderPng = async ({
743
+ cliOptions,
744
+ definition,
745
+ inputPath,
746
+ outputPath,
747
+ origin,
748
+ browserAssets,
749
+ }) => {
750
+ const selectedStateIndex = cliOptions.stateIndex ?? 0;
751
+ const selectedState = definition.states[selectedStateIndex];
752
+
753
+ if (!selectedState) {
754
+ throw new Error(
755
+ `State index ${selectedStateIndex} is out of range for ${definition.states.length} state(s).`,
756
+ );
757
+ }
758
+
759
+ const settings = normalizeRenderSettings({ cliOptions, definition });
760
+ const renderStartedAt = performance.now();
761
+ const base64 = await capturePng({
762
+ origin,
763
+ width: settings.width,
764
+ height: settings.height,
765
+ backgroundColor: settings.backgroundColor,
766
+ state: selectedState,
767
+ browserAssets,
768
+ timeMS: cliOptions.timeMS,
769
+ waitForRenderComplete: cliOptions.waitForRenderComplete,
770
+ timeoutMS: cliOptions.timeoutMS,
771
+ browserExecutablePath: cliOptions.browserExecutablePath,
772
+ });
773
+ const renderDurationMS = performance.now() - renderStartedAt;
774
+
775
+ const writeStartedAt = performance.now();
776
+ await writePngOutput(outputPath, base64);
777
+ const writeDurationMS = performance.now() - writeStartedAt;
778
+
779
+ return {
780
+ inputPath,
781
+ outputPath,
782
+ renderDurationMS,
783
+ writeDurationMS,
784
+ };
785
+ };
786
+
787
+ const runRender = async ({ cliOptions, cwd }) => {
788
+ if (!cliOptions.inputPath) {
789
+ throw new Error("An input YAML file is required.");
790
+ }
791
+
792
+ if (!cliOptions.outputPath) {
793
+ throw new Error("An output path is required.");
794
+ }
795
+
796
+ const format = resolveOutputFormat({
797
+ outputPath: cliOptions.outputPath,
798
+ format: cliOptions.format,
799
+ });
800
+ validateOptionsForFormat(cliOptions, format);
801
+
802
+ const inputPath = path.resolve(cwd, cliOptions.inputPath);
803
+ const outputPath = resolveOutputPath({
804
+ cwd,
805
+ outputPath: cliOptions.outputPath,
806
+ format,
807
+ });
808
+
809
+ await ensureBundleExists();
810
+
811
+ const yamlSource = await fsPromises.readFile(inputPath, "utf8");
812
+ const definition = loadRenderDefinition(yamlSource);
813
+ const settings = normalizeRenderSettings({ cliOptions, definition });
814
+ const yamlDir = path.dirname(inputPath);
815
+ const statesForAssetCollection =
816
+ format === "png"
817
+ ? [definition.states[cliOptions.stateIndex ?? 0]].filter(Boolean)
818
+ : parseStateSelection(
819
+ cliOptions.stateSelection,
820
+ definition.states.length,
821
+ ).map((index) => definition.states[index]);
822
+
823
+ const assetDefinitions = collectAssetDefinitions({
824
+ assets: definition.assets,
825
+ states: statesForAssetCollection,
826
+ baseDir: yamlDir,
827
+ });
828
+ const { assetRoutes, browserAssets } = await normalizeBrowserAssets({
829
+ assetDefinitions,
830
+ });
831
+ const assetServer = await startAssetServer({ assetRoutes });
832
+
833
+ try {
834
+ if (format === "png") {
835
+ return {
836
+ format,
837
+ ...(await renderPng({
838
+ cliOptions,
839
+ definition,
840
+ inputPath,
841
+ outputPath,
842
+ origin: assetServer.origin,
843
+ browserAssets,
844
+ })),
845
+ };
846
+ }
847
+
848
+ return {
849
+ format,
850
+ ...(await renderMp4({
851
+ cliOptions,
852
+ definition,
853
+ inputPath,
854
+ outputPath,
855
+ origin: assetServer.origin,
856
+ browserAssets,
857
+ width: settings.width,
858
+ height: settings.height,
859
+ backgroundColor: settings.backgroundColor,
860
+ })),
861
+ };
862
+ } finally {
863
+ await assetServer.close();
864
+ }
865
+ };
866
+
867
+ export { parseStateSelection };
868
+
869
+ export const runRouteGraphicsCli = async ({
870
+ argv = process.argv.slice(2),
871
+ cwd = process.cwd(),
872
+ stdout = process.stdout,
873
+ stderr = process.stderr,
874
+ } = {}) => {
875
+ const startedAt = performance.now();
876
+ let cliOptions;
877
+
878
+ try {
879
+ cliOptions = parseRouteGraphicsCliArgs(argv);
880
+ } catch (error) {
881
+ stderr.write(`${error.message}\n\n${usage}\n`);
882
+ return 1;
883
+ }
884
+
885
+ if (cliOptions.help) {
886
+ stdout.write(
887
+ `${cliOptions.command === "render" ? renderCommandUsage : usage}\n`,
888
+ );
889
+ return 0;
890
+ }
891
+
892
+ try {
893
+ const result = await runRender({ cliOptions, cwd });
894
+ const totalDurationMS = performance.now() - startedAt;
895
+
896
+ stdout.write(`Wrote ${result.outputPath}\n`);
897
+ if (result.format === "mp4") {
898
+ stdout.write(
899
+ `Video: states=${result.stateCount}, recorder=${result.captureMimeType}\n`,
900
+ );
901
+ }
902
+ stdout.write(
903
+ `Timing: render=${formatDuration(result.renderDurationMS)}, write=${formatDuration(result.writeDurationMS)}, total=${formatDuration(totalDurationMS)}\n`,
904
+ );
905
+ return 0;
906
+ } catch (error) {
907
+ if (/Executable doesn't exist|browserType\.launch/i.test(error.message)) {
908
+ stderr.write(
909
+ `${error.message}\nInstall Chromium with \`npx playwright install chromium\` or pass --browser-executable.\n`,
910
+ );
911
+ return 1;
912
+ }
913
+
914
+ stderr.write(`${error.stack ?? error.message}\n`);
915
+ return 1;
916
+ }
917
+ };
918
+
919
+ export { usage };