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,476 @@
1
+ import fs from "node:fs";
2
+ import fsPromises from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { performance } from "node:perf_hooks";
7
+
8
+ import { chromium } from "playwright";
9
+
10
+ import { getRendererBrowserLaunchOptions } from "./browserLaunch.js";
11
+ import { parseStateSelection } from "./stateSelection.js";
12
+
13
+ const createWriteStreamChunkBinding = async (page, outputPath) => {
14
+ const stream = fs.createWriteStream(outputPath);
15
+
16
+ await page.exposeBinding(
17
+ "__routeGraphicsWriteVideoChunk",
18
+ async (_source, base64) => {
19
+ const buffer = Buffer.from(base64, "base64");
20
+ await new Promise((resolve, reject) => {
21
+ stream.write(buffer, (error) => {
22
+ if (error) {
23
+ reject(error);
24
+ return;
25
+ }
26
+ resolve();
27
+ });
28
+ });
29
+ },
30
+ );
31
+
32
+ return () =>
33
+ new Promise((resolve, reject) => {
34
+ stream.end((error) => {
35
+ if (error) {
36
+ reject(error);
37
+ return;
38
+ }
39
+ resolve();
40
+ });
41
+ });
42
+ };
43
+
44
+ const captureVideoWebm = async ({
45
+ origin,
46
+ width,
47
+ height,
48
+ backgroundColor,
49
+ states,
50
+ stateIndexes,
51
+ browserAssets,
52
+ fps,
53
+ holdMS,
54
+ initialHoldMS,
55
+ finalHoldMS,
56
+ maxStateDurationMS,
57
+ browserExecutablePath,
58
+ webmPath,
59
+ }) => {
60
+ const browser = await chromium.launch(
61
+ getRendererBrowserLaunchOptions(browserExecutablePath),
62
+ );
63
+
64
+ try {
65
+ const page = await browser.newPage({
66
+ viewport: {
67
+ width,
68
+ height,
69
+ },
70
+ });
71
+ const pageErrors = [];
72
+
73
+ page.on("pageerror", (error) => {
74
+ pageErrors.push(error.stack ?? error.message);
75
+ });
76
+
77
+ await page.goto(origin, {
78
+ waitUntil: "domcontentloaded",
79
+ });
80
+
81
+ const closeChunkStream = await createWriteStreamChunkBinding(
82
+ page,
83
+ webmPath,
84
+ );
85
+
86
+ let chunkStreamClosed = false;
87
+ const closeChunksOnce = async () => {
88
+ if (chunkStreamClosed) {
89
+ return;
90
+ }
91
+ chunkStreamClosed = true;
92
+ await closeChunkStream();
93
+ };
94
+
95
+ try {
96
+ const result = await page.evaluate(
97
+ async ({ moduleUrl, renderPayload }) => {
98
+ const sleep = (ms) =>
99
+ new Promise((resolve) => {
100
+ window.setTimeout(resolve, Math.max(0, ms));
101
+ });
102
+
103
+ const nextFrame = async (count = 2) => {
104
+ await new Promise((resolve) => {
105
+ let remaining = count;
106
+ const tick = () => {
107
+ if (remaining <= 0) {
108
+ resolve();
109
+ return;
110
+ }
111
+ remaining -= 1;
112
+ requestAnimationFrame(tick);
113
+ };
114
+ requestAnimationFrame(tick);
115
+ });
116
+ };
117
+
118
+ const bytesToBase64 = (bytes) => {
119
+ let binary = "";
120
+ const chunkSize = 0x8000;
121
+ for (let index = 0; index < bytes.length; index += chunkSize) {
122
+ binary += String.fromCharCode(
123
+ ...bytes.subarray(index, index + chunkSize),
124
+ );
125
+ }
126
+ return btoa(binary);
127
+ };
128
+
129
+ const getRecorderMimeType = () => {
130
+ const candidates = [
131
+ "video/webm;codecs=vp9",
132
+ "video/webm;codecs=vp8",
133
+ "video/webm",
134
+ ];
135
+
136
+ return candidates.find((candidate) =>
137
+ MediaRecorder.isTypeSupported(candidate),
138
+ );
139
+ };
140
+
141
+ if (typeof MediaRecorder === "undefined") {
142
+ throw new Error("MediaRecorder is not available in this browser.");
143
+ }
144
+
145
+ const recorderMimeType = getRecorderMimeType();
146
+ if (!recorderMimeType) {
147
+ throw new Error(
148
+ "No supported WebM MediaRecorder codec is available.",
149
+ );
150
+ }
151
+
152
+ const routeGraphicsModule = await import(moduleUrl);
153
+ const {
154
+ default: createRouteGraphics,
155
+ animatedSpritePlugin,
156
+ containerPlugin,
157
+ createAssetBufferManager,
158
+ inputPlugin,
159
+ particlesPlugin,
160
+ rectPlugin,
161
+ sliderPlugin,
162
+ soundPlugin,
163
+ spritePlugin,
164
+ textPlugin,
165
+ textRevealingPlugin,
166
+ tweenPlugin,
167
+ videoPlugin,
168
+ } = routeGraphicsModule;
169
+
170
+ const app = createRouteGraphics();
171
+ const assetBufferManager = createAssetBufferManager();
172
+ const pendingRenderWaiters = [];
173
+
174
+ const createRenderCompleteWaiter = (stateId) =>
175
+ new Promise((resolve, reject) => {
176
+ let waiter;
177
+ const timeoutId = window.setTimeout(() => {
178
+ const waiterIndex = pendingRenderWaiters.findIndex(
179
+ (candidate) => candidate === waiter,
180
+ );
181
+ if (waiterIndex >= 0) {
182
+ pendingRenderWaiters.splice(waiterIndex, 1);
183
+ }
184
+ reject(
185
+ new Error(
186
+ `Timed out waiting for renderComplete for state "${stateId}".`,
187
+ ),
188
+ );
189
+ }, renderPayload.maxStateDurationMS);
190
+
191
+ waiter = {
192
+ stateId,
193
+ resolve: (payload) => {
194
+ window.clearTimeout(timeoutId);
195
+ resolve(payload);
196
+ },
197
+ reject: (error) => {
198
+ window.clearTimeout(timeoutId);
199
+ reject(error);
200
+ },
201
+ };
202
+ pendingRenderWaiters.push(waiter);
203
+ });
204
+
205
+ try {
206
+ await app.init({
207
+ width: renderPayload.width,
208
+ height: renderPayload.height,
209
+ backgroundColor: renderPayload.backgroundColor,
210
+ animationPlaybackMode: "auto",
211
+ plugins: {
212
+ elements: [
213
+ textPlugin,
214
+ rectPlugin,
215
+ spritePlugin,
216
+ videoPlugin,
217
+ sliderPlugin,
218
+ inputPlugin,
219
+ containerPlugin,
220
+ textRevealingPlugin,
221
+ animatedSpritePlugin,
222
+ particlesPlugin,
223
+ ].filter(Boolean),
224
+ animations: [tweenPlugin].filter(Boolean),
225
+ audio: [soundPlugin].filter(Boolean),
226
+ },
227
+ eventHandler: (eventName, payload) => {
228
+ if (eventName !== "renderComplete") {
229
+ return;
230
+ }
231
+
232
+ const waiterIndex = pendingRenderWaiters.findIndex(
233
+ (waiter) =>
234
+ payload?.aborted !== true &&
235
+ (waiter.stateId === payload?.id ||
236
+ waiter.stateId === undefined),
237
+ );
238
+
239
+ if (waiterIndex < 0) {
240
+ return;
241
+ }
242
+
243
+ const [waiter] = pendingRenderWaiters.splice(waiterIndex, 1);
244
+ waiter.resolve(payload);
245
+ },
246
+ debug: false,
247
+ });
248
+
249
+ if (Object.keys(renderPayload.assets).length > 0) {
250
+ await assetBufferManager.load(renderPayload.assets);
251
+ await app.loadAssets(assetBufferManager.getBufferMap());
252
+ }
253
+
254
+ document.body.replaceChildren(app.canvas);
255
+ await nextFrame(2);
256
+
257
+ const stream = app.canvas.captureStream(renderPayload.fps);
258
+ const recorder = new MediaRecorder(stream, {
259
+ mimeType: recorderMimeType,
260
+ });
261
+ const chunkWrites = [];
262
+ const stopped = new Promise((resolve, reject) => {
263
+ recorder.addEventListener("stop", resolve, { once: true });
264
+ recorder.addEventListener("error", (event) => {
265
+ reject(event.error ?? new Error("MediaRecorder failed."));
266
+ });
267
+ });
268
+
269
+ recorder.addEventListener("dataavailable", (event) => {
270
+ if (!event.data || event.data.size === 0) {
271
+ return;
272
+ }
273
+
274
+ chunkWrites.push(
275
+ (async () => {
276
+ const buffer = await event.data.arrayBuffer();
277
+ const base64 = bytesToBase64(new Uint8Array(buffer));
278
+ await window.__routeGraphicsWriteVideoChunk(base64);
279
+ })(),
280
+ );
281
+ });
282
+
283
+ recorder.start(250);
284
+
285
+ try {
286
+ for (
287
+ let renderIndex = 0;
288
+ renderIndex < renderPayload.stateIndexes.length;
289
+ renderIndex += 1
290
+ ) {
291
+ const stateIndex = renderPayload.stateIndexes[renderIndex];
292
+ const state = renderPayload.states[stateIndex];
293
+ const waitForComplete = createRenderCompleteWaiter(state.id);
294
+
295
+ app.render(state);
296
+ app.render(state);
297
+ await waitForComplete;
298
+
299
+ const isFinal =
300
+ renderIndex === renderPayload.stateIndexes.length - 1;
301
+ const hold = isFinal
302
+ ? renderPayload.finalHoldMS
303
+ : renderIndex === 0
304
+ ? renderPayload.initialHoldMS
305
+ : renderPayload.holdMS;
306
+
307
+ if (hold > 0) {
308
+ await sleep(hold);
309
+ }
310
+ }
311
+
312
+ await nextFrame(2);
313
+ } finally {
314
+ if (recorder.state !== "inactive") {
315
+ recorder.stop();
316
+ }
317
+ await stopped;
318
+ await Promise.all(chunkWrites);
319
+ stream.getTracks().forEach((track) => {
320
+ track.stop();
321
+ });
322
+ }
323
+
324
+ return {
325
+ mimeType: recorderMimeType,
326
+ };
327
+ } finally {
328
+ pendingRenderWaiters.splice(0).forEach((waiter) => {
329
+ waiter.reject(
330
+ new Error("Render session ended before completion."),
331
+ );
332
+ });
333
+ app.destroy();
334
+ }
335
+ },
336
+ {
337
+ moduleUrl: `${origin}/dist/RouteGraphics.js`,
338
+ renderPayload: {
339
+ width,
340
+ height,
341
+ backgroundColor,
342
+ states,
343
+ stateIndexes,
344
+ assets: browserAssets,
345
+ fps,
346
+ holdMS,
347
+ initialHoldMS,
348
+ finalHoldMS,
349
+ maxStateDurationMS,
350
+ },
351
+ },
352
+ );
353
+
354
+ await closeChunksOnce();
355
+
356
+ if (pageErrors.length > 0) {
357
+ throw new Error(pageErrors.join("\n"));
358
+ }
359
+
360
+ return result;
361
+ } catch (error) {
362
+ await closeChunksOnce();
363
+ throw error;
364
+ }
365
+ } finally {
366
+ await browser.close();
367
+ }
368
+ };
369
+
370
+ const runProcess = async (command, args) =>
371
+ new Promise((resolve, reject) => {
372
+ const child = spawn(command, args, {
373
+ stdio: ["ignore", "pipe", "pipe"],
374
+ });
375
+ const stdout = [];
376
+ const stderr = [];
377
+
378
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
379
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
380
+ child.on("error", reject);
381
+ child.on("close", (code) => {
382
+ const stdoutText = Buffer.concat(stdout).toString("utf8");
383
+ const stderrText = Buffer.concat(stderr).toString("utf8");
384
+
385
+ if (code !== 0) {
386
+ reject(
387
+ new Error(
388
+ `${command} exited with code ${code}.\n${stderrText || stdoutText}`,
389
+ ),
390
+ );
391
+ return;
392
+ }
393
+
394
+ resolve({ stdout: stdoutText, stderr: stderrText });
395
+ });
396
+ });
397
+
398
+ const transcodeWebmToMp4 = async ({ ffmpegPath, inputPath, outputPath }) => {
399
+ await fsPromises.mkdir(path.dirname(outputPath), { recursive: true });
400
+ await runProcess(ffmpegPath, [
401
+ "-y",
402
+ "-i",
403
+ inputPath,
404
+ "-an",
405
+ "-c:v",
406
+ "libx264",
407
+ "-pix_fmt",
408
+ "yuv420p",
409
+ "-movflags",
410
+ "+faststart",
411
+ outputPath,
412
+ ]);
413
+ };
414
+
415
+ export const renderMp4 = async ({
416
+ cliOptions,
417
+ definition,
418
+ inputPath,
419
+ outputPath,
420
+ origin,
421
+ browserAssets,
422
+ width,
423
+ height,
424
+ backgroundColor,
425
+ }) => {
426
+ const stateIndexes = parseStateSelection(
427
+ cliOptions.stateSelection,
428
+ definition.states.length,
429
+ );
430
+ const tempDir = await fsPromises.mkdtemp(
431
+ path.join(os.tmpdir(), "route-graphics-video-"),
432
+ );
433
+ const webmPath = path.join(tempDir, "capture.webm");
434
+ const ffmpegPath = cliOptions.ffmpegPath ?? "ffmpeg";
435
+
436
+ try {
437
+ const renderStartedAt = performance.now();
438
+ const captureInfo = await captureVideoWebm({
439
+ origin,
440
+ width,
441
+ height,
442
+ backgroundColor,
443
+ states: definition.states,
444
+ stateIndexes,
445
+ browserAssets,
446
+ fps: cliOptions.fps ?? 30,
447
+ holdMS: cliOptions.holdMS ?? 0,
448
+ initialHoldMS: cliOptions.initialHoldMS ?? cliOptions.holdMS ?? 0,
449
+ finalHoldMS: cliOptions.finalHoldMS ?? 1000,
450
+ maxStateDurationMS:
451
+ cliOptions.maxStateDurationMS ?? cliOptions.timeoutMS ?? 15000,
452
+ browserExecutablePath: cliOptions.browserExecutablePath,
453
+ webmPath,
454
+ });
455
+ const renderDurationMS = performance.now() - renderStartedAt;
456
+
457
+ const writeStartedAt = performance.now();
458
+ await transcodeWebmToMp4({
459
+ ffmpegPath,
460
+ inputPath: webmPath,
461
+ outputPath,
462
+ });
463
+ const writeDurationMS = performance.now() - writeStartedAt;
464
+
465
+ return {
466
+ inputPath,
467
+ outputPath,
468
+ renderDurationMS,
469
+ writeDurationMS,
470
+ captureMimeType: captureInfo.mimeType,
471
+ stateCount: stateIndexes.length,
472
+ };
473
+ } finally {
474
+ await fsPromises.rm(tempDir, { recursive: true, force: true });
475
+ }
476
+ };