@vgai/sdk 0.4.0-canary.20260715.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.
Files changed (51) hide show
  1. package/package.json +27 -0
  2. package/src/cinematic/capabilities-operations.ts +128 -0
  3. package/src/cinematic/cue-operations.ts +198 -0
  4. package/src/cinematic/gsap-operations.ts +126 -0
  5. package/src/cinematic/index.ts +59 -0
  6. package/src/cinematic/preview-operations.ts +279 -0
  7. package/src/cinematic/preview-transport.ts +244 -0
  8. package/src/cinematic/render-operations.ts +409 -0
  9. package/src/cinematic/render-transport.ts +238 -0
  10. package/src/cinematic/theatre-operations.ts +306 -0
  11. package/src/editor/camera-operations.ts +169 -0
  12. package/src/editor/console-operations.ts +87 -0
  13. package/src/editor/hierarchy-operations.ts +95 -0
  14. package/src/editor/index.ts +62 -0
  15. package/src/editor/open-operations.ts +209 -0
  16. package/src/editor/screenshot-operations.ts +99 -0
  17. package/src/editor/selection-operations.ts +144 -0
  18. package/src/editor/session-operations.ts +73 -0
  19. package/src/editor/source-location-operations.ts +106 -0
  20. package/src/editor/transport.ts +647 -0
  21. package/src/errors.ts +72 -0
  22. package/src/http/http-projection.ts +349 -0
  23. package/src/http/index.ts +11 -0
  24. package/src/index.ts +67 -0
  25. package/src/mcp/index.ts +16 -0
  26. package/src/mcp/mcp-projection.ts +288 -0
  27. package/src/operations.ts +83 -0
  28. package/src/play/control-operations.ts +205 -0
  29. package/src/play/debug-command-operations.ts +245 -0
  30. package/src/play/index.ts +66 -0
  31. package/src/play/input-operations.ts +316 -0
  32. package/src/play/lifecycle-operations.ts +271 -0
  33. package/src/play/log-operations.ts +279 -0
  34. package/src/play/run-ticks-operations.ts +141 -0
  35. package/src/play/state-operations.ts +210 -0
  36. package/src/play/status-operations.ts +160 -0
  37. package/src/play/transport.ts +728 -0
  38. package/src/project/asset-operations.ts +243 -0
  39. package/src/project/component-operations.ts +337 -0
  40. package/src/project/discovery-operations.ts +269 -0
  41. package/src/project/entity-operations.ts +366 -0
  42. package/src/project/index.ts +55 -0
  43. package/src/project/input-map-operations.ts +233 -0
  44. package/src/project/manifest-operations.ts +355 -0
  45. package/src/project/scene-operations.ts +426 -0
  46. package/src/project/shared.ts +299 -0
  47. package/src/registry.ts +285 -0
  48. package/src/render/capabilities/ffmpeg.ts +141 -0
  49. package/src/render/index.ts +15 -0
  50. package/src/render/render-cinematic.ts +1847 -0
  51. package/src/types.ts +101 -0
