@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 +2 -2
- package/dist/index.js +1 -1
- package/dist/services/browserManager.d.ts +1 -0
- package/dist/services/browserManager.js +11 -8
- package/dist/services/drawElementService.d.ts +22 -9
- package/dist/services/drawElementService.js +48 -21
- package/dist/services/frameCapture.d.ts +16 -3
- package/dist/services/frameCapture.js +120 -16
- package/dist/services/pageMotionBlur.d.ts +8 -3
- package/dist/services/pageMotionBlur.js +53 -6
- package/dist/services/parallelCoordinator.js +15 -4
- package/dist/services/riskFrames.d.ts +2 -1
- package/dist/services/riskFrames.js +10 -2
- package/dist/services/videoFrameExtractor.js +13 -2
- package/dist/types.d.ts +18 -5
- package/package.json +3 -3
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(
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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(
|
|
62
|
-
// `transparent` is retained for call-site clarity;
|
|
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 (
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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
|
|
596
|
-
|
|
597
|
-
session.
|
|
598
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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` | `
|
|
378
|
-
* `
|
|
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.
|
|
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.
|
|
65
|
-
"@hyperframes/parsers": "^0.9.
|
|
64
|
+
"@hyperframes/core": "^0.9.21",
|
|
65
|
+
"@hyperframes/parsers": "^0.9.21"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@types/node": "^25.0.10",
|