@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.
- package/package.json +27 -0
- package/src/cinematic/capabilities-operations.ts +128 -0
- package/src/cinematic/cue-operations.ts +198 -0
- package/src/cinematic/gsap-operations.ts +126 -0
- package/src/cinematic/index.ts +59 -0
- package/src/cinematic/preview-operations.ts +279 -0
- package/src/cinematic/preview-transport.ts +244 -0
- package/src/cinematic/render-operations.ts +409 -0
- package/src/cinematic/render-transport.ts +238 -0
- package/src/cinematic/theatre-operations.ts +306 -0
- package/src/editor/camera-operations.ts +169 -0
- package/src/editor/console-operations.ts +87 -0
- package/src/editor/hierarchy-operations.ts +95 -0
- package/src/editor/index.ts +62 -0
- package/src/editor/open-operations.ts +209 -0
- package/src/editor/screenshot-operations.ts +99 -0
- package/src/editor/selection-operations.ts +144 -0
- package/src/editor/session-operations.ts +73 -0
- package/src/editor/source-location-operations.ts +106 -0
- package/src/editor/transport.ts +647 -0
- package/src/errors.ts +72 -0
- package/src/http/http-projection.ts +349 -0
- package/src/http/index.ts +11 -0
- package/src/index.ts +67 -0
- package/src/mcp/index.ts +16 -0
- package/src/mcp/mcp-projection.ts +288 -0
- package/src/operations.ts +83 -0
- package/src/play/control-operations.ts +205 -0
- package/src/play/debug-command-operations.ts +245 -0
- package/src/play/index.ts +66 -0
- package/src/play/input-operations.ts +316 -0
- package/src/play/lifecycle-operations.ts +271 -0
- package/src/play/log-operations.ts +279 -0
- package/src/play/run-ticks-operations.ts +141 -0
- package/src/play/state-operations.ts +210 -0
- package/src/play/status-operations.ts +160 -0
- package/src/play/transport.ts +728 -0
- package/src/project/asset-operations.ts +243 -0
- package/src/project/component-operations.ts +337 -0
- package/src/project/discovery-operations.ts +269 -0
- package/src/project/entity-operations.ts +366 -0
- package/src/project/index.ts +55 -0
- package/src/project/input-map-operations.ts +233 -0
- package/src/project/manifest-operations.ts +355 -0
- package/src/project/scene-operations.ts +426 -0
- package/src/project/shared.ts +299 -0
- package/src/registry.ts +285 -0
- package/src/render/capabilities/ffmpeg.ts +141 -0
- package/src/render/index.ts +15 -0
- package/src/render/render-cinematic.ts +1847 -0
- 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
|
+
}
|