@hyperframes/engine 0.9.19 → 0.9.20

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)
@@ -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
@@ -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;
@@ -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";
@@ -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) {
@@ -1709,6 +1720,85 @@ function recordCaptureWarnings(session, warnings) {
1709
1720
  console.warn(`[FrameCapture:${warning.code}] ${warning.message}`, warning.details ?? {});
1710
1721
  }
1711
1722
  }
1723
+ function recordReadiness(session, warnings) {
1724
+ recordCaptureWarnings(session, warnings);
1725
+ for (const warning of warnings) {
1726
+ if (warning.code !== "media_readiness_timeout" || warning.details?.mediaType !== "image")
1727
+ continue;
1728
+ session.lateImages ??= new Set();
1729
+ for (const source of warning.details.sources ?? [])
1730
+ session.lateImages.add(source);
1731
+ }
1732
+ }
1733
+ /** Thrown when a frame would show an image that never arrived: the video would silently lack it. */
1734
+ export class ImageMissingFromFrameError extends Error {
1735
+ frameIndex;
1736
+ sources;
1737
+ failedToLoad;
1738
+ constructor(frameIndex, sources, failedToLoad) {
1739
+ super(`Frame ${frameIndex} shows ${sources.join(", ")}, which ${failedToLoad ? "failed to load" : "did not load in time"}, ` +
1740
+ `so the render stopped rather than make a video missing it. ` +
1741
+ (failedToLoad
1742
+ ? `Check that the image exists and can be opened.`
1743
+ : `Try fewer workers (--workers 1) or a longer --player-ready-timeout.`));
1744
+ this.frameIndex = frameIndex;
1745
+ this.sources = sources;
1746
+ this.failedToLoad = failedToLoad;
1747
+ this.name = "ImageMissingFromFrameError";
1748
+ }
1749
+ }
1750
+ const LATE_IMAGE_POLL_MS = 100;
1751
+ /** Before a frame: wait for any late image it shows, for as long as the page was given to get ready, else stop. */
1752
+ export async function waitForShownLateImages(session, frameIndex) {
1753
+ const lateImages = session.lateImages;
1754
+ if (!lateImages?.size)
1755
+ return;
1756
+ session.captureStep = "waiting for a late image";
1757
+ const timeoutMs = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
1758
+ const deadline = Date.now() + timeoutMs;
1759
+ for (;;) {
1760
+ const { arrived, waiting, broken } = await session.page.evaluate(async (urls) => {
1761
+ const listed = new Set(urls);
1762
+ const found = Array.from(document.querySelectorAll("img")).filter((img) => listed.has(img.src));
1763
+ const loaded = found.filter((img) => img.complete && img.naturalWidth > 0);
1764
+ await Promise.allSettled(loaded.map((img) => img.decode()));
1765
+ const inViewport = (img) => {
1766
+ const box = img.getBoundingClientRect();
1767
+ return (box.width > 0 &&
1768
+ box.height > 0 &&
1769
+ box.right > 0 &&
1770
+ box.bottom > 0 &&
1771
+ box.left < window.innerWidth &&
1772
+ box.top < window.innerHeight);
1773
+ };
1774
+ const shown = found.filter((img) => !loaded.includes(img) &&
1775
+ img.checkVisibility({ opacityProperty: true, visibilityProperty: true }) &&
1776
+ inViewport(img));
1777
+ return {
1778
+ arrived: loaded.map((img) => img.src),
1779
+ waiting: shown.filter((img) => !img.complete).map((img) => img.src),
1780
+ broken: shown.filter((img) => img.complete).map((img) => img.src),
1781
+ };
1782
+ }, Array.from(lateImages));
1783
+ for (const source of arrived)
1784
+ lateImages.delete(source);
1785
+ const missing = Date.now() >= deadline ? [...waiting, ...broken] : broken;
1786
+ if (missing.length > 0) {
1787
+ const stopped = new ImageMissingFromFrameError(frameIndex, [...new Set(missing.map(sanitizeDiagnosticUrl))].sort(), broken.length > 0);
1788
+ recordCaptureWarnings(session, [
1789
+ {
1790
+ code: "image_missing_from_frame",
1791
+ message: stopped.message,
1792
+ details: { mediaType: "image", sources: [...stopped.sources] },
1793
+ },
1794
+ ]);
1795
+ throw stopped;
1796
+ }
1797
+ if (waiting.length === 0)
1798
+ return;
1799
+ await new Promise((resolve) => setTimeout(resolve, LATE_IMAGE_POLL_MS));
1800
+ }
1801
+ }
1712
1802
  /** Report background urls that timed out decoding; the shown url drops credentials and query. */
