route-graphics 1.13.0 → 1.15.0

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.
package/README.md CHANGED
@@ -85,25 +85,31 @@ For complete usage details, go to:
85
85
  - [Events & Render Complete](http://route-graphics.routevn.com/docs/guides/events-render-complete/)
86
86
  - [Custom Plugins](http://route-graphics.routevn.com/docs/guides/custom-plugins/)
87
87
 
88
- ## PNG Render CLI Scaffold
88
+ Design notes:
89
89
 
90
- This repo now includes a repo-local CLI scaffold that renders YAML into a PNG by:
90
+ - [Audio Effects](./docs/audio-effects.md)
91
+
92
+ ## Render CLI
93
+
94
+ This repo includes a CLI that renders RouteGraphics YAML into PNG or MP4 output by:
91
95
 
92
96
  1. reading YAML in Node,
93
97
  2. launching headless Chromium through Playwright,
94
98
  3. rendering with the bundled `dist/RouteGraphics.js`, and
95
- 4. exporting pixels with `app.extractBase64()`.
99
+ 4. exporting either a still frame or an autoplayed state sequence.
96
100
 
97
- It is intentionally scaffolded as a repo workflow first, not a published package entrypoint yet.
101
+ Full CLI reference: [`docs/render-cli.md`](./docs/render-cli.md)
98
102
 
99
- Full CLI reference: [`docs/png-render-cli.md`](./docs/png-render-cli.md)
103
+ The legacy PNG-only reference is still available at
104
+ [`docs/png-render-cli.md`](./docs/png-render-cli.md).
100
105
 
101
106
  ```bash
102
107
  # one-time browser install if Chromium is not already available
103
108
  npx playwright install chromium
104
109
 
105
- # render a YAML file to PNG
106
- bun run render:png -- ./examples/hello.yaml -o ./out/hello.png
110
+ # render a YAML file to PNG or MP4
111
+ route-graphics render ./examples/hello.yaml -o ./out/hello.png
112
+ route-graphics render ./examples/storyboard.yaml -o ./out/storyboard.mp4
107
113
  ```
108
114
 
109
115
  Supported top-level YAML shapes:
@@ -185,11 +191,17 @@ elements:
185
191
  src: ./assets/hero.png # rejected by the CLI
186
192
  ```
187
193
 
188
- Useful flags:
194
+ Useful PNG flags:
189
195
 
190
196
  - `--state <index>` selects a state from an array or multi-document YAML file.
191
197
  - `--time <ms>` samples animations at a specific manual timeline position.
192
198
  - `--wait-for-render-complete` waits for a `renderComplete` event before capture.
199
+
200
+ Useful MP4 flags:
201
+
202
+ - `--states <list>` selects state indexes/ranges such as `0,2-5`.
203
+ - `--fps <number>` controls output frame rate.
204
+ - `--hold`, `--initial-hold`, and `--final-hold` control state dwell time.
193
205
  - `--browser-executable <path>` uses a system Chrome/Chromium instead of Playwright's managed browser.
194
206
 
195
207
  ## Development
@@ -198,8 +210,9 @@ Useful flags:
198
210
  # Run tests
199
211
  bun run test
200
212
 
201
- # Render a YAML file into a PNG
202
- bun run render:png -- ./examples/hello.yaml -o ./out/hello.png
213
+ # Render a YAML file into a PNG or MP4
214
+ route-graphics render ./examples/hello.yaml -o ./out/hello.png
215
+ route-graphics render ./examples/hello.yaml -o ./out/hello.mp4
203
216
 
204
217
  # Ensure VT assets are real binaries, not Git LFS pointer files
205
218
  git lfs pull
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import fsPromises from "node:fs/promises";
5
+ import http from "node:http";
6
+ import path from "node:path";
7
+ import { performance } from "node:perf_hooks";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ import { chromium } from "playwright";
11
+
12
+ import {
13
+ collectAssetDefinitions,
14
+ loadRenderDefinition,
15
+ parseBackgroundColor,
16
+ } from "../src/cli/renderConfig.js";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const projectRoot = path.resolve(__dirname, "..");
20
+ const bundlePath = path.join(projectRoot, "dist", "RouteGraphics.js");
21
+
22
+ const usage = `Usage:
23
+ node ./bin/route-graphics-render.js <input.yaml> -o <output.png> [options]
24
+
25
+ Options:
26
+ -o, --output <path> Output PNG path
27
+ --width <pixels> Override render width
28
+ --height <pixels> Override render height
29
+ --state <index> State index when YAML contains multiple states
30
+ --time <ms> Sample animations at a manual time
31
+ --background-color <value> 0xRRGGBB, #RRGGBB, or decimal
32
+ --browser-executable <path> Use a system Chrome/Chromium executable
33
+ --wait-for-render-complete Wait for renderComplete before capture
34
+ --timeout <ms> Browser-side render timeout (default: 15000)
35
+ -h, --help Show this help
36
+ `;
37
+
38
+ const exitWithError = (message, { showUsage = false } = {}) => {
39
+ console.error(message);
40
+ if (showUsage) {
41
+ console.error("");
42
+ console.error(usage);
43
+ }
44
+ process.exit(1);
45
+ };
46
+
47
+ const formatDuration = (durationMS) => {
48
+ if (!Number.isFinite(durationMS)) {
49
+ return "unknown";
50
+ }
51
+
52
+ if (durationMS < 1000) {
53
+ return `${Math.round(durationMS)}ms`;
54
+ }
55
+
56
+ return `${(durationMS / 1000).toFixed(2)}s`;
57
+ };
58
+
59
+ const parseIntegerOption = (label, value) => {
60
+ const parsed = Number.parseInt(String(value), 10);
61
+ if (!Number.isFinite(parsed)) {
62
+ throw new Error(`${label} must be an integer.`);
63
+ }
64
+ return parsed;
65
+ };
66
+
67
+ const parseCliArgs = (argv) => {
68
+ const options = {
69
+ waitForRenderComplete: false,
70
+ timeoutMS: 15000,
71
+ stateIndex: 0,
72
+ };
73
+ const positionals = [];
74
+
75
+ for (let index = 0; index < argv.length; index += 1) {
76
+ const token = argv[index];
77
+
78
+ switch (token) {
79
+ case "-h":
80
+ case "--help":
81
+ options.help = true;
82
+ break;
83
+ case "-o":
84
+ case "--output":
85
+ index += 1;
86
+ options.outputPath = argv[index];
87
+ break;
88
+ case "--width":
89
+ index += 1;
90
+ options.width = parseIntegerOption("Width", argv[index]);
91
+ break;
92
+ case "--height":
93
+ index += 1;
94
+ options.height = parseIntegerOption("Height", argv[index]);
95
+ break;
96
+ case "--state":
97
+ index += 1;
98
+ options.stateIndex = parseIntegerOption("State index", argv[index]);
99
+ break;
100
+ case "--time":
101
+ index += 1;
102
+ options.timeMS = parseIntegerOption("Animation time", argv[index]);
103
+ break;
104
+ case "--background-color":
105
+ index += 1;
106
+ options.backgroundColor = argv[index];
107
+ break;
108
+ case "--browser-executable":
109
+ index += 1;
110
+ options.browserExecutablePath = argv[index];
111
+ break;
112
+ case "--timeout":
113
+ index += 1;
114
+ options.timeoutMS = parseIntegerOption("Timeout", argv[index]);
115
+ break;
116
+ case "--wait-for-render-complete":
117
+ options.waitForRenderComplete = true;
118
+ break;
119
+ default:
120
+ if (token.startsWith("-")) {
121
+ throw new Error(`Unknown option: ${token}`);
122
+ }
123
+ positionals.push(token);
124
+ break;
125
+ }
126
+ }
127
+
128
+ if (positionals.length > 1) {
129
+ throw new Error("Only one input YAML file can be provided.");
130
+ }
131
+
132
+ return {
133
+ ...options,
134
+ inputPath: positionals[0],
135
+ };
136
+ };
137
+
138
+ const toBrowserPath = (assetId) => {
139
+ return `/__asset/${encodeURIComponent(assetId)}`;
140
+ };
141
+
142
+ const getServedAssetPath = (assetId, assetPath) => {
143
+ const extension = path.extname(assetPath) || "";
144
+
145
+ return `${toBrowserPath(assetId)}${extension}`;
146
+ };
147
+
148
+ const createRequestHandler = ({ assetRoutes }) => {
149
+ return async (request, response) => {
150
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
151
+
152
+ if (url.pathname === "/") {
153
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
154
+ response.end("<!doctype html><html><body></body></html>");
155
+ return;
156
+ }
157
+
158
+ if (url.pathname === "/dist/RouteGraphics.js") {
159
+ response.writeHead(200, {
160
+ "content-type": "text/javascript; charset=utf-8",
161
+ });
162
+ fs.createReadStream(bundlePath).pipe(response);
163
+ return;
164
+ }
165
+
166
+ if (!url.pathname.startsWith("/__asset/")) {
167
+ response.writeHead(404);
168
+ response.end("Not Found");
169
+ return;
170
+ }
171
+
172
+ const assetRecord = assetRoutes.get(url.pathname);
173
+
174
+ if (!assetRecord) {
175
+ response.writeHead(404);
176
+ response.end("Unknown asset");
177
+ return;
178
+ }
179
+
180
+ const stat = await fsPromises.stat(assetRecord.path);
181
+ const rangeHeader = request.headers.range;
182
+ const headers = {
183
+ "accept-ranges": "bytes",
184
+ "content-type": assetRecord.type,
185
+ };
186
+
187
+ if (!rangeHeader) {
188
+ headers["content-length"] = stat.size;
189
+ response.writeHead(200, headers);
190
+ if (request.method === "HEAD") {
191
+ response.end();
192
+ return;
193
+ }
194
+ fs.createReadStream(assetRecord.path).pipe(response);
195
+ return;
196
+ }
197
+
198
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
199
+ if (!match) {
200
+ response.writeHead(416);
201
+ response.end();
202
+ return;
203
+ }
204
+
205
+ const start = match[1] ? Number.parseInt(match[1], 10) : 0;
206
+ const end = match[2] ? Number.parseInt(match[2], 10) : stat.size - 1;
207
+
208
+ headers["content-length"] = end - start + 1;
209
+ headers["content-range"] = `bytes ${start}-${end}/${stat.size}`;
210
+ response.writeHead(206, headers);
211
+
212
+ if (request.method === "HEAD") {
213
+ response.end();
214
+ return;
215
+ }
216
+
217
+ fs.createReadStream(assetRecord.path, { start, end }).pipe(response);
218
+ };
219
+ };
220
+
221
+ const startAssetServer = async ({ assetRoutes }) => {
222
+ const server = http.createServer((request, response) => {
223
+ createRequestHandler({ assetRoutes })(request, response).catch((error) => {
224
+ response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
225
+ response.end(error.stack ?? error.message);
226
+ });
227
+ });
228
+
229
+ await new Promise((resolve, reject) => {
230
+ server.once("error", reject);
231
+ server.listen(0, "127.0.0.1", resolve);
232
+ });
233
+
234
+ const address = server.address();
235
+ if (!address || typeof address === "string") {
236
+ throw new Error("Could not determine local render server address.");
237
+ }
238
+
239
+ return {
240
+ origin: `http://127.0.0.1:${address.port}`,
241
+ close: () =>
242
+ new Promise((resolve, reject) => {
243
+ server.close((error) => {
244
+ if (error) {
245
+ reject(error);
246
+ return;
247
+ }
248
+ resolve();
249
+ });
250
+ }),
251
+ };
252
+ };
253
+
254
+ const normalizeBrowserAssets = async ({ assetDefinitions }) => {
255
+ const browserAssets = {};
256
+ const assetRoutes = new Map();
257
+
258
+ for (const [key, definition] of Object.entries(assetDefinitions)) {
259
+ if (definition.kind === "remote") {
260
+ browserAssets[key] = {
261
+ type: definition.type,
262
+ url: definition.url,
263
+ };
264
+ continue;
265
+ }
266
+
267
+ await fsPromises.access(definition.path, fs.constants.R_OK);
268
+ const servedPath = getServedAssetPath(key, definition.path);
269
+
270
+ assetRoutes.set(servedPath, {
271
+ path: definition.path,
272
+ type: definition.type,
273
+ });
274
+ browserAssets[key] = {
275
+ type: definition.type,
276
+ url: servedPath,
277
+ };
278
+ }
279
+
280
+ return {
281
+ assetRoutes,
282
+ browserAssets,
283
+ };
284
+ };
285
+
286
+ const buildRenderPayload = async ({ cliOptions, definition, inputPath }) => {
287
+ const yamlDir = path.dirname(inputPath);
288
+ const states = definition.states;
289
+ const selectedState = states[cliOptions.stateIndex];
290
+
291
+ if (!selectedState) {
292
+ throw new Error(
293
+ `State index ${cliOptions.stateIndex} is out of range for ${states.length} state(s).`,
294
+ );
295
+ }
296
+
297
+ return {
298
+ width: cliOptions.width ?? definition.width ?? 1280,
299
+ height: cliOptions.height ?? definition.height ?? 720,
300
+ backgroundColor: parseBackgroundColor(
301
+ cliOptions.backgroundColor ?? definition.backgroundColor,
302
+ ),
303
+ state: selectedState,
304
+ assetDefinitions: collectAssetDefinitions({
305
+ assets: definition.assets,
306
+ states: [selectedState],
307
+ baseDir: yamlDir,
308
+ }),
309
+ };
310
+ };
311
+
312
+ const capturePng = async ({
313
+ origin,
314
+ width,
315
+ height,
316
+ backgroundColor,
317
+ state,
318
+ browserAssets,
319
+ timeMS,
320
+ waitForRenderComplete,
321
+ timeoutMS,
322
+ browserExecutablePath,
323
+ }) => {
324
+ const browser = await chromium.launch({
325
+ headless: true,
326
+ executablePath: browserExecutablePath,
327
+ args: ["--autoplay-policy=no-user-gesture-required"],
328
+ });
329
+
330
+ try {
331
+ const page = await browser.newPage({
332
+ viewport: {
333
+ width,
334
+ height,
335
+ },
336
+ });
337
+ const pageErrors = [];
338
+
339
+ page.on("pageerror", (error) => {
340
+ pageErrors.push(error.stack ?? error.message);
341
+ });
342
+
343
+ await page.goto(origin, {
344
+ waitUntil: "domcontentloaded",
345
+ });
346
+
347
+ const base64 = await page.evaluate(
348
+ async ({ moduleUrl, renderPayload }) => {
349
+ const nextFrame = async (count = 2) => {
350
+ await new Promise((resolve) => {
351
+ let remaining = count;
352
+ const tick = () => {
353
+ if (remaining <= 0) {
354
+ resolve();
355
+ return;
356
+ }
357
+ remaining -= 1;
358
+ requestAnimationFrame(tick);
359
+ };
360
+ requestAnimationFrame(tick);
361
+ });
362
+ };
363
+
364
+ const routeGraphicsModule = await import(moduleUrl);
365
+ const {
366
+ default: createRouteGraphics,
367
+ animatedSpritePlugin,
368
+ containerPlugin,
369
+ createAssetBufferManager,
370
+ inputPlugin,
371
+ particlesPlugin,
372
+ rectPlugin,
373
+ sliderPlugin,
374
+ soundPlugin,
375
+ spritePlugin,
376
+ textPlugin,
377
+ textRevealingPlugin,
378
+ tweenPlugin,
379
+ videoPlugin,
380
+ } = routeGraphicsModule;
381
+
382
+ const app = createRouteGraphics();
383
+ const assetBufferManager = createAssetBufferManager();
384
+ let renderCompleteResolve = () => {};
385
+ let renderTimeoutId = null;
386
+ let renderCompletePromise = Promise.resolve(null);
387
+
388
+ if (renderPayload.waitForRenderComplete) {
389
+ renderCompletePromise = new Promise((resolve, reject) => {
390
+ renderCompleteResolve = resolve;
391
+ renderTimeoutId = window.setTimeout(() => {
392
+ reject(new Error("Timed out waiting for renderComplete."));
393
+ }, renderPayload.timeoutMS);
394
+ });
395
+ }
396
+
397
+ try {
398
+ await app.init({
399
+ width: renderPayload.width,
400
+ height: renderPayload.height,
401
+ backgroundColor: renderPayload.backgroundColor,
402
+ animationPlaybackMode:
403
+ renderPayload.timeMS === null ? "auto" : "manual",
404
+ plugins: {
405
+ elements: [
406
+ textPlugin,
407
+ rectPlugin,
408
+ spritePlugin,
409
+ videoPlugin,
410
+ sliderPlugin,
411
+ inputPlugin,
412
+ containerPlugin,
413
+ textRevealingPlugin,
414
+ animatedSpritePlugin,
415
+ particlesPlugin,
416
+ ].filter(Boolean),
417
+ animations: [tweenPlugin].filter(Boolean),
418
+ audio: [soundPlugin].filter(Boolean),
419
+ },
420
+ eventHandler: (eventName, payload) => {
421
+ if (eventName === "renderComplete" && payload?.aborted !== true) {
422
+ renderCompleteResolve(payload);
423
+ }
424
+ },
425
+ debug: false,
426
+ });
427
+
428
+ if (Object.keys(renderPayload.assets).length > 0) {
429
+ await assetBufferManager.load(renderPayload.assets);
430
+ await app.loadAssets(assetBufferManager.getBufferMap());
431
+ }
432
+
433
+ document.body.replaceChildren(app.canvas);
434
+ app.render(renderPayload.state);
435
+ app.render(renderPayload.state);
436
+
437
+ if (renderPayload.timeMS !== null) {
438
+ app.setAnimationTime(renderPayload.timeMS);
439
+ } else if (renderPayload.waitForRenderComplete) {
440
+ await renderCompletePromise;
441
+ }
442
+
443
+ await nextFrame(2);
444
+
445
+ try {
446
+ await app.extractBase64();
447
+ } catch {}
448
+
449
+ await nextFrame(2);
450
+
451
+ return await app.extractBase64();
452
+ } finally {
453
+ if (renderTimeoutId !== null) {
454
+ window.clearTimeout(renderTimeoutId);
455
+ }
456
+ app.destroy();
457
+ }
458
+ },
459
+ {
460
+ moduleUrl: `${origin}/dist/RouteGraphics.js`,
461
+ renderPayload: {
462
+ width,
463
+ height,
464
+ backgroundColor,
465
+ state,
466
+ assets: browserAssets,
467
+ timeMS: timeMS ?? null,
468
+ waitForRenderComplete,
469
+ timeoutMS,
470
+ },
471
+ },
472
+ );
473
+
474
+ if (pageErrors.length > 0) {
475
+ throw new Error(pageErrors.join("\n"));
476
+ }
477
+
478
+ return base64;
479
+ } finally {
480
+ await browser.close();
481
+ }
482
+ };
483
+
484
+ const writePngOutput = async (outputPath, base64Png) => {
485
+ const commaIndex = base64Png.indexOf(",");
486
+ const payload = commaIndex >= 0 ? base64Png.slice(commaIndex + 1) : base64Png;
487
+ const buffer = Buffer.from(payload, "base64");
488
+
489
+ await fsPromises.mkdir(path.dirname(outputPath), { recursive: true });
490
+ await fsPromises.writeFile(outputPath, buffer);
491
+ };
492
+
493
+ const ensureBundleExists = async () => {
494
+ try {
495
+ await fsPromises.access(bundlePath, fs.constants.R_OK);
496
+ } catch {
497
+ throw new Error(
498
+ "dist/RouteGraphics.js is missing. Run `bun run build` before using the renderer CLI.",
499
+ );
500
+ }
501
+ };
502
+
503
+ const main = async () => {
504
+ const startedAt = performance.now();
505
+ let cliOptions;
506
+
507
+ try {
508
+ cliOptions = parseCliArgs(process.argv.slice(2));
509
+ } catch (error) {
510
+ exitWithError(error.message, { showUsage: true });
511
+ }
512
+
513
+ if (cliOptions.help) {
514
+ console.log(usage);
515
+ return;
516
+ }
517
+
518
+ if (!cliOptions.inputPath) {
519
+ exitWithError("An input YAML file is required.", { showUsage: true });
520
+ }
521
+
522
+ if (!cliOptions.outputPath) {
523
+ exitWithError("An output PNG path is required.", { showUsage: true });
524
+ }
525
+
526
+ const inputPath = path.resolve(process.cwd(), cliOptions.inputPath);
527
+ const outputPath = path.resolve(process.cwd(), cliOptions.outputPath);
528
+
529
+ await ensureBundleExists();
530
+
531
+ const yamlSource = await fsPromises.readFile(inputPath, "utf8");
532
+ const definition = loadRenderDefinition(yamlSource);
533
+ const renderPayload = await buildRenderPayload({
534
+ cliOptions,
535
+ definition,
536
+ inputPath,
537
+ });
538
+
539
+ try {
540
+ const { assetRoutes, browserAssets } = await normalizeBrowserAssets({
541
+ assetDefinitions: renderPayload.assetDefinitions,
542
+ });
543
+
544
+ const assetServer = await startAssetServer({ assetRoutes });
545
+
546
+ try {
547
+ const renderStartedAt = performance.now();
548
+ const base64 = await capturePng({
549
+ origin: assetServer.origin,
550
+ width: renderPayload.width,
551
+ height: renderPayload.height,
552
+ backgroundColor: renderPayload.backgroundColor,
553
+ state: renderPayload.state,
554
+ browserAssets,
555
+ timeMS: cliOptions.timeMS,
556
+ waitForRenderComplete: cliOptions.waitForRenderComplete,
557
+ timeoutMS: cliOptions.timeoutMS,
558
+ browserExecutablePath: cliOptions.browserExecutablePath,
559
+ });
560
+ const renderDurationMS = performance.now() - renderStartedAt;
561
+
562
+ const writeStartedAt = performance.now();
563
+ await writePngOutput(outputPath, base64);
564
+ const writeDurationMS = performance.now() - writeStartedAt;
565
+ const totalDurationMS = performance.now() - startedAt;
566
+
567
+ console.log(`Wrote ${outputPath}`);
568
+ console.log(
569
+ `Timing: render=${formatDuration(renderDurationMS)}, write=${formatDuration(writeDurationMS)}, total=${formatDuration(totalDurationMS)}`,
570
+ );
571
+ } finally {
572
+ await assetServer.close();
573
+ }
574
+ } catch (error) {
575
+ if (/Executable doesn't exist|browserType\.launch/i.test(error.message)) {
576
+ exitWithError(
577
+ `${error.message}\nInstall Chromium with \`npx playwright install chromium\` or pass --browser-executable.`,
578
+ );
579
+ }
580
+
581
+ exitWithError(error.stack ?? error.message);
582
+ }
583
+ };
584
+
585
+ void main();
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runRouteGraphicsCli } from "../src/cli/routeGraphicsCli.js";
4
+
5
+ const exitCode = await runRouteGraphicsCli();
6
+ process.exitCode = exitCode;