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