1713
1803
  export function recordBackgroundDecodeTimeouts(session, urls) {
1714
1804
  // Mocked pages answer evaluate() with undefined, so tolerate a missing list.
@@ -2187,7 +2277,7 @@ export async function initializeSession(session) {
2187
2277
  console.warn(`[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
2188
2278
  `Continuing render. Affected videos will appear as blank/black frames.`);
2189
2279
  }
2190
- recordCaptureWarnings(session, await collectMediaReadinessWarnings(page, skipVideoIds, pageReadyTimeout));
2280
+ recordReadiness(session, await collectMediaReadinessWarnings(page, skipVideoIds, pageReadyTimeout));
2191
2281
  await recordLiveMapWarning(session, page);
2192
2282
  await recordSessionInitTelemetry(session, initStart);
2193
2283
  // Ground-truth-check the upstream captureBeyondViewport request (see
@@ -2300,7 +2390,7 @@ export async function initializeSession(session) {
2300
2390
  console.warn(`[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
2301
2391
  `Continuing render. Affected videos will appear as blank/black frames.`);
2302
2392
  }
2303
- recordCaptureWarnings(session, await collectMediaReadinessWarnings(page, bfSkipVideoIds, pageReadyTimeout));
2393
+ recordReadiness(session, await collectMediaReadinessWarnings(page, bfSkipVideoIds, pageReadyTimeout));
2304
2394
  await recordLiveMapWarning(session, page);
2305
2395
  await recordSessionInitTelemetry(session, initStart);
2306
2396
  });
@@ -2419,6 +2509,7 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
2419
2509
  const seekStart = Date.now();
2420
2510
  session.captureStep = "seeking the timeline";
2421
2511
  const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
2512
+ await waitForShownLateImages(session, frameIndex);
2422
2513
  session.captureStep = "decoding CSS background images";
2423
2514
  // Mocked pages answer evaluate() with undefined, so tolerate a missing list.
2424
2515
  recordBackgroundDecodeTimeouts(session, await decodeDynamicCssBackgroundImages(page));
@@ -3530,6 +3621,7 @@ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
3530
3621
  const eventfulSeekStart = Date.now();
3531
3622
  session.captureStep = "seeking the timeline";
3532
3623
  await seekPageTimeline(session.page, frameTime, undefined);
3624
+ await waitForShownLateImages(session, frameIndex);
3533
3625
  const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
3534
3626
  if (session.onBeforeCapture) {
3535
3627
  // Sub-samples leave a video's style alone: inject at frameTime first so the held frame
@@ -3624,7 +3716,7 @@ async function captureFrameCore(session, frameIndex, time) {
3624
3716
  session.capturePerf.totalMs += captureTimeMs;
3625
3717
  session.capturePerf.frameMs.push(captureTimeMs);
3626
3718
  if (session.capturePerf.frames === 1) {
3627
- const blur = await describePageMotionBlur(session.page).catch(() => null);
3719
+ const blur = await describePageMotionBlur(session.page, pageMotionBlurRecorder(session)).catch(() => null);
3628
3720
  if (blur)
3629
3721
  console.error(blur);
3630
3722
  }
@@ -3840,6 +3932,15 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3840
3932
  assertVfxIntact(session);
3841
3933
  for (const frameIndex of frameIndices)
3842
3934
  assertNotRiskFrame(session, frameIndex);
3935
+ // The fused batch seeks inside the page, with no stop between frames to wait for a late image.
3936
+ if (session.lateImages?.size) {
3937
+ const oneByOne = [];
3938
+ for (const [i, frameIndex] of frameIndices.entries()) {
3939
+ const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, times[i] ?? 0);
3940
+ oneByOne.push({ frameIndex, encodeResult });
3941
+ }
3942
+ return oneByOne;
3943
+ }
3843
3944
  const startTime = Date.now();
3844
3945
  const fps = fpsToNumber(options.fps);
3845
3946
  const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
@@ -3966,7 +4067,7 @@ export async function closeCaptureSession(session) {
3966
4067
  await waitForCloseWithTimeout(session.chromeMemory.sampleOnce());
3967
4068
  }
3968
4069
  if (session.page && !session.pageReleased) {
3969
- const blur = await describePageMotionBlurAtEnd(session.page);
4070
+ const blur = await describePageMotionBlurAtEnd(session.page, undefined, pageMotionBlurRecorder(session));
3970
4071
  if (blur)
3971
4072
  console.error(blur);
3972
4073
  }
@@ -4105,7 +4206,7 @@ export async function seekDeVerifyTruth(page, t, suppressEvents) {
4105
4206
  hf.seek(tt, { suppressEvents: suppress });
4106
4207
  }, t, suppressEvents);
4107
4208
  }
4108
- async function captureDeVerificationFrames(session, page, logInitPhase) {
4209
+ export async function captureDeVerificationFrames(session, page, logInitPhase) {
4109
4210
  // Explicit HF_DE_VERIFY wins; otherwise the session's own sample count
4110
4211
  // (raised by the parallel coordinator for multi-worker DE), then default 4.
4111
4212
  const kRaw = Number(process.env.HF_DE_VERIFY ?? session.options.deVerifySamples ?? "4");
@@ -4283,6 +4384,7 @@ export function getCapturePerfSummary(session) {
4283
4384
  chromeGpuProcessSeenLastSample: session.chromeMemory?.stats().gpuProcessSeenLastSample,
4284
4385
  chromeMemorySamples: session.chromeMemory?.stats().samples,
4285
4386
  deGateReason: session.deGateReason,
4387
+ motionBlur: session.pageMotionBlur,
4286
4388
  deFallbackTrigger: session.deFallbackTrigger,
4287
4389
  deWorkerEncode: session.workerEncodeEnabled ?? false,
4288
4390
  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 ` +
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`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.9.19",
3
+ "version": "0.9.20",
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/parsers": "^0.9.20",
65
+ "@hyperframes/core": "^0.9.20"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^25.0.10",