route-graphics 1.13.0 → 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.
package/README.md CHANGED
@@ -85,25 +85,27 @@ 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
+ ## Render CLI
89
89
 
90
- This repo now includes a repo-local CLI scaffold that renders YAML into a PNG by:
90
+ This repo includes a CLI that renders RouteGraphics YAML into PNG or MP4 output by:
91
91
 
92
92
  1. reading YAML in Node,
93
93
  2. launching headless Chromium through Playwright,
94
94
  3. rendering with the bundled `dist/RouteGraphics.js`, and
95
- 4. exporting pixels with `app.extractBase64()`.
95
+ 4. exporting either a still frame or an autoplayed state sequence.
96
96
 
97
- It is intentionally scaffolded as a repo workflow first, not a published package entrypoint yet.
97
+ Full CLI reference: [`docs/render-cli.md`](./docs/render-cli.md)
98
98
 
99
- Full CLI reference: [`docs/png-render-cli.md`](./docs/png-render-cli.md)
99
+ The legacy PNG-only reference is still available at
100
+ [`docs/png-render-cli.md`](./docs/png-render-cli.md).
100
101
 
101
102
  ```bash
102
103
  # one-time browser install if Chromium is not already available
103
104
  npx playwright install chromium
104
105
 
105
- # render a YAML file to PNG
106
- bun run render:png -- ./examples/hello.yaml -o ./out/hello.png
106
+ # render a YAML file to PNG or MP4
107
+ route-graphics render ./examples/hello.yaml -o ./out/hello.png
108
+ route-graphics render ./examples/storyboard.yaml -o ./out/storyboard.mp4
107
109
  ```
108
110
 
109
111
  Supported top-level YAML shapes:
@@ -185,11 +187,17 @@ elements:
185
187
  src: ./assets/hero.png # rejected by the CLI
186
188
  ```
187
189
 
188
- Useful flags:
190
+ Useful PNG flags:
189
191
 
190
192
  - `--state <index>` selects a state from an array or multi-document YAML file.
191
193
  - `--time <ms>` samples animations at a specific manual timeline position.
192
194
  - `--wait-for-render-complete` waits for a `renderComplete` event before capture.
195
+
196
+ Useful MP4 flags:
197
+
198
+ - `--states <list>` selects state indexes/ranges such as `0,2-5`.
199
+ - `--fps <number>` controls output frame rate.
200
+ - `--hold`, `--initial-hold`, and `--final-hold` control state dwell time.
193
201
  - `--browser-executable <path>` uses a system Chrome/Chromium instead of Playwright's managed browser.
194
202
 
195
203
  ## Development
@@ -198,8 +206,9 @@ Useful flags:
198
206
  # Run tests
199
207
  bun run test
200
208
 
201
- # Render a YAML file into a PNG
202
- bun run render:png -- ./examples/hello.yaml -o ./out/hello.png
209
+ # Render a YAML file into a PNG or MP4
210
+ route-graphics render ./examples/hello.yaml -o ./out/hello.png
211
+ route-graphics render ./examples/hello.yaml -o ./out/hello.mp4
203
212
 
204
213
  # Ensure VT assets are real binaries, not Git LFS pointer files
205
214
  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;