@hyperframes/engine 0.6.119 → 0.6.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +24 -7
  2. package/scripts/generate-lut-reference.py +0 -168
  3. package/scripts/test-fitTextFontSize-browser.ts +0 -135
  4. package/src/cdp-headless-experimental.d.ts +0 -54
  5. package/src/config.test.ts +0 -213
  6. package/src/config.ts +0 -417
  7. package/src/index.ts +0 -273
  8. package/src/services/audioMixer.test.ts +0 -326
  9. package/src/services/audioMixer.ts +0 -604
  10. package/src/services/audioMixer.types.ts +0 -35
  11. package/src/services/audioVolumeEnvelope.test.ts +0 -176
  12. package/src/services/audioVolumeEnvelope.ts +0 -138
  13. package/src/services/browserManager.test.ts +0 -330
  14. package/src/services/browserManager.ts +0 -670
  15. package/src/services/chunkEncoder.test.ts +0 -1415
  16. package/src/services/chunkEncoder.ts +0 -831
  17. package/src/services/chunkEncoder.types.ts +0 -60
  18. package/src/services/extractionCache.test.ts +0 -199
  19. package/src/services/extractionCache.ts +0 -216
  20. package/src/services/fileServer.ts +0 -110
  21. package/src/services/frameCapture-discardWarmup.test.ts +0 -183
  22. package/src/services/frameCapture-namePolyfill.test.ts +0 -78
  23. package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
  24. package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
  25. package/src/services/frameCapture-warmupTicks.test.ts +0 -174
  26. package/src/services/frameCapture.test.ts +0 -192
  27. package/src/services/frameCapture.ts +0 -1934
  28. package/src/services/hdrCapture.test.ts +0 -159
  29. package/src/services/hdrCapture.ts +0 -315
  30. package/src/services/parallelCoordinator.test.ts +0 -139
  31. package/src/services/parallelCoordinator.ts +0 -437
  32. package/src/services/screenshotService.test.ts +0 -510
  33. package/src/services/screenshotService.ts +0 -615
  34. package/src/services/streamingEncoder.test.ts +0 -832
  35. package/src/services/streamingEncoder.ts +0 -594
  36. package/src/services/systemMemory.test.ts +0 -324
  37. package/src/services/systemMemory.ts +0 -180
  38. package/src/services/videoFrameExtractor.test.ts +0 -1062
  39. package/src/services/videoFrameExtractor.ts +0 -1139
  40. package/src/services/videoFrameInjector.test.ts +0 -300
  41. package/src/services/videoFrameInjector.ts +0 -687
  42. package/src/services/vp9Options.ts +0 -13
  43. package/src/types.ts +0 -191
  44. package/src/utils/alphaBlit.test.ts +0 -1349
  45. package/src/utils/alphaBlit.ts +0 -1015
  46. package/src/utils/assertSwiftShader.test.ts +0 -130
  47. package/src/utils/assertSwiftShader.ts +0 -126
  48. package/src/utils/ffmpegBinaries.test.ts +0 -43
  49. package/src/utils/ffmpegBinaries.ts +0 -63
  50. package/src/utils/ffprobe.test.ts +0 -342
  51. package/src/utils/ffprobe.ts +0 -457
  52. package/src/utils/gpuEncoder.test.ts +0 -140
  53. package/src/utils/gpuEncoder.ts +0 -268
  54. package/src/utils/hdr.test.ts +0 -191
  55. package/src/utils/hdr.ts +0 -137
  56. package/src/utils/hdrCompositing.test.ts +0 -130
  57. package/src/utils/htmlTemplate.test.ts +0 -42
  58. package/src/utils/htmlTemplate.ts +0 -42
  59. package/src/utils/layerCompositor.test.ts +0 -150
  60. package/src/utils/layerCompositor.ts +0 -58
  61. package/src/utils/parityContract.ts +0 -1
  62. package/src/utils/processTracker.test.ts +0 -74
  63. package/src/utils/processTracker.ts +0 -41
  64. package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
  65. package/src/utils/runFfmpeg.test.ts +0 -102
  66. package/src/utils/runFfmpeg.ts +0 -136
  67. package/src/utils/shaderTransitions.test.ts +0 -738
  68. package/src/utils/shaderTransitions.ts +0 -1130
  69. package/src/utils/uint16-alignment-audit.test.ts +0 -125
  70. package/src/utils/urlDownloader.test.ts +0 -65
  71. package/src/utils/urlDownloader.ts +0 -143
  72. package/tsconfig.json +0 -19
  73. package/vitest.config.ts +0 -7
