@hyperframes/engine 0.9.19 → 0.9.21

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/dist/index.d.ts CHANGED
@@ -40,7 +40,7 @@ export { acquireBrowser, setHostHandlesSigint, releaseBrowser, drainBrowserPool,
40
40
  export { augmentProtocolTimeoutError, isProtocolTimeoutError, } from "./services/protocolTimeoutErrorHint.js";
41
41
  export { augmentPageNavigationTimeoutError, isPageNavigationTimeoutError, type NavigationTimeoutHintContext, } from "./services/pageNavigationTimeoutErrorHint.js";
42
42
  export { forcePreservedDrawingBuffer } from "./services/drawElementService.js";
43
- export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, type DrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCompositionLengthSource, type CompositionLengthSource, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, type BeforeCaptureHook, type DiscardWarmupInnerCapture, type StaticVerificationOutcome, VfxFailureError, waitForImagesReady, evaluateHfDiagnostic, buildZeroDurationDiagnostic, applyVideoMetadataHints, } from "./services/frameCapture.js";
43
+ export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, type DrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCompositionLengthSource, type CompositionLengthSource, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, type BeforeCaptureHook, type DiscardWarmupInnerCapture, type StaticVerificationOutcome, VfxFailureError, ImageMissingFromFrameError, waitForImagesReady, evaluateHfDiagnostic, buildZeroDurationDiagnostic, applyVideoMetadataHints, } from "./services/frameCapture.js";
44
44
  export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type CaptureFailureKind, type CaptureWorkerDiagnostic, } from "./services/captureFailure.js";
45
45
  export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
46
46
  export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
@@ -68,7 +68,7 @@ export { frameFileExtension } from "./services/frameCapture.js";
68
68
  export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
69
69
  export { frameSequencePath } from "./services/extractedFrameIndex.js";
70
70
  export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionBlur.js";
71
- export type { PageMotionBlurOff } from "./types.js";
71
+ export type { PageMotionBlurOff, PageMotionBlurSummary } from "./types.js";
72
72
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
73
73
  export { isBrowserSafePort, listenOnBrowserSafePort, settleOnBrowserSafePort, } from "./utils/browserSafePort.js";
74
74
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
package/dist/index.js CHANGED
@@ -42,7 +42,7 @@ export { augmentProtocolTimeoutError, isProtocolTimeoutError, } from "./services
42
42
  export { augmentPageNavigationTimeoutError, isPageNavigationTimeoutError, } from "./services/pageNavigationTimeoutErrorHint.js";
43
43
  // ── Frame capture pipeline ──────────────────────────────────────────────────────
44
44
  export { forcePreservedDrawingBuffer } from "./services/drawElementService.js";
45
- export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCompositionLengthSource, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, isTransientBrowserError, isMemoryExhaustionError, VfxFailureError, waitForImagesReady, evaluateHfDiagnostic, buildZeroDurationDiagnostic, applyVideoMetadataHints, } from "./services/frameCapture.js";
45
+ export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCompositionLengthSource, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, isTransientBrowserError, isMemoryExhaustionError, VfxFailureError, ImageMissingFromFrameError, waitForImagesReady, evaluateHfDiagnostic, buildZeroDurationDiagnostic, applyVideoMetadataHints, } from "./services/frameCapture.js";
46
46
  export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, } from "./services/captureFailure.js";
47
47
  export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
48
48
  // ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
@@ -16,6 +16,7 @@ export declare const ownedLaunchOptions: () => {
16
16
  handleSIGINT: boolean;
17
17
  pipe: boolean;
18
18
  };
