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 +23 -10
- package/bin/route-graphics-render.js +585 -0
- package/bin/route-graphics.js +6 -0
- package/dist/RouteGraphics.js +119 -119
- package/package.json +8 -3
- package/src/cli/browserLaunch.js +5 -0
- package/src/cli/renderConfig.js +513 -0
- package/src/cli/renderVideo.js +476 -0
- package/src/cli/routeGraphicsCli.js +919 -0
- package/src/cli/stateSelection.js +61 -0
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
|
-
|
|
88
|
+
Design notes:
|
|
89
89
|
|
|
90
|
-
|
|
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
|
|
99
|
+
4. exporting either a still frame or an autoplayed state sequence.
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
Full CLI reference: [`docs/render-cli.md`](./docs/render-cli.md)
|
|
98
102
|
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|