@@ -0,0 +1,1847 @@
1
+ /**
2
+ * `vgai render-cinematic` — the I0 crux deliverable
3
+ * (`docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md` §18 "I0 — recording
4
+ * spike", §15 Workstream I). Produces a REAL, deterministic, playable video
5
+ * file from an authored Theatre-driven cinematic: launches headless
6
+ * Chromium (SwiftShader) against a render-mode page (`?vgai-render=1`,
7
+ * `packages/engine/src/runtime/render-control.ts`'s `window.__vgaiRender`
8
+ * seam), seeks+captures every frame as a composited PNG, encodes with
9
+ * FFmpeg, and verifies the output with ffprobe before ever reporting
10
+ * success (I7 AC: "A failed render never reports success merely because an
11
+ * output file exists").
12
+ *
13
+ * Scope note (I0 is a spike, not the full I1-I8 slice): sequence-controlled
14
+ * capture only (no `--simulate`, I5); video-only output (no audio mux, I6's
15
+ * audio half); MP4/WebM/PNG-sequence in OPAQUE background only (I1's
16
+ * alpha/background policy is VALIDATED — an invalid transparent+MP4
17
+ * combination fails fast, before the browser ever launches — but alpha
18
+ * capture itself is not exercised here, since H.264 cannot carry it and
19
+ * this spike's fixture scene has an opaque background regardless).
20
+ *
21
+ * I1 (request/result schemas): {@link RenderCinematicRequest} /
22
+ * {@link ResolvedRenderCinematicRequest} / {@link RenderCinematicResult}
23
+ * below are this module's version of those shared shapes — defaults are
24
+ * resolved explicitly and returned on the result (`request` field), and
25
+ * {@link resolveRenderCinematicRequest} validates codec/container/alpha
26
+ * combinations before {@link renderCinematic} ever touches Playwright.
27
+ */
28
+
29
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
30
+ import { createHash } from 'node:crypto';
31
+ import { existsSync } from 'node:fs';
32
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
33
+ import { createServer } from 'node:net';
34
+ import { tmpdir } from 'node:os';
35
+ import { dirname, join, resolve as resolvePath } from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+ import type { Browser, Page } from 'playwright';
38
+ import { chromium } from 'playwright';
39
+ import { checkFfmpegCapability, type FfmpegCapabilityError } from './capabilities/ffmpeg';
40
+
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+
43
+ /** Engine monorepo root, resolved from this file's own location (mirrors `packages/vgai-cli/src/index.ts`'s `ENGINE_ROOT`; one extra `..` vs. that file since this module lives one directory deeper, at `packages/vgai-sdk/src/render/`). Injectable for tests. */
44
+ export const DEFAULT_ENGINE_ROOT = resolvePath(__dirname, '..', '..', '..', '..');
45
+
46
+ /** The I0 fixture's own vite config — the render-mode Theatre cinematic page (`packages/engine/e2e/render-cinematic/`). */
47
+ export function defaultFixtureViteConfig(engineRoot: string = DEFAULT_ENGINE_ROOT): string {
48
+ return join(engineRoot, 'packages/engine/e2e/render-cinematic/vite.config.ts');
49
+ }
50
+
51
+ /** The I8 reference cinematic's own vite config — the capstone render-mode
52
+ * page that composes Theatre, a Director, cinematic cues, registered GSAP,
53
+ * XState-driven character animation, camera ownership, and a React HUD/
54
+ * letterbox (`packages/engine/e2e/reference-cinematic/`). Mirrors
55
+ * {@link defaultFixtureViteConfig}'s shape exactly, one directory over. */
56
+ export function defaultReferenceCinematicViteConfig(
57
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
58
+ ): string {
59
+ return join(engineRoot, 'packages/engine/e2e/reference-cinematic/vite.config.ts');
60
+ }
61
+
62
+ /** The I6 audio-bearing cinematic fixture's own vite config — Theatre-driven
63
+ * visuals identical to the I0 fixture, PLUS a declared Tone score (via
64
+ * `installRenderAudioHarness`), which is what makes `renderCinematic`
65
+ * automatically mux audio for it (`packages/engine/e2e/audio-cinematic/`). */
66
+ export function defaultAudioCinematicViteConfig(engineRoot: string = DEFAULT_ENGINE_ROOT): string {
67
+ return join(engineRoot, 'packages/engine/e2e/audio-cinematic/vite.config.ts');
68
+ }
69
+
70
+ /** The I5 `--simulate` fixture's own vite config — a real Rapier physics
71
+ * body falling under gravity, NO Theatre/GSAP/XState (see that fixture's
72
+ * top doc comment), which is what proves the `--simulate` gameplay-advance
73
+ * path rather than the I0/I4/I8 sequence-controlled one
74
+ * (`packages/engine/e2e/simulate-cinematic/`). */
75
+ export function defaultSimulateCinematicViteConfig(
76
+ engineRoot: string = DEFAULT_ENGINE_ROOT,
77
+ ): string {
78
+ return join(engineRoot, 'packages/engine/e2e/simulate-cinematic/vite.config.ts');
79
+ }
80
+
81
+ export type RenderCinematicFormat = 'mp4' | 'webm' | 'png';
82
+ export type RenderCinematicBackground = 'opaque' | 'transparent';
83
+ /**
84
+ * I6 audio mode (`docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md` §15 I6 +
85
+ * the audio-capture half of §13 G4):
86
+ * - `'auto'` (default) — mux audio iff the render page declares a Tone
87
+ * score (`window.__vgaiRenderAudio.hasAudio === true`,
88
+ * `@engine/runtime/render-audio-control`); a page with no score renders
89
+ * video-only, exactly like before this mode existed. NOT a silent
90
+ * best-effort: once a score IS declared, a failure to render/mux it is a
91
+ * hard pipeline failure (see `maybeCaptureAudio`), never a silently
92
+ * dropped track.
93
+ * - `'on'` — require a declared score; fails fast (before any encode) if
94
+ * the page has none.
95
+ * - `'off'` — never mux audio, even if the page declares a score. The
96
+ * explicit video-only escape hatch (I6 AC: "a video-only path must
97
+ * remain").
98
+ */
99
+ export type RenderCinematicAudioMode = 'auto' | 'on' | 'off';
100
+
101
+ /** I1 — the render request. Every field but `entry`/`out` is optional; see {@link resolveRenderCinematicRequest} for defaults. */
102
+ export interface RenderCinematicRequest {
103
+ /** Either an already-serving page URL (`http://…`) OR a filesystem path to a Vite config to spawn (e.g. {@link defaultFixtureViteConfig}'s return value). */
104
+ entry: string;
105
+ /** Output file path (mp4/webm) or output directory (png format). */
106
+ out: string;
107
+ format?: RenderCinematicFormat;
108
+ fps?: number;
109
+ /** Range start, seconds. Default 0. */
110
+ start?: number;
111
+ /** Range end, seconds. Mutually resolved with `frameCount` — supply exactly one. */
112
+ end?: number;
113
+ /** Frame count. Mutually resolved with `end` — supply exactly one. */
114
+ frameCount?: number;
115
+ width?: number;
116
+ height?: number;
117
+ /** Device pixel ratio. Default 1. */
118
+ dpr?: number;
119
+ /** `Math.random` seed (render-seed.ts's `?vgai-seed`). Default a fixed constant, for reproducibility across calls that don't pass one. */
120
+ seed?: number;
121
+ background?: RenderCinematicBackground;
122
+ /** I6 audio mode — see {@link RenderCinematicAudioMode}. Default `'auto'`. */
123
+ audio?: RenderCinematicAudioMode;
124
+ /** Fail instead of overwriting an existing `out`. Default false (overwrite). */
125
+ overwrite?: boolean;
126
+ /** Retain the temporary per-frame PNG directory after encoding. Default false (mp4/webm) — always effectively true for `format: 'png'` (the frames ARE the output). */
127
+ keepFrames?: boolean;
128
+ /**
129
+ * Dev-server port when `entry` is a Vite config path. Default (I7 fold-in
130
+ * #9b): a genuinely free port is discovered right before launch — two
131
+ * concurrent renders that both omit `--port` no longer collide on Vite's
132
+ * `--strictPort` (the prior fixed default, 5799, did exactly that). Pass
133
+ * an explicit port to pin one (e.g. to satisfy a firewall rule); every
134
+ * existing caller that already passes an explicit port is byte-for-byte
135
+ * unaffected.
136
+ */
137
+ port?: number;
138
+ /** Engine monorepo root, for resolving `npx vite`'s cwd when `entry` is a config path. Test-only override. */
139
+ engineRoot?: string;
140
+ /**
141
+ * I5 — `--simulate`: advance the FULL fixed-step gameplay loop (physics,
142
+ * gameplay phases, AI, particles, participating audio) between captured
143
+ * frames via `window.__vgaiRender.simulateSubsteps` (`@engine/runtime/
144
+ * render-control`), instead of the I4 default sequence-controlled path
145
+ * (Theatre/GSAP time-seek only, no gameplay advance at all). Default
146
+ * `false` — every existing caller (I0/I6/I8) is byte-unaffected.
147
+ */
148
+ simulate?: boolean;
149
+ /** I5 — fixed gameplay substeps (`game.runFrame(fixedDt)` calls) advanced
150
+ * BETWEEN each captured output frame when `simulate` is true. Default 4.
151
+ * Ignored (not validated) when `simulate` is false/absent. */
152
+ simulateSubsteps?: number;
153
+ /** I5 AC: "Simulation state can warm up before the capture range" — fixed
154
+ * gameplay substeps advanced ONCE, before frame 0 is captured, when
155
+ * `simulate` is true. Default 0 (no warmup). Ignored when `simulate` is
156
+ * false/absent. */
157
+ simulateWarmupSubsteps?: number;
158
+ /**
159
+ * I7 fold-in (I5 nit fix) — an OPTIONAL override for the render-control
160
+ * harness's fixed gameplay substep `dt` (seconds), threaded through as a
161
+ * `?vgai-simulate-fixed-dt=` query param a fixture MAY read (the committed
162
+ * I5 fixture, `packages/engine/e2e/simulate-cinematic/main.ts`, does; a
163
+ * fixture that doesn't read it keeps its own hardcoded default). This is
164
+ * the CONFIGURATION half of the fix — the manifest's `simulate.fixedTimestep`
165
+ * does NOT simply echo this field back; it separately READS the harness's
166
+ * own `__vgaiRender.simulateFixedDt()` (see {@link readSimulateFixedDt}),
167
+ * which is the honesty half: a fixture that ignores this override, or was
168
+ * never told about it (an unmodified real game's own render-mode page,
169
+ * for instance), still reports whatever dt it is ACTUALLY using, never a
170
+ * value merely echoed from the request. Ignored when `simulate` is
171
+ * false/absent.
172
+ */
173
+ simulateFixedDt?: number;
174
+ }
175
+
176
+ /** I1 — the request with every default resolved, exactly as returned on {@link RenderCinematicResult.request}. */
177
+ export interface ResolvedRenderCinematicRequest {
178
+ readonly entry: string;
179
+ readonly out: string;
180
+ readonly format: RenderCinematicFormat;
181
+ readonly fps: number;
182
+ readonly start: number;
183
+ readonly end: number;
184
+ readonly frameCount: number;
185
+ readonly width: number;
186
+ readonly height: number;
187
+ readonly dpr: number;
188
+ readonly seed: number;
189
+ readonly background: RenderCinematicBackground;
190
+ readonly audio: RenderCinematicAudioMode;
191
+ readonly overwrite: boolean;
192
+ readonly keepFrames: boolean;
193
+ readonly port: number;
194
+ readonly engineRoot: string;
195
+ readonly simulate: boolean;
196
+ readonly simulateSubsteps: number;
197
+ readonly simulateWarmupSubsteps: number;
198
+ /** I7 fold-in — see {@link RenderCinematicRequest.simulateFixedDt}. `undefined` when the caller didn't override it (the fixture keeps its own default). */
199
+ readonly simulateFixedDt: number | undefined;
200
+ }
201
+
202
+ /** Thrown by {@link resolveRenderCinematicRequest} for an invalid request — I1 AC: "fail before launching the browser". */
203
+ export class RenderCinematicRequestError extends Error {}
204
+
205
+ /** Thrown when ffmpeg/ffprobe preflight fails (I1 AC, `capabilities/ffmpeg.ts`). */
206
+ export class RenderCinematicFfmpegError extends Error {
207
+ constructor(readonly errors: readonly FfmpegCapabilityError[]) {
208
+ super(errors.map((e) => e.message).join(' '));
209
+ }
210
+ }
211
+
212
+ /** Thrown when the encoded output fails ffprobe verification — I7 AC: never report success on a mismatch. */
213
+ export class RenderCinematicVerificationError extends Error {}
214
+
215
+ /**
216
+ * Thrown when {@link RenderCinematicOptions.signal} is aborted mid-render
217
+ * (B5, `packages/vgai-sdk`'s `cinematic.render.cancel` — the SDK operation
218
+ * layer wraps this pipeline as a job and needs a real cancellation seam to
219
+ * fulfil "cancellation removes incomplete temporary frames unless retention
220
+ * is requested", §8 B5 AC). ADDITIVE: `signal` is optional and every
221
+ * existing caller (the CLI, the I0/I6/I8 e2e specs) passes none, so this
222
+ * class is never constructed and the check below is never true for them —
223
+ * their behavior is byte-for-byte unchanged.
224
+ *
225
+ * `keepFrames` carries a CANCEL-TIME retention override, read off
226
+ * `signal.reason` (an `AbortController.abort(reason)` payload) rather than
227
+ * the original request's own `keepFrames` — a caller may render with
228
+ * `keepFrames: false` but still want to inspect whatever was captured
229
+ * before the cancellation, or vice versa. `undefined` means "no override
230
+ * given" and the ORIGINAL request's `resolved.keepFrames` decides, exactly
231
+ * as it already does for every other failure path.
232
+ */
233
+ export class RenderCinematicCancelledError extends Error {
234
+ constructor(
235
+ message: string,
236
+ readonly keepFrames: boolean | undefined,
237
+ ) {
238
+ super(message);
239
+ this.name = 'RenderCinematicCancelledError';
240
+ }
241
+ }
242
+
243
+ /** Read an optional cancel-time retention override off `signal.reason` — split out purely so both call sites below share one interpretation of the reason shape. */
244
+ function keepFramesOverride(signal: AbortSignal | undefined): boolean | undefined {
245
+ const reason = signal?.reason as { keepFrames?: boolean } | undefined;
246
+ return reason?.keepFrames;
247
+ }
248
+
249
+ const DEFAULT_SEED = 0x5eed_c0de;
250
+ /** Only actually used when `entry` is an http(s) URL (no dev server spawn — see {@link resolveEffectivePort}) or as this pure function's own documented default before that override applies. */
251
+ const DEFAULT_PORT = 5799;
252
+
253
+ /**
254
+ * I7 fold-in (#9b — port collision): bind an ephemeral TCP port (`:0`, OS-
255
+ * assigned) on loopback, read back the port the kernel picked, then release
256
+ * it immediately — the same "guaranteed free right now" idiom
257
+ * `packages/vgai-cli/src/oclif-projection.test.ts`'s `getClosedPort` and
258
+ * `editor-sessions.ts`'s `findFreePort` already use elsewhere in this repo.
259
+ * There is an inherent (and, in practice, vanishingly small) TOCTOU race
260
+ * between this socket closing and Vite's own `--strictPort` bind a moment
261
+ * later — the same race every "ask the OS for a free port" helper has — but
262
+ * it is categorically better than a FIXED default two concurrent renders are
263
+ * GUARANTEED to collide on.
264
+ */
265
+ function findFreeRenderPort(): Promise<number> {
266
+ return new Promise((resolveP, reject) => {
267
+ const srv = createServer();
268
+ srv.once('error', reject);
269
+ srv.listen(0, '127.0.0.1', () => {
270
+ const address = srv.address();
271
+ const port = typeof address === 'object' && address !== null ? address.port : undefined;
272
+ srv.close((closeErr) => {
273
+ if (closeErr) {
274
+ reject(closeErr);
275
+ } else if (port === undefined) {
276
+ reject(new Error('findFreeRenderPort: could not determine the bound port.'));
277
+ } else {
278
+ resolveP(port);
279
+ }
280
+ });
281
+ });
282
+ });
283
+ }
284
+
285
+ /**
286
+ * I7 fold-in (#9b): when the caller didn't pin an explicit `port`, resolve a
287
+ * genuinely free one right before spawning the dev server, overriding the
288
+ * resolved request's `DEFAULT_PORT` placeholder. Checked against the
289
+ * ORIGINAL (unresolved) `request.port`, not `resolved.port` — the latter
290
+ * already has `DEFAULT_PORT` filled in by {@link resolveRenderCinematicRequest}
291
+ * and so can never tell "the caller explicitly asked for 5799" apart from
292
+ * "the caller asked for nothing at all". An `entry` that's already an
293
+ * http(s) URL never spawns a server (see {@link launchEntryServer}), so its
294
+ * port is moot — left untouched.
295
+ */
296
+ async function resolveEffectivePort(
297
+ request: RenderCinematicRequest,
298
+ resolved: ResolvedRenderCinematicRequest,
299
+ ): Promise<ResolvedRenderCinematicRequest> {
300
+ if (request.port !== undefined) return resolved;
301
+ if (/^https?:\/\//.test(resolved.entry)) return resolved;
302
+ const port = await findFreeRenderPort();
303
+ return { ...resolved, port };
304
+ }
305
+
306
+ function requirePositive(value: number, flag: string): number {
307
+ if (!Number.isFinite(value) || value <= 0) {
308
+ throw new RenderCinematicRequestError(`${flag} must be a positive number, got ${value}`);
309
+ }
310
+ return value;
311
+ }
312
+
313
+ /** Resolve `start`/`end`/`frameCount`/`fps` into a consistent `{ end, frameCount }` pair — split out of {@link resolveRenderCinematicRequest} purely to keep that function's branch count low. */
314
+ function resolveFrameRange(
315
+ req: Pick<RenderCinematicRequest, 'end' | 'frameCount'>,
316
+ start: number,
317
+ fps: number,
318
+ ): { end: number; frameCount: number } {
319
+ if (req.end !== undefined && req.frameCount !== undefined) {
320
+ throw new RenderCinematicRequestError(
321
+ 'Supply exactly one of --end or --frame-count, not both.',
322
+ );
323
+ }
324
+ if (req.frameCount !== undefined) {
325
+ if (!Number.isInteger(req.frameCount) || req.frameCount <= 0) {
326
+ throw new RenderCinematicRequestError(
327
+ `--frame-count must be a positive integer, got ${req.frameCount}`,
328
+ );
329
+ }
330
+ return { frameCount: req.frameCount, end: start + req.frameCount / fps };
331
+ }
332
+ const end = req.end ?? start + 1;
333
+ if (!Number.isFinite(end) || end <= start) {
334
+ throw new RenderCinematicRequestError(`--end (${end}) must be greater than --start (${start})`);
335
+ }
336
+ const frameCount = Math.round((end - start) * fps);
337
+ if (frameCount <= 0) {
338
+ throw new RenderCinematicRequestError(
339
+ `Resolved frame count is ${frameCount} (from --start/--end/--fps) — must be positive.`,
340
+ );
341
+ }
342
+ return { end, frameCount };
343
+ }
344
+
345
+ /** Validate `width`/`height` are positive integers — split out of {@link resolveRenderCinematicRequest} purely to keep that function's branch count low. */
346
+ function requireResolution(width: number, height: number): void {
347
+ if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
348
+ throw new RenderCinematicRequestError(
349
+ `--width/--height must be positive integers, got ${width}x${height}`,
350
+ );
351
+ }
352
+ }
353
+
354
+ /** I1 AC: "Invalid codec/container/alpha combinations fail before launching the browser" — H.264/MP4 has no alpha channel. Split out of {@link resolveRenderCinematicRequest} purely to keep that function's branch count low. */
355
+ function requireValidBackgroundFormat(
356
+ background: RenderCinematicBackground,
357
+ format: RenderCinematicFormat,
358
+ ): void {
359
+ if (background === 'transparent' && format === 'mp4') {
360
+ throw new RenderCinematicRequestError(
361
+ '--background transparent is incompatible with --format mp4 (H.264 has no alpha channel). ' +
362
+ 'Use --format webm or --format png for alpha output.',
363
+ );
364
+ }
365
+ }
366
+
367
+ /** I6 AC: "PNG sequence output requires no video encoding" — a PNG sequence
368
+ * has no container to mux audio into, so an EXPLICIT `--audio on` against
369
+ * `--format png` is a contradictory request and must fail before the
370
+ * browser ever launches, same as {@link requireValidBackgroundFormat}.
371
+ * `'auto'`/`'off'` against `png` are NOT errors — `'auto'` just silently
372
+ * resolves to no audio for png (there is nowhere to put it), which is not
373
+ * the same as a caller explicitly demanding audio and not getting it. */
374
+ function requireValidAudioFormat(
375
+ audio: RenderCinematicAudioMode,
376
+ format: RenderCinematicFormat,
377
+ ): void {
378
+ if (audio === 'on' && format === 'png') {
379
+ throw new RenderCinematicRequestError(
380
+ '--audio on is incompatible with --format png (a PNG sequence has no container to mux audio ' +
381
+ 'into). Use --format mp4 or --format webm for an audio-bearing render.',
382
+ );
383
+ }
384
+ }
385
+
386
+ /** I5 AC: substep counts must be non-negative integers — split out of
387
+ * {@link resolveRenderCinematicRequest} purely to keep that function's
388
+ * branch count down (same convention as this file's other `require*`
389
+ * helpers). Zero is valid for `simulateWarmupSubsteps` (no warmup) but NOT
390
+ * for `simulateSubsteps` when `simulate` is true — a `--simulate` render
391
+ * that never advances gameplay would silently degrade to the I4 default
392
+ * with no error, which is exactly the "warning instead of a failure"
393
+ * anti-pattern this codebase avoids (I7's own "never report success
394
+ * merely because..." convention, applied here at the request-validation
395
+ * boundary instead). */
396
+ function requireSimulateSubsteps(value: number, flag: string, allowZero: boolean): number {
397
+ if (!Number.isInteger(value) || value < 0 || (!allowZero && value === 0)) {
398
+ throw new RenderCinematicRequestError(
399
+ `${flag} must be a ${allowZero ? 'non-negative' : 'positive'} integer, got ${value}`,
400
+ );
401
+ }
402
+ return value;
403
+ }
404
+
405
+ /** Resolve `simulate`/`simulateSubsteps`/`simulateWarmupSubsteps`/`simulateFixedDt` —
406
+ * split out of {@link resolveRenderCinematicRequest} purely to keep that
407
+ * function's own branch count down (same convention as
408
+ * {@link resolveFrameRange}). */
409
+ function resolveSimulateFields(req: RenderCinematicRequest): {
410
+ simulate: boolean;
411
+ simulateSubsteps: number;
412
+ simulateWarmupSubsteps: number;
413
+ simulateFixedDt: number | undefined;
414
+ } {
415
+ const simulate = req.simulate ?? false;
416
+ const simulateSubsteps = requireSimulateSubsteps(
417
+ req.simulateSubsteps ?? 4,
418
+ '--simulate-substeps',
419
+ /* allowZero */ !simulate,
420
+ );
421
+ const simulateWarmupSubsteps = requireSimulateSubsteps(
422
+ req.simulateWarmupSubsteps ?? 0,
423
+ '--simulate-warmup-substeps',
424
+ /* allowZero */ true,
425
+ );
426
+ const simulateFixedDt =
427
+ req.simulateFixedDt !== undefined
428
+ ? requirePositive(req.simulateFixedDt, '--simulate-fixed-dt')
429
+ : undefined;
430
+ return { simulate, simulateSubsteps, simulateWarmupSubsteps, simulateFixedDt };
431
+ }
432
+
433
+ export function resolveRenderCinematicRequest(
434
+ req: RenderCinematicRequest,
435
+ ): ResolvedRenderCinematicRequest {
436
+ const format = req.format ?? 'mp4';
437
+ const fps = requirePositive(req.fps ?? 30, '--fps');
438
+ const start = req.start ?? 0;
439
+ if (!Number.isFinite(start) || start < 0) {
440
+ throw new RenderCinematicRequestError(`--start must be a non-negative number, got ${start}`);
441
+ }
442
+ const { end, frameCount } = resolveFrameRange(req, start, fps);
443
+
444
+ const width = req.width ?? 640;
445
+ const height = req.height ?? 360;
446
+ requireResolution(width, height);
447
+ const dpr = requirePositive(req.dpr ?? 1, '--dpr');
448
+
449
+ const background = req.background ?? 'opaque';
450
+ requireValidBackgroundFormat(background, format);
451
+
452
+ const audio = req.audio ?? 'auto';
453
+ requireValidAudioFormat(audio, format);
454
+
455
+ const { simulate, simulateSubsteps, simulateWarmupSubsteps, simulateFixedDt } =
456
+ resolveSimulateFields(req);
457
+
458
+ return {
459
+ entry: req.entry,
460
+ out: req.out,
461
+ format,
462
+ fps,
463
+ start,
464
+ end,
465
+ frameCount,
466
+ width,
467
+ height,
468
+ dpr,
469
+ seed: req.seed ?? DEFAULT_SEED,
470
+ background,
471
+ audio,
472
+ overwrite: req.overwrite ?? false,
473
+ keepFrames: req.keepFrames ?? false,
474
+ // I7 fold-in #9b: this DEFAULT_PORT fallback is only actually used when
475
+ // `resolveEffectivePort` (below) doesn't override it — i.e. `entry` is
476
+ // an already-serving http(s) URL (no dev server is ever spawned, so no
477
+ // port collision is possible). Every Vite-config `entry` gets a REAL
478
+ // free port at launch time instead — see `resolveEffectivePort`.
479
+ port: req.port ?? DEFAULT_PORT,
480
+ engineRoot: req.engineRoot ?? DEFAULT_ENGINE_ROOT,
481
+ simulate,
482
+ simulateSubsteps,
483
+ simulateWarmupSubsteps,
484
+ simulateFixedDt,
485
+ };
486
+ }
487
+
488
+ /** I7 — progress events the pipeline reports (launch/readiness/frame/encode/completion). */
489
+ export type RenderCinematicProgress =
490
+ | { readonly phase: 'preflight' }
491
+ | { readonly phase: 'server-launch'; readonly url: string }
492
+ | { readonly phase: 'server-ready'; readonly url: string }
493
+ | { readonly phase: 'browser-launch' }
494
+ | { readonly phase: 'page-ready'; readonly readiness: unknown }
495
+ /** I5 — emitted once, before frame 0, only for a `simulate: true` request
496
+ * with `simulateWarmupSubsteps > 0`. */
497
+ | { readonly phase: 'warmup'; readonly steps: number }
498
+ | { readonly phase: 'frame'; readonly n: number; readonly of: number }
499
+ | { readonly phase: 'audio-detect' }
500
+ | { readonly phase: 'audio-render' }
501
+ | { readonly phase: 'audio-render-done'; readonly durationSeconds: number }
502
+ | { readonly phase: 'encode-start'; readonly format: RenderCinematicFormat }
503
+ | { readonly phase: 'encode-done'; readonly outputPath: string }
504
+ | { readonly phase: 'audio-mux-start' }
505
+ | { readonly phase: 'audio-mux-done'; readonly outputPath: string }
506
+ | { readonly phase: 'verify'; readonly outputPath: string }
507
+ | { readonly phase: 'complete'; readonly outputPath: string };
508
+
509
+ export interface RenderCinematicOptions {
510
+ onProgress?(event: RenderCinematicProgress): void;
511
+ /**
512
+ * Optional cooperative-cancellation signal (ADDITIVE — B5). Checked once
513
+ * per captured frame and once before the audio render; when aborted,
514
+ * {@link renderCinematic} throws {@link RenderCinematicCancelledError}
515
+ * instead of continuing, and — unless `signal.reason`'s `keepFrames` (or,
516
+ * absent that, the request's own `keepFrames`) says otherwise — removes
517
+ * the incomplete temporary frames directory before rethrowing. `undefined`
518
+ * (every pre-existing caller) preserves prior behavior exactly: the
519
+ * `signal?.aborted` checks below are simply never reached.
520
+ */
521
+ signal?: AbortSignal;
522
+ }
523
+
524
+ /** One FFmpeg encode invocation, recorded verbatim in the manifest (I6 AC). */
525
+ export interface RenderManifestEncode {
526
+ readonly format: RenderCinematicFormat;
527
+ readonly outputPath: string;
528
+ readonly command: readonly string[];
529
+ readonly codec: { readonly video: string; readonly container: string };
530
+ }
531
+
532
+ /** I6 AC: "Codec settings and FFmpeg command are recorded in a render
533
+ * manifest" — the audio half. Present iff a Tone score was declared,
534
+ * rendered, muxed, AND ffprobe-verified present on the final output; a
535
+ * render with no declared score (or an explicit `--audio off`) has no
536
+ * `audio` field at all, rather than a `present: false` placeholder — the
537
+ * same "absent, not falsely-populated" convention `RenderCinematicResult`
538
+ * already uses for `encode`/`ffprobe` on a `png` render.
539
+ *
540
+ * G4 AC3 ("audio start offset, duration, sample rate, channel count, and
541
+ * tail policy are explicit"): `sampleRate`/`channels`/`durationSeconds`
542
+ * live here; start offset is `RenderCinematicResult.request.start` (the
543
+ * same requested-range start passed to `renderAudio(start, end)` — see
544
+ * `maybeCaptureAudio` in this file); tail policy is "exact, hard-fail on
545
+ * mismatch" (no pad/truncate step exists or is needed — see the duration
546
+ * check in `maybeCaptureAudio`). */
547
+ export interface RenderManifestAudio {
548
+ readonly codec: string;
549
+ readonly container: RenderCinematicFormat;
550
+ readonly sampleRate: number;
551
+ readonly channels: number;
552
+ readonly durationSeconds: number;
553
+ /** sha256 over the raw WAV file bytes rendered in-page, BEFORE muxing —
554
+ * the audio-side equivalent of {@link RenderCinematicResult.framesDigest}:
555
+ * identical across two renders of the same request iff the offline Tone
556
+ * render (G12) was genuinely bit-identical both times. */
557
+ readonly wavDigest: string;
558
+ readonly muxCommand: readonly string[];
559
+ }
560
+
561
+ /** I5 — the `--simulate` manifest fields (present iff `request.simulate` is
562
+ * true). Echoes what already lives on `request` (`seed`/`simulateSubsteps`/
563
+ * `simulateWarmupSubsteps`) as one grouped, self-describing object, plus
564
+ * the fixed substep `dt` actually used — so a manifest reader doesn't have
565
+ * to reconstruct "was this a --simulate render, and with what settings"
566
+ * from the flat request fields. */
567
+ export interface RenderManifestSimulate {
568
+ readonly seed: number;
569
+ readonly substeps: number;
570
+ readonly warmupSubsteps: number;
571
+ readonly fixedTimestep: number;
572
+ }
573
+
574
+ /** I7 — the final result. Never returned on a failed/unverified render (the promise rejects instead). */
575
+ export interface RenderCinematicResult {
576
+ readonly request: ResolvedRenderCinematicRequest;
577
+ readonly outputPath: string;
578
+ readonly manifestPath: string;
579
+ readonly frameCount: number;
580
+ readonly durationSeconds: number;
581
+ readonly frameHashes: readonly string[];
582
+ /** sha256 over the newline-joined, in-order per-frame sha256 hashes — a single value that changes iff ANY frame's bytes change. */
583
+ readonly framesDigest: string;
584
+ readonly encode?: RenderManifestEncode;
585
+ readonly ffprobe?: { readonly nbFrames: number; readonly durationSeconds: number };
586
+ /** I6 audio manifest fields — see {@link RenderManifestAudio}. */
587
+ readonly audio?: RenderManifestAudio;
588
+ /** I5 — present iff `request.simulate` is true. See {@link RenderManifestSimulate}. */
589
+ readonly simulate?: RenderManifestSimulate;
590
+ /** I5 AC 5 — every subsystem the render page declares (or this seam
591
+ * itself detects) as excluded from deterministic participation. Always
592
+ * present (an empty array is a real, checked answer — "nothing
593
+ * excluded" — not an absent field), unlike `encode`/`audio`/`simulate`
594
+ * which are genuinely optional per-request. See
595
+ * `render-control.ts`'s `VgaiRenderHarness.excludedFromDeterminism`. */
596
+ readonly excludedFromDeterminism: readonly string[];
597
+ readonly warnings: readonly string[];
598
+ }
599
+
600
+ function sha256(buf: Buffer): string {
601
+ return createHash('sha256').update(buf).digest('hex');
602
+ }
603
+
604
+ function padFrameName(n: number, digits: number): string {
605
+ return `frame-${String(n).padStart(digits, '0')}.png`;
606
+ }
607
+
608
+ async function waitForHttpOk(url: string, timeoutMs: number, signal?: AbortSignal): Promise<void> {
609
+ const deadline = Date.now() + timeoutMs;
610
+ let lastErr: unknown;
611
+ while (Date.now() < deadline) {
612
+ // I7 fold-in (#9a): don't keep polling a dev server that's booting for a
613
+ // render that was already cancelled — return promptly so the caller's
614
+ // own catch path (`launchEntryServer`) can group-kill the half-started
615
+ // vite process right away instead of waiting out the full 30s timeout.
616
+ if (signal?.aborted) {
617
+ throw new Error('Render cancelled while waiting for the dev server to become ready.');
618
+ }
619
+ try {
620
+ const res = await fetch(url);
621
+ if (res.ok || res.status === 404) return; // dev server answering is enough; 404 still proves it's up
622
+ } catch (err) {
623
+ lastErr = err;
624
+ }
625
+ await new Promise((r) => setTimeout(r, 200));
626
+ }
627
+ throw new Error(
628
+ `Dev server at ${url} did not become ready within ${timeoutMs}ms` +
629
+ (lastErr instanceof Error ? ` (last error: ${lastErr.message})` : ''),
630
+ );
631
+ }
632
+
633
+ interface LaunchedServer {
634
+ readonly baseUrl: string;
635
+ stop(): Promise<void>;
636
+ }
637
+
638
+ /** Resolve `entry` into a running dev server: reuse a URL as-is, or spawn `npx vite --config <entry>` (mirrors `run-doctor.ts`/`runConformance`'s `npx tsx <runner>` spawn idiom, `packages/vgai-cli/src/index.ts`). */
639
+ async function launchEntryServer(
640
+ entry: string,
641
+ port: number,
642
+ engineRoot: string,
643
+ onProgress: ((e: RenderCinematicProgress) => void) | undefined,
644
+ signal?: AbortSignal,
645
+ ): Promise<LaunchedServer> {
646
+ if (/^https?:\/\//.test(entry)) {
647
+ return { baseUrl: entry.replace(/\/$/, ''), stop: async () => {} };
648
+ }
649
+
650
+ const configPath = resolvePath(entry);
651
+ if (!existsSync(configPath)) {
652
+ throw new RenderCinematicRequestError(
653
+ `--entry "${entry}" is neither an http(s) URL nor an existing Vite config file.`,
654
+ );
655
+ }
656
+ const baseUrl = `http://127.0.0.1:${port}`;
657
+ onProgress?.({ phase: 'server-launch', url: baseUrl });
658
+
659
+ // `detached: true` makes this child the leader of its OWN process group —
660
+ // required for `stopServerProcess`'s `process.kill(-pid, sig)` group-kill
661
+ // below to reach the REAL `vite` node process underneath the `npx`
662
+ // wrapper, not just the wrapper itself. Without this, a plain
663
+ // `child.kill('SIGTERM')` only signals `npx`, which does NOT propagate to
664
+ // the node process it execs (the exact hazard documented at
665
+ // `packages/editor/e2e/helpers/server.ts`'s `stopProcess`) — the real vite
666
+ // server leaks, its stdout/stderr pipes stay open, and THIS process (still
667
+ // reading those pipes) never exits even after the render is fully done.
668
+ const child: ChildProcess = spawn(
669
+ 'npx',
670
+ ['vite', '--config', configPath, '--port', String(port), '--strictPort'],
671
+ { cwd: engineRoot, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
672
+ );
673
+ let serverOutput = '';
674
+ child.stdout?.on('data', (d) => {
675
+ serverOutput += String(d);
676
+ });
677
+ child.stderr?.on('data', (d) => {
678
+ serverOutput += String(d);
679
+ });
680
+
681
+ let exited = false;
682
+ child.on('exit', () => {
683
+ exited = true;
684
+ });
685
+
686
+ try {
687
+ await waitForHttpOk(`${baseUrl}/`, 30_000, signal);
688
+ } catch (err) {
689
+ await stopServerProcess(child);
690
+ throw new Error(`${(err as Error).message}\nVite dev server output:\n${serverOutput}`);
691
+ }
692
+ if (exited) {
693
+ throw new Error(`Vite dev server exited before becoming ready.\nOutput:\n${serverOutput}`);
694
+ }
695
+
696
+ onProgress?.({ phase: 'server-ready', url: baseUrl });
697
+
698
+ return {
699
+ baseUrl,
700
+ async stop() {
701
+ if (exited) return;
702
+ await stopServerProcess(child);
703
+ },
704
+ };
705
+ }
706
+
707
+ /**
708
+ * Terminate `proc` AND its whole process group (see the `detached: true`
709
+ * comment at its call site above) — a plain `proc.kill()` leaves the real
710
+ * `vite` node process (an `npx` grandchild) running, which both leaks a
711
+ * port-bound dev server across renders and keeps THIS process's event loop
712
+ * alive via the still-open stdout/stderr pipes, hanging `renderCinematic`'s
713
+ * caller indefinitely after the render itself has already finished. Mirrors
714
+ * `packages/editor/e2e/helpers/server.ts`'s `stopProcess` (POSIX process-
715
+ * group signal via the negative pid, falling back to signalling just the
716
+ * child if the group is already gone), trimmed to the Linux/macOS path this
717
+ * pipeline actually runs on.
718
+ */
719
+ function stopServerProcess(proc: ChildProcess): Promise<void> {
720
+ const signalGroup = (sig: NodeJS.Signals): void => {
721
+ if (proc.pid === undefined) return;
722
+ try {
723
+ process.kill(-proc.pid, sig); // negative pid = the whole process group
724
+ } catch {
725
+ try {
726
+ proc.kill(sig); // group already gone — fall back to the child itself
727
+ } catch {
728
+ /* already dead */
729
+ }
730
+ }
731
+ };
732
+ return new Promise((resolvePromise) => {
733
+ if (proc.exitCode !== null || proc.signalCode !== null) {
734
+ signalGroup('SIGKILL');
735
+ resolvePromise();
736
+ return;
737
+ }
738
+ const timer = setTimeout(() => {
739
+ signalGroup('SIGKILL'); // belt and braces — don't hang the render on a stuck dev server
740
+ resolvePromise();
741
+ }, 5_000);
742
+ proc.on('exit', () => {
743
+ clearTimeout(timer);
744
+ signalGroup('SIGKILL'); // the wrapper is dead; sweep the rest of the group too
745
+ resolvePromise();
746
+ });
747
+ signalGroup('SIGTERM');
748
+ });
749
+ }
750
+
751
+ interface ReadinessSubsystem {
752
+ readonly status: string;
753
+ readonly detail?: string;
754
+ }
755
+ interface ReadinessReport {
756
+ readonly assets: ReadinessSubsystem;
757
+ readonly shaders: ReadinessSubsystem;
758
+ readonly fonts: ReadinessSubsystem;
759
+ readonly theatre: ReadinessSubsystem;
760
+ readonly tone: ReadinessSubsystem;
761
+ readonly scene: ReadinessSubsystem;
762
+ }
763
+
764
+ /** Capture every frame `0..frameCount-1` as a composited PNG under `framesDir`, returning per-frame sha256 hashes in order. */
765
+ async function captureFrames(
766
+ page: Page,
767
+ framesDir: string,
768
+ resolved: ResolvedRenderCinematicRequest,
769
+ onProgress: ((e: RenderCinematicProgress) => void) | undefined,
770
+ signal?: AbortSignal,
771
+ ): Promise<string[]> {
772
+ // I5 AC: "Simulation state can warm up before the capture range" — advance
773
+ // the fixed-step gameplay loop ONCE, before frame 0 is ever seeked/
774
+ // captured. A no-warmup request (`simulateWarmupSubsteps === 0`, the
775
+ // default) skips this entirely — no behavior change for any pre-I5 caller.
776
+ if (resolved.simulate && resolved.simulateWarmupSubsteps > 0) {
777
+ onProgress?.({ phase: 'warmup', steps: resolved.simulateWarmupSubsteps });
778
+ await page.evaluate((steps) => {
779
+ (
780
+ globalThis as unknown as { __vgaiRender: { simulateSubsteps(steps: number): void } }
781
+ ).__vgaiRender.simulateSubsteps(steps);
782
+ }, resolved.simulateWarmupSubsteps);
783
+ }
784
+
785
+ const digits = Math.max(6, String(resolved.frameCount).length);
786
+ const hashes: string[] = [];
787
+ for (let n = 0; n < resolved.frameCount; n++) {
788
+ if (signal?.aborted) {
789
+ throw new RenderCinematicCancelledError(
790
+ `Render cancelled after ${n}/${resolved.frameCount} frames captured.`,
791
+ keepFramesOverride(signal),
792
+ );
793
+ }
794
+ await page.evaluate(
795
+ async ({ n, fps, simulate, simulateSubsteps }) => {
796
+ const harness = (
797
+ globalThis as unknown as {
798
+ __vgaiRender: {
799
+ advanceFrame(dt: number): void;
800
+ seekFrame(n: number, fps: number): void;
801
+ renderOnce(): Promise<void>;
802
+ simulateSubsteps(steps: number): void;
803
+ };
804
+ }
805
+ ).__vgaiRender;
806
+ if (simulate) {
807
+ // I5 — the `--simulate` seam: advance the FULL fixed-step
808
+ // gameplay loop (physics/gameLogic/animation/... — every phase)
809
+ // between this and the previous captured frame. Deliberately NOT
810
+ // `advanceFrame` here — that ticks only I8's FrameAdvancer
811
+ // registry (bypassing gameplay phases entirely), which would
812
+ // double-drive any system that also rides a real gameplay phase
813
+ // via `simulateSubsteps`'s own `game.runFrame` calls.
814
+ harness.simulateSubsteps(simulateSubsteps);
815
+ } else {
816
+ // I4 default (sequence-controlled, unchanged since I8): advance
817
+ // any registered frame-advancers (e.g. an XState/AnimationMixer
818
+ // binding's `tick`, which the 'animation'-phase gameplay loop
819
+ // would otherwise drive but renderOnce() deliberately bypasses —
820
+ // see render-control.ts's `VgaiRenderHarness.advanceFrame` doc
821
+ // comment) by exactly one output frame's worth of time, BEFORE
822
+ // seeking/rendering. A fixture that registered no advancers (e.g.
823
+ // the I0 fixture) sees a harmless no-op here.
824
+ harness.advanceFrame(1 / fps);
825
+ }
826
+ harness.seekFrame(n, fps);
827
+ await harness.renderOnce();
828
+ },
829
+ {
830
+ n,
831
+ fps: resolved.fps,
832
+ simulate: resolved.simulate,
833
+ simulateSubsteps: resolved.simulateSubsteps,
834
+ },
835
+ );
836
+ // Composite capture (I3 AC): the #app HOST container, not the bare
837
+ // canvas — this is what makes a future DOM/React overlay composite
838
+ // correctly. Exact WxH*DPR resolution comes from the browser context's
839
+ // own viewport + deviceScaleFactor (set at context-creation time).
840
+ const buffer = await page.locator('#app').screenshot({
841
+ omitBackground: resolved.background === 'transparent',
842
+ });
843
+ const name = padFrameName(n, digits);
844
+ await writeFile(join(framesDir, name), buffer);
845
+ hashes.push(sha256(buffer));
846
+ onProgress?.({ phase: 'frame', n, of: resolved.frameCount });
847
+ }
848
+ return hashes;
849
+ }
850
+
851
+ /** Read `window.__vgaiRender.excludedFromDeterminism()` (I5 AC 5) — split
852
+ * out purely to keep {@link renderCinematic}'s own body readable. Present
853
+ * on every render-mode page (not just `--simulate` ones): the harness
854
+ * always exposes this method (`render-control.ts`), so a caller can always
855
+ * see what the fixture declares, whether or not gameplay is advancing. */
856
+ async function readExcludedFromDeterminism(page: Page): Promise<string[]> {
857
+ return page.evaluate(() =>
858
+ (
859
+ globalThis as unknown as { __vgaiRender: { excludedFromDeterminism(): string[] } }
860
+ ).__vgaiRender.excludedFromDeterminism(),
861
+ );
862
+ }
863
+
864
+ /**
865
+ * I7 fold-in (I5 nit fix) — read `window.__vgaiRender.simulateFixedDt()`
866
+ * (`@engine/runtime/render-control`'s `VgaiRenderHarness.simulateFixedDt`),
867
+ * the ACTUAL fixed gameplay substep `dt` the harness is using, instead of
868
+ * this pipeline assuming every fixture matches its own `1/60` default. Only
869
+ * called for a `resolved.simulate: true` request (see the call site in
870
+ * {@link renderCinematic}). Returns `undefined` — rather than throwing — for
871
+ * a render-mode page that predates this harness method (back-compat: an
872
+ * older/unmodified real game's own render-mode page might not have it yet);
873
+ * {@link buildSimulateManifest} falls back to the documented `1/60` engine
874
+ * default in that case, with an honest comment, never a silent guess dressed
875
+ * up as a measured value.
876
+ */
877
+ async function readSimulateFixedDt(page: Page): Promise<number | undefined> {
878
+ return page.evaluate(() => {
879
+ const harness = (globalThis as unknown as { __vgaiRender: { simulateFixedDt?: () => number } })
880
+ .__vgaiRender;
881
+ return typeof harness.simulateFixedDt === 'function' ? harness.simulateFixedDt() : undefined;
882
+ });
883
+ }
884
+
885
+ /**
886
+ * I7 fold-in (#9a — cancellation): run ffmpeg via the ASYNC `spawn`, not
887
+ * `spawnSync`. `spawnSync` blocks Node's entire event loop for the whole
888
+ * invocation, so a cooperative `signal.aborted` check can never even run
889
+ * while an encode is in flight — cancelling a render mid-encode used to be
890
+ * unable to kill ffmpeg at all until the encode finished on its own. This
891
+ * version listens for `signal`'s abort event and SIGKILLs the ffmpeg child
892
+ * immediately, same "terminate right away" guarantee `renderCinematic`'s own
893
+ * `onAbort` gives the browser/vite-group.
894
+ */
895
+ function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {
896
+ return new Promise((resolveP, reject) => {
897
+ if (signal?.aborted) {
898
+ reject(
899
+ new RenderCinematicCancelledError(
900
+ 'Render cancelled before ffmpeg started.',
901
+ keepFramesOverride(signal),
902
+ ),
903
+ );
904
+ return;
905
+ }
906
+ const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
907
+ let stderr = '';
908
+ child.stderr?.on('data', (d) => {
909
+ stderr += String(d);
910
+ });
911
+ let killedByAbort = false;
912
+ const onAbort = (): void => {
913
+ killedByAbort = true;
914
+ child.kill('SIGKILL');
915
+ };
916
+ signal?.addEventListener('abort', onAbort, { once: true });
917
+ child.on('error', (err) => {
918
+ signal?.removeEventListener('abort', onAbort);
919
+ reject(err);
920
+ });
921
+ child.on('exit', (code) => {
922
+ signal?.removeEventListener('abort', onAbort);
923
+ if (killedByAbort) {
924
+ reject(
925
+ new RenderCinematicCancelledError(
926
+ 'ffmpeg terminated by render cancellation.',
927
+ keepFramesOverride(signal),
928
+ ),
929
+ );
930
+ return;
931
+ }
932
+ if (code !== 0) {
933
+ reject(
934
+ new Error(`ffmpeg exited ${code ?? '(signal)'}: ffmpeg ${args.join(' ')}\n${stderr}`),
935
+ );
936
+ return;
937
+ }
938
+ resolveP();
939
+ });
940
+ });
941
+ }
942
+
943
+ function runFfprobeFrameCountAndDuration(path: string): {
944
+ nbFrames: number;
945
+ durationSeconds: number;
946
+ } {
947
+ const result = spawnSync(
948
+ 'ffprobe',
949
+ [
950
+ '-v',
951
+ 'error',
952
+ '-select_streams',
953
+ 'v:0',
954
+ '-count_frames',
955
+ '-show_entries',
956
+ 'stream=nb_read_frames',
957
+ '-show_entries',
958
+ 'format=duration',
959
+ '-of',
960
+ 'json',
961
+ path,
962
+ ],
963
+ { encoding: 'utf-8' },
964
+ );
965
+ if (result.status !== 0) {
966
+ throw new RenderCinematicVerificationError(
967
+ `ffprobe failed on ${path} (exit ${result.status ?? '(signal)'}): ${result.stderr ?? ''}`,
968
+ );
969
+ }
970
+ const parsed = JSON.parse(result.stdout) as {
971
+ streams?: Array<{ nb_read_frames?: string }>;
972
+ format?: { duration?: string };
973
+ };
974
+ const nbFramesRaw = parsed.streams?.[0]?.nb_read_frames;
975
+ const durationRaw = parsed.format?.duration;
976
+ const nbFrames = nbFramesRaw !== undefined ? Number(nbFramesRaw) : Number.NaN;
977
+ const durationSeconds = durationRaw !== undefined ? Number(durationRaw) : Number.NaN;
978
+ if (!Number.isFinite(nbFrames) || !Number.isFinite(durationSeconds)) {
979
+ throw new RenderCinematicVerificationError(
980
+ `ffprobe returned an unparseable result for ${path}: ${result.stdout}`,
981
+ );
982
+ }
983
+ return { nbFrames, durationSeconds };
984
+ }
985
+
986
+ /** ffprobe the first audio stream of `path` (codec name + duration), or
987
+ * `undefined` if the container has none — split out purely to keep {@link
988
+ * encodeAndVerify} readable. Some containers/codec combinations don't
989
+ * report a per-stream `duration` (it's absent/`"N/A"`); this falls back to
990
+ * the container-level `format.duration` in that case rather than treating
991
+ * a missing per-stream field as "no duration information exists at all". */
992
+ function runFfprobeAudioStream(
993
+ path: string,
994
+ ): { codec: string; durationSeconds: number } | undefined {
995
+ const result = spawnSync(
996
+ 'ffprobe',
997
+ [
998
+ '-v',
999
+ 'error',
1000
+ '-select_streams',
1001
+ 'a:0',
1002
+ '-show_entries',
1003
+ 'stream=codec_name,duration',
1004
+ '-show_entries',
1005
+ 'format=duration',
1006
+ '-of',
1007
+ 'json',
1008
+ path,
1009
+ ],
1010
+ { encoding: 'utf-8' },
1011
+ );
1012
+ if (result.status !== 0) {
1013
+ throw new RenderCinematicVerificationError(
1014
+ `ffprobe (audio stream) failed on ${path} (exit ${result.status ?? '(signal)'}): ${result.stderr ?? ''}`,
1015
+ );
1016
+ }
1017
+ const parsed = JSON.parse(result.stdout) as {
1018
+ streams?: Array<{ codec_name?: string; duration?: string }>;
1019
+ format?: { duration?: string };
1020
+ };
1021
+ const stream = parsed.streams?.[0];
1022
+ if (!stream) return undefined;
1023
+
1024
+ const codec = stream.codec_name ?? 'unknown';
1025
+ const perStreamDuration = stream.duration !== undefined ? Number(stream.duration) : Number.NaN;
1026
+ const durationSeconds = Number.isFinite(perStreamDuration)
1027
+ ? perStreamDuration
1028
+ : Number(parsed.format?.duration ?? Number.NaN);
1029
+ if (!Number.isFinite(durationSeconds)) {
1030
+ throw new RenderCinematicVerificationError(
1031
+ `ffprobe returned an unparseable audio duration for ${path}: ${result.stdout}`,
1032
+ );
1033
+ }
1034
+ return { codec, durationSeconds };
1035
+ }
1036
+
1037
+ /** Verify the muxed output actually carries an audio stream whose duration
1038
+ * matches the video (I6 AC: "exact range alignment") — split out of {@link
1039
+ * encodeAndVerify} to keep its own branch count down. Mirrors {@link
1040
+ * verifyEncodedOutput}'s "throw, never a warning" convention: a render that
1041
+ * declared a Tone score must never report success with audio silently
1042
+ * missing or misaligned. */
1043
+ function verifyAudioOutput(
1044
+ audioStream: { codec: string; durationSeconds: number } | undefined,
1045
+ outAbs: string,
1046
+ resolved: ResolvedRenderCinematicRequest,
1047
+ ): void {
1048
+ if (!audioStream) {
1049
+ throw new RenderCinematicVerificationError(
1050
+ `Expected an audio stream in ${outAbs} (the render page declared a Tone score) but ffprobe ` +
1051
+ 'found none. The mux step did NOT verify — this is a failure, not a success with a warning.',
1052
+ );
1053
+ }
1054
+ const expectedDuration = resolved.frameCount / resolved.fps;
1055
+ const durationDelta = Math.abs(audioStream.durationSeconds - expectedDuration);
1056
+ const tolerance = 0.5 / resolved.fps;
1057
+ if (durationDelta > tolerance) {
1058
+ throw new RenderCinematicVerificationError(
1059
+ `ffprobe audio duration (${audioStream.durationSeconds}s) does not match the expected video ` +
1060
+ `duration (${expectedDuration}s, tolerance ${tolerance}s) for ${outAbs}.`,
1061
+ );
1062
+ }
1063
+ }
1064
+
1065
+ /** Build one FFmpeg image-sequence-to-video invocation's args for `format` — split out of {@link renderCinematic} to keep its own branch count down. Returns `undefined` for `format: 'png'` (no encode step). */
1066
+ function buildEncodeArgs(
1067
+ format: RenderCinematicFormat,
1068
+ pattern: string,
1069
+ outAbs: string,
1070
+ resolved: ResolvedRenderCinematicRequest,
1071
+ ): { args: string[]; codec: { video: string; container: string } } | undefined {
1072
+ const common = ['-y', '-loglevel', 'error', '-framerate', String(resolved.fps), '-i', pattern];
1073
+ if (format === 'mp4') {
1074
+ return {
1075
+ args: [
1076
+ ...common,
1077
+ '-c:v',
1078
+ 'libx264',
1079
+ '-pix_fmt',
1080
+ 'yuv420p',
1081
+ '-r',
1082
+ String(resolved.fps),
1083
+ '-movflags',
1084
+ '+faststart',
1085
+ outAbs,
1086
+ ],
1087
+ codec: { video: 'libx264', container: 'mp4' },
1088
+ };
1089
+ }
1090
+ if (format === 'webm') {
1091
+ return {
1092
+ args: [
1093
+ ...common,
1094
+ '-c:v',
1095
+ 'libvpx-vp9',
1096
+ '-pix_fmt',
1097
+ resolved.background === 'transparent' ? 'yuva420p' : 'yuv420p',
1098
+ '-b:v',
1099
+ '0',
1100
+ '-crf',
1101
+ '32',
1102
+ '-r',
1103
+ String(resolved.fps),
1104
+ outAbs,
1105
+ ],
1106
+ codec: { video: 'libvpx-vp9', container: 'webm' },
1107
+ };
1108
+ }
1109
+ return undefined;
1110
+ }
1111
+
1112
+ /** Build one FFmpeg mux invocation's args for `format` — combines a
1113
+ * video-only encode ({@link buildEncodeArgs}'s output, stream-COPIED, never
1114
+ * re-encoded) with the WAV `renderToneOffline` produced, per the I6 AC's
1115
+ * literal "MP4 with `-c:v copy -c:a aac`" / "WebM with an opus/vorbis audio
1116
+ * stream". Split into its own function (rather than folded into {@link
1117
+ * buildEncodeArgs}) because it takes TWO inputs and runs as a SEPARATE
1118
+ * ffmpeg invocation after the video-only encode, not a single-pass
1119
+ * image-sequence encode. */
1120
+ function buildMuxArgs(
1121
+ format: RenderCinematicFormat,
1122
+ videoOnlyPath: string,
1123
+ wavPath: string,
1124
+ outAbs: string,
1125
+ ): { args: string[]; codec: string } {
1126
+ const common = ['-y', '-loglevel', 'error', '-i', videoOnlyPath, '-i', wavPath, '-c:v', 'copy'];
1127
+ if (format === 'mp4') {
1128
+ return {
1129
+ args: [...common, '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', outAbs],
1130
+ codec: 'aac',
1131
+ };
1132
+ }
1133
+ // format === 'webm'
1134
+ return {
1135
+ args: [...common, '-c:a', 'libopus', outAbs],
1136
+ codec: 'libopus',
1137
+ };
1138
+ }
1139
+
1140
+ /** Verify an encoded output's frame count/duration against the request via ffprobe (I7 AC: never report success on a mismatch) — split out of {@link renderCinematic} to keep its own branch count down. */
1141
+ function verifyEncodedOutput(
1142
+ ffprobeResult: { nbFrames: number; durationSeconds: number },
1143
+ outAbs: string,
1144
+ resolved: ResolvedRenderCinematicRequest,
1145
+ ): void {
1146
+ if (ffprobeResult.nbFrames !== resolved.frameCount) {
1147
+ throw new RenderCinematicVerificationError(
1148
+ `ffprobe frame count (${ffprobeResult.nbFrames}) does not match the requested frame count ` +
1149
+ `(${resolved.frameCount}) for ${outAbs}. The render pipeline did NOT verify — this is a failure, ` +
1150
+ 'not a success with a warning.',
1151
+ );
1152
+ }
1153
+ const expectedDuration = resolved.frameCount / resolved.fps;
1154
+ const durationDelta = Math.abs(ffprobeResult.durationSeconds - expectedDuration);
1155
+ // Container rounding tolerance: half a frame's worth of time.
1156
+ const tolerance = 0.5 / resolved.fps;
1157
+ if (durationDelta > tolerance) {
1158
+ throw new RenderCinematicVerificationError(
1159
+ `ffprobe duration (${ffprobeResult.durationSeconds}s) does not match the expected duration ` +
1160
+ `(${expectedDuration}s, tolerance ${tolerance}s) for ${outAbs}.`,
1161
+ );
1162
+ }
1163
+ }
1164
+
1165
+ /** A rendered-and-WAV-encoded audio track, ready to mux — the return of
1166
+ * {@link maybeCaptureAudio}, consumed by {@link encodeAndVerify}. */
1167
+ interface AudioCapturePlan {
1168
+ readonly wavPath: string;
1169
+ readonly sampleRate: number;
1170
+ readonly channels: number;
1171
+ readonly durationSeconds: number;
1172
+ readonly wavDigest: string;
1173
+ }
1174
+
1175
+ /**
1176
+ * I6/G4 — detect whether the render page declares a Tone score
1177
+ * (`window.__vgaiRenderAudio.hasAudio`, `@engine/runtime/render-audio-
1178
+ * control`) and, if so, render it offline for the EXACT requested
1179
+ * `[start, end)` range and write it to a WAV file inside `framesDir`. Must
1180
+ * run BEFORE the page/browser is closed (it drives `page.evaluate`) — see
1181
+ * its call site in {@link renderCinematic}, between `captureFrames` and the
1182
+ * browser teardown.
1183
+ *
1184
+ * `resolved.audio === 'off'` skips this entirely (the explicit video-only
1185
+ * escape hatch). `'on'` demands a declared score and throws before ANY
1186
+ * encode work if the page has none. `'auto'` (default) is silent ONLY when
1187
+ * no score is declared at all — once `hasAudio` is true, every failure past
1188
+ * that point (a mismatched duration, a `renderAudio()` rejection) throws
1189
+ * and aborts the whole render (propagating up through `renderCinematic`'s
1190
+ * own try/catch, which then removes any partial output) — I6's "partial
1191
+ * failures preserve diagnostics" AC read together with I7's "never report
1192
+ * success merely because an output file exists": once a score is declared,
1193
+ * shipping a video without its audio would be exactly that.
1194
+ */
1195
+ async function maybeCaptureAudio(
1196
+ page: Page,
1197
+ resolved: ResolvedRenderCinematicRequest,
1198
+ framesDir: string,
1199
+ onProgress: ((e: RenderCinematicProgress) => void) | undefined,
1200
+ signal?: AbortSignal,
1201
+ ): Promise<AudioCapturePlan | undefined> {
1202
+ if (resolved.audio === 'off') return undefined;
1203
+
1204
+ if (signal?.aborted) {
1205
+ throw new RenderCinematicCancelledError(
1206
+ 'Render cancelled before audio capture.',
1207
+ keepFramesOverride(signal),
1208
+ );
1209
+ }
1210
+
1211
+ onProgress?.({ phase: 'audio-detect' });
1212
+ const hasAudio = await page.evaluate(
1213
+ () =>
1214
+ (globalThis as unknown as { __vgaiRenderAudio?: { hasAudio?: boolean } }).__vgaiRenderAudio
1215
+ ?.hasAudio === true,
1216
+ );
1217
+ if (!hasAudio) {
1218
+ if (resolved.audio === 'on') {
1219
+ throw new RenderCinematicRequestError(
1220
+ '--audio on was requested, but the render page never published window.__vgaiRenderAudio ' +
1221
+ '(it does not declare a Tone score via installRenderAudioHarness — ' +
1222
+ '@engine/runtime/render-audio-control).',
1223
+ );
1224
+ }
1225
+ return undefined; // 'auto' + no declared score: video-only, exactly as before I6.
1226
+ }
1227
+
1228
+ onProgress?.({ phase: 'audio-render' });
1229
+ const rendered = await page.evaluate(
1230
+ ({ start, end }) =>
1231
+ (
1232
+ globalThis as unknown as {
1233
+ __vgaiRenderAudio: {
1234
+ renderAudio(
1235
+ start: number,
1236
+ end: number,
1237
+ ): Promise<{
1238
+ wavBase64: string;
1239
+ sampleRate: number;
1240
+ channels: number;
1241
+ durationSeconds: number;
1242
+ rms: number;
1243
+ peak: number;
1244
+ }>;
1245
+ };
1246
+ }
1247
+ ).__vgaiRenderAudio.renderAudio(start, end),
1248
+ { start: resolved.start, end: resolved.end },
1249
+ );
1250
+
1251
+ // Exact range alignment (I6 AC) — checked against the WAV's OWN reported
1252
+ // duration before it is ever muxed, not just against the final container
1253
+ // after the fact.
1254
+ //
1255
+ // G4 AC3 explicitness — start offset, duration, and tail policy:
1256
+ // - Start offset is `resolved.start`, the requested range's own start
1257
+ // (0 for a from-the-top render, non-zero for a mid-cinematic slice) —
1258
+ // passed straight to `renderAudio(start, end)` above, never hardcoded
1259
+ // to 0. Verified live by the i6 spec's "range/offset correctness" case
1260
+ // (renders `[1.0, 1.5)` and asserts non-silent, correctly-shifted audio).
1261
+ // - Duration is `resolved.end - resolved.start`, i.e. exactly
1262
+ // `frameCount / fps` — the same range the video side captures.
1263
+ // - Tail policy is NEITHER pad NOR truncate: `Tone.Offline`
1264
+ // (`audio/tone-offline-render.ts`) always renders exactly
1265
+ // `durationSeconds * sampleRate` samples by construction, so there is
1266
+ // no short/long tail to reconcile. This check exists purely as a
1267
+ // hard-fail guard (never silently pad/truncate a mismatch) — any
1268
+ // deviation beyond half a frame throws and aborts the render rather
1269
+ // than muxing misaligned audio.
1270
+ const expectedDuration = resolved.frameCount / resolved.fps;
1271
+ const durationDelta = Math.abs(rendered.durationSeconds - expectedDuration);
1272
+ const tolerance = 0.5 / resolved.fps;
1273
+ if (durationDelta > tolerance) {
1274
+ throw new RenderCinematicVerificationError(
1275
+ `Rendered audio duration (${rendered.durationSeconds}s) does not match the expected video ` +
1276
+ `duration (${expectedDuration}s, tolerance ${tolerance}s) — refusing to mux mismatched audio.`,
1277
+ );
1278
+ }
1279
+
1280
+ const wavBytes = Buffer.from(rendered.wavBase64, 'base64');
1281
+ const wavPath = join(framesDir, 'audio.wav');
1282
+ await writeFile(wavPath, wavBytes);
1283
+ onProgress?.({ phase: 'audio-render-done', durationSeconds: rendered.durationSeconds });
1284
+
1285
+ return {
1286
+ wavPath,
1287
+ sampleRate: rendered.sampleRate,
1288
+ channels: rendered.channels,
1289
+ durationSeconds: rendered.durationSeconds,
1290
+ wavDigest: sha256(wavBytes),
1291
+ };
1292
+ }
1293
+
1294
+ /** Encode `framesDir`'s PNG sequence (when `format` isn't `'png'`), mux in `audioPlan` (when present), and verify the result with ffprobe — split out of {@link renderCinematic} to keep its own branch count down. ASYNC (I7 fold-in #9a) so a `signal` abort mid-encode can actually SIGKILL the in-flight ffmpeg child — see {@link runFfmpeg}. */
1295
+ async function encodeAndVerify(
1296
+ framesDir: string,
1297
+ outAbs: string,
1298
+ resolved: ResolvedRenderCinematicRequest,
1299
+ onProgress: ((e: RenderCinematicProgress) => void) | undefined,
1300
+ audioPlan: AudioCapturePlan | undefined,
1301
+ signal?: AbortSignal,
1302
+ ): Promise<{
1303
+ encode?: RenderManifestEncode;
1304
+ ffprobeResult?: { nbFrames: number; durationSeconds: number };
1305
+ audio?: RenderManifestAudio;
1306
+ }> {
1307
+ if (resolved.format === 'png') return {};
1308
+
1309
+ const digits = Math.max(6, String(resolved.frameCount).length);
1310
+ const pattern = join(framesDir, `frame-%0${digits}d.png`);
1311
+ // With audio to mux, the frame-sequence encode targets a SCRATCH path
1312
+ // inside framesDir (cleaned up with the rest of that temp dir), and the
1313
+ // requested `outAbs` is produced by the separate mux step below instead —
1314
+ // with no audio, this is `outAbs` directly, IDENTICAL to pre-I6 behavior.
1315
+ const videoEncodeTarget = audioPlan ? join(framesDir, `video-only.${resolved.format}`) : outAbs;
1316
+ const built = buildEncodeArgs(resolved.format, pattern, videoEncodeTarget, resolved);
1317
+ if (!built) return {};
1318
+
1319
+ onProgress?.({ phase: 'encode-start', format: resolved.format });
1320
+ await runFfmpeg(built.args, signal);
1321
+ const encode: RenderManifestEncode = {
1322
+ format: resolved.format,
1323
+ outputPath: audioPlan ? outAbs : videoEncodeTarget,
1324
+ command: ['ffmpeg', ...built.args],
1325
+ codec: built.codec,
1326
+ };
1327
+ onProgress?.({ phase: 'encode-done', outputPath: videoEncodeTarget });
1328
+
1329
+ let audio: RenderManifestAudio | undefined;
1330
+ if (audioPlan) {
1331
+ if (signal?.aborted) {
1332
+ throw new RenderCinematicCancelledError(
1333
+ 'Render cancelled before audio mux.',
1334
+ keepFramesOverride(signal),
1335
+ );
1336
+ }
1337
+ onProgress?.({ phase: 'audio-mux-start' });
1338
+ const muxBuilt = buildMuxArgs(resolved.format, videoEncodeTarget, audioPlan.wavPath, outAbs);
1339
+ await runFfmpeg(muxBuilt.args, signal);
1340
+ onProgress?.({ phase: 'audio-mux-done', outputPath: outAbs });
1341
+ audio = {
1342
+ codec: muxBuilt.codec,
1343
+ container: resolved.format,
1344
+ sampleRate: audioPlan.sampleRate,
1345
+ channels: audioPlan.channels,
1346
+ durationSeconds: audioPlan.durationSeconds,
1347
+ wavDigest: audioPlan.wavDigest,
1348
+ muxCommand: ['ffmpeg', ...muxBuilt.args],
1349
+ };
1350
+ }
1351
+
1352
+ onProgress?.({ phase: 'verify', outputPath: outAbs });
1353
+ // `-c:v copy` in the mux step never re-encodes the video stream, so the
1354
+ // frame count/duration ffprobe already proved on a video-only encode
1355
+ // remains a valid check against the FINAL (possibly muxed) `outAbs`.
1356
+ const ffprobeResult = runFfprobeFrameCountAndDuration(outAbs);
1357
+ verifyEncodedOutput(ffprobeResult, outAbs, resolved);
1358
+
1359
+ if (audioPlan) {
1360
+ const audioStream = runFfprobeAudioStream(outAbs);
1361
+ verifyAudioOutput(audioStream, outAbs, resolved);
1362
+ }
1363
+
1364
+ return { encode, ffprobeResult, ...(audio !== undefined ? { audio } : {}) };
1365
+ }
1366
+
1367
+ interface OpenedRenderPage {
1368
+ readonly page: Page;
1369
+ readonly readiness: ReadinessReport;
1370
+ }
1371
+
1372
+ /** Launch Chromium, open the render-mode page at an exact viewport/DPR with DOM/CSS animation frozen, and wait for `__vgaiRender.ready()` — split out of {@link renderCinematic} to keep its own branch count down. Caller owns closing the returned page's browser/context. */
1373
+ async function openRenderPage(
1374
+ browser: Browser,
1375
+ server: LaunchedServer,
1376
+ resolved: ResolvedRenderCinematicRequest,
1377
+ onProgress: ((e: RenderCinematicProgress) => void) | undefined,
1378
+ ): Promise<OpenedRenderPage> {
1379
+ const context = await browser.newContext({
1380
+ viewport: { width: resolved.width, height: resolved.height },
1381
+ deviceScaleFactor: resolved.dpr,
1382
+ });
1383
+ const page = await context.newPage();
1384
+
1385
+ // DOM/CSS animation freeze (I2 AC: "DOM/CSS animation clocks are frozen
1386
+ // during capture") — this is the CAPTURE HOST's job, per the spec's own
1387
+ // split (the runtime side is `render-control.ts`'s composition fence).
1388
+ const cdp = await context.newCDPSession(page);
1389
+ await cdp.send('Animation.enable');
1390
+ await cdp.send('Animation.setPlaybackRate', { playbackRate: 0 });
1391
+ await page.emulateMedia({ reducedMotion: 'reduce' });
1392
+
1393
+ const pageUrl =
1394
+ `${server.baseUrl}/?vgai-render=1&vgai-seed=${resolved.seed}` +
1395
+ `&vgai-width=${resolved.width}&vgai-height=${resolved.height}` +
1396
+ // I7 fold-in (I5 nit fix) — see RenderCinematicRequest.simulateFixedDt's
1397
+ // doc comment: a fixture MAY read this to override its harness's
1398
+ // simulateFixedDt; omitted entirely when the caller didn't ask for one,
1399
+ // so a fixture with no reader for this param sees no behavior change.
1400
+ (resolved.simulateFixedDt !== undefined
1401
+ ? `&vgai-simulate-fixed-dt=${resolved.simulateFixedDt}`
1402
+ : '');
1403
+ await page.goto(pageUrl, { waitUntil: 'load' });
1404
+ await page.waitForFunction(() =>
1405
+ Boolean((globalThis as unknown as { __vgaiRender?: unknown }).__vgaiRender),
1406
+ );
1407
+
1408
+ const readiness = (await page.evaluate(() =>
1409
+ (
1410
+ globalThis as unknown as { __vgaiRender: { ready(): Promise<ReadinessReport> } }
1411
+ ).__vgaiRender.ready(),
1412
+ )) as ReadinessReport;
1413
+ onProgress?.({ phase: 'page-ready', readiness });
1414
+ for (const [name, entry] of Object.entries(readiness)) {
1415
+ if (entry.status === 'error') {
1416
+ throw new Error(
1417
+ `Render page readiness failed for "${name}": ${entry.detail ?? '(no detail)'}`,
1418
+ );
1419
+ }
1420
+ }
1421
+
1422
+ return { page, readiness };
1423
+ }
1424
+
1425
+ /** A live render-mode page session, opened WITHOUT driving a frame-capture
1426
+ * loop — see {@link openRenderCinematicPage}. */
1427
+ export interface RenderCinematicPageSession {
1428
+ readonly page: Page;
1429
+ readonly resolved: ResolvedRenderCinematicRequest;
1430
+ /** Closes the page/context, the browser, and stops the spawned dev server (if any). */
1431
+ close(): Promise<void>;
1432
+ }
1433
+
1434
+ /**
1435
+ * Open a live render-mode page — preflight, dev-server launch, headless
1436
+ * Chromium launch, DOM/CSS-animation freeze, navigate, and wait for
1437
+ * `__vgaiRender.ready()` — WITHOUT running {@link renderCinematic}'s own
1438
+ * frame-capture/encode loop. This exposes the raw Playwright `Page` for a
1439
+ * caller that needs a CUSTOM `window.__vgaiRender` call sequence beyond the
1440
+ * standard "advanceFrame, seekFrame, renderOnce, screenshot" loop
1441
+ * {@link renderCinematic} drives internally — the motivating case is a test
1442
+ * that proves the I8 `advanceFrame` render-integration seam by choosing
1443
+ * exactly when (and how many times) to call it relative to `seekFrame`
1444
+ * (`packages/engine/e2e/tests/render-cinematic-i8.spec.ts`).
1445
+ *
1446
+ * Reuses the exact same {@link launchEntryServer}/{@link openRenderPage}
1447
+ * building blocks {@link renderCinematic} itself calls — this is an
1448
+ * ADDITIVE export, not a refactor of the main pipeline's control flow, so it
1449
+ * carries no risk to the existing `renderCinematic` behavior/tests.
1450
+ */
1451
+ export async function openRenderCinematicPage(
1452
+ request: RenderCinematicRequest,
1453
+ ): Promise<RenderCinematicPageSession> {
1454
+ const resolved = await resolveEffectivePort(request, resolveRenderCinematicRequest(request));
1455
+ const server = await launchEntryServer(
1456
+ resolved.entry,
1457
+ resolved.port,
1458
+ resolved.engineRoot,
1459
+ undefined,
1460
+ );
1461
+ let browser: Browser | undefined;
1462
+ try {
1463
+ browser = await chromium.launch({
1464
+ headless: true,
1465
+ args: ['--use-gl=angle', '--use-angle=swiftshader'],
1466
+ // B7 review fold-in (SIGINT race): Playwright's DEFAULT is to install
1467
+ // its own SIGINT/SIGTERM/SIGHUP handlers on the launched browser that
1468
+ // close it and then call `process.exit(130)` directly — racing (and,
1469
+ // on the Ink-mounted TTY path, BEATING) the CLI's own
1470
+ // `EXIT_CODES.CANCELLED` (4) exit. Cancellation here is exclusively
1471
+ // owned by the caller's `AbortSignal`/process-signal wiring (see
1472
+ // `onAbort` below in `renderCinematic`, and `runRenderCinematic`'s
1473
+ // `process.on('SIGINT'/'SIGTERM', ...)` in the CLI) — Playwright
1474
+ // must never race that single owner with its own `process.exit()`.
1475
+ handleSIGINT: false,
1476
+ handleSIGTERM: false,
1477
+ handleSIGHUP: false,
1478
+ });
1479
+ const { page } = await openRenderPage(browser, server, resolved, undefined);
1480
+ const openedBrowser = browser;
1481
+ return {
1482
+ page,
1483
+ resolved,
1484
+ async close() {
1485
+ await page
1486
+ .context()
1487
+ .close()
1488
+ .catch(() => {});
1489
+ await openedBrowser.close().catch(() => {});
1490
+ await server.stop().catch(() => {});
1491
+ },
1492
+ };
1493
+ } catch (err) {
1494
+ if (browser) await browser.close().catch(() => {});
1495
+ await server.stop().catch(() => {});
1496
+ throw err;
1497
+ }
1498
+ }
1499
+
1500
+ interface PreparedOutputs {
1501
+ readonly outAbs: string;
1502
+ readonly framesDir: string;
1503
+ }
1504
+
1505
+ /** Preflight ffmpeg, validate `out` doesn't collide, and create the frames dir — split out of {@link renderCinematic} to keep its own branch count down. Everything here runs BEFORE any browser is launched (I1 AC). */
1506
+ async function preparePipelineOutputs(
1507
+ resolved: ResolvedRenderCinematicRequest,
1508
+ ): Promise<PreparedOutputs> {
1509
+ if (resolved.format !== 'png') {
1510
+ const ffmpegCheck = checkFfmpegCapability();
1511
+ if (!ffmpegCheck.ok) throw new RenderCinematicFfmpegError(ffmpegCheck.errors);
1512
+ }
1513
+
1514
+ const outAbs = resolvePath(resolved.out);
1515
+ if (!resolved.overwrite && existsSync(outAbs) && resolved.format !== 'png') {
1516
+ throw new RenderCinematicRequestError(
1517
+ `${outAbs} already exists. Pass --overwrite to replace it.`,
1518
+ );
1519
+ }
1520
+ await mkdir(dirname(outAbs), { recursive: true });
1521
+
1522
+ const framesDir =
1523
+ resolved.format === 'png' ? outAbs : await mkdtemp(join(tmpdir(), 'vgai-render-cinematic-'));
1524
+ if (resolved.format === 'png') await mkdir(framesDir, { recursive: true });
1525
+
1526
+ return { outAbs, framesDir };
1527
+ }
1528
+
1529
+ /**
1530
+ * Build the I5 `RenderManifestSimulate` object iff `resolved.simulate` —
1531
+ * split out purely to keep {@link renderCinematic}'s own body short.
1532
+ *
1533
+ * I7 fold-in (I5 nit fix): `fixedTimestep` used to be hardcoded to `1/60`
1534
+ * regardless of what the fixture actually configured
1535
+ * (`RenderControlHarnessOptions.simulateFixedDt`) — silently wrong for any
1536
+ * fixture that set a different substep dt. `actualFixedDt` is
1537
+ * {@link readSimulateFixedDt}'s result — the REAL value read off the live
1538
+ * harness right before the page closed. The `?? 1 / 60` fallback below is
1539
+ * ONLY reached for a render-mode page that predates the
1540
+ * `simulateFixedDt()` harness method (an older/unmodified page) — an
1541
+ * honestly-commented "best known default", not a value ever claimed to be
1542
+ * measured.
1543
+ */
1544
+ function buildSimulateManifest(
1545
+ resolved: ResolvedRenderCinematicRequest,
1546
+ actualFixedDt: number | undefined,
1547
+ ): RenderManifestSimulate | undefined {
1548
+ if (!resolved.simulate) return undefined;
1549
+ return {
1550
+ seed: resolved.seed,
1551
+ substeps: resolved.simulateSubsteps,
1552
+ warmupSubsteps: resolved.simulateWarmupSubsteps,
1553
+ // Honest fallback only: the harness didn't expose `simulateFixedDt()`
1554
+ // (predates I7), so this is the engine-wide documented default
1555
+ // (`render-control.ts`'s own `1/60`), NOT a measured value.
1556
+ fixedTimestep: actualFixedDt ?? 1 / 60,
1557
+ };
1558
+ }
1559
+
1560
+ /** Write the render manifest and clean up the temp frames dir per retention policy — split out of {@link renderCinematic} to keep its own branch count down. */
1561
+ async function finalizeResult(
1562
+ prepared: PreparedOutputs,
1563
+ resolved: ResolvedRenderCinematicRequest,
1564
+ frameHashes: readonly string[],
1565
+ encode: RenderManifestEncode | undefined,
1566
+ ffprobeResult: { nbFrames: number; durationSeconds: number } | undefined,
1567
+ audio: RenderManifestAudio | undefined,
1568
+ excludedFromDeterminism: readonly string[],
1569
+ warnings: readonly string[],
1570
+ actualSimulateFixedDt: number | undefined,
1571
+ ): Promise<RenderCinematicResult> {
1572
+ const { outAbs, framesDir } = prepared;
1573
+ const framesDigest = sha256(Buffer.from(frameHashes.join('\n'), 'utf-8'));
1574
+ const outputPath = resolved.format === 'png' ? framesDir : outAbs;
1575
+ const durationSeconds = resolved.frameCount / resolved.fps;
1576
+ const simulate = buildSimulateManifest(resolved, actualSimulateFixedDt);
1577
+
1578
+ const manifestPath = `${outAbs}.manifest.json`;
1579
+ const manifest = {
1580
+ request: resolved,
1581
+ outputPath,
1582
+ frameCount: resolved.frameCount,
1583
+ durationSeconds,
1584
+ frameHashes,
1585
+ framesDigest,
1586
+ encode,
1587
+ ffprobe: ffprobeResult,
1588
+ audio,
1589
+ simulate,
1590
+ excludedFromDeterminism,
1591
+ warnings,
1592
+ generatedAt: new Date().toISOString(),
1593
+ };
1594
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
1595
+
1596
+ if (!resolved.keepFrames && resolved.format !== 'png') {
1597
+ await rm(framesDir, { recursive: true, force: true });
1598
+ }
1599
+
1600
+ return {
1601
+ request: resolved,
1602
+ outputPath,
1603
+ manifestPath,
1604
+ frameCount: resolved.frameCount,
1605
+ durationSeconds,
1606
+ frameHashes,
1607
+ framesDigest,
1608
+ ...(encode !== undefined ? { encode } : {}),
1609
+ ...(ffprobeResult !== undefined ? { ffprobe: ffprobeResult } : {}),
1610
+ ...(audio !== undefined ? { audio } : {}),
1611
+ ...(simulate !== undefined ? { simulate } : {}),
1612
+ excludedFromDeterminism,
1613
+ warnings,
1614
+ };
1615
+ }
1616
+
1617
+ /**
1618
+ * Failure-path cleanup for {@link renderCinematic}'s `catch` block — split
1619
+ * out purely to keep that function's own cognitive-complexity score down
1620
+ * (same helper-splitting convention this file already uses throughout,
1621
+ * e.g. {@link resolveFrameRange}).
1622
+ *
1623
+ * Never leaves a partially-encoded output looking like a real one on
1624
+ * failure (I7 AC) — the temp frames dir is left in place (minus the
1625
+ * `format: 'png'` case, which never had one) so a human can inspect what
1626
+ * was captured before the failure.
1627
+ *
1628
+ * ADDITIVE (B5): a genuine CANCELLATION (as opposed to an ordinary failure,
1629
+ * which deliberately preserves the temp frames dir for inspection — see
1630
+ * above) ALSO removes the incomplete temp frames dir, unless retention was
1631
+ * requested — either at cancel-time (`signal.reason.keepFrames`, carried on
1632
+ * the thrown error) or, absent that override, by the original request's own
1633
+ * `keepFrames`. Never reached when `options.signal` was never given (every
1634
+ * pre-existing caller): `signal` is `undefined` and never `.aborted` in that
1635
+ * case.
1636
+ *
1637
+ * Detecting "was this a cancellation" via `signal?.aborted` — NOT merely
1638
+ * `err instanceof RenderCinematicCancelledError` — matters because of a real
1639
+ * race documented at `renderCinematic`'s own `onAbort` listener: that
1640
+ * listener closes the browser/vite-group the INSTANT `signal` fires, which
1641
+ * can race ahead of the cooperative `signal?.aborted` checks inside
1642
+ * `captureFrames`/`maybeCaptureAudio`/`encodeAndVerify`. When it wins that
1643
+ * race, the in-flight `page.evaluate`/`page.locator().screenshot()` call
1644
+ * itself rejects with Playwright's own "Target page, context or browser has
1645
+ * been closed" error instead — a real `Error`, not a
1646
+ * `RenderCinematicCancelledError` — even though the render was genuinely
1647
+ * cancelled. Gating cleanup on `err instanceof RenderCinematicCancelledError`
1648
+ * alone left the temp frames dir leaked on exactly that race (reproduced via
1649
+ * a real `vgai render-cinematic` + SIGINT on this box, not merely a unit
1650
+ * test — the committed I7 Playwright proof's synchronous `controller.abort()`
1651
+ * call, right after an `onProgress` callback returns, never lands mid-flight
1652
+ * and so never exercised this path).
1653
+ */
1654
+ async function cleanupAfterFailure(
1655
+ err: unknown,
1656
+ resolved: ResolvedRenderCinematicRequest,
1657
+ outAbs: string,
1658
+ framesDir: string,
1659
+ signal?: AbortSignal,
1660
+ ): Promise<void> {
1661
+ if (resolved.format !== 'png' && existsSync(outAbs) && !resolved.keepFrames) {
1662
+ await rm(outAbs, { force: true }).catch(() => {});
1663
+ }
1664
+ const wasCancelled = err instanceof RenderCinematicCancelledError || signal?.aborted === true;
1665
+ if (wasCancelled && resolved.format !== 'png') {
1666
+ const keep =
1667
+ (err instanceof RenderCinematicCancelledError ? err.keepFrames : undefined) ??
1668
+ keepFramesOverride(signal) ??
1669
+ resolved.keepFrames;
1670
+ if (!keep) {
1671
+ await rm(framesDir, { recursive: true, force: true }).catch(() => {});
1672
+ }
1673
+ }
1674
+ }
1675
+
1676
+ /**
1677
+ * `vgai render-cinematic` — the full pipeline (I0/I2-I4/I6/I7). Preflights
1678
+ * ffmpeg/ffprobe and validates the request BEFORE launching any browser
1679
+ * (I1 AC), then: serves/launches the render-mode page, waits on
1680
+ * `__vgaiRender.ready()`, sets an exact viewport + deviceScaleFactor,
1681
+ * freezes DOM/CSS animation via CDP, captures every composited frame,
1682
+ * encodes with FFmpeg, and verifies the output with ffprobe — throwing
1683
+ * (never resolving) if verification fails, so a caller can never mistake a
1684
+ * partially-broken output for a successful render.
1685
+ */
1686
+ export async function renderCinematic(
1687
+ request: RenderCinematicRequest,
1688
+ options: RenderCinematicOptions = {},
1689
+ ): Promise<RenderCinematicResult> {
1690
+ const onProgress = options.onProgress;
1691
+ const signal = options.signal;
1692
+ const resolved = await resolveEffectivePort(request, resolveRenderCinematicRequest(request));
1693
+
1694
+ onProgress?.({ phase: 'preflight' });
1695
+ const prepared = await preparePipelineOutputs(resolved);
1696
+ const { outAbs, framesDir } = prepared;
1697
+
1698
+ if (signal?.aborted) {
1699
+ // I7 fold-in (#9a — cancellation race): this is the EARLIEST possible
1700
+ // cancellation point — `preparePipelineOutputs` (immediately above)
1701
+ // already created `framesDir` on disk, so throwing here without cleanup
1702
+ // leaked it (reproduced by a real abort landing before the dev server
1703
+ // even starts, not merely a hypothetical). `cleanupAfterFailure` is a
1704
+ // no-op on everything else at this point (no `outAbs` output could
1705
+ // possibly exist yet), so this is just the framesDir removal, done
1706
+ // explicitly rather than pulled into a broader try/catch this early.
1707
+ const err = new RenderCinematicCancelledError(
1708
+ 'Render cancelled before the dev server launched.',
1709
+ keepFramesOverride(signal),
1710
+ );
1711
+ await cleanupAfterFailure(err, resolved, outAbs, framesDir, signal);
1712
+ throw err;
1713
+ }
1714
+
1715
+ // I7 fold-in (#9a — cancellation race): `launchEntryServer` runs BEFORE
1716
+ // the main try/catch below (there is no `server`/`browser` to tear down
1717
+ // in that `finally` yet), but it can still throw a genuine
1718
+ // cancellation-driven failure — `waitForHttpOk`'s own `signal?.aborted`
1719
+ // check inside `launchEntryServer` throws a plain `Error` (wrapped again
1720
+ // by `launchEntryServer`'s own catch, losing any `RenderCinematicCancelledError`-
1721
+ // ness entirely) if `signal` aborts while still waiting for the dev server
1722
+ // to come up. Without this try/catch, THAT throw skipped
1723
+ // `cleanupAfterFailure` completely (it's outside the main try block) and
1724
+ // leaked the temp frames dir `preparePipelineOutputs` already created
1725
+ // above — reproduced by a real abort landing this early, not merely a
1726
+ // hypothetical.
1727
+ let server: LaunchedServer;
1728
+ try {
1729
+ server = await launchEntryServer(
1730
+ resolved.entry,
1731
+ resolved.port,
1732
+ resolved.engineRoot,
1733
+ onProgress,
1734
+ signal,
1735
+ );
1736
+ } catch (err) {
1737
+ await cleanupAfterFailure(err, resolved, outAbs, framesDir, signal);
1738
+ throw err;
1739
+ }
1740
+
1741
+ let browser: Browser | undefined;
1742
+ const warnings: string[] = [];
1743
+ // I7 fold-in (#9a — cancellation/orphan): tear down the browser AND the
1744
+ // vite dev server's WHOLE PROCESS GROUP the INSTANT `signal` aborts,
1745
+ // rather than waiting for the next cooperative per-frame check
1746
+ // (`captureFrames`'s `signal?.aborted` guard) to be reached. This is what
1747
+ // makes a slow/hung in-flight `page.evaluate`/`page.screenshot` call, or a
1748
+ // long single ffmpeg encode, never keep the browser/vite group alive a
1749
+ // moment longer than necessary — `server.stop()` already group-kills vite
1750
+ // (`stopServerProcess`, above), and both calls are idempotent/`.catch`-
1751
+ // guarded so racing against the normal `finally` cleanup below is safe.
1752
+ const onAbort = (): void => {
1753
+ browser?.close().catch(() => {});
1754
+ server.stop().catch(() => {});
1755
+ };
1756
+ signal?.addEventListener('abort', onAbort, { once: true });
1757
+ try {
1758
+ onProgress?.({ phase: 'browser-launch' });
1759
+ browser = await chromium.launch({
1760
+ headless: true,
1761
+ args: ['--use-gl=angle', '--use-angle=swiftshader'],
1762
+ // B7 review fold-in (SIGINT race, #10): without this, Playwright
1763
+ // installs its OWN SIGINT/SIGTERM/SIGHUP handlers that close the
1764
+ // browser and call `process.exit(130)` directly — racing the CLI's
1765
+ // `EXIT_CODES.CANCELLED` (4) exit this function's own `onAbort`
1766
+ // (above) and the CLI's `process.on('SIGINT'/'SIGTERM', ...)`
1767
+ // (`runRenderCinematic` in `packages/vgai-cli/src/index.ts`) already
1768
+ // drive via `signal`. On the piped/non-TTY CLI path the CLI's own
1769
+ // `process.exit(EXIT_CODES.CANCELLED)` happened to win that race most
1770
+ // of the time; on the Ink-mounted TTY path the extra
1771
+ // settle-then-unmount delay before the CLI's own exit reliably LOST
1772
+ // it, surfacing a raw Playwright "Target page ... has been closed"
1773
+ // error and exit code 130 instead of the I7 contract. The
1774
+ // `AbortController`/signal wiring is the single owner of cancellation
1775
+ // end-to-end — Playwright must never race it with its own
1776
+ // `process.exit()`.
1777
+ handleSIGINT: false,
1778
+ handleSIGTERM: false,
1779
+ handleSIGHUP: false,
1780
+ });
1781
+ const { page } = await openRenderPage(browser, server, resolved, onProgress);
1782
+
1783
+ const frameHashes = await captureFrames(page, framesDir, resolved, onProgress, signal);
1784
+
1785
+ // I6/G4 — must run BEFORE the page/browser closes (drives page.evaluate
1786
+ // against window.__vgaiRenderAudio). A declared-but-failing score throws
1787
+ // here and aborts the whole render (see maybeCaptureAudio's doc comment).
1788
+ const audioPlan = await maybeCaptureAudio(page, resolved, framesDir, onProgress, signal);
1789
+
1790
+ // I5 AC 5 — read BEFORE the page/browser closes, same constraint as the
1791
+ // audio capture above (drives page.evaluate against window.__vgaiRender).
1792
+ const excludedFromDeterminism = await readExcludedFromDeterminism(page);
1793
+ // I7 fold-in (I5 nit) — the ACTUAL harness dt, read before the page/
1794
+ // browser closes, same constraint as the two calls above. `undefined`
1795
+ // for a non-`--simulate` request (never called) or a render-mode page
1796
+ // that predates this harness method (back-compat fallback in
1797
+ // `buildSimulateManifest`).
1798
+ const actualSimulateFixedDt = resolved.simulate ? await readSimulateFixedDt(page) : undefined;
1799
+
1800
+ await page.context().close();
1801
+ await browser.close();
1802
+ browser = undefined;
1803
+
1804
+ if (signal?.aborted) {
1805
+ throw new RenderCinematicCancelledError(
1806
+ 'Render cancelled before encode.',
1807
+ keepFramesOverride(signal),
1808
+ );
1809
+ }
1810
+
1811
+ // format === 'png': no encode step, framesDir IS outAbs already.
1812
+ const { encode, ffprobeResult, audio } = await encodeAndVerify(
1813
+ framesDir,
1814
+ outAbs,
1815
+ resolved,
1816
+ onProgress,
1817
+ audioPlan,
1818
+ signal,
1819
+ );
1820
+
1821
+ const result = await finalizeResult(
1822
+ prepared,
1823
+ resolved,
1824
+ frameHashes,
1825
+ encode,
1826
+ ffprobeResult,
1827
+ audio,
1828
+ excludedFromDeterminism,
1829
+ warnings,
1830
+ actualSimulateFixedDt,
1831
+ );
1832
+ onProgress?.({ phase: 'complete', outputPath: result.outputPath });
1833
+ return result;
1834
+ } catch (err) {
1835
+ await cleanupAfterFailure(err, resolved, outAbs, framesDir, signal);
1836
+ throw err;
1837
+ } finally {
1838
+ signal?.removeEventListener('abort', onAbort);
1839
+ if (browser) await browser.close().catch(() => {});
1840
+ await server.stop().catch(() => {});
1841
+ }
1842
+ }
1843
+
1844
+ /** Re-read a manifest written by {@link renderCinematic} — used by the CLI's human-readable printer and by tests. */
1845
+ export async function readRenderManifest(manifestPath: string): Promise<unknown> {
1846
+ return JSON.parse(await readFile(manifestPath, 'utf-8'));
1847
+ }