19
+ export declare const SOFTWARE_WEBGL_RENDERER_TOKENS: readonly string[];
19
20
  export interface GpuProbeLifecycle {
20
21
  launch(puppeteer: PuppeteerNode, options: LaunchOptions): Promise<Browser>;
21
22
  close(browser: Browser): Promise<void>;
@@ -27,15 +27,18 @@ export function setHostHandlesSigint(owned) {
27
27
  hostHandlesSigint = owned;
28
28
  }
29
29
  export const ownedLaunchOptions = () => ({ handleSIGINT: !hostHandlesSigint, pipe: true });
30
+ export const SOFTWARE_WEBGL_RENDERER_TOKENS = [
31
+ "swiftshader",
32
+ "llvmpipe",
33
+ "lavapipe",
34
+ "softpipe",
35
+ "mesa offscreen",
36
+ "microsoft basic render driver",
37
+ "software rasterizer",
38
+ ];
30
39
  function isSoftwareWebGlRenderer(rendererInfo) {
31
40
  const renderer = rendererInfo.trim().toLowerCase();
32
- return (renderer.includes("swiftshader") ||
33
- renderer.includes("llvmpipe") ||
34
- renderer.includes("lavapipe") ||
35
- renderer.includes("softpipe") ||
36
- renderer.includes("mesa offscreen") ||
37
- renderer.includes("microsoft basic render driver") ||
38
- renderer.includes("software rasterizer"));
41
+ return SOFTWARE_WEBGL_RENDERER_TOKENS.some((token) => renderer.includes(token));
39
42
  }
40
43
  async function getPuppeteer() {
41
44
  if (_puppeteer)
@@ -999,7 +1002,7 @@ export async function assertWebGpuAdapterAvailable(page, requiresWebGpu, softwar
999
1002
  return await Promise.race([
1000
1003
  probe(),
1001
1004
  new Promise((resolve) => {
1002
- timer = setTimeout(() => {
1005
+ timer = (globalThis.window?.__HF_VIRTUAL_TIME__?.originalSetTimeout ?? setTimeout)(() => {
1003
1006
  expired = true;
1004
1007
  resolve(false);
1005
1008
  }, timeoutMs);
@@ -13,6 +13,7 @@
13
13
  * Blink>Canvas, 2026-06-08).
14
14
  */
15
15
  import type { Page } from "puppeteer-core";
16
+ import type { CaptureMode } from "./browserLeasePool.js";
16
17
  /**
17
18
  * Discriminant prefix embedded in every "capture canvas isn't set up yet"
18
19
  * error THIS module throws/returns (as opposed to the native
@@ -52,13 +53,23 @@ export declare const DE_CANVAS_NOT_INITIALIZED_CODE = "HF_DE_CANVAS_NOT_INITIALI
52
53
  * destination additionally drops promoted sub-layers (Chromium bug
53
54
  * 521434899). The speedup is real only on a hardware GPU (macOS 1.6×), so
54
55
  * SwiftShader always routes to the platform baseline.
55
- *
56
- * The former <video> gate (a proxy for the word-by-word caption opacity pattern)
57
- * was removed once Chrome 151 fixed crbug 521861819: video + nested-fade comps now
58
- * render correctly on the drawElement path (verified PSNR=inf vs baseline). 151 is
59
- * the pinned floor. See docs/fast-capture-limitations.md Lim 2.
56
+ * - No WebGL at all (a GPU-less host asked for hardware GL) is software at best: `webgl_unavailable`.
57
+ * - A BeginFrame launch (Linux headless-shell): the self-verify truth page has no BeginFrame
58
+ * driver, so drawElement is gated as `beginframe_no_self_verify`.
59
+ */
60
+ export declare function resolveDrawElementCaptureMode(softwareBackend: boolean, transparent: boolean): "drawelement" | "screenshot";
61
+ export type DrawElementGateReason = "swiftshader" | "webgl_unavailable" | "beginframe_no_self_verify";
62
+ /**
63
+ * The init-time gate that routes drawElement to the baseline capture, or null when it may engage.
64
+ * `swiftshader` covers any software GL; `webgl_unavailable` is a host with no WebGL at all.
65
+ * `beginframe_no_self_verify` is a BeginFrame launch, which cannot take the self-verify ground truth.
66
+ * `forceDE` (HF_FORCE_DRAWELEMENT) bypasses only the launch gate. Pure; exported for tests.
60
67
  */
61
- export declare function resolveDrawElementCaptureMode(isSwiftShader: boolean, transparent: boolean): "drawelement" | "screenshot";
68
+ export declare function drawElementGate(args: {
69
+ backend: Pick<GpuBackendInfo, "isSwiftShader" | "webglAvailable">;
70
+ launchCaptureMode: CaptureMode;
71
+ forceDE: boolean;
72
+ }): DrawElementGateReason | null;
62
73
  /**
63
74
  * Instrument `HTMLCanvasElement.getContext` before any page script runs.
64
75
  *
@@ -82,8 +93,10 @@ export declare function instrumentAcceleratedCanvases(): void;
82
93
  */
83
94
  export declare function forcePreservedDrawingBuffer(): void;
84
95
  export interface GpuBackendInfo {
85
- /** SwiftShader (software rasterizer) — e.g. Docker headless-shell. */
96
+ /** Software GL (SwiftShader, llvmpipe, ...) — e.g. Docker headless-shell. Named for the common case. */
86
97
  isSwiftShader: boolean;
98
+ /** False when no WebGL context could be created (null or threw): not a real GPU. */
99
+ webglAvailable: boolean;
87
100
  /**
88
101
  * Raw UNMASKED_RENDERER_WEBGL string (e.g. "ANGLE (Apple, ANGLE Metal
89
102
  * Renderer: Apple M4 Pro, ...)", "ANGLE (NVIDIA, GeForce RTX 3080 Direct3D11
@@ -112,8 +125,8 @@ export declare function classifyGpuRenderer(renderer: string | null | undefined)
112
125
  * Detect the page's WebGL backend: SwiftShader vs a real GPU, plus the raw
113
126
  * renderer string for telemetry.
114
127
  *
115
- * `isSwiftShader` is true inside Docker headless-shell with
116
- * --use-angle=swiftshader. Call once after window.__hf is ready; cache the
128
+ * `isSwiftShader` is true for any software GL, e.g. Docker headless-shell with
129
+ * --use-angle=swiftshader; `webglAvailable` is false with no WebGL at all. Call once after window.__hf is ready; cache the
117
130
  * result on the session.
118
131
  */
119
132
  export declare function detectGpuBackend(page: Page): Promise<GpuBackendInfo>;
@@ -13,6 +13,7 @@
13
13
  * compositor sub-layers on a transparent canvas destination (Chromium bug, filed
14
14
  * Blink>Canvas, 2026-06-08).
15
15
  */
16
+ import { SOFTWARE_WEBGL_RENDERER_TOKENS } from "./browserManager.js";
16
17
  /**
17
18
  * Discriminant prefix embedded in every "capture canvas isn't set up yet"
18
19
  * error THIS module throws/returns (as opposed to the native
@@ -52,21 +53,35 @@ export const DE_CANVAS_NOT_INITIALIZED_CODE = "HF_DE_CANVAS_NOT_INITIALIZED";
52
53
  * destination additionally drops promoted sub-layers (Chromium bug
53
54
  * 521434899). The speedup is real only on a hardware GPU (macOS 1.6×), so
54
55
  * SwiftShader always routes to the platform baseline.
55
- *
56
- * The former <video> gate (a proxy for the word-by-word caption opacity pattern)
57
- * was removed once Chrome 151 fixed crbug 521861819: video + nested-fade comps now
58
- * render correctly on the drawElement path (verified PSNR=inf vs baseline). 151 is
59
- * the pinned floor. See docs/fast-capture-limitations.md Lim 2.
56
+ * - No WebGL at all (a GPU-less host asked for hardware GL) is software at best: `webgl_unavailable`.
57
+ * - A BeginFrame launch (Linux headless-shell): the self-verify truth page has no BeginFrame
58
+ * driver, so drawElement is gated as `beginframe_no_self_verify`.
60
59
  */
61
- export function resolveDrawElementCaptureMode(isSwiftShader, transparent) {
62
- // `transparent` is retained for call-site clarity; SwiftShader blocks
60
+ export function resolveDrawElementCaptureMode(softwareBackend, transparent) {
61
+ // `transparent` is retained for call-site clarity; a software backend blocks
63
62
  // unconditionally now (no GPU egress to skip — parity at best), which
64
63
  // subsumes the former transparent-only SwiftShader case.
65
64
  void transparent;
66
- if (isSwiftShader)
65
+ if (softwareBackend)
67
66
  return "screenshot";
68
67
  return "drawelement";
69
68
  }
69
+ /**
70
+ * The init-time gate that routes drawElement to the baseline capture, or null when it may engage.
71
+ * `swiftshader` covers any software GL; `webgl_unavailable` is a host with no WebGL at all.
72
+ * `beginframe_no_self_verify` is a BeginFrame launch, which cannot take the self-verify ground truth.
73
+ * `forceDE` (HF_FORCE_DRAWELEMENT) bypasses only the launch gate. Pure; exported for tests.
74
+ */
75
+ export function drawElementGate(args) {
76
+ const { backend, launchCaptureMode, forceDE } = args;
77
+ if (backend.isSwiftShader)
78
+ return "swiftshader";
79
+ if (!backend.webglAvailable)
80
+ return "webgl_unavailable";
81
+ if (launchCaptureMode === "beginframe" && !forceDE)
82
+ return "beginframe_no_self_verify";
83
+ return null;
84
+ }
70
85
  /**
71
86
  * Instrument `HTMLCanvasElement.getContext` before any page script runs.
72
87
  *
@@ -173,23 +188,35 @@ export function classifyGpuRenderer(renderer) {
173
188
  * Detect the page's WebGL backend: SwiftShader vs a real GPU, plus the raw
174
189
  * renderer string for telemetry.
175
190
  *
176
- * `isSwiftShader` is true inside Docker headless-shell with
177
- * --use-angle=swiftshader. Call once after window.__hf is ready; cache the
191
+ * `isSwiftShader` is true for any software GL, e.g. Docker headless-shell with
192
+ * --use-angle=swiftshader; `webglAvailable` is false with no WebGL at all. Call once after window.__hf is ready; cache the
178
193
  * result on the session.
179
194
  */
180
195
  export async function detectGpuBackend(page) {
181
- return page.evaluate(() => {
196
+ return page.evaluate((softwareTokens) => {
182
197
  const canvas = document.createElement("canvas");
183
- const gl = canvas.getContext("webgl") ||
184
- canvas.getContext("experimental-webgl");
198
+ let gl = null;
199
+ try {
200
+ gl =
201
+ canvas.getContext("webgl") ||
202
+ canvas.getContext("experimental-webgl");
203
+ }
204
+ catch {
205
+ gl = null;
206
+ }
185
207
  if (!gl)
186
- return { isSwiftShader: false, renderer: null };
208
+ return { isSwiftShader: false, webglAvailable: false, renderer: null };
187
209
  const ext = gl.getExtension("WEBGL_debug_renderer_info");
188
210
  if (!ext)
189
- return { isSwiftShader: false, renderer: null };
211
+ return { isSwiftShader: false, webglAvailable: true, renderer: null };
190
212
  const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
191
- return { isSwiftShader: renderer.toLowerCase().includes("swiftshader"), renderer };
192
- });
213
+ const lower = renderer.toLowerCase();
214
+ return {
215
+ isSwiftShader: softwareTokens.some((token) => lower.includes(token)),
216
+ webglAvailable: true,
217
+ renderer,
218
+ };
219
+ }, SOFTWARE_WEBGL_RENDERER_TOKENS);
193
220
  }
194
221
  /**
195
222
  * Back-compat wrapper over {@link detectGpuBackend} for callers that only
@@ -461,7 +488,7 @@ syncToPaintEvent = true) {
461
488
  }
462
489
  // Encode OUTSIDE the paint handler — heavy canvas work inside the
463
490
  // paint event can stall the renderer.
464
- setTimeout(() => {
491
+ (window.__HF_VIRTUAL_TIME__?.originalSetTimeout ?? setTimeout)(() => {
465
492
  try {
466
493
  resolveCapture(fmt === "png"
467
494
  ? canvas.toDataURL("image/png")
@@ -493,7 +520,7 @@ syncToPaintEvent = true) {
493
520
  // throttled page), fall back to an unsynchronized draw after 250 ms —
494
521
  // worst case one-frame-stale content (the root's alpha may lag its
495
522
  // transform by that frame) rather than a hung render.
496
- setTimeout(() => {
523
+ (window.__HF_VIRTUAL_TIME__?.originalSetTimeout ?? setTimeout)(() => {
497
524
  canvas.removeEventListener("paint", onPaint);
498
525
  drawAndEncode();
499
526
  }, 250);
@@ -843,7 +870,7 @@ export async function produceDrawElementFrame(page, width, height, quality = 80,
843
870
  // Sentinel dirty + requestPaint() — see __hfDeInvalidate in
844
871
  // injectDrawElementCanvas.
845
872
  usedRequestPaint = aw.__hfDeInvalidate?.() === true;
846
- setTimeout(() => {
873
+ (window.__HF_VIRTUAL_TIME__?.originalSetTimeout ?? setTimeout)(() => {
847
874
  canvas.removeEventListener("paint", onPaint);
848
875
  drawAndKick();
849
876
  }, 250);
@@ -930,7 +957,7 @@ export async function produceDrawElementFrameBatch(page, times, width, height, q
930
957
  // Sentinel dirty + requestPaint() — see __hfDeInvalidate in
931
958
  // injectDrawElementCanvas.
932
959
  usedRequestPaint = aw.__hfDeInvalidate?.() === true;
933
- setTimeout(settle, 250);
960
+ (window.__HF_VIRTUAL_TIME__?.originalSetTimeout ?? setTimeout)(settle, 250);
934
961
  });
935
962
  let prevBitmap = Promise.resolve();
936
963
  let prevBitmapIdx = -1;
@@ -12,7 +12,7 @@ import { type MotionBlurPlan } from "./motionBlur.js";
12
12
  import { type BrowserLease, type CaptureMode } from "./browserManager.js";
13
13
  import { pageScreenshotCapture } from "./screenshotService.js";
14
14
  import { type EngineConfig } from "../config.js";
15
- import type { CaptureOptions, CaptureVideoMetadataHint, CaptureResult, CaptureBufferResult, CapturePerfSummary, CaptureWarning, SubTimelineWaitMemo, SubTimelineWaitOutcome } from "../types.js";
15
+ import type { CaptureOptions, CaptureVideoMetadataHint, CaptureResult, CaptureBufferResult, CapturePerfSummary, CaptureWarning, PageMotionBlurSummary, SubTimelineWaitMemo, SubTimelineWaitOutcome } from "../types.js";
16
16
  import { type ChromeMemorySampler, type ChromePids } from "./chromeMemorySampler.js";
17
17
  import type { CompositionLengthSource } from "@hyperframes/core/runtime/composition-length";
18
18
  export { isMemoryExhaustionError, isTransientBrowserError } from "./captureFailure.js";
@@ -25,7 +25,7 @@ export type BeforeCaptureHook = ((page: Page, time: number, heldVideoTime?: numb
25
25
  fresh?: () => BeforeCaptureHook | null;
26
26
  };
27
27
  /** The per-frame wait a capture is in, worded for the stall error a user reads. */
28
- type CaptureStep = "seeking the timeline" | "decoding CSS background images" | "injecting video frames" | "waiting for page seek handlers" | "loading color-grading LUTs" | "compositing the page" | "capturing the frame";
28
+ type CaptureStep = "seeking the timeline" | "decoding CSS background images" | "injecting video frames" | "waiting for page seek handlers" | "loading color-grading LUTs" | "compositing the page" | "capturing the frame" | "waiting for a late image";
29
29
  export interface CaptureSession {
30
30
  browser: Browser;
31
31
  /** Exact ownership token for this browser acquisition. */
@@ -53,6 +53,7 @@ export interface CaptureSession {
53
53
  lastFrameAbsoluteIndex?: number;
54
54
  /** Count of frames served from a reused buffer (dedup telemetry). */
55
55
  staticDedupCount?: number;
56
+ pageMotionBlur?: PageMotionBlurSummary;
56
57
  /** Live Chrome memory sampler; started in initializeSession, stopped in closeCaptureSession. */
57
58
  chromeMemory?: ChromeMemorySampler;
58
59
  /**
@@ -109,6 +110,8 @@ export interface CaptureSession {
109
110
  subTimelineWaitMemo: SubTimelineWaitMemo;
110
111
  /** Structured readiness warnings surfaced to the producer's render policy. */
111
112
  warnings: CaptureWarning[];
113
+ /** Images still loading when capture began: a frame that shows one waits for it (waitForShownLateImages). */
114
+ lateImages?: Set<string>;
112
115
  initTelemetry?: {
113
116
  initDurationMs: number;
114
117
  tweenCount: number;
@@ -415,7 +418,7 @@ export declare function formatConsoleDiagnostic(type: string, text: string, loca
415
418
  text: string;
416
419
  suppressHostLog: boolean;
417
420
  };
418
- export declare const HF_READY_DIAGNOSTIC_EXPR = "(async function() {\n var hf = window.__hf;\n var player = window.__player;\n var renderReady = !!window.__renderReady;\n var hasSeek = !!(hf && typeof hf.seek === \"function\");\n var duration = hf ? hf.duration : -1;\n var hasTimeline = !!(window.__timelines && Object.keys(window.__timelines).length > 0);\n var root = document.querySelector(\"[data-composition-id]\");\n var declaredDuration = root ? Number(root.getAttribute(\"data-duration\")) : -1;\n var registry = (window.__hf && window.__hf.buildReady) || {};\n var keys = Object.keys(registry);\n // A microtask race mis-sorts a non-native thenable (extra adoption tick)\n // and rejects the whole expression on a rejected entry. Settling each\n // key onto its own record and waiting one macrotask avoids both.\n var settled = {};\n if (keys.length > 0) {\n keys.forEach(function(key) {\n Promise.resolve(registry[key]).then(\n function() { settled[key] = \"resolved\"; },\n function() { settled[key] = \"rejected\"; },\n );\n });\n await new Promise(function(r) { setTimeout(r, 0); });\n }\n // A thenable resolving via its own setTimeout(0) can still misreport as\n // pending here (macrotask registration order, not resolution order), a\n // narrow case that self-corrects on the next ~1s diagnostic tick.\n var pendingBuildReadyKeys = keys.filter(function(key) { return !settled[key]; });\n var rejectedBuildReadyKeys = keys.filter(function(key) { return settled[key] === \"rejected\"; });\n return {\n renderReady: renderReady,\n hasHf: !!hf,\n hasSeek: hasSeek,\n hasPlayer: !!player,\n duration: duration,\n hasTimeline: hasTimeline,\n declaredDuration: declaredDuration,\n pendingBuildReadyKeys: pendingBuildReadyKeys,\n rejectedBuildReadyKeys: rejectedBuildReadyKeys,\n };\n})()";
421
+ export declare const HF_READY_DIAGNOSTIC_EXPR = "(async function() {\n var hf = window.__hf;\n var player = window.__player;\n var renderReady = !!window.__renderReady;\n var hasSeek = !!(hf && typeof hf.seek === \"function\");\n var duration = hf ? hf.duration : -1;\n var hasTimeline = !!(window.__timelines && Object.keys(window.__timelines).length > 0);\n var root = document.querySelector(\"[data-composition-id]\");\n var declaredDuration = root ? Number(root.getAttribute(\"data-duration\")) : -1;\n var registry = (window.__hf && window.__hf.buildReady) || {};\n var keys = Object.keys(registry);\n // A microtask race mis-sorts a non-native thenable (extra adoption tick)\n // and rejects the whole expression on a rejected entry. Settling each\n // key onto its own record and waiting one macrotask avoids both.\n var settled = {};\n if (keys.length > 0) {\n keys.forEach(function(key) {\n Promise.resolve(registry[key]).then(\n function() { settled[key] = \"resolved\"; },\n function() { settled[key] = \"rejected\"; },\n );\n });\n await new Promise(function(r) {\n (window.__HF_VIRTUAL_TIME__ && window.__HF_VIRTUAL_TIME__.originalSetTimeout || setTimeout)(r, 0);\n });\n }\n // A thenable resolving via its own setTimeout(0) can still misreport as\n // pending here (macrotask registration order, not resolution order), a\n // narrow case that self-corrects on the next ~1s diagnostic tick.\n var pendingBuildReadyKeys = keys.filter(function(key) { return !settled[key]; });\n var rejectedBuildReadyKeys = keys.filter(function(key) { return settled[key] === \"rejected\"; });\n return {\n renderReady: renderReady,\n hasHf: !!hf,\n hasSeek: hasSeek,\n hasPlayer: !!player,\n duration: duration,\n hasTimeline: hasTimeline,\n declaredDuration: declaredDuration,\n pendingBuildReadyKeys: pendingBuildReadyKeys,\n rejectedBuildReadyKeys: rejectedBuildReadyKeys,\n };\n})()";
419
422
  export interface HfDiagnostic {
420
423
  renderReady: boolean;
421
424
  hasHf: boolean;
@@ -455,6 +458,15 @@ export declare function pollImagesReady(page: Page, timeoutMs: number, intervalM
455
458
  export declare function waitForImagesReady(page: Page, timeoutMs: number, onUnloaded: (sources: string) => void): Promise<boolean>;
456
459
  /** @internal exported for contract testing. */
457
460
  export declare function collectMediaReadinessWarnings(page: Page, skipIds: readonly string[], timeoutMs: number): Promise<CaptureWarning[]>;
461
+ /** Thrown when a frame would show an image that never arrived: the video would silently lack it. */
462
+ export declare class ImageMissingFromFrameError extends Error {
463
+ readonly frameIndex: number;
464
+ readonly sources: readonly string[];
465
+ readonly failedToLoad: boolean;
466
+ constructor(frameIndex: number, sources: readonly string[], failedToLoad: boolean);
467
+ }
468
+ /** Before a frame: wait for any late image it shows, for as long as the page was given to get ready, else stop. */
469
+ export declare function waitForShownLateImages(session: CaptureSession, frameIndex: number): Promise<void>;
458
470
  /** Report background urls that timed out decoding; the shown url drops credentials and query. */
459
471
  export declare function recordBackgroundDecodeTimeouts(session: CaptureSession, urls: readonly string[] | undefined): void;
460
472
  export declare function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void;
@@ -798,6 +810,7 @@ export declare function getCompositionLengthSource(session: CaptureSession): Pro
798
810
  export declare function computeDeVerifySampleFractions(k: number): number[];
799
811
  /** Seeks the page the truth is read from; callbacks run unless suppressed (suppressed, a count-up's text is stale). */
800
812
  export declare function seekDeVerifyTruth(page: Page, t: number, suppressEvents: boolean): Promise<void>;
813
+ export declare function captureDeVerificationFrames(session: CaptureSession, page: Page, logInitPhase: (phase: string) => void): Promise<void>;
801
814
  /**
802
815
  * Percentile of a positive-real sample set (nearest-rank; matches how the
803
816
  * existing {@link medianOf} p50 helper picks the middle index). `p` is a
@@ -15,11 +15,11 @@ import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
15
15
  import { asDrawElementCaptureFailure, DrawElementCaptureError } from "./drawElementCaptureError.js";
16
16
  import { encodePng } from "../utils/alphaBlit.js";
17
17
  import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
18
- import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
18
+ import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, pageMotionBlurRecorder, } from "./pageMotionBlur.js";
19
19
  // ── Extracted modules ───────────────────────────────────────────────────────
20
20
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, captureWithoutWaylandDisplay, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
21
21
  import { beginFrameCapture, forgetBeginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, capturesAlpha, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
22
- import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, forcePreservedDrawingBuffer, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
22
+ import { classifyGpuRenderer, detectGpuBackend, drawElementGate, injectDrawElementCanvas, captureDrawElementFrame, forcePreservedDrawingBuffer, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
23
23
  import { initThreeDProjection, scanCssEffects } from "./threeDProjection.js";
24
24
  import { planRiskFrames, scanCssEffectLiveFrames, riskSessionFits, withFramesAfterCallback, MAX_RISK_FRAME_FRACTION, } from "./riskFrames.js";
25
25
  import { getAvailableMemoryMb } from "./systemMemory.js";
@@ -163,7 +163,7 @@ export async function decodeDynamicCssBackgroundImages(page) {
163
163
  image.src = url;
164
164
  let timer;
165
165
  const expired = new Promise((resolve) => {
166
- timer = setTimeout(() => resolve("late"), limitMs);
166
+ timer = (window.__HF_VIRTUAL_TIME__?.originalSetTimeout ?? setTimeout)(() => resolve("late"), limitMs);
167
167
  });
168
168
  try {
169
169
  const outcome = await Promise.race([
@@ -453,6 +453,14 @@ async function waitForCloseWithTimeout(promise) {
453
453
  clearTimeout(timer);
454
454
  return !timedOut;
455
455
  }
456
+ const DE_GATE_MESSAGES = {
457
+ swiftshader: "SwiftShader (software rasterizer, no GPU egress to skip, drawElement is " +
458
+ "parity-or-slower; see fast-capture-limitations.md)",
459
+ webgl_unavailable: "WebGL is unavailable (no GPU behind this browser, so it rasterizes in software and " +
460
+ "drawElement has no GPU egress to skip)",
461
+ beginframe_no_self_verify: "this browser was launched for BeginFrame capture, whose self-verify truth page has no " +
462
+ "BeginFrame driver, so drawElement frames could not be checked",
463
+ };
456
464
  /**
457
465
  * Post-readiness capture-surface init, shared by the screenshot and BeginFrame
458
466
  * init paths (called after the page is fully ready). When `useDrawElement` is
@@ -592,17 +600,20 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
592
600
  // Both reproduced on Chrome <=150 (video+caption-fade ~12 dB; stacked fade
593
601
  // 24.5 dB) and both render correctly on 151 (verified: video+nested-fade repro
594
602
  // PSNR=inf; efb59c5b 24.5→47.4 dB, 0 damaged frames). 151 is the pinned floor.
595
- const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent);
596
- if (mode === "screenshot") {
597
- session.deGateReason = "swiftshader";
598
- session.deFallbackTrigger = "swiftshader";
603
+ const gateReason = drawElementGate({
604
+ backend: gpuBackend,
605
+ launchCaptureMode: session.launchCaptureMode,
606
+ forceDE,
607
+ });
608
+ if (gateReason !== null) {
609
+ session.deGateReason = gateReason;
610
+ session.deFallbackTrigger = gateReason;
599
611
  // Fall back to the browser's LAUNCH mode, not unconditionally to
600
612
  // "screenshot": on a BeginFrame-launched browser (Linux fast capture)
601
613
  // Page.captureScreenshot hangs for the full protocol timeout, while
602
614
  // beginFrameCapture is the platform's normal baseline path.
603
615
  console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
604
- "SwiftShader (software rasterizer, no GPU egress to skip, drawElement is " +
605
- "parity-or-slower; see fast-capture-limitations.md)");
616
+ DE_GATE_MESSAGES[gateReason]);
606
617
  await routeToFallback();
607
618
  }
608
619
  else {
@@ -771,7 +782,7 @@ async function cssEffectRiskFrames(session, totalFrames) {
771
782
  if (!scan.reason || !scan.bounded)
772
783
  return null;
773
784
  const fps = fpsToNumber(session.options.fps);
774
- const live = await scanCssEffectLiveFrames(scratch.page, fps, totalFrames, totalFrames * MAX_RISK_FRAME_FRACTION, { onProgress: session.options.onEffectScanProgress });
785
+ const live = await scanCssEffectLiveFrames(scratch.page, fps, totalFrames, totalFrames * MAX_RISK_FRAME_FRACTION, { onProgress: session.options.onEffectScanProgress, onEnd: session.options.onEffectScanEnd });
775
786
  return live && withFramesAfterCallback(live, scan.callbackFrom, fps, totalFrames);
776
787
  }
777
788
  catch (error) {
@@ -1197,7 +1208,9 @@ export const HF_READY_DIAGNOSTIC_EXPR = `(async function() {
1197
1208
  function() { settled[key] = "rejected"; },
1198
1209
  );
1199
1210
  });
1200
- await new Promise(function(r) { setTimeout(r, 0); });
1211
+ await new Promise(function(r) {
1212
+ (window.__HF_VIRTUAL_TIME__ && window.__HF_VIRTUAL_TIME__.originalSetTimeout || setTimeout)(r, 0);
1213
+ });
1201
1214
  }
1202
1215
  // A thenable resolving via its own setTimeout(0) can still misreport as
1203
1216
  // pending here (macrotask registration order, not resolution order), a
@@ -1709,6 +1722,85 @@ function recordCaptureWarnings(session, warnings) {
1709
1722
  console.warn(`[FrameCapture:${warning.code}] ${warning.message}`, warning.details ?? {});
1710
1723
  }
1711
1724
  }
1725
+ function recordReadiness(session, warnings) {
1726
+ recordCaptureWarnings(session, warnings);
1727
+ for (const warning of warnings) {
1728
+ if (warning.code !== "media_readiness_timeout" || warning.details?.mediaType !== "image")
1729
+ continue;
1730
+ session.lateImages ??= new Set();
1731
+ for (const source of warning.details.sources ?? [])
1732
+ session.lateImages.add(source);
1733
+ }
1734
+ }
1735
+ /** Thrown when a frame would show an image that never arrived: the video would silently lack it. */
1736
+ export class ImageMissingFromFrameError extends Error {
1737
+ frameIndex;
1738
+ sources;
1739
+ failedToLoad;
1740
+ constructor(frameIndex, sources, failedToLoad) {
1741
+ super(`Frame ${frameIndex} shows ${sources.join(", ")}, which ${failedToLoad ? "failed to load" : "did not load in time"}, ` +
1742
+ `so the render stopped rather than make a video missing it. ` +
1743
+ (failedToLoad
1744
+ ? `Check that the image exists and can be opened.`
1745
+ : `Try fewer workers (--workers 1) or a longer --player-ready-timeout.`));
1746
+ this.frameIndex = frameIndex;
1747
+ this.sources = sources;
1748
+ this.failedToLoad = failedToLoad;
1749
+ this.name = "ImageMissingFromFrameError";
1750
+ }
1751
+ }
1752
+ const LATE_IMAGE_POLL_MS = 100;
1753
+ /** Before a frame: wait for any late image it shows, for as long as the page was given to get ready, else stop. */
1754
+ export async function waitForShownLateImages(session, frameIndex) {
1755
+ const lateImages = session.lateImages;
1756
+ if (!lateImages?.size)
1757
+ return;
1758
+ session.captureStep = "waiting for a late image";
1759
+ const timeoutMs = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
1760
+ const deadline = Date.now() + timeoutMs;
1761
+ for (;;) {
1762
+ const { arrived, waiting, broken } = await session.page.evaluate(async (urls) => {
1763
+ const listed = new Set(urls);
1764
+ const found = Array.from(document.querySelectorAll("img")).filter((img) => listed.has(img.src));
1765
+ const loaded = found.filter((img) => img.complete && img.naturalWidth > 0);
1766
+ await Promise.allSettled(loaded.map((img) => img.decode()));
1767
+ const inViewport = (img) => {
1768
+ const box = img.getBoundingClientRect();
1769
+ return (box.width > 0 &&
1770
+ box.height > 0 &&
1771
+ box.right > 0 &&
1772
+ box.bottom > 0 &&
1773
+ box.left < window.innerWidth &&
1774
+ box.top < window.innerHeight);
1775
+ };
1776
+ const shown = found.filter((img) => !loaded.includes(img) &&
1777
+ img.checkVisibility({ opacityProperty: true, visibilityProperty: true }) &&
1778
+ inViewport(img));
1779
+ return {
1780
+ arrived: loaded.map((img) => img.src),
1781
+ waiting: shown.filter((img) => !img.complete).map((img) => img.src),
1782
+ broken: shown.filter((img) => img.complete).map((img) => img.src),
1783
+ };
1784
+ }, Array.from(lateImages));
1785
+ for (const source of arrived)
1786
+ lateImages.delete(source);
1787
+ const missing = Date.now() >= deadline ? [...waiting, ...broken] : broken;
1788
+ if (missing.length > 0) {
1789
+ const stopped = new ImageMissingFromFrameError(frameIndex, [...new Set(missing.map(sanitizeDiagnosticUrl))].sort(), broken.length > 0);
1790
+ recordCaptureWarnings(session, [
1791
+ {
1792
+ code: "image_missing_from_frame",
1793
+ message: stopped.message,
1794
+ details: { mediaType: "image", sources: [...stopped.sources] },
1795
+ },
1796
+ ]);
1797
+ throw stopped;
1798
+ }
1799
+ if (waiting.length === 0)
1800
+ return;
1801
+ await new Promise((resolve) => setTimeout(resolve, LATE_IMAGE_POLL_MS));
1802
+ }
1803
+ }
1712
1804
  /** Report background urls that timed out decoding; the shown url drops credentials and query. */
1713
1805
  export function recordBackgroundDecodeTimeouts(session, urls) {
1714
1806
  // Mocked pages answer evaluate() with undefined, so tolerate a missing list.
@@ -2187,7 +2279,7 @@ export async function initializeSession(session) {
2187
2279
  console.warn(`[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
2188
2280
  `Continuing render. Affected videos will appear as blank/black frames.`);
2189
2281
  }
2190
- recordCaptureWarnings(session, await collectMediaReadinessWarnings(page, skipVideoIds, pageReadyTimeout));
2282
+ recordReadiness(session, await collectMediaReadinessWarnings(page, skipVideoIds, pageReadyTimeout));
2191
2283
  await recordLiveMapWarning(session, page);
2192
2284
  await recordSessionInitTelemetry(session, initStart);
2193
2285
  // Ground-truth-check the upstream captureBeyondViewport request (see
@@ -2300,7 +2392,7 @@ export async function initializeSession(session) {
2300
2392
  console.warn(`[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
2301
2393
  `Continuing render. Affected videos will appear as blank/black frames.`);
2302
2394
  }
2303
- recordCaptureWarnings(session, await collectMediaReadinessWarnings(page, bfSkipVideoIds, pageReadyTimeout));
2395
+ recordReadiness(session, await collectMediaReadinessWarnings(page, bfSkipVideoIds, pageReadyTimeout));
2304
2396
  await recordLiveMapWarning(session, page);
2305
2397
  await recordSessionInitTelemetry(session, initStart);
2306
2398
  });
@@ -2419,6 +2511,7 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
2419
2511
  const seekStart = Date.now();
2420
2512
  session.captureStep = "seeking the timeline";
2421
2513
  const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
2514
+ await waitForShownLateImages(session, frameIndex);
2422
2515
  session.captureStep = "decoding CSS background images";
2423
2516
  // Mocked pages answer evaluate() with undefined, so tolerate a missing list.
2424
2517
  recordBackgroundDecodeTimeouts(session, await decodeDynamicCssBackgroundImages(page));
@@ -3530,6 +3623,7 @@ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
3530
3623
  const eventfulSeekStart = Date.now();
3531
3624
  session.captureStep = "seeking the timeline";
3532
3625
  await seekPageTimeline(session.page, frameTime, undefined);
3626
+ await waitForShownLateImages(session, frameIndex);
3533
3627
  const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
3534
3628
  if (session.onBeforeCapture) {
3535
3629
  // Sub-samples leave a video's style alone: inject at frameTime first so the held frame
@@ -3624,7 +3718,7 @@ async function captureFrameCore(session, frameIndex, time) {
3624
3718
  session.capturePerf.totalMs += captureTimeMs;
3625
3719
  session.capturePerf.frameMs.push(captureTimeMs);
3626
3720
  if (session.capturePerf.frames === 1) {
3627
- const blur = await describePageMotionBlur(session.page).catch(() => null);
3721
+ const blur = await describePageMotionBlur(session.page, pageMotionBlurRecorder(session)).catch(() => null);
3628
3722
  if (blur)
3629
3723
  console.error(blur);
3630
3724
  }
@@ -3840,6 +3934,15 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3840
3934
  assertVfxIntact(session);
3841
3935
  for (const frameIndex of frameIndices)
3842
3936
  assertNotRiskFrame(session, frameIndex);
3937
+ // The fused batch seeks inside the page, with no stop between frames to wait for a late image.
3938
+ if (session.lateImages?.size) {
3939
+ const oneByOne = [];
3940
+ for (const [i, frameIndex] of frameIndices.entries()) {
3941
+ const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, times[i] ?? 0);
3942
+ oneByOne.push({ frameIndex, encodeResult });
3943
+ }
3944
+ return oneByOne;
3945
+ }
3843
3946
  const startTime = Date.now();
3844
3947
  const fps = fpsToNumber(options.fps);
3845
3948
  const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
@@ -3966,7 +4069,7 @@ export async function closeCaptureSession(session) {
3966
4069
  await waitForCloseWithTimeout(session.chromeMemory.sampleOnce());
3967
4070
  }
3968
4071
  if (session.page && !session.pageReleased) {
3969
- const blur = await describePageMotionBlurAtEnd(session.page);
4072
+ const blur = await describePageMotionBlurAtEnd(session.page, undefined, pageMotionBlurRecorder(session));
3970
4073
  if (blur)
3971
4074
  console.error(blur);
3972
4075
  }
@@ -4105,7 +4208,7 @@ export async function seekDeVerifyTruth(page, t, suppressEvents) {
4105
4208
  hf.seek(tt, { suppressEvents: suppress });
4106
4209
  }, t, suppressEvents);
4107
4210
  }
4108
- async function captureDeVerificationFrames(session, page, logInitPhase) {
4211
+ export async function captureDeVerificationFrames(session, page, logInitPhase) {
4109
4212
  // Explicit HF_DE_VERIFY wins; otherwise the session's own sample count
4110
4213
  // (raised by the parallel coordinator for multi-worker DE), then default 4.
4111
4214
  const kRaw = Number(process.env.HF_DE_VERIFY ?? session.options.deVerifySamples ?? "4");
@@ -4283,6 +4386,7 @@ export function getCapturePerfSummary(session) {
4283
4386
  chromeGpuProcessSeenLastSample: session.chromeMemory?.stats().gpuProcessSeenLastSample,
4284
4387
  chromeMemorySamples: session.chromeMemory?.stats().samples,
4285
4388
  deGateReason: session.deGateReason,
4389
+ motionBlur: session.pageMotionBlur,
4286
4390
  deFallbackTrigger: session.deFallbackTrigger,
4287
4391
  deWorkerEncode: session.workerEncodeEnabled ?? false,
4288
4392
  deVerifyArmed: session.deVerifyFrames?.size ?? 0,
@@ -4,9 +4,14 @@
4
4
  * when the render must match a Studio preview that could not blur (`pageMotionBlur: "off"`).
5
5
  */
6
6
  import type { Page } from "puppeteer-core";
7
- import type { CaptureOptions } from "../types.js";
7
+ import type { CaptureOptions, PageMotionBlurSummary } from "../types.js";
8
8
  export declare function installPageMotionBlur(page: Pick<Page, "evaluateOnNewDocument">, options: Pick<CaptureOptions, "motionBlur" | "pageMotionBlur" | "deviceScaleFactor">): Promise<void>;
9
+ export type RecordPageMotionBlur = (summary: PageMotionBlurSummary) => void;
10
+ /** Keeps one summary object per session, updated in place, so a perf summary that holds it sees the session's last read. */
11
+ export declare function pageMotionBlurRecorder(session: {
12
+ pageMotionBlur?: PageMotionBlurSummary;
13
+ }): RecordPageMotionBlur;
9
14
  /** One line saying what the page's blur did for this session; null when the page never asked for blur. */
10
- export declare function describePageMotionBlur(page: Pick<Page, "evaluate">): Promise<string | null>;
15
+ export declare function describePageMotionBlur(page: Pick<Page, "evaluate">, record?: RecordPageMotionBlur): Promise<string | null>;
11
16
  /** The session's closing line, only when frames fell back to plain DOM: the first-frame line cannot know. */
12
- export declare function describePageMotionBlurAtEnd(page: Pick<Page, "evaluate">, timeoutMs?: number): Promise<string | null>;
17
+ export declare function describePageMotionBlurAtEnd(page: Pick<Page, "evaluate">, timeoutMs?: number, record?: RecordPageMotionBlur): Promise<string | null>;
@@ -3,7 +3,7 @@
3
3
  * its scripts run: when the engine blurs (it would average frames that are already blurred) or
4
4
  * when the render must match a Studio preview that could not blur (`pageMotionBlur: "off"`).
5
5
  */
6
- import { PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL, acceleratedCanvasTrackerSource, } from "@hyperframes/core";
6
+ import { MOTION_BLUR_FINGERPRINT_FIELDS, PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL, acceleratedCanvasTrackerSource, } from "@hyperframes/core";
7
7
  function pageOffReason(options) {
8
8
  if (options.motionBlur)
9
9
  return "engine-motion-blur";
@@ -26,37 +26,84 @@ export async function installPageMotionBlur(page, options) {
26
26
  window[key] = v;
27
27
  }, PAGE_MOTION_BLUR_OFF_GLOBAL, off);
28
28
  }
29
+ function parseFingerprint(raw) {
30
+ if (!raw || typeof raw !== "object")
31
+ return null;
32
+ const count = (field) => Number(Reflect.get(raw, field)) || 0;
33
+ return Object.fromEntries(MOTION_BLUR_FINGERPRINT_FIELDS.map((field) => [field, count(field)]));
34
+ }
29
35
  async function readPageMotionBlur(page) {
30
- return page.evaluate(() => {
36
+ const read = await page.evaluate(() => {
31
37
  const api = Reflect.get(window, "__hfMotionBlur");
32
38
  const report = api && typeof api === "object" ? Reflect.get(api, "report") : null;
33
39
  const got = typeof report === "function" ? report() : null;
34
40
  if (!got || typeof got !== "object")
35
41
  return null;
36
42
  const tier = Reflect.get(got, "tier");
43
+ const reasonCode = Reflect.get(got, "reasonCode");
37
44
  return {
38
45
  state: String(Reflect.get(got, "state")),
39
- code: String(Reflect.get(got, "reasonCode")),
46
+ reasonCode: typeof reasonCode === "string" ? reasonCode : null,
40
47
  movers: Number(Reflect.get(got, "movers")),
41
48
  failedFrames: Number(Reflect.get(got, "failedFrames")) || 0,
42
49
  tier: typeof tier === "string" ? tier : null,
50
+ readEvents: Reflect.get(got, "readEvents") === true,
51
+ fingerprint: Reflect.get(got, "fingerprint"),
43
52
  };
44
53
  });
54
+ return read && { ...read, fingerprint: parseFingerprint(read.fingerprint) };
55
+ }
56
+ function fingerprintCounts(fingerprint) {
57
+ if (!fingerprint)
58
+ return "";
59
+ return MOTION_BLUR_FINGERPRINT_FIELDS.filter((field) => fingerprint[field] > 0)
60
+ .map((field) => `${field}:${fingerprint[field]}`)
61
+ .join(",");
62
+ }
63
+ const formatPageMotionBlur = (r) => {
64
+ const counts = fingerprintCounts(r.fingerprint);
65
+ return (`[motion-blur] ${r.state} reasonCode=${r.reasonCode} movers=${r.movers} failedFrames=${r.failedFrames}` +
66
+ `${r.tier ? ` tier=${r.tier}` : ""}${r.readEvents ? " readEvents=true" : ""}${counts ? ` fp=${counts}` : ""}`);
67
+ };
68
+ const summarize = (r) => ({
69
+ tier: r.tier === "capture" || r.tier === "filter" ? r.tier : null,
70
+ reasonCode: r.reasonCode,
71
+ movers: r.movers,
72
+ failedFrames: r.failedFrames,
73
+ readEvents: r.readEvents,
74
+ fingerprint: r.fingerprint,
75
+ });
76
+ /** Keeps one summary object per session, updated in place, so a perf summary that holds it sees the session's last read. */
77
+ export function pageMotionBlurRecorder(session) {
78
+ return (next) => {
79
+ const held = session.pageMotionBlur;
80
+ if (!held) {
81
+ session.pageMotionBlur = next;
82
+ return;
83
+ }
84
+ Object.assign(held, next, {
85
+ tier: next.tier ?? held.tier,
86
+ fingerprint: next.fingerprint ?? held.fingerprint,
87
+ });
88
+ };
45
89
  }
46
- const formatPageMotionBlur = (r) => `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}${r.tier ? ` tier=${r.tier}` : ""}`;
47
90
  /** One line saying what the page's blur did for this session; null when the page never asked for blur. */
48
- export async function describePageMotionBlur(page) {
91
+ export async function describePageMotionBlur(page, record) {
49
92
  const r = await readPageMotionBlur(page);
93
+ if (r)
94
+ record?.(summarize(r));
50
95
  return r && formatPageMotionBlur(r);
51
96
  }
52
97
  /** The session's closing line, only when frames fell back to plain DOM: the first-frame line cannot know. */
53
- export async function describePageMotionBlurAtEnd(page, timeoutMs = 1500) {
98
+ export async function describePageMotionBlurAtEnd(page, timeoutMs = 1500, record) {
54
99
  let timer;
55
100
  const stuck = new Promise((resolve) => {
56
101
  timer = setTimeout(() => resolve(null), timeoutMs);
57
102
  });
58
103
  try {
59
104
  const r = await Promise.race([readPageMotionBlur(page), stuck]);
105
+ if (r)
106
+ record?.(summarize(r));
60
107
  return r && r.failedFrames > 0 ? `${formatPageMotionBlur(r)} (session end)` : null;
61
108
  }
62
109
  catch {
@@ -524,8 +524,21 @@ export function isFfmpegInfrastructureFailure(err) {
524
524
  * remaining frame unverified.
525
525
  */
526
526
  async function psnrForDiskSample(framePath, truth, workerId, idx) {
527
+ const skipSample = (err) => {
528
+ console.warn(`[Parallel] drawElement disk self-verify sample skipped (worker ${workerId}, ` +
529
+ `frame ${idx}): ${err instanceof Error ? err.message : String(err)}`);
530
+ return null;
531
+ };
532
+ // Read apart from the compare: a frame file's ENOENT says nothing about ffmpeg.
533
+ let frame;
527
534
  try {
528
- return await psnrDb(await readFile(framePath), truth);
535
+ frame = await readFile(framePath);
536
+ }
537
+ catch (err) {
538
+ return skipSample(err);
539
+ }
540
+ try {
541
+ return await psnrDb(frame, truth);
529
542
  }
530
543
  catch (err) {
531
544
  if (isFfmpegInfrastructureFailure(err)) {
@@ -536,9 +549,7 @@ async function psnrForDiskSample(framePath, truth, workerId, idx) {
536
549
  "hit this after a successful preflight, ffmpeg was replaced mid-render or " +
537
550
  "HYPERFRAMES_FFMPEG_PATH now points at a different binary.", { cause: err });
538
551
  }
539
- console.warn(`[Parallel] drawElement disk self-verify sample skipped (worker ${workerId}, ` +
540
- `frame ${idx}): ${err instanceof Error ? err.message : String(err)}`);
541
- return null;
552
+ return skipSample(err);
542
553
  }
543
554
  }
544
555
  // Branches are the gate conditions themselves (mode/armed/streaming guards +
@@ -18,12 +18,13 @@ interface ScanOptions {
18
18
  budgetMs?: number;
19
19
  now?: () => number;
20
20
  onProgress?: (scannedFrames: number, totalFrames: number) => void;
21
+ onEnd?: () => void;
21
22
  }
22
23
  /**
23
24
  * Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
24
25
  * Null once more than `maxFrames` frames qualify, or once the whole scan would take over `budgetMs`.
25
26
  */
26
- export declare function scanCssEffectLiveFrames(page: Page, fps: number, totalFrames: number, maxFrames: number, { budgetMs, now, onProgress }?: ScanOptions): Promise<Set<number> | null>;
27
+ export declare function scanCssEffectLiveFrames(page: Page, fps: number, totalFrames: number, maxFrames: number, { budgetMs, now, onProgress, onEnd, }?: ScanOptions): Promise<Set<number> | null>;
27
28
  /**
28
29
  * The given frames with a one-frame margin, ranges closer than {@link RISK_RANGE_HYSTERESIS_FRAMES}
29
30
  * joined. Null when they cover more than {@link MAX_RISK_FRAME_FRACTION} of the render.
@@ -61,7 +61,15 @@ function withinBudget(work, ms) {
61
61
  * Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
62
62
  * Null once more than `maxFrames` frames qualify, or once the whole scan would take over `budgetMs`.
63
63
  */
64
- export async function scanCssEffectLiveFrames(page, fps, totalFrames, maxFrames, { budgetMs = SCAN_BUDGET_MS, now = () => performance.now(), onProgress } = {}) {
64
+ export async function scanCssEffectLiveFrames(page, fps, totalFrames, maxFrames, { budgetMs = SCAN_BUDGET_MS, now = () => performance.now(), onProgress = () => { }, onEnd, } = {}) {
65
+ try {
66
+ return await scanLiveFrames(page, fps, totalFrames, maxFrames, budgetMs, now, onProgress);
67
+ }
68
+ finally {
69
+ onEnd?.();
70
+ }
71
+ }
72
+ async function scanLiveFrames(page, fps, totalFrames, maxFrames, budgetMs, now, onProgress) {
65
73
  const live = new Set();
66
74
  const started = now();
67
75
  for (let from = 0; from < totalFrames && live.size <= maxFrames; from += SCAN_CHUNK_FRAMES) {
@@ -77,7 +85,7 @@ export async function scanCssEffectLiveFrames(page, fps, totalFrames, maxFrames,
77
85
  }
78
86
  for (const i of shown)
79
87
  live.add(from + i);
80
- onProgress?.(to, totalFrames);
88
+ onProgress(to, totalFrames);
81
89
  const projectedMs = ((now() - started) / to) * totalFrames;
82
90
  if (to < totalFrames && projectedMs > budgetMs) {
83
91
  console.error(`[engine] fast capture: the effect scan of ${totalFrames} frames would take ` +
@@ -506,9 +506,20 @@ export function parseImageElements(html) {
506
506
  return images;
507
507
  }
508
508
  const firstFrameColourCache = new Map();
509
- /** A source's SPS transfers, so a PQ or SDR keyframe keeps an HLG one at 100 nits; null when the read failed. */
509
+ /** A source's SPS transfers, read as the trace streams (a run keeps only stderr's tail); null when the read failed. */
510
510
  function readKeyframeTransfers(videoPath, codec) {
511
- return inExtractionSlot(() => runFfmpeg([...urlInputArgs(videoPath), ...keyframeTransferArgs(videoPath, codec)])).then((result) => (result.success ? parseKeyframeTransfers(result.stderr) : null));
511
+ let traced = "";
512
+ let rest = "";
513
+ const onStderr = (chunk) => {
514
+ const lines = (rest + chunk).split("\n");
515
+ rest = lines.pop() ?? "";
516
+ for (const line of lines)
517
+ if (line.includes("transfer_characteristics"))
518
+ traced += `${line}\n`;
519
+ };
520
+ return inExtractionSlot(() => runFfmpeg([...urlInputArgs(videoPath), ...keyframeTransferArgs(videoPath, codec)], {
521
+ onStderr,
522
+ })).then((result) => (result.success ? parseKeyframeTransfers(traced + rest) : null));
512
523
  }
513
524
  /** The first shown frame's colour tags (see {@link firstFrameColourArgs}), probed once per source file. */
514
525
  function readFirstFrameColour(videoPath, codec) {
package/dist/types.d.ts CHANGED
@@ -6,11 +6,19 @@
6
6
  */
7
7
  import type { ReferenceBackground } from "./services/referenceBackground.js";
8
8
  import type { AudioFailureDiagnostic } from "./services/audioMixer.types.js";
9
- import type { Fps } from "@hyperframes/core";
9
+ import type { Fps, MotionBlurFingerprint } from "@hyperframes/core";
10
10
  import type { CompositionLengthSource } from "@hyperframes/core/runtime/composition-length";
11
11
  import type { ChromeMemoryStats } from "./services/chromeMemorySampler.js";
12
12
  import type { MotionBlurOptions } from "./services/motionBlur.js";
13
13
  export type PageMotionBlurOff = "off" | "hdr-render" | "layered-composite" | "distributed-render";
14
+ export interface PageMotionBlurSummary {
15
+ tier: "capture" | "filter" | null;
16
+ reasonCode: string | null;
17
+ movers: number;
18
+ failedFrames: number;
19
+ readEvents: boolean;
20
+ fingerprint: MotionBlurFingerprint | null;
21
+ }
14
22
  /**
15
23
  * Outcome of waiting for a sub-composition's GSAP timelines to register.
16
24
  * Threaded string-typed through `CapturePerfSummary` / `RenderPerfSummary` /
@@ -25,7 +33,7 @@ export interface SubTimelineWaitMemo {
25
33
  unregisteredIds?: readonly string[];
26
34
  loggedSceneCallbackErrors?: string[];
27
35
  }
28
- export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "script_error_isolated" | "vfx_failure" | "live_map_detected";
36
+ export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "image_missing_from_frame" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "script_error_isolated" | "vfx_failure" | "live_map_detected";
29
37
  /** Structured correctness warning produced while preparing a capture session. */
30
38
  export interface CaptureWarning {
31
39
  code: CaptureWarningCode;
@@ -237,6 +245,7 @@ export interface CaptureOptions {
237
245
  deVerifySamples?: number;
238
246
  deFrameFallback?: boolean;
239
247
  onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void;
248
+ onEffectScanEnd?: () => void;
240
249
  }
241
250
  export interface CaptureVideoMetadataHint {
242
251
  id: string;
@@ -374,11 +383,12 @@ export interface CapturePerfSummary {
374
383
  chromeMemorySamples?: number;
375
384
  /**
376
385
  * Low-cardinality init-time gate that routed a drawElement-eligible session
377
- * to the baseline: `swiftshader` | `css_effect:<fx>` | `at_risk_timeline` |
378
- * `3d_init_failed` | `supersampling` | `render_mode_hint`. Undefined when
379
- * drawElement ran or was never attempted.
386
+ * to the baseline: `swiftshader` | `webgl_unavailable` | `beginframe_no_self_verify` |
387
+ * `css_effect:<fx>` | `at_risk_timeline` | `3d_init_failed` | `supersampling` |
388
+ * `render_mode_hint`. Undefined when drawElement ran or was never attempted.
380
389
  */
381
390
  deGateReason?: string;
391
+ motionBlur?: PageMotionBlurSummary;
382
392
  /**
383
393
  * Full-fidelity fallback trigger — the specific reason (CSS FX + property,
384
394
  * e.g. `filter:blur`, `filter:drop-shadow`, `backdrop-filter`, `clip-path`,
@@ -413,5 +423,8 @@ export interface CapturePerfSummary {
413
423
  declare global {
414
424
  interface Window {
415
425
  __hf?: HfProtocol;
426
+ __HF_VIRTUAL_TIME__?: {
427
+ originalSetTimeout?: typeof setTimeout;
428
+ };
416
429
  }
417
430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -61,8 +61,8 @@
61
61
  "puppeteer": "~25.11.0",
62
62
  "puppeteer-core": "^25.11.0",
63
63
  "undici": "7.25.0",
64
- "@hyperframes/core": "^0.9.19",
65
- "@hyperframes/parsers": "^0.9.19"
64
+ "@hyperframes/core": "^0.9.21",
65
+ "@hyperframes/parsers": "^0.9.21"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^25.0.10",