@@ -1,1934 +0,0 @@
1
- // fallow-ignore-file complexity
2
- /**
3
- * Frame Capture Service
4
- *
5
- * Uses Puppeteer to capture frames from any web page implementing the
6
- * window.__hf seek protocol. Navigates to a file server URL, waits for
7
- * the page to expose window.__hf, then captures frames deterministically
8
- * via Chrome's BeginFrame API or Page.captureScreenshot fallback.
9
- */
10
-
11
- import { type Browser, type Page, type Viewport, type ConsoleMessage } from "puppeteer-core";
12
- import { existsSync, mkdirSync, writeFileSync } from "fs";
13
- import { join } from "path";
14
- import { quantizeTimeToFrame, fpsToNumber } from "@hyperframes/core";
15
-
16
- // ── Extracted modules ───────────────────────────────────────────────────────
17
- import {
18
- acquireBrowser,
19
- releaseBrowser,
20
- forceReleaseBrowser,
21
- buildChromeArgs,
22
- resolveBrowserGpuMode,
23
- resolveHeadlessShellPath,
24
- type CaptureMode,
25
- } from "./browserManager.js";
26
- import {
27
- beginFrameCapture,
28
- getCdpSession,
29
- pageScreenshotCapture,
30
- initTransparentBackground,
31
- } from "./screenshotService.js";
32
- import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
33
- import type {
34
- CaptureOptions,
35
- CaptureVideoMetadataHint,
36
- CaptureResult,
37
- CaptureBufferResult,
38
- CapturePerfSummary,
39
- } from "../types.js";
40
-
41
- export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
42
-
43
- /** Called after seeking, before screenshot. Use for video frame injection or other pre-capture work. */
44
- export type BeforeCaptureHook = (page: Page, time: number) => Promise<void>;
45
-
46
- export interface CaptureSession {
47
- browser: Browser;
48
- page: Page;
49
- options: CaptureOptions;
50
- serverUrl: string;
51
- outputDir: string;
52
- onBeforeCapture: BeforeCaptureHook | null;
53
- isInitialized: boolean;
54
- /**
55
- * Static-frame dedup (default-on; opt out with `HF_STATIC_DEDUP=false`): indices of frames byte-identical
56
- * to their predecessor (no GSAP tween / clip cut active in either), predicted from
57
- * window.__timelines and empirically anchor-verified. These reuse `lastFrameBuffer`
58
- * instead of re-seeking + re-screenshotting. Undefined when disabled or ineligible.
59
- */
60
- staticFrames?: Set<number>;
61
- /** Last non-deduped frame buffer, reused for every `staticFrames` index in its run. */
62
- lastFrameBuffer?: Buffer;
63
- /** Count of frames served from a reused buffer (dedup telemetry). */
64
- staticDedupCount?: number;
65
- // ── Static-dedup observability (set by armStaticDedup; surfaced via
66
- // getCapturePerfSummary → RenderPerfSummary → the render_complete event) ──
67
- // NOTE: `armed` and `predicted` are NOT stored — they derive from
68
- // `staticFrames` (armed ⟺ non-empty set; predicted === size) in
69
- // getCapturePerfSummary, so they can't desync from the actual reuse set.
70
- /** Dedup was enabled for this render (default-on; opt out with `HF_STATIC_DEDUP=false`). */
71
- staticDedupEnabled?: boolean;
72
- /**
73
- * Short machine code for WHY dedup did not arm, for a low-cardinality breakdown.
74
- * One of: `capture_mode` | `video_injection` | `page_composite` |
75
- * `ineligible` | `verification_failed` | `verification_budget`. Undefined when armed or disabled.
76
- */
77
- staticDedupSkipReason?: string;
78
- // Tracks whether the page/browser handles have already been released by
79
- // closeCaptureSession. Used to make closeCaptureSession idempotent under
80
- // browser-pool semantics (see the function body for the full invariant).
81
- pageReleased?: boolean;
82
- browserReleased?: boolean;
83
- browserConsoleBuffer: string[];
84
- initTelemetry?: {
85
- initDurationMs: number;
86
- tweenCount: number;
87
- };
88
- capturePerf: {
89
- frames: number;
90
- seekMs: number;
91
- beforeCaptureMs: number;
92
- screenshotMs: number;
93
- totalMs: number;
94
- };
95
- captureMode: CaptureMode;
96
- // BeginFrame state
97
- beginFrameTimeTicks: number;
98
- beginFrameIntervalMs: number;
99
- beginFrameHasDamageCount: number;
100
- beginFrameNoDamageCount: number;
101
- /** Optional producer config — when set, overrides module-level env var constants. */
102
- config?: Partial<EngineConfig>;
103
- }
104
-
105
- // Circular buffer for browser console messages dumped on render failure diagnostics.
106
- // Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
107
- const BROWSER_CONSOLE_BUFFER_SIZE = 200;
108
- const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
109
-
110
- function appendBrowserDiagnostic(session: CaptureSession, text: string): void {
111
- session.browserConsoleBuffer.push(text);
112
- if (session.browserConsoleBuffer.length > BROWSER_CONSOLE_BUFFER_SIZE) {
113
- session.browserConsoleBuffer.shift();
114
- }
115
- }
116
-
117
- async function collectSessionInitTelemetry(
118
- page: Page,
119
- initStart: number,
120
- ): Promise<{ initDurationMs: number; tweenCount: number }> {
121
- const initDurationMs = Date.now() - initStart;
122
- let tweenCount = 0;
123
- try {
124
- tweenCount = await page.evaluate(() => {
125
- const timelines =
126
- (window as unknown as { __timelines?: Record<string, unknown> }).__timelines || {};
127
- const seen = new Set<object>();
128
- let count = 0;
129
- for (const timeline of Object.values(timelines)) {
130
- const maybeTimeline = timeline as { getChildren?: unknown };
131
- if (typeof maybeTimeline?.getChildren !== "function") continue;
132
- const children = maybeTimeline.getChildren(true, true, false) as unknown[];
133
- for (const child of children) {
134
- if (child && typeof child === "object" && !seen.has(child)) {
135
- seen.add(child);
136
- count++;
137
- }
138
- }
139
- }
140
- return count;
141
- });
142
- } catch {
143
- tweenCount = 0;
144
- }
145
- return { initDurationMs, tweenCount };
146
- }
147
-
148
- async function recordSessionInitTelemetry(
149
- session: CaptureSession,
150
- initStart: number,
151
- ): Promise<void> {
152
- const telemetry = await collectSessionInitTelemetry(session.page, initStart);
153
- session.initTelemetry = telemetry;
154
- appendBrowserDiagnostic(
155
- session,
156
- `[FrameCapture:INIT] complete initDurationMs=${telemetry.initDurationMs} tweenCount=${telemetry.tweenCount}`,
157
- );
158
- }
159
-
160
- export function sanitizeDiagnosticUrl(input: string): string {
161
- if (!input) return "(empty)";
162
- if (input.startsWith("data:")) return "data:<redacted>";
163
- if (input.startsWith("blob:")) return "blob:<redacted>";
164
- if (input.startsWith("/")) {
165
- try {
166
- const url = new URL(input, "http://hyperframes.local");
167
- return url.pathname;
168
- } catch {
169
- return input;
170
- }
171
- }
172
-
173
- try {
174
- const url = new URL(input);
175
- url.username = "";
176
- url.password = "";
177
- url.search = "";
178
- url.hash = "";
179
- return url.toString();
180
- } catch {
181
- return input;
182
- }
183
- }
184
-
185
- export function formatNavigationFailureDiagnostic(input: {
186
- captureMode: CaptureMode;
187
- url: string;
188
- timeoutMs: number;
189
- elapsedMs: number;
190
- error: unknown;
191
- }): string {
192
- const message = input.error instanceof Error ? input.error.message : String(input.error);
193
- return (
194
- `[FrameCapture:ERROR] page.goto failed ` +
195
- `mode=${input.captureMode} timeoutMs=${input.timeoutMs} elapsedMs=${input.elapsedMs} ` +
196
- `url=${sanitizeDiagnosticUrl(input.url)} error=${message}`
197
- );
198
- }
199
-
200
- export function formatNavigationStartDiagnostic(input: {
201
- captureMode: CaptureMode;
202
- url: string;
203
- timeoutMs: number;
204
- }): string {
205
- return (
206
- `[FrameCapture:NAV] page.goto start ` +
207
- `mode=${input.captureMode} timeoutMs=${input.timeoutMs} ` +
208
- `url=${sanitizeDiagnosticUrl(input.url)}`
209
- );
210
- }
211
-
212
- export function formatRequestFailureDiagnostic(input: {
213
- method: string;
214
- resourceType: string;
215
- url: string;
216
- failureText: string;
217
- }): string {
218
- return (
219
- `[Browser:REQUESTFAILED] ${input.method} ${sanitizeDiagnosticUrl(input.url)} ` +
220
- `resource=${input.resourceType} error=${input.failureText}`
221
- );
222
- }
223
-
224
- export function formatHttpErrorDiagnostic(input: {
225
- method: string;
226
- resourceType: string;
227
- url: string;
228
- status: number;
229
- statusText: string;
230
- }): string {
231
- const statusText = input.statusText ? ` ${input.statusText}` : "";
232
- return (
233
- `[Browser:HTTP${input.status}] ${input.method} ${sanitizeDiagnosticUrl(input.url)} ` +
234
- `resource=${input.resourceType}${statusText}`
235
- );
236
- }
237
-
238
- /**
239
- * Fixed warmup-loop iteration count used when `CaptureOptions.lockWarmupTicks`
240
- * is `true`. Picked to roughly match the median tick count observed by the
241
- * unlocked wall-clock loop during a typical 2s page load at 30fps — so
242
- * `beginFrameTimeTicks` lands in a similar range regardless of host speed.
243
- */
244
- export const LOCKED_WARMUP_TICKS = 60;
245
-
246
- /**
247
- * Internal driver for the BeginFrame warmup loop.
248
- *
249
- * - Unlocked: exits as soon as `state.running` flips to `false`. Tick count
250
- * varies with wall-clock page-load time.
251
- * - Locked: ignores `state.running` entirely and exits once it has driven
252
- * exactly `LOCKED_WARMUP_TICKS` iterations. Caller awaits this promise
253
- * after page-readiness so `session.beginFrameTimeTicks` is identical
254
- * across hosts.
255
- * - `tick` errors are swallowed (Chrome's `beginFrame` is best-effort
256
- * during page load — the page hasn't installed CDP listeners yet). When
257
- * `tick` throws, the iteration count does NOT advance.
258
- *
259
- * `intervalMs` is the BeginFrame interval (≈33ms at 30fps).
260
- *
261
- * `frameTimeTicks` is derived as `ticks * intervalMs` and exposed via
262
- * {@link warmupFrameTimeTicks} — not stored on the state, to keep `ticks`
263
- * the single source of truth.
264
- */
265
- export interface WarmupTickState {
266
- running: boolean;
267
- ticks: number;
268
- }
269
-
270
- export interface WarmupTickOptions {
271
- intervalMs: number;
272
- lockWarmupTicks: boolean;
273
- tick: (frameTimeTicks: number, intervalMs: number) => Promise<void>;
274
- /** Injectable so tests can advance "time" without real setTimeout. */
275
- sleep?: (ms: number) => Promise<void>;
276
- }
277
-
278
- const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
279
-
280
- /**
281
- * Derive the current simulated frame time from a warmup state. Single source
282
- * of truth so tests and callers stay in sync.
283
- */
284
- export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number): number {
285
- return state.ticks * intervalMs;
286
- }
287
-
288
- export async function driveWarmupTicks(
289
- options: WarmupTickOptions,
290
- state: WarmupTickState,
291
- ): Promise<void> {
292
- const sleep = options.sleep ?? realSleep;
293
- while (true) {
294
- if (options.lockWarmupTicks) {
295
- // Locked mode exits on the iteration count, ignoring `state.running` —
296
- // the caller flips `running=false` after page-readiness but we keep
297
- // ticking until LOCKED_WARMUP_TICKS so the count is host-independent.
298
- if (state.ticks >= LOCKED_WARMUP_TICKS) return;
299
- } else {
300
- // Unlocked mode is wall-clock-bounded.
301
- if (!state.running) return;
302
- }
303
- try {
304
- await options.tick(state.ticks * options.intervalMs, options.intervalMs);
305
- state.ticks += 1;
306
- } catch {
307
- // Page not ready yet; keep spinning.
308
- }
309
- await sleep(options.intervalMs);
310
- }
311
- }
312
-
313
- async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
314
- let timedOut = false;
315
- let timer: ReturnType<typeof setTimeout> | undefined;
316
- await Promise.race([
317
- promise.then(
318
- () => undefined,
319
- () => undefined,
320
- ),
321
- new Promise<void>((resolve) => {
322
- timer = setTimeout(() => {
323
- timedOut = true;
324
- resolve();
325
- }, CAPTURE_SESSION_CLOSE_TIMEOUT_MS);
326
- }),
327
- ]);
328
- if (timer) clearTimeout(timer);
329
- return !timedOut;
330
- }
331
-
332
- // fallow-ignore-next-line unit-size
333
- export async function createCaptureSession(
334
- serverUrl: string,
335
- outputDir: string,
336
- options: CaptureOptions,
337
- onBeforeCapture: BeforeCaptureHook | null = null,
338
- config?: Partial<EngineConfig>,
339
- ): Promise<CaptureSession> {
340
- if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
341
-
342
- // Determine capture mode before building args — BeginFrame flags only apply on Linux.
343
- // BeginFrame's compositor does not preserve alpha; callers that pass
344
- // `options.format === "png"` for transparent capture should also set
345
- // `config.forceScreenshot = true` (the producer's renderOrchestrator does this
346
- // automatically when `RenderConfig.format` is an alpha-capable value).
347
- const headlessShell = resolveHeadlessShellPath(config);
348
- const isLinux = process.platform === "linux";
349
- const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
350
- // BeginFrame's screenshot does not honor a viewport `deviceScaleFactor`
351
- // (the captured surface is sized by the OS window in CSS pixels regardless
352
- // of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
353
- // need explicit clip+scale on `Page.captureScreenshot`, so fall back to
354
- // the screenshot path for any DPR > 1.
355
- const supersampling = (options.deviceScaleFactor ?? 1) > 1;
356
- const preMode: CaptureMode =
357
- headlessShell && isLinux && !forceScreenshot && !supersampling ? "beginframe" : "screenshot";
358
- const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
359
- const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
360
- chromePath: headlessShell ?? undefined,
361
- browserTimeout: config?.browserTimeout,
362
- });
363
- const chromeArgs = buildChromeArgs(
364
- { width: options.width, height: options.height, captureMode: preMode },
365
- { ...config, browserGpuMode: resolvedGpuMode },
366
- );
367
-
368
- const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
369
-
370
- const page = await browser.newPage();
371
- // Polyfill esbuild's keepNames helper inside the page.
372
- //
373
- // The engine is published as raw TypeScript (`packages/engine/package.json`
374
- // points `main`/`exports` at `./src/index.ts`) and downstream consumers
375
- // execute it through transpilers that may inject `__name(fn, "name")`
376
- // wrappers around named functions. Empirically, this happens with:
377
- // - tsx (its esbuild loader runs with keepNames=true), used by the
378
- // producer's parity-harness, ad-hoc dev scripts, and the
379
- // `bun run --filter @hyperframes/engine test` Vitest path.
380
- // - any tsup/esbuild build that explicitly enables keepNames.
381
- //
382
- // The HeyGen CLI (`packages/cli`) bundles this engine via tsup with
383
- // keepNames left at its default (false) — verified by grepping
384
- // `packages/cli/dist/cli.js`, where `__name(...)` call sites are absent.
385
- // Bun's TS loader also does not currently inject `__name`. Even so,
386
- // anything that calls `page.evaluate(fn)` with a nested named function
387
- // under tsx (most local development and tests) will serialize bodies
388
- // like `__name(nested,"nested")` and crash with `__name is not defined`
389
- // in the browser. The shim makes such calls a no-op.
390
- //
391
- // An alternative is to load browser-side code as raw text and inject it
392
- // via `page.addScriptTag({ content: ... })` — see
393
- // `packages/cli/src/commands/contrast-audit.browser.js` for that pattern.
394
- // Until every `page.evaluate(fn)` call site migrates, this polyfill is
395
- // the single line of defense. The companion regression test in
396
- // `frameCapture-namePolyfill.test.ts` verifies the shim stays wired up.
397
- await page.evaluateOnNewDocument(() => {
398
- const w = window as unknown as { __name?: <T>(fn: T, _name: string) => T };
399
- if (typeof w.__name !== "function") {
400
- w.__name = <T>(fn: T, _name: string): T => fn;
401
- }
402
- });
403
- // Inject render-time variable overrides before any page script runs, so the
404
- // runtime helper `getVariables()` returns the merged result on its first
405
- // call. Pass the JSON string and parse inside the page so we don't require
406
- // any JSON-incompatible value to round-trip through Puppeteer's serializer.
407
- if (options.variables && Object.keys(options.variables).length > 0) {
408
- const variablesJson = JSON.stringify(options.variables);
409
- await page.evaluateOnNewDocument((json: string) => {
410
- type WindowWithVariables = Window & { __hfVariables?: Record<string, unknown> };
411
- try {
412
- (window as WindowWithVariables).__hfVariables = JSON.parse(json);
413
- } catch {
414
- // The CLI validated the JSON before this point — a parse failure here
415
- // means the page swapped JSON.parse, which is the page's problem.
416
- }
417
- }, variablesJson);
418
- }
419
- const browserVersion = await browser.version();
420
- const expectedMajor = config?.expectedChromiumMajor;
421
- if (Number.isFinite(expectedMajor)) {
422
- const actualChromiumMajor = Number.parseInt(
423
- (browserVersion.match(/(\d+)\./) || [])[1] || "",
424
- 10,
425
- );
426
- if (Number.isFinite(actualChromiumMajor) && actualChromiumMajor !== expectedMajor) {
427
- throw new Error(
428
- `[FrameCapture] Chromium major mismatch expected=${expectedMajor} actual=${actualChromiumMajor} raw=${browserVersion}`,
429
- );
430
- }
431
- }
432
- const viewport: Viewport = {
433
- width: options.width,
434
- height: options.height,
435
- deviceScaleFactor: options.deviceScaleFactor || 1,
436
- };
437
- await page.setViewport(viewport);
438
-
439
- // Transparent-background setup is intentionally NOT done here. Chrome resets
440
- // the default-background-color override on navigation, and the
441
- // `[data-composition-id]{background:transparent}` stylesheet that
442
- // `initTransparentBackground` injects must land in a real `document.head`.
443
- // See `initializeSession()` below — it calls `initTransparentBackground` for
444
- // PNG captures after `page.goto(...)` and the `window.__hf` readiness poll.
445
-
446
- return {
447
- browser,
448
- page,
449
- options,
450
- serverUrl,
451
- outputDir,
452
- onBeforeCapture,
453
- isInitialized: false,
454
- browserConsoleBuffer: [],
455
- capturePerf: {
456
- frames: 0,
457
- seekMs: 0,
458
- beforeCaptureMs: 0,
459
- screenshotMs: 0,
460
- totalMs: 0,
461
- },
462
- captureMode,
463
- beginFrameTimeTicks: 0,
464
- // Frame interval in ms: 1000 * den / num. For 30/1 → 33.333…, for
465
- // 30000/1001 (NTSC) → 33.366…. JavaScript number precision is fine at
466
- // these scales — no rounding required.
467
- beginFrameIntervalMs: (1000 * options.fps.den) / Math.max(1, options.fps.num),
468
- beginFrameHasDamageCount: 0,
469
- beginFrameNoDamageCount: 0,
470
- config,
471
- };
472
- }
473
-
474
- /**
475
- * Classify a console "Failed to load resource" error as a font-load failure.
476
- *
477
- * These are expected when deterministic font injection replaces Google Fonts
478
- * @import URLs with embedded base64 — or when the render environment has no
479
- * network access to Google Fonts. Suppressing them reduces noise in render
480
- * output without hiding real asset failures (images, videos, scripts, etc.).
481
- *
482
- * Chrome's `msg.text()` for a failed resource is typically just
483
- * `"Failed to load resource: net::ERR_FAILED"` — the URL is only on
484
- * `msg.location().url`. We match against both so the filter works regardless
485
- * of which form Chrome emits.
486
- */
487
- export function isFontResourceError(type: string, text: string, locationUrl: string): boolean {
488
- if (type !== "error") return false;
489
- if (!text.startsWith("Failed to load resource")) return false;
490
- return /fonts\.googleapis|fonts\.gstatic|\.(woff2?|ttf|otf)(\b|$)/i.test(
491
- `${locationUrl} ${text}`,
492
- );
493
- }
494
-
495
- async function pollPageExpression(
496
- page: Page,
497
- expression: string,
498
- timeoutMs: number,
499
- intervalMs: number = 100,
500
- ): Promise<boolean> {
501
- const deadline = Date.now() + timeoutMs;
502
- while (Date.now() < deadline) {
503
- const ready = Boolean(await page.evaluate(expression));
504
- if (ready) return true;
505
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
506
- }
507
- return Boolean(await page.evaluate(expression));
508
- }
509
-
510
- const HF_READY_DIAGNOSTIC_EXPR = `(function() {
511
- var hf = window.__hf;
512
- var player = window.__player;
513
- var renderReady = !!window.__renderReady;
514
- var hasSeek = !!(hf && typeof hf.seek === "function");
515
- var duration = hf ? hf.duration : -1;
516
- var hasTimeline = !!(window.__timelines && Object.keys(window.__timelines).length > 0);
517
- var root = document.querySelector("[data-composition-id]");
518
- var declaredDuration = root ? Number(root.getAttribute("data-duration")) : -1;
519
- return {
520
- renderReady: renderReady,
521
- hasHf: !!hf,
522
- hasSeek: hasSeek,
523
- hasPlayer: !!player,
524
- duration: duration,
525
- hasTimeline: hasTimeline,
526
- declaredDuration: declaredDuration,
527
- };
528
- })()`;
529
-
530
- // fallow-ignore-next-line complexity
531
- function buildZeroDurationDiagnostic(diag: {
532
- renderReady: boolean;
533
- hasHf: boolean;
534
- hasSeek: boolean;
535
- hasPlayer: boolean;
536
- duration: number;
537
- hasTimeline: boolean;
538
- declaredDuration: number;
539
- }): string {
540
- const hints: string[] = [];
541
- if (!diag.hasPlayer) {
542
- hints.push("window.__player was never set — the HyperFrames runtime did not initialize.");
543
- }
544
- if (!diag.hasTimeline) {
545
- hints.push(
546
- "No GSAP timeline registered (window.__timelines is empty). " +
547
- "If using CSS/WAAPI/Lottie/Three.js animations, add data-duration to the root element.",
548
- );
549
- }
550
- if (diag.declaredDuration <= 0 && !diag.hasTimeline) {
551
- hints.push(
552
- 'Fix: add data-duration="<seconds>" to your root <div data-composition-id="..."> element.',
553
- );
554
- }
555
- if (diag.hasSeek && diag.duration === 0 && diag.renderReady) {
556
- hints.push("The runtime finished initializing but reported zero duration — this is permanent.");
557
- }
558
- return (
559
- `[FrameCapture] Composition has zero duration.\n` +
560
- ` Runtime ready: ${diag.renderReady}, __player: ${diag.hasPlayer}, ` +
561
- `__hf.seek: ${diag.hasSeek}, GSAP timeline: ${diag.hasTimeline}, ` +
562
- `data-duration: ${diag.declaredDuration > 0 ? diag.declaredDuration + "s" : "not set"}\n` +
563
- (hints.length > 0 ? hints.map((h) => ` → ${h}`).join("\n") : "")
564
- );
565
- }
566
-
567
- interface HfDiagnostic {
568
- renderReady: boolean;
569
- hasHf: boolean;
570
- hasSeek: boolean;
571
- hasPlayer: boolean;
572
- duration: number;
573
- hasTimeline: boolean;
574
- declaredDuration: number;
575
- }
576
-
577
- async function evaluateHfDiagnostic(page: Page): Promise<HfDiagnostic> {
578
- return (await page.evaluate(HF_READY_DIAGNOSTIC_EXPR)) as HfDiagnostic;
579
- }
580
-
581
- async function pollHfReady(page: Page, timeoutMs: number, intervalMs: number = 100): Promise<void> {
582
- const readyExpr = `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`;
583
- const FAST_FAIL_AFTER_MS = 10_000;
584
- // Throttle diagnostic CDP calls to ~1000ms — running evaluateHfDiagnostic on
585
- // every 100ms poll tick after the 10s mark generates ~350 unnecessary CDP
586
- // round-trips per failed render. One diagnostic per second is enough.
587
- const DIAGNOSTIC_INTERVAL_MS = 1_000;
588
- const deadline = Date.now() + timeoutMs;
589
- let lastDiagnosticAt = 0;
590
-
591
- while (Date.now() < deadline) {
592
- const ready = Boolean(await page.evaluate(readyExpr));
593
- if (ready) return;
594
-
595
- const elapsed = timeoutMs - (deadline - Date.now());
596
- if (elapsed >= FAST_FAIL_AFTER_MS) {
597
- const now = Date.now();
598
- if (now - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) {
599
- lastDiagnosticAt = now;
600
- const diag = await evaluateHfDiagnostic(page);
601
- // Only fast-fail when BOTH signals are permanently zero:
602
- // 1. No GSAP timeline registered (GSAP sets duration synchronously
603
- // before __renderReady, so a missing timeline won't self-correct).
604
- // 2. No data-duration declared on the root element.
605
- // A composition with a GSAP timeline but no data-duration is still
606
- // valid — GSAP drives duration via __timelines, not data-duration.
607
- if (diag.renderReady && diag.hasSeek && !diag.hasTimeline && diag.declaredDuration <= 0) {
608
- throw new Error(buildZeroDurationDiagnostic(diag));
609
- }
610
- }
611
- }
612
-
613
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
614
- }
615
-
616
- const diag = await evaluateHfDiagnostic(page);
617
- if (diag.hasSeek && diag.duration === 0) {
618
- throw new Error(buildZeroDurationDiagnostic(diag));
619
- }
620
- throw new Error(
621
- `[FrameCapture] window.__hf not ready after ${timeoutMs}ms. ` +
622
- `Page must expose window.__hf = { duration, seek }.\n` +
623
- ` State: __hf=${diag.hasHf}, seek=${diag.hasSeek}, player=${diag.hasPlayer}, ` +
624
- `renderReady=${diag.renderReady}, duration=${diag.duration}`,
625
- );
626
- }
627
-
628
- async function pollSubCompositionTimelines(
629
- page: Page,
630
- timeoutMs: number,
631
- intervalMs: number = 150,
632
- ): Promise<void> {
633
- const expression = `(function() {
634
- var hosts = document.querySelectorAll("[data-composition-id]");
635
- if (hosts.length === 0) return true;
636
- var timelines = window.__timelines || {};
637
- for (var i = 0; i < hosts.length; i++) {
638
- var id = hosts[i].getAttribute("data-composition-id");
639
- if (!id) continue;
640
- if (!timelines[id]) return false;
641
- }
642
- return true;
643
- })()`;
644
- const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs);
645
- // Always force a timeline rebind once sub-composition timelines are
646
- // confirmed present. The previous implementation only called rebind
647
- // when the timeline count grew during the poll, which missed the case
648
- // where all sub-comp scripts had already executed before the poll
649
- // started — leaving child timelines un-nested in the root and causing
650
- // the earliest sub-composition (data-start near 0) to render without
651
- // its GSAP animations.
652
- if (ready) {
653
- await page.evaluate(`(function() {
654
- if (typeof window.__hfForceTimelineRebind === "function") {
655
- window.__hfForceTimelineRebind();
656
- }
657
- })()`);
658
- }
659
- if (!ready) {
660
- const missing = await page.evaluate(`(function() {
661
- var hosts = document.querySelectorAll("[data-composition-id]");
662
- var timelines = window.__timelines || {};
663
- var m = [];
664
- for (var i = 0; i < hosts.length; i++) {
665
- var id = hosts[i].getAttribute("data-composition-id");
666
- if (id && !timelines[id]) m.push(id);
667
- }
668
- return m.join(", ");
669
- })()`);
670
- console.warn(
671
- `[FrameCapture] Sub-composition timelines not registered after ${timeoutMs}ms: ${missing}. ` +
672
- `Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes.`,
673
- );
674
- }
675
- }
676
-
677
- async function pollVideosReady(
678
- page: Page,
679
- skipIds: readonly string[],
680
- timeoutMs: number,
681
- intervalMs: number = 100,
682
- ): Promise<boolean> {
683
- const check = async (): Promise<boolean> => {
684
- return Boolean(
685
- await page.evaluate((skipIdList: readonly string[]) => {
686
- const skip = new Set(skipIdList);
687
- const vids = Array.from(document.querySelectorAll("video")).filter((v) => !skip.has(v.id));
688
- return (
689
- vids.length === 0 ||
690
- vids.every((v) => {
691
- const ve = v as HTMLVideoElement;
692
- if (ve.readyState >= 2) return true;
693
- if (ve.error) return true;
694
- if (ve.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) return true;
695
- return false;
696
- })
697
- );
698
- }, skipIds),
699
- );
700
- };
701
- const deadline = Date.now() + timeoutMs;
702
- while (Date.now() < deadline) {
703
- if (await check()) return true;
704
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
705
- }
706
- return check();
707
- }
708
-
709
- // Wait for every `<img>` with a non-`data:` src to have settled — either
710
- // successfully loaded (`complete && naturalWidth > 0`) or failed with a
711
- // broken-image marker (`complete && naturalWidth === 0`, the HTMLImageElement
712
- // equivalent of HTMLMediaElement.error). htmlCompiler localises remote `<img>`
713
- // URLs to the local file server before this point, so in practice this polls
714
- // for the local fetch to land — but the guard is a defensive net so that any
715
- // future composition path that leaves a remote URL in place won't capture
716
- // frames before the pixels arrive. Mirrors `pollVideosReady` for parity with
717
- // the video-side readiness contract (videos exit-early on `ve.error`; images
718
- // exit-early on `complete && naturalWidth === 0`).
719
- /** @internal exported for unit testing only */
720
- export async function pollImagesReady(
721
- page: Page,
722
- timeoutMs: number,
723
- intervalMs: number = 100,
724
- ): Promise<boolean> {
725
- const check = async (): Promise<boolean> => {
726
- return Boolean(
727
- await page.evaluate(() => {
728
- const imgs = Array.from(document.querySelectorAll("img"));
729
- return (
730
- imgs.length === 0 ||
731
- imgs.every((img) => {
732
- const ie = img as HTMLImageElement;
733
- const src = ie.getAttribute("src") || "";
734
- if (!src || src.startsWith("data:")) return true;
735
- // A `complete` image with zero naturalWidth has settled with an
736
- // error (404 / decode failure / CORS rejection / blocked). Treat
737
- // as done — waiting won't make it load — and let the render
738
- // continue with the broken-image marker visible. Mirrors how
739
- // pollVideosReady treats `ve.error`.
740
- if (ie.complete && ie.naturalWidth === 0) return true;
741
- if (ie.complete && ie.naturalWidth > 0) return true;
742
- return false;
743
- })
744
- );
745
- }),
746
- );
747
- };
748
- const deadline = Date.now() + timeoutMs;
749
- while (Date.now() < deadline) {
750
- if (await check()) return true;
751
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
752
- }
753
- return check();
754
- }
755
-
756
- // Force every successfully-loaded `<img>` to be GPU-uploaded before the first
757
- // frame capture. `naturalWidth > 0` means the bitmap has been decoded into
758
- // CPU memory, but compositor-side GPU upload can still happen lazily on first
759
- // paint. Calling `img.decode()` returns a Promise that resolves once the image
760
- // is ready for synchronous painting — eliminating the small first-frame race
761
- // between "image is technically loaded" and "the rasterized texture is on the
762
- // GPU and ready to composite".
763
- //
764
- // Note this is purely an init-time guard; it doesn't prevent Chrome from
765
- // evicting decoded pixels mid-render. The producer-side `localizeRemoteImageSources`
766
- // is what bounds the eviction risk (a re-fetch hits the local file server's
767
- // disk-backed paging, not S3 over the network).
768
- //
769
- // Critical: `decode()` on an in-flight image waits for the fetch to resolve.
770
- // If `pollImagesReady` timed out with some images still loading (`!complete`),
771
- // calling `decode()` on them would block here until the network finally
772
- // completes — or until puppeteer's evaluate timeout fires and throws an
773
- // uncaught error that aborts the render. Skip in-flight and broken images;
774
- // only force GPU upload for images that successfully loaded.
775
- async function decodeAllImages(page: Page): Promise<void> {
776
- await page.evaluate(async () => {
777
- const imgs = Array.from(document.querySelectorAll("img"));
778
- await Promise.all(
779
- imgs.map((img) => {
780
- const ie = img as HTMLImageElement;
781
- if (typeof ie.decode !== "function") return Promise.resolve();
782
- // Skip still-loading images (in-flight decode() would hang) and
783
- // broken images (decode() rejects, but pre-filtering is clearer
784
- // than relying on the .catch).
785
- if (!ie.complete || ie.naturalWidth === 0) return Promise.resolve();
786
- return ie.decode().catch(() => undefined);
787
- }),
788
- );
789
- });
790
- }
791
-
792
- async function applyVideoMetadataHints(
793
- page: Page,
794
- hints: readonly CaptureVideoMetadataHint[] | undefined,
795
- ): Promise<void> {
796
- if (!hints || hints.length === 0) return;
797
-
798
- // fallow-ignore-next-line complexity
799
- await page.evaluate(
800
- (metadataHints: CaptureVideoMetadataHint[]) => {
801
- for (const hint of metadataHints) {
802
- if (
803
- !hint.id ||
804
- !Number.isFinite(hint.width) ||
805
- !Number.isFinite(hint.height) ||
806
- hint.width <= 0 ||
807
- hint.height <= 0
808
- ) {
809
- continue;
810
- }
811
-
812
- const video = document.getElementById(hint.id) as HTMLVideoElement | null;
813
- if (!video) continue;
814
-
815
- if (!video.hasAttribute("width")) video.setAttribute("width", String(hint.width));
816
- if (!video.hasAttribute("height")) video.setAttribute("height", String(hint.height));
817
-
818
- const computed = window.getComputedStyle(video);
819
- if (
820
- !video.style.aspectRatio &&
821
- (!computed.aspectRatio || computed.aspectRatio === "auto")
822
- ) {
823
- video.style.aspectRatio = `${hint.width} / ${hint.height}`;
824
- }
825
- }
826
- },
827
- [...hints],
828
- );
829
- }
830
-
831
- async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Promise<void> {
832
- const hasTailwindReady = await page.evaluate(
833
- `(() => { const ready = window.__tailwindReady; return !!ready && typeof ready.then === "function"; })()`,
834
- );
835
- if (!hasTailwindReady) return;
836
-
837
- const ready = await Promise.race([
838
- page.evaluate(
839
- `Promise.resolve(window.__tailwindReady).then(() => true, () => false)`,
840
- ) as Promise<boolean>,
841
- new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
842
- ]);
843
-
844
- if (!ready) {
845
- throw new Error(
846
- `[FrameCapture] window.__tailwindReady not resolved after ${timeoutMs}ms. Tailwind browser runtime must finish before frame capture starts.`,
847
- );
848
- }
849
- }
850
-
851
- // fallow-ignore-next-line unit-size
852
- export async function initializeSession(session: CaptureSession): Promise<void> {
853
- const { page, serverUrl } = session;
854
-
855
- // Forward browser console to host with [Browser] prefix
856
- // fallow-ignore-next-line complexity
857
- page.on("console", (msg: ConsoleMessage) => {
858
- const type = msg.type();
859
- const text = msg.text();
860
- const locationUrl = msg.location()?.url ?? "";
861
- const isFontLoadError = isFontResourceError(type, text, locationUrl);
862
-
863
- // Other "Failed to load resource" 404s are typically non-blocking (e.g.
864
- // favicon, sourcemaps, optional assets). Prefix them so users know they
865
- // are harmless and don't confuse them with real render errors.
866
- const isResourceLoadError =
867
- type === "error" && text.startsWith("Failed to load resource") && !isFontLoadError;
868
-
869
- const prefix = isResourceLoadError
870
- ? "[non-blocking]"
871
- : type === "error"
872
- ? "[Browser:ERROR]"
873
- : type === "warn"
874
- ? "[Browser:WARN]"
875
- : "[Browser]";
876
- if (!isFontLoadError) {
877
- console.log(`${prefix} ${text}`);
878
- }
879
-
880
- appendBrowserDiagnostic(session, `${prefix} ${text}`);
881
- });
882
-
883
- page.on("pageerror", (err) => {
884
- const message = err instanceof Error ? err.message : String(err);
885
- const text = `[Browser:PAGEERROR] ${message}`;
886
-
887
- // Benign play/pause race during frame capture — suppress terminal noise, keep in buffer.
888
- const isPlayAbort =
889
- /^AbortError:/.test(message) && message.includes("play()") && message.includes("pause()");
890
- if (!isPlayAbort) {
891
- console.error(text);
892
- }
893
-
894
- appendBrowserDiagnostic(session, text);
895
- });
896
-
897
- page.on("requestfailed", (request) => {
898
- appendBrowserDiagnostic(
899
- session,
900
- formatRequestFailureDiagnostic({
901
- method: request.method(),
902
- resourceType: request.resourceType(),
903
- url: request.url(),
904
- failureText: request.failure()?.errorText ?? "unknown",
905
- }),
906
- );
907
- });
908
-
909
- page.on("response", (response) => {
910
- const status = response.status();
911
- if (status < 400) return;
912
-
913
- const request = response.request();
914
- appendBrowserDiagnostic(
915
- session,
916
- formatHttpErrorDiagnostic({
917
- method: request.method(),
918
- resourceType: request.resourceType(),
919
- url: response.url(),
920
- status,
921
- statusText: response.statusText(),
922
- }),
923
- );
924
- });
925
-
926
- // Navigate to the file server
927
- const url = `${serverUrl}/index.html`;
928
- const pageNavigationTimeout =
929
- session.config?.pageNavigationTimeout ?? DEFAULT_CONFIG.pageNavigationTimeout;
930
- const initStart = Date.now();
931
- const logInitPhase = (phase: string) => {
932
- console.log(`[initSession:${session.captureMode}] ${phase} (${Date.now() - initStart}ms)`);
933
- };
934
- const gotoEntryPage = async (): Promise<void> => {
935
- appendBrowserDiagnostic(
936
- session,
937
- formatNavigationStartDiagnostic({
938
- captureMode: session.captureMode,
939
- url,
940
- timeoutMs: pageNavigationTimeout,
941
- }),
942
- );
943
- logInitPhase("page.goto start");
944
- try {
945
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: pageNavigationTimeout });
946
- } catch (error) {
947
- appendBrowserDiagnostic(
948
- session,
949
- formatNavigationFailureDiagnostic({
950
- captureMode: session.captureMode,
951
- url,
952
- timeoutMs: pageNavigationTimeout,
953
- elapsedMs: Date.now() - initStart,
954
- error,
955
- }),
956
- );
957
- throw error;
958
- }
959
- };
960
-
961
- if (session.captureMode === "screenshot") {
962
- // Screenshot mode: standard navigation, rAF works normally
963
- await gotoEntryPage();
964
- logInitPhase("page.goto complete");
965
-
966
- const pageReadyTimeout =
967
- session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
968
- await pollHfReady(page, pageReadyTimeout);
969
- logInitPhase("pollHfReady complete");
970
-
971
- await pollSubCompositionTimelines(page, pageReadyTimeout);
972
- logInitPhase("pollSubCompositionTimelines complete");
973
-
974
- await applyVideoMetadataHints(page, session.options.videoMetadataHints);
975
- logInitPhase("applyVideoMetadataHints complete");
976
-
977
- // Wait for all video elements to have decoded their CURRENT frame, not
978
- // just metadata. readyState >= 2 (HAVE_CURRENT_DATA) means a frame is
979
- // actually rasterized and ready to paint — at >= 1 (HAVE_METADATA) we
980
- // only know the dimensions, and the first <video> screenshot can come
981
- // back as a black/blank rectangle. This bites compositions with two
982
- // <video> elements of different codecs (h264 mp4 + VP9 webm) where the
983
- // faster decoder lets the readiness check pass while the slower one
984
- // hasn't painted, producing a black "first frame" for the slower clip.
985
- // skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
986
- // sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
987
- // supply intrinsic dimensions for skipped videos whose layout depends on
988
- // aspect ratio, while Chromium may still fail to decode/load metadata.
989
- const videosReady = await pollVideosReady(
990
- page,
991
- session.options.skipReadinessVideoIds ?? [],
992
- pageReadyTimeout,
993
- );
994
- logInitPhase("pollVideosReady complete");
995
- if (!videosReady) {
996
- const failedVideos = await page.evaluate((skipIdList: readonly string[]) => {
997
- const skip = new Set(skipIdList);
998
- return Array.from(document.querySelectorAll("video"))
999
- .filter((v) => !skip.has(v.id))
1000
- .filter((v) => (v as HTMLVideoElement).readyState < 2 && !(v as HTMLVideoElement).error)
1001
- .map((v) => (v as HTMLVideoElement).src || v.getAttribute("src") || "(no src)")
1002
- .join(", ");
1003
- }, session.options.skipReadinessVideoIds ?? []);
1004
- console.warn(
1005
- `[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
1006
- `Continuing render — affected videos will appear as blank/black frames.`,
1007
- );
1008
- }
1009
-
1010
- const imagesReady = await pollImagesReady(page, pageReadyTimeout);
1011
- if (!imagesReady) {
1012
- const failedImages = await page.evaluate(() => {
1013
- return Array.from(document.querySelectorAll("img"))
1014
- .filter((img) => {
1015
- const ie = img as HTMLImageElement;
1016
- const src = ie.getAttribute("src") || "";
1017
- if (!src || src.startsWith("data:")) return false;
1018
- return !(ie.complete && ie.naturalWidth > 0);
1019
- })
1020
- .map((img) => (img as HTMLImageElement).src || img.getAttribute("src") || "(no src)")
1021
- .join(", ");
1022
- });
1023
- console.warn(
1024
- `[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
1025
- `Continuing render — affected images may appear blank/missing in early frames.`,
1026
- );
1027
- }
1028
- await decodeAllImages(page);
1029
- logInitPhase("images ready + decoded");
1030
-
1031
- await page.evaluate(`document.fonts?.ready`);
1032
- logInitPhase("fonts ready");
1033
- await waitForOptionalTailwindReady(page, pageReadyTimeout);
1034
- logInitPhase("tailwind ready");
1035
- await recordSessionInitTelemetry(session, initStart);
1036
-
1037
- // For PNG captures, force the page background fully transparent so the
1038
- // captured screenshots carry a real alpha channel. Must run AFTER
1039
- // navigation (Chrome resets the override on every goto) and AFTER the
1040
- // page is loaded (the injected stylesheet needs a real document.head).
1041
- // The override is overridden by `body { background: ... }` and
1042
- // `#root { background: ... }` rules — the helper handles that with a
1043
- // `[data-composition-id]{background:transparent !important}` injection.
1044
- if (session.options.format === "png") {
1045
- await initTransparentBackground(session.page);
1046
- }
1047
-
1048
- await armStaticDedup(session, session.page, logInitPhase);
1049
- session.isInitialized = true;
1050
- return;
1051
- }
1052
-
1053
- // In BeginFrame mode, Chrome's event loop is paused until we issue frames.
1054
- // Start a warmup loop to drive rAF/setTimeout callbacks during page load.
1055
- //
1056
- // The unlocked path runs while `warmupState.running` stays true — wall-
1057
- // clock-bounded. The locked path (`options.lockWarmupTicks`) additionally
1058
- // exits at exactly `LOCKED_WARMUP_TICKS` iterations so `beginFrameTimeTicks`
1059
- // is deterministic across hosts with different page-load latencies.
1060
- const warmupIntervalMs = 33; // ~30fps
1061
- const warmupState: WarmupTickState = {
1062
- running: true,
1063
- ticks: 0,
1064
- };
1065
- const lockWarmupTicks = session.options.lockWarmupTicks === true;
1066
- let warmupClient: import("puppeteer-core").CDPSession | null = null;
1067
-
1068
- const acquireWarmupClient = async (): Promise<void> => {
1069
- try {
1070
- warmupClient = await getCdpSession(page);
1071
- await warmupClient.send("HeadlessExperimental.enable");
1072
- } catch {
1073
- /* page not ready yet */
1074
- }
1075
- };
1076
-
1077
- const warmupLoopPromise = (async () => {
1078
- await acquireWarmupClient();
1079
- await driveWarmupTicks(
1080
- {
1081
- intervalMs: warmupIntervalMs,
1082
- lockWarmupTicks,
1083
- tick: async (frameTimeTicks, interval) => {
1084
- if (!warmupClient) {
1085
- // No CDP yet — let driveWarmupTicks count the tick anyway so the
1086
- // locked iteration count is reached deterministically. Throwing
1087
- // would skip the ticks++ increment, leaking host-load variance
1088
- // back into the count.
1089
- return;
1090
- }
1091
- await warmupClient.send("HeadlessExperimental.beginFrame", {
1092
- frameTimeTicks,
1093
- interval,
1094
- noDisplayUpdates: true,
1095
- });
1096
- },
1097
- },
1098
- warmupState,
1099
- );
1100
- })();
1101
- warmupLoopPromise.catch(() => {});
1102
- logInitPhase("warmup loop started");
1103
-
1104
- await gotoEntryPage();
1105
- logInitPhase("page.goto complete");
1106
-
1107
- // Poll for window.__hf readiness using manual evaluate loop (waitForFunction
1108
- // uses rAF polling internally, which won't fire in beginFrame mode).
1109
- const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
1110
- try {
1111
- await pollHfReady(page, pageReadyTimeout);
1112
- logInitPhase("pollHfReady complete");
1113
- } catch (err) {
1114
- warmupState.running = false;
1115
- throw err;
1116
- }
1117
-
1118
- await pollSubCompositionTimelines(page, pageReadyTimeout);
1119
- logInitPhase("pollSubCompositionTimelines complete");
1120
-
1121
- await applyVideoMetadataHints(page, session.options.videoMetadataHints);
1122
- logInitPhase("applyVideoMetadataHints complete");
1123
-
1124
- // Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).
1125
- const bfVideosReady = await pollVideosReady(
1126
- page,
1127
- session.options.skipReadinessVideoIds ?? [],
1128
- session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout,
1129
- );
1130
- if (!bfVideosReady) {
1131
- const failedVideos = await page.evaluate((skipIdList: readonly string[]) => {
1132
- const skip = new Set(skipIdList);
1133
- return Array.from(document.querySelectorAll("video"))
1134
- .filter((v) => !skip.has(v.id))
1135
- .filter((v) => (v as HTMLVideoElement).readyState < 2 && !(v as HTMLVideoElement).error)
1136
- .map((v) => (v as HTMLVideoElement).src || v.getAttribute("src") || "(no src)")
1137
- .join(", ");
1138
- }, session.options.skipReadinessVideoIds ?? []);
1139
- console.warn(
1140
- `[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
1141
- `Continuing render — affected videos will appear as blank/black frames.`,
1142
- );
1143
- }
1144
- logInitPhase("pollVideosReady complete");
1145
-
1146
- // Image readiness — parity with pollVideosReady. Defense against remote
1147
- // <img> URLs that bypass the htmlCompiler localize step.
1148
- const bfImagesReady = await pollImagesReady(page, pageReadyTimeout);
1149
- if (!bfImagesReady) {
1150
- const failedImages = await page.evaluate(() => {
1151
- return Array.from(document.querySelectorAll("img"))
1152
- .filter((img) => {
1153
- const ie = img as HTMLImageElement;
1154
- const src = ie.getAttribute("src") || "";
1155
- if (!src || src.startsWith("data:")) return false;
1156
- return !(ie.complete && ie.naturalWidth > 0);
1157
- })
1158
- .map((img) => (img as HTMLImageElement).src || img.getAttribute("src") || "(no src)")
1159
- .join(", ");
1160
- });
1161
- console.warn(
1162
- `[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
1163
- `Continuing render — affected images may appear blank/missing in early frames.`,
1164
- );
1165
- }
1166
- await decodeAllImages(page);
1167
- logInitPhase("images ready + decoded");
1168
-
1169
- await page.evaluate(`document.fonts?.ready`);
1170
- logInitPhase("fonts ready");
1171
- await waitForOptionalTailwindReady(page, pageReadyTimeout);
1172
- logInitPhase("tailwind ready");
1173
- await recordSessionInitTelemetry(session, initStart);
1174
-
1175
- // Stop warmup. Unlocked mode exits on this flag; locked mode keeps ticking
1176
- // until LOCKED_WARMUP_TICKS, so we await its promise to ensure the count is
1177
- // exact before deriving the baseline.
1178
- warmupState.running = false;
1179
- if (lockWarmupTicks) {
1180
- await warmupLoopPromise.catch(() => {});
1181
- }
1182
-
1183
- // Set base frame time ticks past warmup range. Locked mode pins to the
1184
- // constant so chunk workers on different hosts compute the same baseline.
1185
- const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
1186
- session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
1187
-
1188
- // For PNG captures, inject the transparent-background override + stylesheet
1189
- // (see the screenshot-mode branch above for the rationale). BeginFrame mode
1190
- // does not actually preserve alpha through its compositor — callers that
1191
- // need transparent output should set `forceScreenshot: true` so this branch
1192
- // is bypassed entirely. The call is left here as defense-in-depth for any
1193
- // future BeginFrame alpha support.
1194
- if (session.options.format === "png") {
1195
- await initTransparentBackground(session.page);
1196
- }
1197
-
1198
- await armStaticDedup(session, session.page, logInitPhase);
1199
- session.isInitialized = true;
1200
- }
1201
-
1202
- async function captureFrameErrorDiagnostics(
1203
- session: CaptureSession,
1204
- frameIndex: number,
1205
- time: number,
1206
- error: Error,
1207
- ): Promise<string | null> {
1208
- try {
1209
- const diagnosticsDir = join(session.outputDir, "diagnostics");
1210
- if (!existsSync(diagnosticsDir)) mkdirSync(diagnosticsDir, { recursive: true });
1211
- const base = join(diagnosticsDir, `frame-error-${frameIndex}`);
1212
- await session.page.screenshot({ path: `${base}.png`, type: "png", fullPage: true });
1213
- const html = await session.page.content();
1214
- writeFileSync(`${base}.html`, html, "utf-8");
1215
- writeFileSync(
1216
- `${base}.json`,
1217
- JSON.stringify(
1218
- {
1219
- frameIndex,
1220
- time,
1221
- error: error.message,
1222
- stack: error.stack,
1223
- browserConsoleTail: session.browserConsoleBuffer.slice(-30),
1224
- },
1225
- null,
1226
- 2,
1227
- ),
1228
- "utf-8",
1229
- );
1230
- return `${base}.json`;
1231
- } catch {
1232
- return null;
1233
- }
1234
- }
1235
-
1236
- /**
1237
- * Internal helper: seek timeline and inject video frames.
1238
- * Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
1239
- * Returns timing breakdown for perf tracking.
1240
- */
1241
- async function prepareFrameForCapture(
1242
- session: CaptureSession,
1243
- frameIndex: number,
1244
- time: number,
1245
- ): Promise<{
1246
- quantizedTime: number;
1247
- seekMs: number;
1248
- beforeCaptureMs: number;
1249
- }> {
1250
- const { page, options } = session;
1251
-
1252
- if (!session.isInitialized) {
1253
- throw new Error("[FrameCapture] Session not initialized");
1254
- }
1255
-
1256
- const quantizedTime = quantizeTimeToFrame(time, fpsToNumber(options.fps));
1257
-
1258
- const seekStart = Date.now();
1259
- // Seek via the __hf protocol. The page's seek() implementation handles
1260
- // all framework-specific logic (GSAP stepping, CSS animation sync, etc.)
1261
- // Seek + check page-side composite pending flag in one round-trip.
1262
- const hasPendingComposite = await page.evaluate((t: number) => {
1263
- if (window.__hf && typeof window.__hf.seek === "function") {
1264
- window.__hf.seek(t);
1265
- }
1266
- return !!(window as unknown as { __hf_page_composite_pending?: boolean })
1267
- .__hf_page_composite_pending;
1268
- }, quantizedTime);
1269
-
1270
- const seekMs = Date.now() - seekStart;
1271
-
1272
- // Before-capture hook (e.g. video frame injection) — runs before
1273
- // page-side compositor clones so cloneNode picks up injected <img>
1274
- // replacements for <video> elements.
1275
- const beforeCaptureStart = Date.now();
1276
- if (session.onBeforeCapture) {
1277
- await session.onBeforeCapture(page, quantizedTime);
1278
- }
1279
- const beforeCaptureMs = Date.now() - beforeCaptureStart;
1280
-
1281
- // Page-side compositing three-phase protocol:
1282
- // 1. prepare — clone scenes (now containing injected video <img>s)
1283
- // 2. micro-screenshot — force browser to paint cloned elements
1284
- // 3. resolve — drawElementImage reads paint records, shader composites
1285
- if (hasPendingComposite && session.captureMode !== "beginframe") {
1286
- await page.evaluate(async () => {
1287
- const w = window as unknown as { __hf_page_composite_prepare?: () => Promise<boolean> };
1288
- if (typeof w.__hf_page_composite_prepare === "function") {
1289
- await w.__hf_page_composite_prepare();
1290
- }
1291
- });
1292
- const cdp = await getCdpSession(page);
1293
- await cdp.send("Page.captureScreenshot", {
1294
- format: "jpeg",
1295
- quality: 1,
1296
- clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 },
1297
- });
1298
- await page.evaluate(() => {
1299
- const w = window as unknown as { __hf_page_composite_resolve?: () => boolean };
1300
- if (typeof w.__hf_page_composite_resolve === "function") {
1301
- w.__hf_page_composite_resolve();
1302
- }
1303
- });
1304
- }
1305
-
1306
- return { quantizedTime, seekMs, beforeCaptureMs };
1307
- }
1308
-
1309
- // ── Static-frame dedup (default-on, opt-out HF_STATIC_DEDUP=false) ─────────────
1310
- // Skip re-seeking + re-screenshotting frames that are byte-identical to their
1311
- // predecessor. A frame is dedupable iff no GSAP tween or clip cut is active in it or
1312
- // its predecessor (predicted from window.__timelines), AND an empirical anchor-compare
1313
- // confirms it. Capture-mode-independent (works on screenshot + beginframe), lossless
1314
- // (verification disables the whole comp on any drift), default off. Pays on
1315
- // static-hold content (title cards, slideshows, data-viz pauses); a no-op on
1316
- // continuously-animated comps and disqualified by video/canvas/non-GSAP animation.
1317
-
1318
- /**
1319
- * Clip-cut boundary frames (±1) from the [data-start] schedule. A hard scene swap at a
1320
- * cut changes content with no tween; treat those frames as animated so the post-cut
1321
- * frame is captured fresh and later static frames reuse the correct scene.
1322
- */
1323
- async function computeClipBoundaryFrames(page: Page, fps: number): Promise<Set<number>> {
1324
- const schedule = await page.evaluate(() =>
1325
- Array.from(document.querySelectorAll("[data-start]")).map((el) => ({
1326
- start: parseFloat((el as HTMLElement).dataset.start || ""),
1327
- dur: parseFloat((el as HTMLElement).dataset.duration || ""),
1328
- })),
1329
- );
1330
- const frames = new Set<number>();
1331
- for (const { start, dur } of schedule) {
1332
- if (Number.isNaN(start)) continue;
1333
- const edges = [Math.round(start * fps)];
1334
- if (!Number.isNaN(dur)) edges.push(Math.round((start + dur) * fps));
1335
- for (const e of edges) {
1336
- for (const f of [e - 1, e, e + 1]) {
1337
- if (f >= 0) frames.add(f);
1338
- }
1339
- }
1340
- }
1341
- return frames;
1342
- }
1343
-
1344
- /**
1345
- * Predict the dedupable (static) frame set from window.__timelines. A frame f (f>0) is
1346
- * static iff NEITHER f NOR f-1 falls inside any GSAP tween interval — content didn't
1347
- * change f-1→f, so f can reuse f-1's buffer. Requiring BOTH neighbours static under-
1348
- * claims by one frame at each tween edge (the SAFE direction). Disqualifies the whole
1349
- * comp on any signal the tween-walker can't see: video / canvas / webgl (redraw without
1350
- * a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
1351
- */
1352
- async function computeStaticFrameSet(
1353
- page: Page,
1354
- fps: number,
1355
- ): Promise<{
1356
- totalFrames: number;
1357
- staticFrameSet: Set<number>;
1358
- hasVideo: boolean;
1359
- hasCanvas: boolean;
1360
- hasNonGsapAnim: boolean;
1361
- tweenCount: number;
1362
- eligible: boolean;
1363
- reason: string;
1364
- }> {
1365
- const result = await page.evaluate(() => {
1366
- type AnyTween = {
1367
- startTime(): number;
1368
- duration(): number;
1369
- totalDuration?(): number;
1370
- getChildren?(nested: boolean, tweens: boolean, timelines: boolean): AnyTween[];
1371
- };
1372
- const intervals: Array<{ start: number; end: number }> = [];
1373
- let tweenCount = 0;
1374
- // totalDuration() (NOT duration()): a repeat/yoyo tween animates past one iteration;
1375
- // a repeating timeline is marked opaque over its whole span (conservative).
1376
- function walk(tl: AnyTween, offset: number): void {
1377
- if (typeof tl.getChildren !== "function") return;
1378
- for (const child of tl.getChildren(false, true, true)) {
1379
- const start = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
1380
- const single = typeof child.duration === "function" ? child.duration() : 0;
1381
- const total = typeof child.totalDuration === "function" ? child.totalDuration() : single;
1382
- if (typeof child.getChildren === "function") {
1383
- if (total > single + 1e-6) intervals.push({ start, end: start + total });
1384
- else walk(child, start);
1385
- } else {
1386
- tweenCount++;
1387
- intervals.push({ start, end: start + total });
1388
- }
1389
- }
1390
- }
1391
- const w = window as unknown as {
1392
- __timelines?: Record<string, AnyTween>;
1393
- __hf?: { duration?: number };
1394
- };
1395
- for (const tl of Object.values(w.__timelines || {})) {
1396
- if (tl && typeof tl.getChildren === "function") walk(tl, 0);
1397
- }
1398
- const hasVideo = !!document.querySelector("video");
1399
- const hasCanvas = !!document.querySelector("canvas");
1400
- // A non-numeric data-start (reference expression like "intro+0.5") can't be turned
1401
- // into a clip-cut boundary by computeClipBoundaryFrames' parseFloat, so the cut goes
1402
- // unprotected and could be deduped into the previous scene. Disqualify the comp.
1403
- const hasUnresolvableClipStart = Array.from(document.querySelectorAll("[data-start]")).some(
1404
- (el) => {
1405
- const v = (el as HTMLElement).dataset.start;
1406
- return v != null && v.trim() !== "" && !Number.isFinite(parseFloat(v));
1407
- },
1408
- );
1409
- // Non-GSAP animation (CSS @keyframes / transitions / WAAPI) surfaces via
1410
- // getAnimations(); any running/paused one can change content without a tween.
1411
- let hasNonGsapAnim = false;
1412
- try {
1413
- const docAnims = (document as unknown as { getAnimations?: () => Animation[] }).getAnimations;
1414
- if (typeof docAnims === "function") {
1415
- hasNonGsapAnim = docAnims.call(document).some((a) => {
1416
- const t = a as Animation & { playState?: string };
1417
- return t.playState === "running" || t.playState === "paused";
1418
- });
1419
- }
1420
- } catch {
1421
- hasNonGsapAnim = true;
1422
- }
1423
- return {
1424
- intervals,
1425
- tweenCount,
1426
- duration: w.__hf?.duration ?? 0,
1427
- hasVideo,
1428
- hasCanvas,
1429
- hasNonGsapAnim,
1430
- hasUnresolvableClipStart,
1431
- };
1432
- });
1433
-
1434
- const {
1435
- intervals,
1436
- tweenCount,
1437
- duration,
1438
- hasVideo,
1439
- hasCanvas,
1440
- hasNonGsapAnim,
1441
- hasUnresolvableClipStart,
1442
- } = result as {
1443
- intervals: Array<{ start: number; end: number }>;
1444
- tweenCount: number;
1445
- duration: number;
1446
- hasVideo: boolean;
1447
- hasCanvas: boolean;
1448
- hasNonGsapAnim: boolean;
1449
- hasUnresolvableClipStart: boolean;
1450
- };
1451
- const totalFrames = Math.max(1, Math.ceil(duration * fps));
1452
- const animated = new Set<number>();
1453
- for (const { start, end } of intervals) {
1454
- const lo = Math.max(0, Math.floor(start * fps));
1455
- const hi = Math.min(totalFrames - 1, Math.ceil(end * fps));
1456
- for (let f = lo; f <= hi; f++) animated.add(f);
1457
- }
1458
- for (const f of await computeClipBoundaryFrames(page, fps)) animated.add(f);
1459
- const reasons: string[] = [];
1460
- if (!(duration > 0)) reasons.push("unknown/zero duration");
1461
- if (hasVideo) reasons.push("video");
1462
- if (hasCanvas) reasons.push("canvas/webgl");
1463
- if (tweenCount === 0) reasons.push("no GSAP tweens (non-GSAP animation)");
1464
- if (hasNonGsapAnim) reasons.push("running CSS/WAAPI animation");
1465
- if (hasUnresolvableClipStart) reasons.push("unresolvable clip start (reference expression)");
1466
- const eligible = reasons.length === 0;
1467
- const staticFrameSet = new Set<number>();
1468
- if (eligible) {
1469
- for (let f = 1; f < totalFrames; f++) {
1470
- if (!animated.has(f) && !animated.has(f - 1)) staticFrameSet.add(f);
1471
- }
1472
- }
1473
- return {
1474
- totalFrames,
1475
- staticFrameSet,
1476
- hasVideo,
1477
- hasCanvas,
1478
- hasNonGsapAnim,
1479
- tweenCount,
1480
- eligible,
1481
- reason: eligible ? "eligible" : reasons.join("+"),
1482
- };
1483
- }
1484
-
1485
- /**
1486
- * Empirically verify the predicted-static set before trusting it. Group static frames
1487
- * into runs; each run [a..b] reuses anchor a-1. CRITICAL: compare against the ANCHOR,
1488
- * not the predecessor — a slow drift with sub-quantization per-frame deltas is byte-
1489
- * identical frame-to-frame yet drifts far from the anchor by the run's end (the real
1490
- * frozen error). Capture each run's anchor once, compare END + a midpoint to it; any
1491
- * mismatch ⇒ the run isn't truly static ⇒ disable dedup whole-comp. Capture-mode-
1492
- * independent (seeks + screenshots in normal DOM). Returns the first bad frame, or null.
1493
- */
1494
- async function verifyStaticFramesSafe(
1495
- session: CaptureSession,
1496
- page: Page,
1497
- staticFrames: Set<number>,
1498
- fps: number,
1499
- sampleCount: number,
1500
- ): Promise<{ badFrame: number; budgetExhausted: boolean } | null> {
1501
- const frames = [...staticFrames].sort((a, b) => a - b);
1502
- if (frames.length === 0) return null;
1503
- // Runs are maximal-contiguous (adjacent frames merge), so a run's anchor a-1 is
1504
- // guaranteed NOT static — always a freshly-captured frame.
1505
- const runs: Array<{ a: number; b: number }> = [];
1506
- for (const f of frames) {
1507
- const last = runs[runs.length - 1];
1508
- if (last && f === last.b + 1) last.b = f;
1509
- else runs.push({ a: f, b: f });
1510
- }
1511
- const seekCapture = async (frameIdx: number): Promise<Buffer> => {
1512
- const t = quantizeTimeToFrame(frameIdx / fps, fps);
1513
- await page.evaluate((tt: number) => {
1514
- const hf = (window as unknown as { __hf?: { seek?: (t: number) => void } }).__hf;
1515
- if (hf && typeof hf.seek === "function") hf.seek(tt);
1516
- }, t);
1517
- return pageScreenshotCapture(page, session.options);
1518
- };
1519
- // Verify EVERY run in order (no longest-first truncation that would leave runs armed
1520
- // but unverified). Per run, compare the FIRST reused frame `a`, the END `b` (max
1521
- // accumulated drift), and interior points at a stride — against the anchor the run
1522
- // actually reuses. `sampleCount` sets the interior density (points per run ~ that many
1523
- // for a long run); a hard cap bounds pathological run counts, and hitting it DISABLES
1524
- // dedup (conservative: never trust an unverified set).
1525
- const perRun = Math.max(3, Math.min(sampleCount, 8));
1526
- const hardCap = Math.max(sampleCount * 8, 400);
1527
- let spent = 0;
1528
- for (const { a, b } of runs) {
1529
- const anchor = a - 1;
1530
- if (anchor < 0) continue;
1531
- const anchorBuf = await seekCapture(anchor);
1532
- spent++;
1533
- const span = b - a;
1534
- const stride = span > 0 ? Math.max(1, Math.floor(span / (perRun - 1))) : 1;
1535
- const pts = new Set<number>();
1536
- for (let f = a; f <= b; f += stride) pts.add(f);
1537
- pts.add(b); // always include the end (max drift)
1538
- for (const f of [...pts].sort((x, y) => x - y)) {
1539
- const cur = await seekCapture(f);
1540
- spent++;
1541
- if (!anchorBuf.equals(cur)) return { badFrame: f, budgetExhausted: false };
1542
- }
1543
- // Budget exhausted → can't fully verify → disarm. Reported distinctly from real
1544
- // drift so a `verification_budget` spike in telemetry signals "tune HF_STATIC_DEDUP_SAMPLES",
1545
- // not "compositions are non-static".
1546
- if (spent > hardCap) return { badFrame: a, budgetExhausted: true };
1547
- }
1548
- return null;
1549
- }
1550
-
1551
- /**
1552
- * Arm static-frame dedup for this render (default-on; opt out with HF_STATIC_DEDUP=false).
1553
- * Runs at init in normal DOM state so the verification screenshots are valid. Predicts
1554
- * the static set, anchor-verifies it (skip with HF_STATIC_DEDUP_VERIFY=false — unsafe),
1555
- * and on success stores it on the session for captureFrameCore to reuse. Sample budget
1556
- * via HF_STATIC_DEDUP_SAMPLES (default 24).
1557
- */
1558
- async function armStaticDedup(
1559
- session: CaptureSession,
1560
- page: Page,
1561
- logInitPhase: (phase: string) => void,
1562
- ): Promise<void> {
1563
- // Default ON for everyone; opt out via HF_STATIC_DEDUP in {false,0,off} (resolved into
1564
- // EngineConfig.staticFrameDedup by resolveConfig). Verification is the safety net at scale.
1565
- // Default-on: only an explicit `staticFrameDedup === false` (resolved from
1566
- // HF_STATIC_DEDUP) disables; a missing config leaves dedup enabled.
1567
- session.staticDedupEnabled = session.config?.staticFrameDedup !== false;
1568
- if (!session.staticDedupEnabled) return;
1569
- // Conservative gates: dedup is verified against the plain screenshot path, so only arm
1570
- // where the production capture matches what verification measures, and where reuse is
1571
- // sound. Skip when:
1572
- // - capture mode is not screenshot (BeginFrame advances the compositor clock per
1573
- // frame; skipping beginFrame for static frames gaps the tick sequence, and the
1574
- // verifier uses pageScreenshotCapture not beginFrameCapture — its proof wouldn't
1575
- // transfer);
1576
- // - a before-capture hook is set (per-frame video-frame injection — those frames are
1577
- // NOT static even if the GSAP timeline is idle, and the injector is skipped on reuse);
1578
- // - page-side compositing is active (shader transitions / drawElement composite paint
1579
- // a frame the plain verification screenshot doesn't reproduce).
1580
- if (session.captureMode !== "screenshot") {
1581
- session.staticDedupSkipReason = "capture_mode";
1582
- logInitPhase(
1583
- `static-frame dedup: disabled (capture mode ${session.captureMode}, not screenshot)`,
1584
- );
1585
- return;
1586
- }
1587
- if (session.onBeforeCapture) {
1588
- session.staticDedupSkipReason = "video_injection";
1589
- logInitPhase("static-frame dedup: disabled (before-capture hook / video injection active)");
1590
- return;
1591
- }
1592
- const pageComposite = await page
1593
- .evaluate(
1594
- () =>
1595
- typeof (window as unknown as { __hf_page_composite_prepare?: unknown })
1596
- .__hf_page_composite_prepare === "function",
1597
- )
1598
- .catch(() => true); // fail CLOSED: if we can't determine, assume compositing → skip dedup
1599
- if (pageComposite) {
1600
- session.staticDedupSkipReason = "page_composite";
1601
- logInitPhase("static-frame dedup: disabled (page-side compositing active)");
1602
- return;
1603
- }
1604
- const fps = fpsToNumber(session.options.fps);
1605
- const stats = await computeStaticFrameSet(page, fps);
1606
- if (!stats.eligible || stats.staticFrameSet.size === 0) {
1607
- session.staticDedupSkipReason = "ineligible";
1608
- logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
1609
- return;
1610
- }
1611
- const rawSamples = Number(process.env.HF_STATIC_DEDUP_SAMPLES ?? "24");
1612
- const samples = Number.isFinite(rawSamples) && rawSamples >= 1 ? rawSamples : 24;
1613
- const verdict =
1614
- process.env.HF_STATIC_DEDUP_VERIFY === "false"
1615
- ? null
1616
- : await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples);
1617
- if (verdict !== null) {
1618
- session.staticDedupSkipReason = verdict.budgetExhausted
1619
- ? "verification_budget"
1620
- : "verification_failed";
1621
- logInitPhase(
1622
- verdict.budgetExhausted
1623
- ? `static-frame dedup: disabled (verification budget exhausted before frame ${verdict.badFrame}; ` +
1624
- `raise HF_STATIC_DEDUP_SAMPLES to verify more)`
1625
- : `static-frame dedup: disabled (verification failed — content drifts from anchor at ` +
1626
- `predicted-static frame ${verdict.badFrame})`,
1627
- );
1628
- return;
1629
- }
1630
- // armed + predicted are derived from staticFrames in getCapturePerfSummary.
1631
- session.staticFrames = stats.staticFrameSet;
1632
- logInitPhase(
1633
- `static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` +
1634
- `(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`,
1635
- );
1636
- }
1637
-
1638
- /**
1639
- * Internal core: prepare, screenshot, and track perf.
1640
- * Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
1641
- * Returns the screenshot buffer, quantized time, and total capture time.
1642
- */
1643
- async function captureFrameCore(
1644
- session: CaptureSession,
1645
- frameIndex: number,
1646
- time: number,
1647
- ): Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }> {
1648
- const { page, options } = session;
1649
- const startTime = Date.now();
1650
-
1651
- // Static-frame dedup: this frame is byte-identical to its predecessor (predicted +
1652
- // anchor-verified at init) → reuse the prior buffer, skip the seek + screenshot.
1653
- // KEY: index by the ABSOLUTE composition frame (derived from `time`), NOT the
1654
- // `frameIndex` arg — chunked/parallel/distributed callers pass a chunk-RELATIVE
1655
- // frameIndex (captureStage passes the loop `i`, parallelCoordinator passes
1656
- // `i-outputFrameOffset`) while staticFrames is keyed in absolute frames. Using `time`
1657
- // is correct on every path (sequential, per-worker range, distributed chunk) because
1658
- // `time` is always the absolute composition time for the frame. Each session captures
1659
- // its range in ascending order, so lastFrameBuffer is the correct in-range anchor (and
1660
- // since a static run is verified identical, reusing the run's first in-range capture
1661
- // equals reusing the global anchor). Telemetry: count reuses separately; do NOT bump
1662
- // capturePerf.frames (that would dilute the per-frame timing averages).
1663
- // Use the SAME floor+epsilon idiom as quantizeTimeToFrame so the dedup lookup agrees
1664
- // with the frame the seek actually lands on, even if `time` ever isn't exactly i/fps.
1665
- const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
1666
- if (session.staticFrames?.has(absFrameIndex) && session.lastFrameBuffer) {
1667
- session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
1668
- return {
1669
- buffer: session.lastFrameBuffer,
1670
- quantizedTime: quantizeTimeToFrame(time, fpsToNumber(options.fps)),
1671
- captureTimeMs: Date.now() - startTime,
1672
- };
1673
- }
1674
-
1675
- try {
1676
- const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(
1677
- session,
1678
- frameIndex,
1679
- time,
1680
- );
1681
-
1682
- const screenshotStart = Date.now();
1683
- let screenshotBuffer: Buffer;
1684
-
1685
- if (session.captureMode === "beginframe") {
1686
- const frameTimeTicks =
1687
- session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
1688
- const result = await beginFrameCapture(
1689
- page,
1690
- options,
1691
- frameTimeTicks,
1692
- session.beginFrameIntervalMs,
1693
- );
1694
- if (result.hasDamage) session.beginFrameHasDamageCount++;
1695
- else session.beginFrameNoDamageCount++;
1696
- screenshotBuffer = result.buffer;
1697
- } else {
1698
- screenshotBuffer = await pageScreenshotCapture(page, options);
1699
- }
1700
-
1701
- const screenshotMs = Date.now() - screenshotStart;
1702
- const captureTimeMs = Date.now() - startTime;
1703
-
1704
- session.capturePerf.frames += 1;
1705
- session.capturePerf.seekMs += seekMs;
1706
- session.capturePerf.beforeCaptureMs += beforeCaptureMs;
1707
- session.capturePerf.screenshotMs += screenshotMs;
1708
- session.capturePerf.totalMs += captureTimeMs;
1709
-
1710
- // Retain this freshly-captured buffer so the following static frames can reuse it.
1711
- if (session.staticFrames) session.lastFrameBuffer = screenshotBuffer;
1712
-
1713
- return { buffer: screenshotBuffer, quantizedTime, captureTimeMs };
1714
- } catch (captureError) {
1715
- if (session.isInitialized) {
1716
- await captureFrameErrorDiagnostics(
1717
- session,
1718
- frameIndex,
1719
- time,
1720
- captureError instanceof Error ? captureError : new Error(String(captureError)),
1721
- );
1722
- }
1723
- throw captureError;
1724
- }
1725
- }
1726
-
1727
- export async function captureFrame(
1728
- session: CaptureSession,
1729
- frameIndex: number,
1730
- time: number,
1731
- ): Promise<CaptureResult> {
1732
- const { options, outputDir } = session;
1733
- const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(
1734
- session,
1735
- frameIndex,
1736
- time,
1737
- );
1738
-
1739
- const ext = options.format === "png" ? "png" : "jpg";
1740
- const frameName = `frame_${String(frameIndex).padStart(6, "0")}.${ext}`;
1741
- const framePath = join(outputDir, frameName);
1742
- writeFileSync(framePath, buffer);
1743
-
1744
- return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
1745
- }
1746
-
1747
- /**
1748
- * Capture a frame and return the screenshot as a Buffer instead of writing to disk.
1749
- * Used by the streaming encode pipeline to pipe frames directly to FFmpeg stdin.
1750
- */
1751
- export async function captureFrameToBuffer(
1752
- session: CaptureSession,
1753
- frameIndex: number,
1754
- time: number,
1755
- ): Promise<CaptureBufferResult> {
1756
- const { buffer, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
1757
-
1758
- return { buffer, captureTimeMs };
1759
- }
1760
-
1761
- /**
1762
- * Type of the "inner capture" function consumed by
1763
- * {@link discardWarmupCapture}. Matches the real `captureFrameCore` signature
1764
- * with the buffer-bearing result trimmed to what the caller actually uses
1765
- * (the wrapper never inspects the buffer). Exposed so unit tests can inject
1766
- * a stub instead of driving Chrome end-to-end.
1767
- */
1768
- export type DiscardWarmupInnerCapture = (
1769
- session: CaptureSession,
1770
- frameIndex: number,
1771
- time: number,
1772
- ) => Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }>;
1773
-
1774
- /**
1775
- * Perform one capture, throw away the buffer, and restore any session
1776
- * side-effects (perf counters, BeginFrame damage tallies) so downstream
1777
- * captures see state identical to a fresh session.
1778
- *
1779
- * Distributed chunk workers need this because Chrome's BeginFrame screenshot
1780
- * pipeline maintains a per-process `lastFrameCache`: when a captured frame's
1781
- * `hasDamage` reports `false`, the screenshot path returns the previously
1782
- * captured buffer. For chunk N (N > 0) the worker has no prior frame in its
1783
- * cache, so the very first capture's `hasDamage` reporting diverges from
1784
- * what an in-process render at the same absolute frame index would see (the
1785
- * in-process renderer always has frame N-1 cached). One discard capture
1786
- * before the first real capture primes the cache.
1787
- *
1788
- * The function intentionally restores perf state so the warmup capture does
1789
- * NOT bias `getCapturePerfSummary()`'s per-frame averages.
1790
- *
1791
- * No file is written; the buffer is discarded.
1792
- *
1793
- * @param session — initialized capture session
1794
- * @param frameIndex — frame index to warm up with (default 0). Chunk
1795
- * workers typically pass their chunk's first absolute frame index.
1796
- * @param time — time in seconds (default 0). Chunk workers typically pass
1797
- * the corresponding `frameIndex / fps`.
1798
- * @param innerCapture — injectable for tests; defaults to the real
1799
- * `captureFrameCore`.
1800
- */
1801
- export async function discardWarmupCapture(
1802
- session: CaptureSession,
1803
- frameIndex: number = 0,
1804
- time: number = 0,
1805
- innerCapture: DiscardWarmupInnerCapture = captureFrameCore,
1806
- ): Promise<void> {
1807
- // Snapshot the side-effect counters captureFrameCore mutates. We use a
1808
- // shallow `{...}` for capturePerf because all five fields are primitive
1809
- // numbers — no nested state to deep-copy.
1810
- const perfBefore = { ...session.capturePerf };
1811
- const hasDamageBefore = session.beginFrameHasDamageCount;
1812
- const noDamageBefore = session.beginFrameNoDamageCount;
1813
- const dedupCountBefore = session.staticDedupCount;
1814
- const lastFrameBufferBefore = session.lastFrameBuffer;
1815
- try {
1816
- await innerCapture(session, frameIndex, time);
1817
- } finally {
1818
- // Always restore — even on error. A failed warmup capture should not
1819
- // leak inflated perf counters, a phantom dedup reuse, or a warmup-era
1820
- // lastFrameBuffer anchor into the real capture summary/state.
1821
- session.capturePerf = perfBefore;
1822
- session.beginFrameHasDamageCount = hasDamageBefore;
1823
- session.beginFrameNoDamageCount = noDamageBefore;
1824
- session.staticDedupCount = dedupCountBefore;
1825
- session.lastFrameBuffer = lastFrameBufferBefore;
1826
- }
1827
- }
1828
-
1829
- export async function closeCaptureSession(session: CaptureSession): Promise<void> {
1830
- // Realized static-dedup telemetry: how much the cache actually helped this
1831
- // render (vs the prediction logged at arm time). Both capture paths
1832
- // (sequential orchestrator + parallel workers) close their session here, so
1833
- // this is the one uniform emit point. Zero the count afterward so the
1834
- // idempotent re-close (HDR cleanup) doesn't double-log.
1835
- const reused = session.staticDedupCount ?? 0;
1836
- if (session.staticFrames && reused > 0) {
1837
- const captured = session.capturePerf.frames; // excludes reuses by design
1838
- const total = captured + reused;
1839
- const pct = total > 0 ? Math.round((reused / total) * 100) : 0;
1840
- const avgTotalMs = captured > 0 ? Math.round(session.capturePerf.totalMs / captured) : 0;
1841
- console.log(
1842
- `[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
1843
- `est. ~${reused * avgTotalMs}ms saved (avg ${avgTotalMs}ms/frame)`,
1844
- );
1845
- session.staticDedupCount = 0;
1846
- }
1847
- // INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR
1848
- // cleanup path tracks a `domSessionClosed` flag and may still re-call this
1849
- // in the outer finally if the inner cleanup raised before the flag flipped.
1850
- //
1851
- // Naive idempotency would be unsafe under pool semantics: releaseBrowser
1852
- // decrements pooledBrowserRefCount, so calling it twice for the same
1853
- // acquire could close a browser that another session still holds. We make
1854
- // it safe by gating each release behind a per-session "released" flag —
1855
- // the second call sees the flag already set and skips the release.
1856
- //
1857
- // We set the flag AFTER (not before) the await so that if a release throws
1858
- // midway, the unreleased resource is retried by the outer defensive call.
1859
- // Example: page release succeeds, browser release throws → pageReleased=true
1860
- // but browserReleased=false → second call no-ops on page and retries browser.
1861
- // This matches the orchestrator's intent for HDR cleanup.
1862
- if (!session.pageReleased && session.page) {
1863
- const pageClosed = await waitForCloseWithTimeout(session.page.close());
1864
- if (!pageClosed) {
1865
- console.warn("[FrameCapture] Timed out closing page; forcing browser process shutdown");
1866
- forceReleaseBrowser(session.browser);
1867
- session.browserReleased = true;
1868
- }
1869
- session.pageReleased = true;
1870
- }
1871
- if (!session.browserReleased && session.browser) {
1872
- const browserClosed = await waitForCloseWithTimeout(
1873
- releaseBrowser(session.browser, session.config),
1874
- );
1875
- if (!browserClosed) {
1876
- console.warn("[FrameCapture] Timed out closing browser; forcing browser process shutdown");
1877
- forceReleaseBrowser(session.browser);
1878
- }
1879
- session.browserReleased = true;
1880
- }
1881
- session.isInitialized = false;
1882
- }
1883
-
1884
- export function prepareCaptureSessionForReuse(
1885
- session: CaptureSession,
1886
- outputDir: string,
1887
- onBeforeCapture: BeforeCaptureHook | null,
1888
- ): void {
1889
- if (!existsSync(outputDir)) {
1890
- mkdirSync(outputDir, { recursive: true });
1891
- }
1892
- session.outputDir = outputDir;
1893
- session.onBeforeCapture = onBeforeCapture;
1894
- session.capturePerf = {
1895
- frames: 0,
1896
- seekMs: 0,
1897
- beforeCaptureMs: 0,
1898
- screenshotMs: 0,
1899
- totalMs: 0,
1900
- };
1901
- session.beginFrameHasDamageCount = 0;
1902
- session.beginFrameNoDamageCount = 0;
1903
- // Reset per-render dedup state so a buffer captured by the prior render/probe can't
1904
- // bleed into this render's first static frame. staticFrames (the armed set) is left
1905
- // intact: it's keyed in absolute frames and stays valid for a same-composition reuse;
1906
- // lastFrameBuffer must be re-seeded by this render's first fresh capture.
1907
- session.lastFrameBuffer = undefined;
1908
- session.staticDedupCount = 0;
1909
- }
1910
-
1911
- export async function getCompositionDuration(session: CaptureSession): Promise<number> {
1912
- if (!session.isInitialized) throw new Error("[FrameCapture] Session not initialized");
1913
-
1914
- return session.page.evaluate(() => {
1915
- return window.__hf?.duration ?? 0;
1916
- });
1917
- }
1918
-
1919
- export function getCapturePerfSummary(session: CaptureSession): CapturePerfSummary {
1920
- const frames = Math.max(1, session.capturePerf.frames);
1921
- return {
1922
- frames: session.capturePerf.frames,
1923
- avgTotalMs: Math.round(session.capturePerf.totalMs / frames),
1924
- avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
1925
- avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
1926
- avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
1927
- staticDedupReused: session.staticDedupCount ?? 0,
1928
- staticDedupEnabled: session.staticDedupEnabled ?? false,
1929
- // armed ⟺ a non-empty static set survived verification; predicted === its size.
1930
- staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
1931
- staticDedupPredicted: session.staticFrames?.size ?? 0,
1932
- staticDedupSkipReason: session.staticDedupSkipReason,
1933
- };
1934
- }