Files
hyperframes/packages/engine/src/services/frameCapture.ts
T
Vance IngallsandClaude Fable 5 b26c27576b feat(engine,producer,cli): verify video comps via deferred DE init + capture p50 (#2015)
* feat(engine,producer,cli): verify video comps via deferred DE init + capture p50

Closes the two biggest gaps in the first day of v0.7.38 wild data: 88% of
drawElement renders (video comps initialized via probe sessions) ran with
self-verification unarmed, and speedup was measurable on only 3 of 76 renders.

- Deferred drawElement init: probe sessions initialize before video
  extraction, so they have no frame injector — ground-truth screenshots
  would capture black <video> boxes, and verification skipped the whole
  comp. DE init now stops after the gates for injector-less video comps
  (deInitDeferred; autoAlpha flag retracted in case no path completes it)
  and completeDeferredDrawElementInit finishes verification + canvas
  injection + worker-encode at capture time, once
  prepareCaptureSessionForReuse has attached the injector. Validated
  end-to-end: a probe-path video comp now arms 4 ground-truth frames with
  real video pixels (3x inf + 64.7dB) and renders drawElement verified.
- capture_p50_ms: per-frame capture durations are sampled
  (capturePerf.frameMs; batch frames get the batch mean) and the median
  ships as CapturePerfSummary.p50TotalMs -> RenderPerfSummary.captureP50Ms
  -> render_complete capture_p50_ms. Unlike capture_avg_ms it is immune
  to first-frame warmup and stage-setup amortization — smoke: avg 15ms vs
  p50 8ms on the same render, p50 matching the measured steady-state
  floor. Dashboard speedup tiles can drop their frame-count floor once
  this ships.
- video_count on render_complete: segments speedup by video-injection
  comps (whose per-frame gain is legitimately lower) vs pure-graphics.

Canary suite 7/7; engine suite 905 passed (1 pre-existing upstream
failure); tsc/oxlint/oxfmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(producer): complete deferred drawElement init on the disk capture path

Review (miga): a probe-initialized video comp falling back to the disk path
kept deInitDeferred and silently stayed in screenshot mode — a regression
for PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true renders that previously ran
drawElement there. Complete the deferred init on the sequential disk path
under the same explicit-opt-in test the orchestrator clamp uses; default-on
renders stay on the screenshot baseline (this path has no drain-time
self-verification, per the #1998 confinement rule).

Validated: video comp + PRODUCER_ENABLE_STREAMING_ENCODE=false + explicit
opt-in logs "(deferred drawElement init)" completion on capture_disk and
renders correct video pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:47:29 -07:00

3115 lines
134 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fallow-ignore-file complexity code-duplication
/**
* Frame Capture Service
*
* Uses Puppeteer to capture frames from any web page implementing the
* window.__hf seek protocol. Navigates to a file server URL, waits for
* the page to expose window.__hf, then captures frames deterministically
* via Chrome's BeginFrame API or Page.captureScreenshot fallback.
*/
import { type Browser, type Page, type Viewport, type ConsoleMessage } from "puppeteer-core";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { quantizeTimeToFrame, fpsToNumber } from "@hyperframes/core";
// ── Extracted modules ───────────────────────────────────────────────────────
import {
acquireBrowser,
releaseBrowser,
forceReleaseBrowser,
buildChromeArgs,
resolveBrowserGpuMode,
resolveHeadlessShellPath,
type CaptureMode,
} from "./browserManager.js";
import {
beginFrameCapture,
getCdpSession,
pageScreenshotCapture,
initTransparentBackground,
shouldDefaultCaptureBeyondViewport,
} from "./screenshotService.js";
import {
detectSwiftShader,
injectDrawElementCanvas,
captureDrawElementFrame,
resolveDrawElementCaptureMode,
instrumentAcceleratedCanvases,
initDrawElementWorkerEncode,
cleanupDrawElementWorkerEncode,
produceDrawElementFrame,
produceDrawElementFrameBatch,
} from "./drawElementService.js";
import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import type {
CaptureOptions,
CaptureVideoMetadataHint,
CaptureResult,
CaptureBufferResult,
CapturePerfSummary,
} from "../types.js";
export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
/** Called after seeking, before screenshot. Use for video frame injection or other pre-capture work. */
export type BeforeCaptureHook = (page: Page, time: number) => Promise<void>;
export interface CaptureSession {
browser: Browser;
page: Page;
options: CaptureOptions;
serverUrl: string;
outputDir: string;
onBeforeCapture: BeforeCaptureHook | null;
isInitialized: boolean;
/**
* Static-frame dedup (default-on; opt out with `HF_STATIC_DEDUP=false`): indices of frames byte-identical
* to their predecessor (no GSAP tween / clip cut active in either), predicted from
* window.__timelines and empirically anchor-verified. These reuse `lastFrameBuffer`
* instead of re-seeking + re-screenshotting. Undefined when disabled or ineligible.
*/
staticFrames?: Set<number>;
/** Last non-deduped frame buffer, reused for every `staticFrames` index in its run. */
lastFrameBuffer?: Buffer;
/** Count of frames served from a reused buffer (dedup telemetry). */
staticDedupCount?: number;
// ── Static-dedup observability (set by armStaticDedup; surfaced via
// getCapturePerfSummary → RenderPerfSummary → the render_complete event) ──
// NOTE: `armed` and `predicted` are NOT stored — they derive from
// `staticFrames` (armed ⟺ non-empty set; predicted === size) in
// getCapturePerfSummary, so they can't desync from the actual reuse set.
/** Dedup was enabled for this render (default-on; opt out with `HF_STATIC_DEDUP=false`). */
staticDedupEnabled?: boolean;
/**
* Short machine code for WHY dedup did not arm, for a low-cardinality breakdown.
* One of: `capture_mode` | `video_injection` | `page_composite` |
* `ineligible` | `verification_failed` | `verification_budget`. Undefined when armed or disabled.
*/
staticDedupSkipReason?: string;
// Tracks whether the page/browser handles have already been released by
// closeCaptureSession. Used to make closeCaptureSession idempotent under
// browser-pool semantics (see the function body for the full invariant).
pageReleased?: boolean;
browserReleased?: boolean;
browserConsoleBuffer: string[];
initTelemetry?: {
initDurationMs: number;
tweenCount: number;
};
capturePerf: {
frames: number;
seekMs: number;
beforeCaptureMs: number;
screenshotMs: number;
totalMs: number;
/** Per-frame capture durations (batch frames get the batch mean). Basis for
* the warmup-robust p50 in the perf summary. */
frameMs: number[];
};
captureMode: CaptureMode;
/**
* Browser LAUNCH mode, immutable after createCaptureSession. `captureMode`
* is reassigned by initializeSession (e.g. to "drawelement"), so callers
* that need to know whether this browser actually drives BeginFrame (the
* SwiftShader liveness probe) read this field instead.
*/
launchCaptureMode: CaptureMode;
// BeginFrame state
beginFrameTimeTicks: number;
beginFrameIntervalMs: number;
beginFrameHasDamageCount: number;
beginFrameNoDamageCount: number;
/** Optional producer config — when set, overrides module-level env var constants. */
config?: Partial<EngineConfig>;
/** True if running on SwiftShader (detected at init). Undefined before init. */
isSwiftShader?: boolean;
/** drawElementImage canvas was injected and is ready for capture. */
drawElementReady?: boolean;
/**
* Worker-encode pipeline is active for this session. Set by
* `initDrawElementOrTransparentBackground` when `enableDrawElementWorkerEncode`
* is true and capture mode resolved to "drawelement".
*/
workerEncodeEnabled?: boolean;
/**
* Frame indices that must be captured via screenshot rather than drawElement.
* Populated at init by the clip-cut boundary predictor (Lim 6): frames where
* the outgoing clip is dropped a frame before the incoming clip's paint record
* is ready → black frame. Controlled by `HF_FAST_CAPTURE_BOUNDARY_SS=false`.
* Empty/undefined when the predictor produces no frames.
*/
clipBoundaryFrames?: Set<number>;
/** Rolling drawElement frame byte-sizes (last ~60), for silent-blank-drop detection:
* drawElement intermittently returns an anomalously small (blank) frame with no
* throw; a frame far below the running median is re-captured via screenshot. */
deFrameSizes?: number[];
/** Last non-deduped encode result, reused for a static frame on the drawElement
* worker-encode path (mirrors `lastFrameBuffer` on the screenshot path). Only set
* when static-frame dedup is armed on the drawElement path. */
lastEncodeResult?: Promise<Buffer>;
/** Per-render self-verification ground truth (ungated-release safety net):
* K screenshot frames captured at init BEFORE the drawElement canvas is
* injected (the only window where a page screenshot shows the live DOM, not
* the capture canvas's stale bitmap). The producer drain compares the DE
* frame at each index against these; a breach aborts the render with
* DrawElementVerificationError and the orchestrator re-renders via the
* screenshot path. */
deVerifyFrames?: Map<number, Buffer>;
/** Low-cardinality init-gate reason when drawElement routed to baseline (telemetry). */
deGateReason?: string;
/** Wall-clock ms spent capturing self-verification ground truth at init (telemetry). */
deVerifyInitMs?: number;
/** Count of per-frame "No cached paint record" screenshot fallbacks (telemetry). */
deNcprFallbacks?: number;
/**
* drawElement init passed every gate but stopped before verification +
* canvas injection: the session has no video-frame injector yet (probe
* sessions initialize before extraction) and the comp has <video> elements,
* so ground-truth screenshots would capture black video boxes. The capture
* stage completes the init via completeDeferredDrawElementInit once
* prepareCaptureSessionForReuse attaches the injector.
*/
deInitDeferred?: boolean;
}
/**
* drawElement self-verification failure — a captured DE frame diverged from its
* pre-injection screenshot ground truth (or a blank frame survived a retry).
* The orchestrator catches this and re-renders the whole job with
* forceScreenshot. Discriminant-based guard (not instanceof) so it survives
* duplicated module instances across package boundaries.
*/
export class DrawElementVerificationError extends Error {
constructor(message: string) {
super(message);
this.name = "DrawElementVerificationError";
// Discriminant property, assigned dynamically: isDrawElementVerificationError
// reads it structurally so detection survives duplicated module instances
// across package boundaries (where instanceof fails).
(this as unknown as { deVerificationFailure: boolean }).deVerificationFailure = true;
}
}
export function isDrawElementVerificationError(err: unknown): boolean {
// Walk the cause chain — the producer wraps capture errors (CaptureStageError).
let e: unknown = err;
for (let depth = 0; depth < 5 && typeof e === "object" && e !== null; depth++) {
if ((e as { deVerificationFailure?: boolean }).deVerificationFailure === true) return true;
e = (e as { cause?: unknown }).cause;
}
return false;
}
// Circular buffer for browser console messages dumped on render failure diagnostics.
// Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
function appendBrowserDiagnostic(session: CaptureSession, text: string): void {
session.browserConsoleBuffer.push(text);
if (session.browserConsoleBuffer.length > BROWSER_CONSOLE_BUFFER_SIZE) {
session.browserConsoleBuffer.shift();
}
}
async function collectSessionInitTelemetry(
page: Page,
initStart: number,
): Promise<{ initDurationMs: number; tweenCount: number }> {
const initDurationMs = Date.now() - initStart;
let tweenCount = 0;
try {
tweenCount = await page.evaluate(() => {
const timelines =
(window as unknown as { __timelines?: Record<string, unknown> }).__timelines || {};
const seen = new Set<object>();
let count = 0;
for (const timeline of Object.values(timelines)) {
const maybeTimeline = timeline as { getChildren?: unknown };
if (typeof maybeTimeline?.getChildren !== "function") continue;
const children = maybeTimeline.getChildren(true, true, false) as unknown[];
for (const child of children) {
if (child && typeof child === "object" && !seen.has(child)) {
seen.add(child);
count++;
}
}
}
return count;
});
} catch {
tweenCount = 0;
}
return { initDurationMs, tweenCount };
}
async function recordSessionInitTelemetry(
session: CaptureSession,
initStart: number,
): Promise<void> {
const telemetry = await collectSessionInitTelemetry(session.page, initStart);
session.initTelemetry = telemetry;
appendBrowserDiagnostic(
session,
`[FrameCapture:INIT] complete initDurationMs=${telemetry.initDurationMs} tweenCount=${telemetry.tweenCount}`,
);
}
export function sanitizeDiagnosticUrl(input: string): string {
if (!input) return "(empty)";
if (input.startsWith("data:")) return "data:<redacted>";
if (input.startsWith("blob:")) return "blob:<redacted>";
if (input.startsWith("/")) {
try {
const url = new URL(input, "http://hyperframes.local");
return url.pathname;
} catch {
return input;
}
}
try {
const url = new URL(input);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return input;
}
}
export function formatNavigationFailureDiagnostic(input: {
captureMode: CaptureMode;
url: string;
timeoutMs: number;
elapsedMs: number;
error: unknown;
}): string {
const message = input.error instanceof Error ? input.error.message : String(input.error);
return (
`[FrameCapture:ERROR] page.goto failed ` +
`mode=${input.captureMode} timeoutMs=${input.timeoutMs} elapsedMs=${input.elapsedMs} ` +
`url=${sanitizeDiagnosticUrl(input.url)} error=${message}`
);
}
export function formatNavigationStartDiagnostic(input: {
captureMode: CaptureMode;
url: string;
timeoutMs: number;
}): string {
return (
`[FrameCapture:NAV] page.goto start ` +
`mode=${input.captureMode} timeoutMs=${input.timeoutMs} ` +
`url=${sanitizeDiagnosticUrl(input.url)}`
);
}
export function formatRequestFailureDiagnostic(input: {
method: string;
resourceType: string;
url: string;
failureText: string;
}): string {
return (
`[Browser:REQUESTFAILED] ${input.method} ${sanitizeDiagnosticUrl(input.url)} ` +
`resource=${input.resourceType} error=${input.failureText}`
);
}
export function formatHttpErrorDiagnostic(input: {
method: string;
resourceType: string;
url: string;
status: number;
statusText: string;
}): string {
const statusText = input.statusText ? ` ${input.statusText}` : "";
return (
`[Browser:HTTP${input.status}] ${input.method} ${sanitizeDiagnosticUrl(input.url)} ` +
`resource=${input.resourceType}${statusText}`
);
}
/**
* Fixed warmup-loop iteration count used when `CaptureOptions.lockWarmupTicks`
* is `true`. Picked to roughly match the median tick count observed by the
* unlocked wall-clock loop during a typical 2s page load at 30fps — so
* `beginFrameTimeTicks` lands in a similar range regardless of host speed.
*/
export const LOCKED_WARMUP_TICKS = 60;
/**
* Internal driver for the BeginFrame warmup loop.
*
* - Unlocked: exits as soon as `state.running` flips to `false`. Tick count
* varies with wall-clock page-load time.
* - Locked: ignores `state.running` entirely and exits once it has driven
* exactly `LOCKED_WARMUP_TICKS` iterations. Caller awaits this promise
* after page-readiness so `session.beginFrameTimeTicks` is identical
* across hosts.
* - `tick` errors are swallowed (Chrome's `beginFrame` is best-effort
* during page load — the page hasn't installed CDP listeners yet). When
* `tick` throws, the iteration count does NOT advance.
*
* `intervalMs` is the BeginFrame interval (≈33ms at 30fps).
*
* `frameTimeTicks` is derived as `ticks * intervalMs` and exposed via
* {@link warmupFrameTimeTicks} — not stored on the state, to keep `ticks`
* the single source of truth.
*/
export interface WarmupTickState {
running: boolean;
ticks: number;
}
export interface WarmupTickOptions {
intervalMs: number;
lockWarmupTicks: boolean;
tick: (frameTimeTicks: number, intervalMs: number) => Promise<void>;
/** Injectable so tests can advance "time" without real setTimeout. */
sleep?: (ms: number) => Promise<void>;
}
const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Derive the current simulated frame time from a warmup state. Single source
* of truth so tests and callers stay in sync.
*/
export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number): number {
return state.ticks * intervalMs;
}
export async function driveWarmupTicks(
options: WarmupTickOptions,
state: WarmupTickState,
): Promise<void> {
const sleep = options.sleep ?? realSleep;
while (true) {
if (options.lockWarmupTicks) {
// Locked mode exits on the iteration count, ignoring `state.running` —
// the caller flips `running=false` after page-readiness but we keep
// ticking until LOCKED_WARMUP_TICKS so the count is host-independent.
if (state.ticks >= LOCKED_WARMUP_TICKS) return;
} else {
// Unlocked mode is wall-clock-bounded.
if (!state.running) return;
}
try {
await options.tick(state.ticks * options.intervalMs, options.intervalMs);
state.ticks += 1;
} catch {
// Page not ready yet; keep spinning.
}
await sleep(options.intervalMs);
}
}
export function resolveCaptureSessionOptions(
options: CaptureOptions,
browserVersion: string,
platform: NodeJS.Platform = process.platform,
): CaptureOptions {
return {
...options,
captureBeyondViewport:
options.captureBeyondViewport ?? shouldDefaultCaptureBeyondViewport(browserVersion, platform),
};
}
async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
promise.then(
() => undefined,
() => undefined,
),
new Promise<void>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve();
}, CAPTURE_SESSION_CLOSE_TIMEOUT_MS);
}),
]);
if (timer) clearTimeout(timer);
return !timedOut;
}
/**
* Post-readiness capture-surface init, shared by the screenshot and BeginFrame
* init paths (called after the page is fully ready). When `useDrawElement` is
* set, detect SwiftShader and route: transparent+SwiftShader falls back to
* screenshot capture (the drawElement transparent path is broken on SwiftShader),
* everything else injects the drawElement canvas and switches to "drawelement"
* mode. Otherwise, for PNG output, force a transparent page background so the
* screenshots carry a real alpha channel (Chrome resets the override on every
* navigation, so this must run after page load).
*
* drawElement is also skipped when supersampling (deviceScaleFactor > 1):
* `drawElementImage` reads the canvas at CSS pixels and has no equivalent of
* `Page.captureScreenshot`'s clip+scale, so it would silently capture at 1x and
* drop the requested supersample. Such renders fall through to the screenshot
* path (preMode already forces "screenshot" for DPR > 1).
*/
async function initDrawElementOrTransparentBackground(
session: CaptureSession,
page: Page,
logInitPhase: (phase: string) => void,
): Promise<void> {
const supersampling = (session.options.deviceScaleFactor ?? 1) > 1;
// forceScreenshot is an explicit routing decision made upstream (render-mode
// compat hints like raw requestAnimationFrame, alpha formats, low-memory) —
// drawElement must not override it. Concretely: an rAF-compat comp on
// SwiftShader gets a screenshot-launched (free-running) browser, where
// drawElement runs in paint-event-sync mode; SwiftShader never refreshes a
// 2d canvas bitmap inside a cached paint record there, so every canvas
// captures frozen-blank (raf-ball rendered fully black). On a GPU the same
// path happens to work, but the hint asked for screenshot — honor it.
const forceScreenshot = session.config?.forceScreenshot ?? false;
// DIAGNOSTIC ONLY — HF_FORCE_DRAWELEMENT=1 forces the drawElement path,
// bypassing every compile/init gate AND the compatibility hints (it overrides
// forceScreenshot). Exists for upstream-Chromium repro work (isolating gate
// behavior from drawElementImage behavior, e.g. the crbug 521861819 149-vs-151
// comparison) and for R&D on gated effect classes. Renders under this flag may
// be DAMAGED by design — the gates it skips exist because measured damage
// (blur/backdrop ~18-49dB, 3D backface, SwiftShader sub-layer drops) is real.
// Never set it in production; it is intentionally not documented in user-facing
// help, and the safety-net blank guard also stands down under it so diagnostic
// frames arrive unmodified.
const forceDE = process.env.HF_FORCE_DRAWELEMENT === "1";
const useDrawElement =
((session.config?.useDrawElement ?? false) || forceDE) &&
!supersampling &&
(!forceScreenshot || forceDE);
if ((session.config?.useDrawElement ?? false) && supersampling) {
session.deGateReason = "supersampling";
console.log(
"[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
"ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.",
);
}
if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
session.deGateReason = "render_mode_hint";
console.log(
"[engine] fast capture: falling back to screenshot — render-mode compatibility " +
"hint forced screenshot capture (e.g. raw requestAnimationFrame composition).",
);
}
// Retract the per-page autoAlpha rewrite flag when a runtime gate routes the
// session to screenshot mode. evaluateOnNewDocument already fired; a follow-up
// evaluate overrides it in the live page context so hideTransparentAutoAlpha-
// Targets does not hide elements on the fallback screenshot render
// (up to 21 dB damage if not retracted, A/B proven 2026-06-12).
async function retractAutoAlphaFlag(): Promise<void> {
await page.evaluate(() => {
(
window as Window & { __HF_FAST_CAPTURE_AUTOALPHA__?: boolean }
).__HF_FAST_CAPTURE_AUTOALPHA__ = false;
});
}
if (useDrawElement) {
session.isSwiftShader = await detectSwiftShader(page);
const transparent = session.options.format === "png";
async function routeToFallback(): Promise<void> {
session.captureMode = session.launchCaptureMode;
if (transparent) {
await initTransparentBackground(session.page);
}
await retractAutoAlphaFlag();
// Static-frame dedup is capture-mode-independent (the serial path reuses
// lastFrameBuffer regardless of how the frame was captured) and lossless
// (anchor-verified). A comp only reaches THIS fallback with useDrawElement=true
// AND forceScreenshot=false — i.e. it is deterministic: raw-rAF / iframe /
// htmlInCanvas comps are forced to screenshot upstream (forceScreenshot=true) and
// never enter this block, so they never arm dedup. The comps that DO fall back here
// (blur / backdrop / 3D / at-risk) carry only a compositor
// EFFECT drawElement can't paint, not nondeterminism, so their predicted-static set
// is sound. Verification seeks via Page.captureScreenshot, which hangs on a
// BeginFrame-launched browser — gate on the launch mode (macOS fast-capture launches
// screenshot-mode; Linux/Docker launches beginframe and is skipped).
if (session.launchCaptureMode === "screenshot") {
await armStaticDedup(session, page, logInitPhase);
}
}
// SwiftShader gate: drawElement's only advantage is skipping the GPU→CPU
// screenshot-readback IPC. On a software rasterizer (Docker/CI, no GPU) both
// paths block on identical software raster, so drawElement is parity-or-slower
// — route to the platform baseline.
//
// Two gates were REMOVED here once Chrome 151 fixed crbug 521861819
// (drawElementImage dropped compositor-promoted opacity layers mid-fade):
// - the <video> gate (a proxy for the word-by-word caption opacity pattern,
// Lim 2), and
// - the stacked-fade gate (>=2 overlapping viewport-scale opacity-fade targets).
// Both reproduced on Chrome <=150 (video+caption-fade ~12 dB; stacked fade
// 24.5 dB) and both render correctly on 151 (verified: video+nested-fade repro
// PSNR=inf; efb59c5b 24.5→47.4 dB, 0 damaged frames). 151 is the pinned floor.
const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent);
if (mode === "screenshot") {
session.deGateReason = "swiftshader";
// Fall back to the browser's LAUNCH mode, not unconditionally to
// "screenshot": on a BeginFrame-launched browser (Linux fast capture)
// Page.captureScreenshot hangs for the full protocol timeout, while
// beginFrameCapture is the platform's normal baseline path.
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
"SwiftShader (software rasterizer — no GPU egress to skip, drawElement is " +
"parity-or-slower; see fast-capture-limitations.md)",
);
await routeToFallback();
} else {
// CSS-effect gate: backdrop-filter samples the compositor backdrop and
// filter:blur/drop-shadow render differently through the paint-record
// path — drawElementImage can't reproduce either, producing 1849 dB
// damaged frames (community eval). Fall back to the platform baseline.
// HF_FAST_CAPTURE_CSSFX=true bypasses for R&D.
if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") {
const cssFx = await detectCssEffectRisk(page);
if (cssFx) {
session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`,
);
await routeToFallback();
return;
}
}
// Lim 7: timeline-interval at-risk predictor. Walk window.__timelines and
// find tweens animating compositor-incompatible props (opacity, filter,
// blend-mode, 3D transform, clip-path, mask). drawElementImage drops these
// mid-animation (crbug 521861819 et al), and whether it drops a given one
// cannot be told reliably without rendering (jump-seek != sequential render,
// proven 2026-06-16) nor from geometry (size is a proxy, not the mechanism).
// The only deterministic + reliable route to 0 damage is to gate on the
// PRESENCE of any such tween — a pure fact of the declared timeline, the same
// every run. Conservative by design: comps whose risky tweens drawElement
// would have handled also fall back, but correctness is never at risk and the
// fast path stays open for static + plain-2D-transform comps (x/y/scale are
// NOT in the at-risk set). Tune the gate's frame-fraction floor with
// HF_FAST_CAPTURE_INTERVAL_FRACTION (default 0 = any at-risk frame gates).
// Must run BEFORE canvas injection so a whole-comp fallback doesn't leave the
// drawElement canvas wrapping the composition root. Disable with
// HF_FAST_CAPTURE_INTERVAL_SS=false.
if (!forceDE && process.env.HF_FAST_CAPTURE_INTERVAL_SS !== "false") {
const fps = fpsToNumber(session.options.fps);
const { frames: atRisk, totalFrames } = await computeTimelineAtRiskFrames(page, fps);
const atRiskFraction = atRisk.size / totalFrames;
const fractionFloor = Number(process.env.HF_FAST_CAPTURE_INTERVAL_FRACTION ?? "0");
logInitPhase(
`timeline at-risk predictor: ${atRisk.size}/${totalFrames} frames (${Math.round(atRiskFraction * 100)}%)`,
);
if (atRisk.size > 0 && atRiskFraction > fractionFloor) {
session.deGateReason = "at_risk_timeline";
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
`(blend/3D/clip/mask); drawElementImage drops these mid-animation ` +
`(deterministic timeline gate; see fast-capture-limitations.md Lim 7)`,
);
await routeToFallback();
return;
}
}
// Rewrite CSS 3D contexts into WebGL-projected canvases BEFORE the
// layoutsubtree canvas goes in (rects are measured in normal layout).
// drawElementImage cannot paint 3D rendering contexts — see
// threeDProjection.ts. No-op for compositions without 3D content.
const threeD = await initThreeDProjection(page);
if (!forceDE && !threeD.ok) {
session.deGateReason = "3d_init_failed";
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`3D projection init failed (${threeD.reason ?? "unknown"})`,
);
await routeToFallback();
return;
}
if (threeD.groups > 0) {
logInitPhase(
`3D projection active: ${threeD.groups} context(s), ${threeD.quads} quad(s), ` +
`${threeD.selfQuads ?? 0} self-quad el(s), ${threeD.stubTargets ?? 0} stub target(s)`,
);
}
// Task B: arm static-frame dedup here — drawElement is confirmed (all gates
// passed) and the DOM is still normal (canvas not yet injected, so the
// verification screenshots are valid). drawElement-path only; see armStaticDedup.
await armStaticDedup(session, page, logInitPhase);
// Video comps on injector-less sessions (probe sessions initialize before
// video extraction) DEFER the rest of the drawElement init: ground-truth
// screenshots would capture black <video> boxes, and once the canvas is
// injected they can never be retaken. The capture stage completes the
// init after prepareCaptureSessionForReuse attaches the injector.
// Retract the autoAlpha rewrite flag while deferred — if no path ever
// completes the init (e.g. the disk path takes over), the session
// captures via screenshot, where the armed flag causes measured damage.
if (!session.onBeforeCapture && !forceDE) {
const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
if (hasVideos) {
session.deInitDeferred = true;
await retractAutoAlphaFlag();
logInitPhase("drawElement init deferred: video comp awaiting frame injector");
return;
}
}
await finalizeDrawElementInit(session, page, logInitPhase, { transparent, forceDE });
}
} else if (session.options.format === "png") {
await initTransparentBackground(session.page);
}
}
/**
* The tail of drawElement init: self-verification ground truth (pre-injection),
* canvas injection, capture-mode flip, clip-boundary predictor, worker-encode.
* Runs inline when the session already has its video-frame injector (or the
* comp has no videos); runs deferred via completeDeferredDrawElementInit for
* probe-initialized video comps.
*/
async function finalizeDrawElementInit(
session: CaptureSession,
page: Page,
logInitPhase: (phase: string) => void,
opts: { transparent: boolean; forceDE: boolean },
): Promise<void> {
const { transparent, forceDE } = opts;
// Self-verification ground truth: must run pre-injection — after the canvas
// wraps the root, a page screenshot shows the canvas's last-drawn bitmap,
// not the live DOM (see the Lim 6 boundary-screenshot note).
{
const verifyStart = Date.now();
await captureDeVerificationFrames(session, page, logInitPhase);
session.deVerifyInitMs = Date.now() - verifyStart;
}
await injectDrawElementCanvas(page, session.options.width, session.options.height);
if (transparent) {
await initTransparentBackground(session.page);
}
session.captureMode = "drawelement";
session.drawElementReady = true;
logInitPhase("drawElement canvas injected");
// Lim 6: clip-cut boundary frames — screenshot these instead of drawElement.
if (process.env.HF_FAST_CAPTURE_BOUNDARY_SS !== "false" && !forceDE) {
const fps = fpsToNumber(session.options.fps);
const boundaryFrames = await computeClipBoundaryFrames(page, fps);
if (boundaryFrames.size > 0) {
session.clipBoundaryFrames = boundaryFrames;
logInitPhase(`screenshot fallback: ${boundaryFrames.size} clip-boundary frame(s)`);
}
}
// Worker-encode pipeline: macOS hardware GPU path only (syncToPaintEvent=true,
// beginFrameTimeTicks=0). Skip for BeginFrame (Linux/Docker) and transparent
// (PNG) output — those use the existing synchronous path unchanged.
const workerEncodeEnabled =
(session.config?.enableDrawElementWorkerEncode ?? false) &&
!transparent &&
session.beginFrameTimeTicks === 0;
if (workerEncodeEnabled) {
await initDrawElementWorkerEncode(page);
session.workerEncodeEnabled = true;
logInitPhase("drawElement worker encode initialized");
}
}
/**
* Complete a deferred drawElement init (see CaptureSession.deInitDeferred).
* Call after prepareCaptureSessionForReuse has attached the video-frame
* injector; no-op when the session is not deferred or still has no injector.
* Re-asserts the autoAlpha rewrite flag retracted at deferral time.
*/
export async function completeDeferredDrawElementInit(session: CaptureSession): Promise<void> {
if (!session.deInitDeferred || !session.onBeforeCapture) return;
const page = session.page;
await page.evaluate(() => {
(window as Window & { __HF_FAST_CAPTURE_AUTOALPHA__?: boolean }).__HF_FAST_CAPTURE_AUTOALPHA__ =
true;
});
const logInitPhase = (phase: string) =>
console.log(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
await finalizeDrawElementInit(session, page, logInitPhase, {
transparent: session.options.format === "png",
forceDE: process.env.HF_FORCE_DRAWELEMENT === "1",
});
session.deInitDeferred = false;
}
// fallow-ignore-next-line unit-size
export async function createCaptureSession(
serverUrl: string,
outputDir: string,
options: CaptureOptions,
onBeforeCapture: BeforeCaptureHook | null = null,
config?: Partial<EngineConfig>,
): Promise<CaptureSession> {
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
// Determine capture mode before building args — BeginFrame flags only apply on Linux.
// BeginFrame's compositor does not preserve alpha; callers that pass
// `options.format === "png"` for transparent capture should also set
// `config.forceScreenshot = true` (the producer's renderOrchestrator does this
// automatically when `RenderConfig.format` is an alpha-capable value).
// Exception: `useDrawElement=true` with png self-manages the screenshot-browser
// requirement (both the SwiftShader fallback and the GPU transparent path need
// a screenshot-launched browser — the SwiftShader path calls Page.captureScreenshot
// which hangs on a BeginFrame browser, and the GPU path doesn't need BeginFrame
// because the compositor runs freely on a screenshot-launched browser).
const headlessShell = resolveHeadlessShellPath(config);
const isLinux = process.platform === "linux";
const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
const useDrawElement = config?.useDrawElement ?? false;
const drawElementTransparent = useDrawElement && options.format === "png";
// drawElement and page-side shader compositing are mutually incompatible
// capture strategies: drawElement reads the composition root's paint records
// directly and skips the prepare→micro-screenshot→resolve protocol (the
// micro-screenshot would also hang on an opaque/beginframe-launched browser).
// `resolveConfig` forces page-side compositing off whenever useDrawElement is
// set, so this only trips for a direct caller that bypassed resolveConfig and
// passed both flags — warn once and treat page-side as disabled.
if (
useDrawElement &&
(config?.enablePageSideCompositing ?? DEFAULT_CONFIG.enablePageSideCompositing)
) {
console.warn(
"[engine] useDrawElement is incompatible with page-side shader compositing — " +
"ignoring enablePageSideCompositing for this render. Prefer resolveConfig, " +
"which disables page-side compositing automatically for fast-capture renders.",
);
}
// BeginFrame's screenshot does not honor a viewport `deviceScaleFactor`
// (the captured surface is sized by the OS window in CSS pixels regardless
// of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
// need explicit clip+scale on `Page.captureScreenshot`, so fall back to
// the screenshot path for any DPR > 1.
const supersampling = (options.deviceScaleFactor ?? 1) > 1;
const preMode: CaptureMode =
headlessShell && isLinux && !forceScreenshot && !supersampling && !drawElementTransparent
? "beginframe"
: "screenshot";
const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
chromePath: headlessShell ?? undefined,
browserTimeout: config?.browserTimeout,
});
const chromeArgs = buildChromeArgs(
{ width: options.width, height: options.height, captureMode: preMode },
{ ...config, browserGpuMode: resolvedGpuMode },
);
const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
const page = await browser.newPage();
// Polyfill esbuild's keepNames helper inside the page.
//
// The engine is published as raw TypeScript (`packages/engine/package.json`
// points `main`/`exports` at `./src/index.ts`) and downstream consumers
// execute it through transpilers that may inject `__name(fn, "name")`
// wrappers around named functions. Empirically, this happens with:
// - tsx (its esbuild loader runs with keepNames=true), used by the
// producer's parity-harness, ad-hoc dev scripts, and the
// `bun run --filter @hyperframes/engine test` Vitest path.
// - any tsup/esbuild build that explicitly enables keepNames.
//
// The HeyGen CLI (`packages/cli`) bundles this engine via tsup with
// keepNames left at its default (false) — verified by grepping
// `packages/cli/dist/cli.js`, where `__name(...)` call sites are absent.
// Bun's TS loader also does not currently inject `__name`. Even so,
// anything that calls `page.evaluate(fn)` with a nested named function
// under tsx (most local development and tests) will serialize bodies
// like `__name(nested,"nested")` and crash with `__name is not defined`
// in the browser. The shim makes such calls a no-op.
//
// An alternative is to load browser-side code as raw text and inject it
// via `page.addScriptTag({ content: ... })` — see
// `packages/cli/src/commands/contrast-audit.browser.js` for that pattern.
// Until every `page.evaluate(fn)` call site migrates, this polyfill is
// the single line of defense. The companion regression test in
// `frameCapture-namePolyfill.test.ts` verifies the shim stays wired up.
await page.evaluateOnNewDocument(() => {
const w = window as unknown as { __name?: <T>(fn: T, _name: string) => T };
if (typeof w.__name !== "function") {
w.__name = <T>(fn: T, _name: string): T => fn;
}
});
// Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
// preserveDrawingBuffer before any page script can create a context — their
// paint records freeze at the first frame, so captureDrawElementFrame
// composites their live content via drawImage instead (see
// instrumentAcceleratedCanvases). Must be registered before navigation.
if (useDrawElement) {
await page.evaluateOnNewDocument(instrumentAcceleratedCanvases);
}
// Signal the producer's GSAP stub to rewrite `opacity` → `autoAlpha` in tween
// vars (stacked opacity-0 caption layers break drawElementImage capture).
//
// DEFAULT OFF (opt in with HF_FAST_CAPTURE_AUTOALPHA=true). The rewrite is baked
// at tween-creation (page load) and `retractAutoAlphaFlag` (flag-only) can't
// un-bake it: GSAP autoAlpha sets visibility:hidden under seek capture and renders
// ~28 dB below a clean opacity baseline (corpus eval 2026-06-16, e.g.
// 05f22830/06167790: autoAlpha-off = ∞, autoAlpha-on = 28 dB). The rewrite was a
// workaround for the stacked-fade opacity-layer drop (crbug 521861819), now fixed
// in Chrome 151 — so it damages more than it fixes. Re-enable per render only if a
// drawElement comp shows transparent-layer drop on the pinned 151 floor.
if (useDrawElement && process.env.HF_FAST_CAPTURE_AUTOALPHA === "true") {
await page.evaluateOnNewDocument(() => {
(
window as Window & { __HF_FAST_CAPTURE_AUTOALPHA__?: boolean }
).__HF_FAST_CAPTURE_AUTOALPHA__ = true;
});
}
// Re-apply the captured root's own computed opacity to the 2D context:
// drawElementImage does not reflect post-paint changes to compositor-applied
// properties on the captured element itself (the root's opacity is applied by
// its parent at composite time, never baked into its content snapshot), so an
// animated root fade renders at full opacity. captureDrawElementFrame corrects
// this by the ratio current/base opacity (no-op for a static root). On by
// default; disable with HF_FAST_CAPTURE_ROOT_PROPS=false.
if (useDrawElement && process.env.HF_FAST_CAPTURE_ROOT_PROPS !== "false") {
await page.evaluateOnNewDocument(() => {
(window as unknown as { __HF_ROOT_PROPS__?: boolean }).__HF_ROOT_PROPS__ = true;
});
}
// Inject render-time variable overrides before any page script runs, so the
// runtime helper `getVariables()` returns the merged result on its first
// call. Pass the JSON string and parse inside the page so we don't require
// any JSON-incompatible value to round-trip through Puppeteer's serializer.
if (options.variables && Object.keys(options.variables).length > 0) {
const variablesJson = JSON.stringify(options.variables);
await page.evaluateOnNewDocument((json: string) => {
type WindowWithVariables = Window & { __hfVariables?: Record<string, unknown> };
try {
(window as WindowWithVariables).__hfVariables = JSON.parse(json);
} catch {
// The CLI validated the JSON before this point — a parse failure here
// means the page swapped JSON.parse, which is the page's problem.
}
}, variablesJson);
}
const browserVersion = await browser.version();
const sessionOptions = resolveCaptureSessionOptions(options, browserVersion);
const expectedMajor = config?.expectedChromiumMajor;
if (Number.isFinite(expectedMajor)) {
const actualChromiumMajor = Number.parseInt(
(browserVersion.match(/(\d+)\./) || [])[1] || "",
10,
);
if (Number.isFinite(actualChromiumMajor) && actualChromiumMajor !== expectedMajor) {
throw new Error(
`[FrameCapture] Chromium major mismatch expected=${expectedMajor} actual=${actualChromiumMajor} raw=${browserVersion}`,
);
}
}
const viewport: Viewport = {
width: sessionOptions.width,
height: sessionOptions.height,
deviceScaleFactor: sessionOptions.deviceScaleFactor || 1,
};
await page.setViewport(viewport);
// Transparent-background setup is intentionally NOT done here. Chrome resets
// the default-background-color override on navigation, and the
// `[data-composition-id]{background:transparent}` stylesheet that
// `initTransparentBackground` injects must land in a real `document.head`.
// See `initializeSession()` below — it calls `initTransparentBackground` for
// PNG captures after `page.goto(...)` and the `window.__hf` readiness poll.
return {
browser,
page,
options: sessionOptions,
serverUrl,
outputDir,
onBeforeCapture,
isInitialized: false,
browserConsoleBuffer: [],
capturePerf: {
frames: 0,
seekMs: 0,
beforeCaptureMs: 0,
screenshotMs: 0,
totalMs: 0,
frameMs: [],
},
captureMode,
launchCaptureMode: captureMode,
beginFrameTimeTicks: 0,
// Frame interval in ms: 1000 * den / num. For 30/1 → 33.333…, for
// 30000/1001 (NTSC) → 33.366…. JavaScript number precision is fine at
// these scales — no rounding required.
beginFrameIntervalMs: (1000 * options.fps.den) / Math.max(1, options.fps.num),
beginFrameHasDamageCount: 0,
beginFrameNoDamageCount: 0,
config,
};
}
/**
* Classify a console "Failed to load resource" error as a font-load failure.
*
* These are expected when deterministic font injection replaces Google Fonts
* @import URLs with embedded base64 — or when the render environment has no
* network access to Google Fonts. Suppressing them reduces noise in render
* output without hiding real asset failures (images, videos, scripts, etc.).
*
* Chrome's `msg.text()` for a failed resource is typically just
* `"Failed to load resource: net::ERR_FAILED"` — the URL is only on
* `msg.location().url`. We match against both so the filter works regardless
* of which form Chrome emits.
*/
export function isFontResourceError(type: string, text: string, locationUrl: string): boolean {
if (type !== "error") return false;
if (!text.startsWith("Failed to load resource")) return false;
return /fonts\.googleapis|fonts\.gstatic|\.(woff2?|ttf|otf)(\b|$)/i.test(
`${locationUrl} ${text}`,
);
}
export function formatConsoleDiagnostic(
type: string,
text: string,
locationUrl: string,
): { text: string; suppressHostLog: boolean } {
const isFontLoadError = isFontResourceError(type, text, locationUrl);
if (isFontLoadError) return { text: `[Browser] ${text}`, suppressHostLog: true };
if (text.startsWith("[hyperframes]")) {
return {
text: `[HyperFrames] ${text.slice("[hyperframes]".length).trim()}`,
suppressHostLog: false,
};
}
// Other "Failed to load resource" 404s are typically non-blocking (e.g.
// favicon, sourcemaps, optional assets). Prefix them so users know they
// are harmless and don't confuse them with real render errors.
const isResourceLoadError = type === "error" && text.startsWith("Failed to load resource");
const prefix = isResourceLoadError
? "[non-blocking]"
: type === "error"
? "[Browser:ERROR]"
: type === "warn"
? "[Browser:WARN]"
: "[Browser]";
return { text: `${prefix} ${text}`, suppressHostLog: false };
}
async function pollPageExpression(
page: Page,
expression: string,
timeoutMs: number,
intervalMs: number = 100,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ready = Boolean(await page.evaluate(expression));
if (ready) return true;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return Boolean(await page.evaluate(expression));
}
const HF_READY_DIAGNOSTIC_EXPR = `(function() {
var hf = window.__hf;
var player = window.__player;
var renderReady = !!window.__renderReady;
var hasSeek = !!(hf && typeof hf.seek === "function");
var duration = hf ? hf.duration : -1;
var hasTimeline = !!(window.__timelines && Object.keys(window.__timelines).length > 0);
var root = document.querySelector("[data-composition-id]");
var declaredDuration = root ? Number(root.getAttribute("data-duration")) : -1;
return {
renderReady: renderReady,
hasHf: !!hf,
hasSeek: hasSeek,
hasPlayer: !!player,
duration: duration,
hasTimeline: hasTimeline,
declaredDuration: declaredDuration,
};
})()`;
// fallow-ignore-next-line complexity
function buildZeroDurationDiagnostic(diag: {
renderReady: boolean;
hasHf: boolean;
hasSeek: boolean;
hasPlayer: boolean;
duration: number;
hasTimeline: boolean;
declaredDuration: number;
}): string {
const hints: string[] = [];
if (!diag.hasPlayer) {
hints.push("window.__player was never set — the HyperFrames runtime did not initialize.");
}
if (!diag.hasTimeline) {
hints.push(
"No GSAP timeline registered (window.__timelines is empty). " +
"CSS/WAAPI/Lottie animations are usually auto-detected (the runtime infers " +
"duration from the longest running animation) — this composition's duration " +
"could not be inferred, which usually means an infinite/unbounded animation " +
"(e.g. animation-iteration-count: infinite, repeat: -1 WAAPI, or a looping Lottie " +
"clip) or a Three.js scene with no discoverable AnimationClip.",
);
}
if (diag.declaredDuration <= 0 && !diag.hasTimeline) {
hints.push(
'Fix: add data-duration="<seconds>" to your root <div data-composition-id="..."> element.',
);
}
if (diag.hasSeek && diag.duration === 0 && diag.renderReady) {
hints.push("The runtime finished initializing but reported zero duration — this is permanent.");
}
return (
`[FrameCapture] Composition has zero duration.\n` +
` Runtime ready: ${diag.renderReady}, __player: ${diag.hasPlayer}, ` +
`__hf.seek: ${diag.hasSeek}, GSAP timeline: ${diag.hasTimeline}, ` +
`data-duration: ${diag.declaredDuration > 0 ? diag.declaredDuration + "s" : "not set"}\n` +
(hints.length > 0 ? hints.map((h) => ` → ${h}`).join("\n") : "")
);
}
interface HfDiagnostic {
renderReady: boolean;
hasHf: boolean;
hasSeek: boolean;
hasPlayer: boolean;
duration: number;
hasTimeline: boolean;
declaredDuration: number;
}
async function evaluateHfDiagnostic(page: Page): Promise<HfDiagnostic> {
return (await page.evaluate(HF_READY_DIAGNOSTIC_EXPR)) as HfDiagnostic;
}
async function pollHfReady(page: Page, timeoutMs: number, intervalMs: number = 100): Promise<void> {
const readyExpr = `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`;
const FAST_FAIL_AFTER_MS = 10_000;
// Throttle diagnostic CDP calls to ~1000ms — running evaluateHfDiagnostic on
// every 100ms poll tick after the 10s mark generates ~350 unnecessary CDP
// round-trips per failed render. One diagnostic per second is enough.
const DIAGNOSTIC_INTERVAL_MS = 1_000;
const deadline = Date.now() + timeoutMs;
let lastDiagnosticAt = 0;
while (Date.now() < deadline) {
const ready = Boolean(await page.evaluate(readyExpr));
if (ready) return;
const elapsed = timeoutMs - (deadline - Date.now());
if (elapsed >= FAST_FAIL_AFTER_MS) {
const now = Date.now();
if (now - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) {
lastDiagnosticAt = now;
const diag = await evaluateHfDiagnostic(page);
// Only fast-fail when ALL signals are permanently zero:
// 1. No GSAP timeline registered (GSAP sets duration synchronously
// before __renderReady, so a missing timeline won't self-correct).
// 2. No data-duration declared on the root element.
// 3. hf.duration is still 0 — this also covers CSS/WAAPI/Lottie
// auto-inference (see runtime/init.ts resolveAdapterDurationFloorSeconds):
// those runtimes report a non-zero hf.duration once discovery
// resolves, without any GSAP timeline or data-duration. Checking
// hf.duration directly (rather than only the two authored
// signals) avoids fast-failing a composition whose inferred
// duration just hasn't landed yet.
// A composition with a GSAP timeline but no data-duration is still
// valid — GSAP drives duration via __timelines, not data-duration.
if (
diag.renderReady &&
diag.hasSeek &&
!diag.hasTimeline &&
diag.declaredDuration <= 0 &&
diag.duration <= 0
) {
throw new Error(buildZeroDurationDiagnostic(diag));
}
}
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
const diag = await evaluateHfDiagnostic(page);
if (diag.hasSeek && diag.duration === 0) {
throw new Error(buildZeroDurationDiagnostic(diag));
}
throw new Error(
`[FrameCapture] window.__hf not ready after ${timeoutMs}ms. ` +
`Page must expose window.__hf = { duration, seek }.\n` +
` State: __hf=${diag.hasHf}, seek=${diag.hasSeek}, player=${diag.hasPlayer}, ` +
`renderReady=${diag.renderReady}, duration=${diag.duration}`,
);
}
async function pollSubCompositionTimelines(
page: Page,
timeoutMs: number,
intervalMs: number = 150,
): Promise<void> {
// Hosts may opt out of the timeline wait with `data-no-timeline` —
// compositions driven purely by CSS animations / rAF (the render-compat
// contract) never register window.__timelines[id], and without the opt-out
// they stall here for the full playerReadyTimeout (45 s) on every render.
const expression = `(function() {
var hosts = document.querySelectorAll("[data-composition-id]");
if (hosts.length === 0) return true;
var timelines = window.__timelines || {};
for (var i = 0; i < hosts.length; i++) {
if (hosts[i].hasAttribute("data-no-timeline")) continue;
var id = hosts[i].getAttribute("data-composition-id");
if (!id) continue;
if (!timelines[id]) return false;
}
return true;
})()`;
const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs);
// Always force a timeline rebind once sub-composition timelines are
// confirmed present. The previous implementation only called rebind
// when the timeline count grew during the poll, which missed the case
// where all sub-comp scripts had already executed before the poll
// started — leaving child timelines un-nested in the root and causing
// the earliest sub-composition (data-start near 0) to render without
// its GSAP animations.
if (ready) {
await page.evaluate(`(function() {
if (typeof window.__hfForceTimelineRebind === "function") {
window.__hfForceTimelineRebind();
}
})()`);
}
if (!ready) {
const missing = await page.evaluate(`(function() {
var hosts = document.querySelectorAll("[data-composition-id]");
var timelines = window.__timelines || {};
var m = [];
for (var i = 0; i < hosts.length; i++) {
if (hosts[i].hasAttribute("data-no-timeline")) continue;
var id = hosts[i].getAttribute("data-composition-id");
if (id && !timelines[id]) m.push(id);
}
return m.join(", ");
})()`);
console.warn(
`[FrameCapture] Sub-composition timelines not registered after ${timeoutMs}ms: ${missing}. ` +
`Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes. ` +
`Compositions intentionally driven without GSAP timelines (CSS animations / rAF) can mark the host with data-no-timeline to skip this wait.`,
);
}
}
async function pollVideosReady(
page: Page,
skipIds: readonly string[],
timeoutMs: number,
intervalMs: number = 100,
): Promise<boolean> {
const check = async (): Promise<boolean> => {
return Boolean(
await page.evaluate((skipIdList: readonly string[]) => {
const skip = new Set(skipIdList);
const vids = Array.from(document.querySelectorAll("video")).filter((v) => !skip.has(v.id));
return (
vids.length === 0 ||
vids.every((v) => {
const ve = v as HTMLVideoElement;
if (ve.readyState >= 2) return true;
if (ve.error) return true;
if (ve.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) return true;
return false;
})
);
}, skipIds),
);
};
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return true;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return check();
}
// Wait for every `<img>` with a non-`data:` src to have settled — either
// successfully loaded (`complete && naturalWidth > 0`) or failed with a
// broken-image marker (`complete && naturalWidth === 0`, the HTMLImageElement
// equivalent of HTMLMediaElement.error). htmlCompiler localises remote `<img>`
// URLs to the local file server before this point, so in practice this polls
// for the local fetch to land — but the guard is a defensive net so that any
// future composition path that leaves a remote URL in place won't capture
// frames before the pixels arrive. Mirrors `pollVideosReady` for parity with
// the video-side readiness contract (videos exit-early on `ve.error`; images
// exit-early on `complete && naturalWidth === 0`).
/** @internal exported for unit testing only */
export async function pollImagesReady(
page: Page,
timeoutMs: number,
intervalMs: number = 100,
): Promise<boolean> {
const check = async (): Promise<boolean> => {
return Boolean(
await page.evaluate(() => {
const imgs = Array.from(document.querySelectorAll("img"));
return (
imgs.length === 0 ||
imgs.every((img) => {
const ie = img as HTMLImageElement;
const src = ie.getAttribute("src") || "";
if (!src || src.startsWith("data:")) return true;
// A `complete` image with zero naturalWidth has settled with an
// error (404 / decode failure / CORS rejection / blocked). Treat
// as done — waiting won't make it load — and let the render
// continue with the broken-image marker visible. Mirrors how
// pollVideosReady treats `ve.error`.
if (ie.complete && ie.naturalWidth === 0) return true;
if (ie.complete && ie.naturalWidth > 0) return true;
return false;
})
);
}),
);
};
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return true;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return check();
}
// Force every successfully-loaded `<img>` to be GPU-uploaded before the first
// frame capture. `naturalWidth > 0` means the bitmap has been decoded into
// CPU memory, but compositor-side GPU upload can still happen lazily on first
// paint. Calling `img.decode()` returns a Promise that resolves once the image
// is ready for synchronous painting — eliminating the small first-frame race
// between "image is technically loaded" and "the rasterized texture is on the
// GPU and ready to composite".
//
// Note this is purely an init-time guard; it doesn't prevent Chrome from
// evicting decoded pixels mid-render. The producer-side `localizeRemoteImageSources`
// is what bounds the eviction risk (a re-fetch hits the local file server's
// disk-backed paging, not S3 over the network).
//
// Critical: `decode()` on an in-flight image waits for the fetch to resolve.
// If `pollImagesReady` timed out with some images still loading (`!complete`),
// calling `decode()` on them would block here until the network finally
// completes — or until puppeteer's evaluate timeout fires and throws an
// uncaught error that aborts the render. Skip in-flight and broken images;
// only force GPU upload for images that successfully loaded.
async function decodeAllImages(page: Page): Promise<void> {
await page.evaluate(async () => {
const imgs = Array.from(document.querySelectorAll("img"));
await Promise.all(
imgs.map((img) => {
const ie = img as HTMLImageElement;
if (typeof ie.decode !== "function") return Promise.resolve();
// Skip still-loading images (in-flight decode() would hang) and
// broken images (decode() rejects, but pre-filtering is clearer
// than relying on the .catch).
if (!ie.complete || ie.naturalWidth === 0) return Promise.resolve();
return ie.decode().catch(() => undefined);
}),
);
});
}
async function applyVideoMetadataHints(
page: Page,
hints: readonly CaptureVideoMetadataHint[] | undefined,
): Promise<void> {
if (!hints || hints.length === 0) return;
// fallow-ignore-next-line complexity
await page.evaluate(
(metadataHints: CaptureVideoMetadataHint[]) => {
for (const hint of metadataHints) {
if (
!hint.id ||
!Number.isFinite(hint.width) ||
!Number.isFinite(hint.height) ||
hint.width <= 0 ||
hint.height <= 0
) {
continue;
}
const video = document.getElementById(hint.id) as HTMLVideoElement | null;
if (!video) continue;
if (!video.hasAttribute("width")) video.setAttribute("width", String(hint.width));
if (!video.hasAttribute("height")) video.setAttribute("height", String(hint.height));
const computed = window.getComputedStyle(video);
if (
!video.style.aspectRatio &&
(!computed.aspectRatio || computed.aspectRatio === "auto")
) {
video.style.aspectRatio = `${hint.width} / ${hint.height}`;
}
}
},
[...hints],
);
}
async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Promise<void> {
const hasTailwindReady = await page.evaluate(
`(() => { const ready = window.__tailwindReady; return !!ready && typeof ready.then === "function"; })()`,
);
if (!hasTailwindReady) return;
const ready = await Promise.race([
page.evaluate(
`Promise.resolve(window.__tailwindReady).then(() => true, () => false)`,
) as Promise<boolean>,
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
]);
if (!ready) {
throw new Error(
`[FrameCapture] window.__tailwindReady not resolved after ${timeoutMs}ms. Tailwind browser runtime must finish before frame capture starts.`,
);
}
}
// fallow-ignore-next-line unit-size
export async function initializeSession(session: CaptureSession): Promise<void> {
const { page, serverUrl } = session;
// Forward browser console to host. HyperFrames runtime logs get a dedicated
// prefix so page-context observability is visible in producer stdout.
page.on("console", (msg: ConsoleMessage) => {
const type = msg.type();
const text = msg.text();
const locationUrl = msg.location()?.url ?? "";
const diagnostic = formatConsoleDiagnostic(type, text, locationUrl);
if (!diagnostic.suppressHostLog) console.log(diagnostic.text);
appendBrowserDiagnostic(session, diagnostic.text);
});
page.on("pageerror", (err) => {
const message = err instanceof Error ? err.message : String(err);
const text = `[Browser:PAGEERROR] ${message}`;
// Benign play/pause race during frame capture — suppress terminal noise, keep in buffer.
const isPlayAbort =
/^AbortError:/.test(message) && message.includes("play()") && message.includes("pause()");
if (!isPlayAbort) {
console.error(text);
}
appendBrowserDiagnostic(session, text);
});
page.on("requestfailed", (request) => {
appendBrowserDiagnostic(
session,
formatRequestFailureDiagnostic({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
failureText: request.failure()?.errorText ?? "unknown",
}),
);
});
page.on("response", (response) => {
const status = response.status();
if (status < 400) return;
const request = response.request();
appendBrowserDiagnostic(
session,
formatHttpErrorDiagnostic({
method: request.method(),
resourceType: request.resourceType(),
url: response.url(),
status,
statusText: response.statusText(),
}),
);
});
// Navigate to the file server
const url = `${serverUrl}/index.html`;
const pageNavigationTimeout =
session.config?.pageNavigationTimeout ?? DEFAULT_CONFIG.pageNavigationTimeout;
const initStart = Date.now();
const logInitPhase = (phase: string) => {
console.log(`[initSession:${session.captureMode}] ${phase} (${Date.now() - initStart}ms)`);
};
const gotoEntryPage = async (): Promise<void> => {
appendBrowserDiagnostic(
session,
formatNavigationStartDiagnostic({
captureMode: session.captureMode,
url,
timeoutMs: pageNavigationTimeout,
}),
);
logInitPhase("page.goto start");
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: pageNavigationTimeout });
} catch (error) {
appendBrowserDiagnostic(
session,
formatNavigationFailureDiagnostic({
captureMode: session.captureMode,
url,
timeoutMs: pageNavigationTimeout,
elapsedMs: Date.now() - initStart,
error,
}),
);
throw error;
}
};
if (session.captureMode === "screenshot") {
// Screenshot mode: standard navigation, rAF works normally
await gotoEntryPage();
logInitPhase("page.goto complete");
// Flush the GSAP proxy queue synchronously instead of waiting for
// rAF-based batch ticks (100 ops/tick at ~16ms). In headless mode there's
// no UI responsiveness concern, so draining instantly eliminates the
// largest init-time cost for tween-heavy compositions.
await page.evaluate(`window.__hfFlushSync?.()`);
logInitPhase("GSAP proxy flush complete");
const pageReadyTimeout =
session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
await pollHfReady(page, pageReadyTimeout);
logInitPhase("pollHfReady complete");
await pollSubCompositionTimelines(page, pageReadyTimeout);
logInitPhase("pollSubCompositionTimelines complete");
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
logInitPhase("applyVideoMetadataHints complete");
// Run independent readiness checks in parallel — videos, images, fonts,
// and Tailwind don't depend on each other's completion.
const skipVideoIds = session.options.skipReadinessVideoIds ?? [];
const [videosReady] = await Promise.all([
pollVideosReady(page, skipVideoIds, pageReadyTimeout),
pollImagesReady(page, pageReadyTimeout).then(async (ready) => {
if (!ready) {
const failedImages = await page.evaluate(() => {
return Array.from(document.querySelectorAll("img"))
.filter((img) => {
const ie = img as HTMLImageElement;
const src = ie.getAttribute("src") || "";
if (!src || src.startsWith("data:")) return false;
return !(ie.complete && ie.naturalWidth > 0);
})
.map((img) => (img as HTMLImageElement).src || img.getAttribute("src") || "(no src)")
.join(", ");
});
console.warn(
`[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
`Continuing render — affected images may appear blank/missing in early frames.`,
);
}
await decodeAllImages(page);
return ready;
}),
page.evaluate(`document.fonts?.ready`),
waitForOptionalTailwindReady(page, pageReadyTimeout),
]);
logInitPhase("media + fonts + tailwind ready");
if (!videosReady) {
const failedVideos = await page.evaluate((skipIdList: readonly string[]) => {
const skip = new Set(skipIdList);
return Array.from(document.querySelectorAll("video"))
.filter((v) => !skip.has(v.id))
.filter((v) => (v as HTMLVideoElement).readyState < 2 && !(v as HTMLVideoElement).error)
.map((v) => (v as HTMLVideoElement).src || v.getAttribute("src") || "(no src)")
.join(", ");
}, skipVideoIds);
console.warn(
`[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
`Continuing render — affected videos will appear as blank/black frames.`,
);
}
await recordSessionInitTelemetry(session, initStart);
// drawElement or transparent-background init — runs after page is fully ready.
await initDrawElementOrTransparentBackground(session, page, logInitPhase);
await armStaticDedup(session, session.page, logInitPhase);
session.isInitialized = true;
return;
}
// In BeginFrame mode, Chrome's event loop is paused until we issue frames.
// Start a warmup loop to drive rAF/setTimeout callbacks during page load.
//
// The unlocked path runs while `warmupState.running` stays true — wall-
// clock-bounded. The locked path (`options.lockWarmupTicks`) additionally
// exits at exactly `LOCKED_WARMUP_TICKS` iterations so `beginFrameTimeTicks`
// is deterministic across hosts with different page-load latencies.
const warmupIntervalMs = 33; // ~30fps
const warmupState: WarmupTickState = {
running: true,
ticks: 0,
};
const lockWarmupTicks = session.options.lockWarmupTicks === true;
let warmupClient: import("puppeteer-core").CDPSession | null = null;
const acquireWarmupClient = async (): Promise<void> => {
try {
warmupClient = await getCdpSession(page);
await warmupClient.send("HeadlessExperimental.enable");
} catch {
/* page not ready yet */
}
};
const warmupLoopPromise = (async () => {
await acquireWarmupClient();
await driveWarmupTicks(
{
intervalMs: warmupIntervalMs,
lockWarmupTicks,
tick: async (frameTimeTicks, interval) => {
if (!warmupClient) {
// No CDP yet — let driveWarmupTicks count the tick anyway so the
// locked iteration count is reached deterministically. Throwing
// would skip the ticks++ increment, leaking host-load variance
// back into the count.
return;
}
await warmupClient.send("HeadlessExperimental.beginFrame", {
frameTimeTicks,
interval,
noDisplayUpdates: true,
});
},
},
warmupState,
);
})();
warmupLoopPromise.catch(() => {});
logInitPhase("warmup loop started");
await gotoEntryPage();
logInitPhase("page.goto complete");
// Flush the GSAP proxy queue synchronously. In BeginFrame mode the rAF-based
// batch drain runs on the warmup loop's 33ms ticks — for tween-heavy
// compositions this is the dominant init cost. Flushing synchronously
// eliminates the wait entirely.
await page.evaluate(`window.__hfFlushSync?.()`);
logInitPhase("GSAP proxy flush complete");
// Poll for window.__hf readiness using manual evaluate loop (waitForFunction
// uses rAF polling internally, which won't fire in beginFrame mode).
const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
try {
await pollHfReady(page, pageReadyTimeout);
logInitPhase("pollHfReady complete");
} catch (err) {
warmupState.running = false;
throw err;
}
await pollSubCompositionTimelines(page, pageReadyTimeout);
logInitPhase("pollSubCompositionTimelines complete");
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
logInitPhase("applyVideoMetadataHints complete");
// Run independent readiness checks in parallel — videos, images, fonts,
// and Tailwind don't depend on each other's completion.
const bfSkipVideoIds = session.options.skipReadinessVideoIds ?? [];
const [bfVideosReady] = await Promise.all([
pollVideosReady(page, bfSkipVideoIds, pageReadyTimeout),
pollImagesReady(page, pageReadyTimeout).then(async (ready) => {
if (!ready) {
const failedImages = await page.evaluate(() => {
return Array.from(document.querySelectorAll("img"))
.filter((img) => {
const ie = img as HTMLImageElement;
const src = ie.getAttribute("src") || "";
if (!src || src.startsWith("data:")) return false;
return !(ie.complete && ie.naturalWidth > 0);
})
.map((img) => (img as HTMLImageElement).src || img.getAttribute("src") || "(no src)")
.join(", ");
});
console.warn(
`[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
`Continuing render — affected images may appear blank/missing in early frames.`,
);
}
await decodeAllImages(page);
return ready;
}),
page.evaluate(`document.fonts?.ready`),
waitForOptionalTailwindReady(page, pageReadyTimeout),
]);
logInitPhase("media + fonts + tailwind ready");
if (!bfVideosReady) {
const failedVideos = await page.evaluate((skipIdList: readonly string[]) => {
const skip = new Set(skipIdList);
return Array.from(document.querySelectorAll("video"))
.filter((v) => !skip.has(v.id))
.filter((v) => (v as HTMLVideoElement).readyState < 2 && !(v as HTMLVideoElement).error)
.map((v) => (v as HTMLVideoElement).src || v.getAttribute("src") || "(no src)")
.join(", ");
}, bfSkipVideoIds);
console.warn(
`[FrameCapture] Some video elements did not decode within ${pageReadyTimeout}ms: ${failedVideos}. ` +
`Continuing render — affected videos will appear as blank/black frames.`,
);
}
await recordSessionInitTelemetry(session, initStart);
// Stop warmup. Unlocked mode exits on this flag; locked mode keeps ticking
// until LOCKED_WARMUP_TICKS, so we await its promise to ensure the count is
// exact before deriving the baseline.
warmupState.running = false;
if (lockWarmupTicks) {
await warmupLoopPromise.catch(() => {});
}
// Set base frame time ticks past warmup range. Locked mode pins to the
// constant so chunk workers on different hosts compute the same baseline.
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
// drawElement or transparent-background init — runs after page is fully ready.
// IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame
// drawelement branch gates its BeginFrame call on `beginFrameTimeTicks > 0`;
// if this ran first, ticks would be 0 and the paused compositor would never
// advance for opaque drawElement on Linux. (In beginframe-launched mode,
// transparent is always false — useDrawElement+png forces preMode="screenshot"
// upstream — so the SwiftShader fallback inside the helper is dead-but-harmless
// defense-in-depth here.)
await initDrawElementOrTransparentBackground(session, page, logInitPhase);
await armStaticDedup(session, session.page, logInitPhase);
session.isInitialized = true;
}
async function captureFrameErrorDiagnostics(
session: CaptureSession,
frameIndex: number,
time: number,
error: Error,
): Promise<string | null> {
try {
const diagnosticsDir = join(session.outputDir, "diagnostics");
if (!existsSync(diagnosticsDir)) mkdirSync(diagnosticsDir, { recursive: true });
const base = join(diagnosticsDir, `frame-error-${frameIndex}`);
await session.page.screenshot({ path: `${base}.png`, type: "png", fullPage: true });
const html = await session.page.content();
writeFileSync(`${base}.html`, html, "utf-8");
writeFileSync(
`${base}.json`,
JSON.stringify(
{
frameIndex,
time,
error: error.message,
stack: error.stack,
browserConsoleTail: session.browserConsoleBuffer.slice(-30),
},
null,
2,
),
"utf-8",
);
return `${base}.json`;
} catch {
return null;
}
}
/**
* Internal helper: seek timeline and inject video frames.
* Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
* Returns timing breakdown for perf tracking.
*/
async function prepareFrameForCapture(
session: CaptureSession,
frameIndex: number,
time: number,
): Promise<{
quantizedTime: number;
seekMs: number;
beforeCaptureMs: number;
}> {
const { page, options } = session;
if (!session.isInitialized) {
throw new Error("[FrameCapture] Session not initialized");
}
const quantizedTime = quantizeTimeToFrame(time, fpsToNumber(options.fps));
const seekStart = Date.now();
// Seek via the __hf protocol. The page's seek() implementation handles
// all framework-specific logic (GSAP stepping, CSS animation sync, etc.)
// Seek + check page-side composite pending flag in one round-trip.
const hasPendingComposite = await page.evaluate((t: number) => {
if (window.__hf && typeof window.__hf.seek === "function") {
window.__hf.seek(t);
}
return !!(window as unknown as { __hf_page_composite_pending?: boolean })
.__hf_page_composite_pending;
}, quantizedTime);
const seekMs = Date.now() - seekStart;
// Before-capture hook (e.g. video frame injection) — runs before
// page-side compositor clones so cloneNode picks up injected <img>
// replacements for <video> elements.
const beforeCaptureStart = Date.now();
if (session.onBeforeCapture) {
await session.onBeforeCapture(page, quantizedTime);
}
const beforeCaptureMs = Date.now() - beforeCaptureStart;
// Page-side compositing three-phase protocol:
// 1. prepare — clone scenes (now containing injected video <img>s)
// 2. micro-screenshot — force browser to paint cloned elements
// 3. resolve — drawElementImage reads paint records, shader composites
if (
hasPendingComposite &&
session.captureMode !== "beginframe" &&
session.captureMode !== "drawelement"
) {
await page.evaluate(async () => {
const w = window as unknown as { __hf_page_composite_prepare?: () => Promise<boolean> };
if (typeof w.__hf_page_composite_prepare === "function") {
await w.__hf_page_composite_prepare();
}
});
const cdp = await getCdpSession(page);
await cdp.send("Page.captureScreenshot", {
format: "jpeg",
quality: 1,
clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 },
});
await page.evaluate(() => {
const w = window as unknown as { __hf_page_composite_resolve?: () => boolean };
if (typeof w.__hf_page_composite_resolve === "function") {
w.__hf_page_composite_resolve();
}
});
}
return { quantizedTime, seekMs, beforeCaptureMs };
}
// ── Static-frame dedup (default-on, opt-out HF_STATIC_DEDUP=false) ─────────────
// Skip re-seeking + re-screenshotting frames that are byte-identical to their
// predecessor. A frame is dedupable iff no GSAP tween or clip cut is active in it or
// its predecessor (predicted from window.__timelines), AND an empirical anchor-compare
// confirms it. Capture-mode-independent (works on screenshot + beginframe), lossless
// (verification disables the whole comp on any drift), default off. Pays on
// static-hold content (title cards, slideshows, data-viz pauses); a no-op on
// continuously-animated comps and disqualified by video/canvas/non-GSAP animation.
/**
* Clip-cut boundary frames (±1) from the [data-start] schedule. A hard scene swap at a
* cut changes content with no tween; treat those frames as animated so the post-cut
* frame is captured fresh and later static frames reuse the correct scene.
*/
async function computeClipBoundaryFrames(page: Page, fps: number): Promise<Set<number>> {
const schedule = await page.evaluate(() =>
Array.from(document.querySelectorAll("[data-start]")).map((el) => ({
start: parseFloat((el as HTMLElement).dataset.start || ""),
dur: parseFloat((el as HTMLElement).dataset.duration || ""),
})),
);
const frames = new Set<number>();
for (const { start, dur } of schedule) {
if (Number.isNaN(start)) continue;
const edges = [Math.round(start * fps)];
if (!Number.isNaN(dur)) edges.push(Math.round((start + dur) * fps));
for (const e of edges) {
for (const f of [e - 1, e, e + 1]) {
if (f >= 0) frames.add(f);
}
}
}
return frames;
}
/**
* Predict the dedupable (static) frame set from window.__timelines. A frame f (f>0) is
* static iff NEITHER f NOR f-1 falls inside any GSAP tween interval — content didn't
* change f-1→f, so f can reuse f-1's buffer. Requiring BOTH neighbours static under-
* claims by one frame at each tween edge (the SAFE direction). Disqualifies the whole
* comp on any signal the tween-walker can't see: video / canvas / webgl (redraw without
* a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
*/
async function computeStaticFrameSet(
page: Page,
fps: number,
): Promise<{
totalFrames: number;
staticFrameSet: Set<number>;
hasVideo: boolean;
hasCanvas: boolean;
hasNonGsapAnim: boolean;
tweenCount: number;
eligible: boolean;
reason: string;
}> {
const result = await page.evaluate(() => {
type AnyTween = {
startTime(): number;
duration(): number;
totalDuration?(): number;
getChildren?(nested: boolean, tweens: boolean, timelines: boolean): AnyTween[];
};
const intervals: Array<{ start: number; end: number }> = [];
let tweenCount = 0;
// totalDuration() (NOT duration()): a repeat/yoyo tween animates past one iteration;
// a repeating timeline is marked opaque over its whole span (conservative).
function walk(tl: AnyTween, offset: number): void {
if (typeof tl.getChildren !== "function") return;
for (const child of tl.getChildren(false, true, true)) {
const start = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
const single = typeof child.duration === "function" ? child.duration() : 0;
const total = typeof child.totalDuration === "function" ? child.totalDuration() : single;
if (typeof child.getChildren === "function") {
if (total > single + 1e-6) intervals.push({ start, end: start + total });
else walk(child, start);
} else {
tweenCount++;
intervals.push({ start, end: start + total });
}
}
}
const w = window as unknown as {
__timelines?: Record<string, AnyTween>;
__hf?: { duration?: number };
};
for (const tl of Object.values(w.__timelines || {})) {
if (tl && typeof tl.getChildren === "function") walk(tl, 0);
}
const hasVideo = !!document.querySelector("video");
const hasCanvas = !!document.querySelector("canvas");
// A non-numeric data-start (reference expression like "intro+0.5") can't be turned
// into a clip-cut boundary by computeClipBoundaryFrames' parseFloat, so the cut goes
// unprotected and could be deduped into the previous scene. Disqualify the comp.
const hasUnresolvableClipStart = Array.from(document.querySelectorAll("[data-start]")).some(
(el) => {
const v = (el as HTMLElement).dataset.start;
return v != null && v.trim() !== "" && !Number.isFinite(parseFloat(v));
},
);
// Non-GSAP animation (CSS @keyframes / transitions / WAAPI) surfaces via
// getAnimations(); any running/paused one can change content without a tween.
let hasNonGsapAnim = false;
try {
const docAnims = (document as unknown as { getAnimations?: () => Animation[] }).getAnimations;
if (typeof docAnims === "function") {
hasNonGsapAnim = docAnims.call(document).some((a) => {
const t = a as Animation & { playState?: string };
return t.playState === "running" || t.playState === "paused";
});
}
} catch {
hasNonGsapAnim = true;
}
return {
intervals,
tweenCount,
duration: w.__hf?.duration ?? 0,
hasVideo,
hasCanvas,
hasNonGsapAnim,
hasUnresolvableClipStart,
};
});
const {
intervals,
tweenCount,
duration,
hasVideo,
hasCanvas,
hasNonGsapAnim,
hasUnresolvableClipStart,
} = result as {
intervals: Array<{ start: number; end: number }>;
tweenCount: number;
duration: number;
hasVideo: boolean;
hasCanvas: boolean;
hasNonGsapAnim: boolean;
hasUnresolvableClipStart: boolean;
};
const totalFrames = Math.max(1, Math.ceil(duration * fps));
const animated = new Set<number>();
for (const { start, end } of intervals) {
const lo = Math.max(0, Math.floor(start * fps));
const hi = Math.min(totalFrames - 1, Math.ceil(end * fps));
for (let f = lo; f <= hi; f++) animated.add(f);
}
for (const f of await computeClipBoundaryFrames(page, fps)) animated.add(f);
const reasons: string[] = [];
if (!(duration > 0)) reasons.push("unknown/zero duration");
if (hasVideo) reasons.push("video");
if (hasCanvas) reasons.push("canvas/webgl");
if (tweenCount === 0) reasons.push("no GSAP tweens (non-GSAP animation)");
if (hasNonGsapAnim) reasons.push("running CSS/WAAPI animation");
if (hasUnresolvableClipStart) reasons.push("unresolvable clip start (reference expression)");
const eligible = reasons.length === 0;
const staticFrameSet = new Set<number>();
if (eligible) {
for (let f = 1; f < totalFrames; f++) {
if (!animated.has(f) && !animated.has(f - 1)) staticFrameSet.add(f);
}
}
return {
totalFrames,
staticFrameSet,
hasVideo,
hasCanvas,
hasNonGsapAnim,
tweenCount,
eligible,
reason: eligible ? "eligible" : reasons.join("+"),
};
}
// Fixed density target for verification checks: never leave a gap wider than this
// many frames within a run, independent of the user-tunable sampleCount. This is
// what fixes long runs going nearly unverified — deliberately NOT derived from
// sampleCount, so that knob's effect on density stays monotonic (see below).
const STATIC_VERIFY_REFERENCE_STRIDE = 24;
/**
* Interior verification points for a run [a..b], plus the always-included end `b`.
* Density used to be a flat point-count cap (min(sampleCount, 8)), so a run's
* stride grew with its span — on a long run (many merged static frames), two
* checks could land hundreds of frames apart. A genuine content change in
* between (e.g. text swapped by a mechanism computeStaticFrameSet's GSAP-only
* tween walk can't see) then hides between samples and the whole run gets
* wrongly trusted as static.
*
* `sampleCount` (HF_STATIC_DEDUP_SAMPLES) is a per-run point-count FLOOR, not a
* stride cap — raising it always increases density, never decreases it. (An
* earlier revision of this fix bounded the stride BY sampleCount directly, which
* inverted that: raising sampleCount widened the allowed gap instead of shrinking
* it, and the "raise HF_STATIC_DEDUP_SAMPLES to verify more" log guidance became
* backwards for exactly the long runs it's meant to help.) The length-scaling
* fix itself comes from STATIC_VERIFY_REFERENCE_STRIDE, which is independent of
* sampleCount, so density scales with run length regardless of how that knob is
* set; sampleCount only ever raises density further above that floor.
*
* Pure and exported so its scaling behavior is unit-testable without a real
* page/browser.
*/
export function computeStaticVerificationPoints(
a: number,
b: number,
sampleCount: number,
): number[] {
const span = b - a;
const lengthScaledPoints = span > 0 ? Math.ceil(span / STATIC_VERIFY_REFERENCE_STRIDE) + 1 : 1;
const perRun = Math.max(3, sampleCount, lengthScaledPoints);
const stride = span > 0 ? Math.max(1, Math.floor(span / (perRun - 1))) : 1;
const pts = new Set<number>();
for (let f = a; f <= b; f += stride) pts.add(f);
pts.add(b);
return [...pts].sort((x, y) => x - y);
}
/**
* Empirically verify the predicted-static set before trusting it. Group static frames
* into runs; each run [a..b] reuses anchor a-1. CRITICAL: compare against the ANCHOR,
* not the predecessor — a slow drift with sub-quantization per-frame deltas is byte-
* identical frame-to-frame yet drifts far from the anchor by the run's end (the real
* frozen error). Capture each run's anchor once, compare END + a midpoint to it; any
* mismatch ⇒ the run isn't truly static ⇒ disable dedup whole-comp. Capture-mode-
* independent (seeks + screenshots in normal DOM). Returns the first bad frame, or null.
*/
export async function verifyStaticFramesSafe(
session: CaptureSession,
page: Page,
staticFrames: Set<number>,
fps: number,
sampleCount: number,
): Promise<{ badFrame: number; budgetExhausted: boolean } | null> {
const frames = [...staticFrames].sort((a, b) => a - b);
if (frames.length === 0) return null;
// Runs are maximal-contiguous (adjacent frames merge), so a run's anchor a-1 is
// guaranteed NOT static — always a freshly-captured frame.
const runs: Array<{ a: number; b: number }> = [];
for (const f of frames) {
const last = runs[runs.length - 1];
if (last && f === last.b + 1) last.b = f;
else runs.push({ a: f, b: f });
}
const seekCapture = async (frameIdx: number): Promise<Buffer> => {
const t = quantizeTimeToFrame(frameIdx / fps, fps);
await page.evaluate((tt: number) => {
const hf = (window as unknown as { __hf?: { seek?: (t: number) => void } }).__hf;
if (hf && typeof hf.seek === "function") hf.seek(tt);
}, t);
return pageScreenshotCapture(page, session.options);
};
// Verify EVERY run in order (no longest-first truncation that would leave runs armed
// but unverified). Per run, compare the FIRST reused frame `a`, the END `b` (max
// accumulated drift), and interior points at a stride (see computeStaticVerificationPoints)
// — against the anchor the run actually reuses.
//
// hardCap bounds pathological cases and hitting it DISABLES dedup (conservative:
// never trust an unverified set). It must scale with the new density model:
// each run now costs roughly span/STATIC_VERIFY_REFERENCE_STRIDE + 1 checks (plus
// one anchor), not the ~8 the old flat point cap cost — sizing the budget only off
// sampleCount (which no longer drives density for long runs) would make a
// genuinely-static long composition spuriously disarm under the new, more
// thorough checking. `frames.length` approximates total interior checks; a 3x
// margin absorbs per-run anchor overhead and the 3-point floor on short runs.
const hardCap = Math.max(
sampleCount * 8,
400,
Math.ceil(frames.length / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + runs.length,
);
let spent = 0;
for (const { a, b } of runs) {
const anchor = a - 1;
if (anchor < 0) continue;
const anchorBuf = await seekCapture(anchor);
spent++;
for (const f of computeStaticVerificationPoints(a, b, sampleCount)) {
const cur = await seekCapture(f);
spent++;
if (!anchorBuf.equals(cur)) return { badFrame: f, budgetExhausted: false };
}
// Budget exhausted → can't fully verify → disarm, distinct from real drift so a
// `verification_budget` spike in telemetry reads as "this composition has a lot
// of static material to verify," not "compositions are non-static."
if (spent > hardCap) return { badFrame: a, budgetExhausted: true };
}
return null;
}
/**
* Arm static-frame dedup for this render (default-on; opt out with HF_STATIC_DEDUP=false).
* Runs at init in normal DOM state so the verification screenshots are valid. Predicts
* the static set, anchor-verifies it (skip with HF_STATIC_DEDUP_VERIFY=false — unsafe),
* and on success stores it on the session for captureFrameCore to reuse. Sample budget
* via HF_STATIC_DEDUP_SAMPLES (default 24).
*/
async function armStaticDedup(
session: CaptureSession,
page: Page,
logInitPhase: (phase: string) => void,
): Promise<void> {
// Idempotent: the drawElement init path arms dedup BEFORE canvas injection
// (verification screenshots need the un-injected DOM), and initializeSession
// calls this again unconditionally afterwards. Once staticFrames is
// populated, re-running would overwrite the armed state with
// skipReason="capture_mode" (captureMode is "drawelement" by then) —
// contradictory telemetry — and re-run the verification seeks. No-op instead.
if (session.staticFrames || session.staticDedupSkipReason) return;
// Default ON for everyone; opt out via HF_STATIC_DEDUP in {false,0,off} (resolved into
// EngineConfig.staticFrameDedup by resolveConfig). Verification is the safety net at scale.
// Default-on: only an explicit `staticFrameDedup === false` (resolved from
// HF_STATIC_DEDUP) disables; a missing config leaves dedup enabled.
session.staticDedupEnabled = session.config?.staticFrameDedup !== false;
if (!session.staticDedupEnabled) return;
// Conservative gates: dedup is verified against the plain screenshot path, so only arm
// where the production capture matches what verification measures, and where reuse is
// sound. Skip when:
// - capture mode is not screenshot (BeginFrame advances the compositor clock per
// frame; skipping beginFrame for static frames gaps the tick sequence, and the
// verifier uses pageScreenshotCapture not beginFrameCapture — its proof wouldn't
// transfer);
// - a before-capture hook is set (per-frame video-frame injection — those frames are
// NOT static even if the GSAP timeline is idle, and the injector is skipped on reuse);
// - page-side compositing is active (shader transitions / drawElement composite paint
// a frame the plain verification screenshot doesn't reproduce).
if (session.captureMode !== "screenshot") {
session.staticDedupSkipReason = "capture_mode";
logInitPhase(
`static-frame dedup: disabled (capture mode ${session.captureMode}, not screenshot)`,
);
return;
}
if (session.onBeforeCapture) {
session.staticDedupSkipReason = "video_injection";
logInitPhase("static-frame dedup: disabled (before-capture hook / video injection active)");
return;
}
const pageComposite = await page
.evaluate(
() =>
typeof (window as unknown as { __hf_page_composite_prepare?: unknown })
.__hf_page_composite_prepare === "function",
)
.catch(() => true); // fail CLOSED: if we can't determine, assume compositing → skip dedup
if (pageComposite) {
session.staticDedupSkipReason = "page_composite";
logInitPhase("static-frame dedup: disabled (page-side compositing active)");
return;
}
const fps = fpsToNumber(session.options.fps);
const stats = await computeStaticFrameSet(page, fps);
if (!stats.eligible || stats.staticFrameSet.size === 0) {
session.staticDedupSkipReason = "ineligible";
logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
return;
}
const rawSamples = Number(process.env.HF_STATIC_DEDUP_SAMPLES ?? "24");
const samples = Number.isFinite(rawSamples) && rawSamples >= 1 ? rawSamples : 24;
const verdict =
process.env.HF_STATIC_DEDUP_VERIFY === "false"
? null
: await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples);
if (verdict !== null) {
session.staticDedupSkipReason = verdict.budgetExhausted
? "verification_budget"
: "verification_failed";
logInitPhase(
verdict.budgetExhausted
? `static-frame dedup: disabled (verification budget exhausted before frame ${verdict.badFrame}; ` +
`too much predicted-static material to fully verify — this is the safe fallback, not an error)`
: `static-frame dedup: disabled (verification failed — content drifts from anchor at ` +
`predicted-static frame ${verdict.badFrame})`,
);
return;
}
// armed + predicted are derived from staticFrames in getCapturePerfSummary.
session.staticFrames = stats.staticFrameSet;
logInitPhase(
`static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` +
`(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`,
);
}
/**
* Walk window.__timelines and collect frame intervals where GSAP tweens animate
* compositor-incompatible properties (blend-mode, 3D transforms, clip-path, mask).
* drawElement cannot reproduce these effects mid-tween → capture those frames via
* screenshot instead. opacity/filter fades were dropped from the set once Chrome 151
* fixed crbug 521861819. See docs/fast-capture-limitations.md Lim 7.
*
* Returns the union of at-risk frame indices (±1 margin around each tween interval)
* and totalFrames (for fraction computation by the caller).
*/
async function computeTimelineAtRiskFrames(
page: Page,
fps: number,
): Promise<{ frames: Set<number>; totalFrames: number }> {
const result = await page.evaluate(() => {
// opacity/autoAlpha/filter fades were removed from this set once Chrome 151
// fixed crbug 521861819 (drawElementImage dropped promoted opacity layers
// mid-fade) — those now render correctly on the drawElement path. backdrop-filter
// and filter:blur stay gated by detectCssEffectRisk (architectural single-element
// capture limit, not 521861819). What remains here is the per-tween backstop for
// effects drawElementImage still cannot reproduce mid-animation: mix-blend-mode,
// CSS 3D transforms (crbug 522872457), clip-path, and mask.
const AT_RISK_PROPS = new Set([
"backdropFilter",
"backdrop-filter",
"mixBlendMode",
"mix-blend-mode",
"rotationX",
"rotationY",
"rotateX",
"rotateY",
"z",
"translateZ",
"clipPath",
"clip-path",
"maskImage",
"mask",
]);
type AnyTween = {
startTime(): number;
duration(): number;
vars?: Record<string, unknown>;
getChildren?(nested: boolean, tweens: boolean, timelines: boolean): AnyTween[];
};
function walkTimeline(
tl: AnyTween,
offset: number,
out: Array<{ start: number; end: number }>,
): void {
if (typeof tl.getChildren !== "function") return;
for (const child of tl.getChildren(false, true, true)) {
const childStart = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
const childDur = typeof child.duration === "function" ? child.duration() : 0;
if (typeof child.getChildren === "function") {
walkTimeline(child, childStart, out);
} else {
const vars = child.vars || {};
if (Object.keys(vars).some((k) => AT_RISK_PROPS.has(k))) {
out.push({ start: childStart, end: childStart + childDur });
}
}
}
}
const w = window as unknown as {
__timelines?: Record<string, AnyTween>;
__hf?: { duration?: number };
};
const timelines = w.__timelines || {};
const intervals: Array<{ start: number; end: number }> = [];
for (const tl of Object.values(timelines)) {
if (tl && typeof tl.getChildren === "function") {
walkTimeline(tl, 0, intervals);
}
}
const duration = w.__hf?.duration ?? 0;
return { intervals, duration };
});
const { intervals, duration } = result as {
intervals: Array<{ start: number; end: number }>;
duration: number;
};
const frames = new Set<number>();
for (const { start, end } of intervals) {
const lo = Math.floor(start * fps) - 1;
const hi = Math.ceil(end * fps) + 1;
for (let f = Math.max(0, lo); f <= hi; f++) {
frames.add(f);
}
}
const totalFrames = Math.max(1, Math.ceil(duration * fps));
return { frames, totalFrames };
}
/**
* True for the drawElement `InvalidStateError: No cached paint record for element`
* thrown when a subtree element has no paint record for the current frame (display
* toggled / detached / freshly-shown at a clip-cut boundary). Per-frame, not
* whole-comp — callers fall back to screenshot for the single frame.
*/
function isNoCachedPaintRecordError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return msg.includes("No cached paint record");
}
async function captureFrameCore(
session: CaptureSession,
frameIndex: number,
time: number,
): Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }> {
const { page, options } = session;
const startTime = Date.now();
// Static-frame dedup: this frame is byte-identical to its predecessor (predicted +
// anchor-verified at init) → reuse the prior buffer, skip the seek + screenshot.
// KEY: index by the ABSOLUTE composition frame (derived from `time`), NOT the
// `frameIndex` arg — chunked/parallel/distributed callers pass a chunk-RELATIVE
// frameIndex (captureStage passes the loop `i`, parallelCoordinator passes
// `i-outputFrameOffset`) while staticFrames is keyed in absolute frames. Using `time`
// is correct on every path (sequential, per-worker range, distributed chunk) because
// `time` is always the absolute composition time for the frame. Each session captures
// its range in ascending order, so lastFrameBuffer is the correct in-range anchor (and
// since a static run is verified identical, reusing the run's first in-range capture
// equals reusing the global anchor). Telemetry: count reuses separately; do NOT bump
// capturePerf.frames (that would dilute the per-frame timing averages).
// Use the SAME floor+epsilon idiom as quantizeTimeToFrame so the dedup lookup agrees
// with the frame the seek actually lands on, even if `time` ever isn't exactly i/fps.
const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
if (session.staticFrames?.has(absFrameIndex) && session.lastFrameBuffer) {
session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
return {
buffer: session.lastFrameBuffer,
quantizedTime: quantizeTimeToFrame(time, fpsToNumber(options.fps)),
captureTimeMs: Date.now() - startTime,
};
}
try {
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(
session,
frameIndex,
time,
);
const screenshotStart = Date.now();
let screenshotBuffer: Buffer;
if (session.captureMode === "beginframe") {
const frameTimeTicks =
session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
const result = await beginFrameCapture(
page,
options,
frameTimeTicks,
session.beginFrameIntervalMs,
);
if (result.hasDamage) session.beginFrameHasDamageCount++;
else session.beginFrameNoDamageCount++;
screenshotBuffer = result.buffer;
} else if (
session.captureMode === "drawelement" &&
session.clipBoundaryFrames?.has(frameIndex) &&
process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true"
) {
// Lim 6 (serial path): proactively screenshotting clip-boundary frames is now
// OPT-IN (was default-on). It is net-harmful: drawElement renders most boundary
// frames correctly, but Page.captureScreenshot in drawElement mode captures the
// injected canvas (unpainted at render start → white; mid-render → ~1-frame
// stale), so the "fallback" REPLACES good frames with damaged ones (validated:
// 35e8fa9f 462→0 damaged frames, 4001da8e 11→0, when this is off). The two real
// boundary failure modes are now caught reactively below — the throw case by
// isNoCachedPaintRecordError, the silent-solid-black case by the small-frame
// blank-guard (a solid frame is a tiny JPEG) — without touching frames drawElement
// handles. Force the old behavior with HF_FAST_CAPTURE_BOUNDARY_SS=true. The worker
// path keeps proactive boundary-SS (it has no blank-guard); see
// captureFrameToBufferPipelined and docs/fast-capture-limitations.md.
screenshotBuffer = await pageScreenshotCapture(page, options);
} else if (session.captureMode === "drawelement") {
// Advance compositor state via BeginFrame when available (Linux headless-shell);
// on macOS the compositor advances naturally without BeginFrame.
if (session.beginFrameTimeTicks > 0) {
const client = await getCdpSession(page);
await client.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
// no screenshot param — we capture via canvas
});
}
try {
screenshotBuffer = await captureDrawElementFrame(
page,
options.width,
options.height,
options.format ?? "jpeg",
options.quality ?? 80,
// Paint-event sync only without BeginFrame (macOS / screenshot-launched):
// under BeginFrame control the per-frame beginFrame above already painted
// a fresh snapshot, and no further paint would arrive during a wait.
session.beginFrameTimeTicks === 0,
);
// Silent-blank-drop guard: drawElement occasionally returns a blank/dropped
// frame WITHOUT throwing (paint-record miss; the throw case is handled below).
// Such a frame's JPEG is anomalously tiny vs the comp's running median (a blank
// 1080p frame ~5-9 KB; content frames 50 KB-1 MB). Re-capture via screenshot
// (ground truth) — harmless for legitimately simple frames (screenshot matches).
// Catches scattered intermittent drops (e.g. 4001da8e: 11 blanks in 9300 frames,
// 9.7 dB) that no static gate can see. PNG/transparent excluded (alpha sizing
// differs and that path is its own).
if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
const sizes = (session.deFrameSizes ??= []);
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
const floor = Math.max(20000, median * 0.12);
if (screenshotBuffer.length < floor) {
console.log(
`[engine] fast capture: frame ${frameIndex} — drawElement frame anomalously ` +
`small (${screenshotBuffer.length}B < ${Math.round(floor)}B, likely a silent ` +
`paint-record drop); screenshot fallback (see fast-capture-limitations.md)`,
);
screenshotBuffer = await pageScreenshotCapture(page, options);
} else {
if (sizes.length >= 60) sizes.shift();
sizes.push(screenshotBuffer.length);
}
}
} catch (err) {
// drawElementImage throws `InvalidStateError: No cached paint record for
// element` when an element in the subtree has no paint record this frame
// (display toggled / detached / freshly-shown at a clip-cut boundary). This
// is a per-frame condition, not a whole-comp one — fall back to screenshot
// for THIS frame instead of aborting the render. See fast-capture-limitations.md.
if (isNoCachedPaintRecordError(err)) {
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
console.log(
`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
`screenshot fallback for this frame (see fast-capture-limitations.md)`,
);
screenshotBuffer = await pageScreenshotCapture(page, options);
} else {
throw err;
}
}
} else {
screenshotBuffer = await pageScreenshotCapture(page, options);
}
const screenshotMs = Date.now() - screenshotStart;
const captureTimeMs = Date.now() - startTime;
session.capturePerf.frames += 1;
session.capturePerf.seekMs += seekMs;
session.capturePerf.beforeCaptureMs += beforeCaptureMs;
session.capturePerf.screenshotMs += screenshotMs;
session.capturePerf.totalMs += captureTimeMs;
session.capturePerf.frameMs.push(captureTimeMs);
// Retain this freshly-captured buffer so the following static frames can reuse it.
if (session.staticFrames) session.lastFrameBuffer = screenshotBuffer;
return { buffer: screenshotBuffer, quantizedTime, captureTimeMs };
} catch (captureError) {
if (session.isInitialized) {
await captureFrameErrorDiagnostics(
session,
frameIndex,
time,
captureError instanceof Error ? captureError : new Error(String(captureError)),
);
}
throw captureError;
}
}
export async function captureFrame(
session: CaptureSession,
frameIndex: number,
time: number,
): Promise<CaptureResult> {
const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(
session,
frameIndex,
time,
);
const framePath = writeCapturedFrame(session, frameIndex, buffer);
return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
}
/**
* Write an already-captured frame buffer to the session's output dir using the
* canonical `frame_NNNNNN.{jpg,png}` naming. `fileIndex` is the ENCODER-facing
* index (0-based within the captured range), which may differ from the absolute
* composition frame index used for seeking/boundary lookups. Extracted so the
* disk worker-encode pipeline can write a buffer produced by
* `captureFrameToBufferPipelined` without duplicating the naming convention.
*/
export function writeCapturedFrame(
session: CaptureSession,
fileIndex: number,
buffer: Buffer,
): string {
const ext = session.options.format === "png" ? "png" : "jpg";
const framePath = join(session.outputDir, `frame_${String(fileIndex).padStart(6, "0")}.${ext}`);
writeFileSync(framePath, buffer);
return framePath;
}
/**
* Capture a frame and return the screenshot as a Buffer instead of writing to disk.
* Used by the streaming encode pipeline to pipe frames directly to FFmpeg stdin.
*/
export async function captureFrameToBuffer(
session: CaptureSession,
frameIndex: number,
time: number,
): Promise<CaptureBufferResult> {
const { buffer, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
return { buffer, captureTimeMs };
}
/**
* Pipelined drawElement frame capture for the worker-encode path.
*
* Performs seek prep + paint-wait + drawElementImage + composite +
* `createImageBitmap` + transfers the bitmap to the in-page encode worker.
* Returns `encodeResult` immediately (before the worker finishes encoding).
* The caller overlaps frame N's encode with frame N+1's produce phase.
*
* Requirements:
* - `session.workerEncodeEnabled` must be true (set by initializeSession when
* `config.enableDrawElementWorkerEncode` is true and mode resolved to drawelement).
* - JPEG format only. PNG falls back to `captureFrameToBuffer`.
* - macOS hardware GPU path (syncToPaintEvent=true, beginFrameTimeTicks=0).
* BeginFrame (Linux) uses the standard synchronous path unchanged.
*/
export async function captureFrameToBufferPipelined(
session: CaptureSession,
frameIndex: number,
time: number,
): Promise<{ encodeResult: Promise<Buffer>; captureTimeMs: number }> {
const { page, options } = session;
const startTime = Date.now();
// Task B: static-frame dedup (worker path). Reuse the prior frame's encode result
// and skip the seek + drawElement + encode entirely. Same predicate as the serial
// path; clip-cut frames are excluded from staticFrames so they always capture.
if (session.staticFrames?.has(frameIndex) && session.lastEncodeResult) {
session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
session.capturePerf.frames += 1;
return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
}
try {
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(
session,
frameIndex,
time,
);
void quantizedTime;
// Lim 6: clip-cut boundary frame — screenshot ONLY when opt-in, matching the serial
// path (captureFrameCore). Proactive boundary screenshots in drawElement mode are
// net-HARMFUL: with `<canvas layoutsubtree>` the child composition root is laid out
// but not painted to screen — only the canvas 2D bitmap is visible — so
// Page.captureScreenshot captures the injected canvas holding the LAST drawElement
// frame (stale by ≥1 scene at a hard cut), REPLACING a good frame with a stale one.
// Measured on 0531c45f: worker boundary frames showed the previous scene's video.
// Default OFF → boundary frames fall through to produceDrawElementFrame, which draws
// the CURRENT frame into the canvas. (Force old behavior with
// HF_FAST_CAPTURE_BOUNDARY_SS=true.) See captureFrameCore for the serial rationale.
if (
session.clipBoundaryFrames?.has(frameIndex) &&
process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true"
) {
const buffer = await pageScreenshotCapture(page, options);
session.capturePerf.frames += 1;
session.capturePerf.seekMs += seekMs;
session.capturePerf.beforeCaptureMs += beforeCaptureMs;
{
const boundaryMs = Date.now() - startTime;
session.capturePerf.totalMs += boundaryMs;
session.capturePerf.frameMs.push(boundaryMs);
}
const boundaryResult = Promise.resolve(buffer);
if (session.staticFrames) session.lastEncodeResult = boundaryResult;
return { encodeResult: boundaryResult, captureTimeMs: Date.now() - startTime };
}
// Worker-encode is gated to the macOS GPU path (beginFrameTimeTicks === 0,
// syncToPaintEvent = true); see initDrawElementOrTransparentBackground. The
// BeginFrame branch present in the synchronous captureFrameCore is therefore
// unreachable here and intentionally omitted.
const { encodeResult } = await produceDrawElementFrame(
page,
options.width,
options.height,
options.quality ?? 80,
true,
);
const captureTimeMs = Date.now() - startTime;
session.capturePerf.frames += 1;
session.capturePerf.seekMs += seekMs;
session.capturePerf.beforeCaptureMs += beforeCaptureMs;
// screenshotMs reflects produce time only (encode is async, not tracked here)
session.capturePerf.screenshotMs += captureTimeMs - seekMs - beforeCaptureMs;
session.capturePerf.totalMs += captureTimeMs;
session.capturePerf.frameMs.push(captureTimeMs);
// Task B: retain this encode result so a following static frame can reuse it.
if (session.staticFrames) session.lastEncodeResult = encodeResult;
return { encodeResult, captureTimeMs };
} catch (captureError) {
// Per-frame `No cached paint record`: fall back to screenshot for THIS frame
// instead of aborting the render (clip-cut boundary / freshly-shown element).
// The worker isn't involved for this frame; return a resolved encodeResult so
// the pipeline loop writes it like any other. See fast-capture-limitations.md.
if (isNoCachedPaintRecordError(captureError)) {
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
console.log(
`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
`screenshot fallback for this frame (see fast-capture-limitations.md)`,
);
const buffer = await pageScreenshotCapture(page, options);
return { encodeResult: Promise.resolve(buffer), captureTimeMs: Date.now() - startTime };
}
// Mirror captureFrameCore: capture per-frame diagnostics (frame-error
// PNG/HTML/JSON + console tail) before rethrowing so pipelined-path
// failures are debuggable like the serial path.
if (session.isInitialized) {
await captureFrameErrorDiagnostics(
session,
frameIndex,
time,
captureError instanceof Error ? captureError : new Error(String(captureError)),
);
}
throw captureError;
}
}
/**
* Verification-grade single-frame recapture for the producer's blank-frame
* guard. Unlike {@link captureFrameToBufferPipelined} it takes NO shortcuts
* and has NO fallbacks, both of which can return the WRONG FRAME's pixels at
* drain time:
* - the static-dedup fast path returns session.lastEncodeResult, which by
* drain time can hold a frame several indices AHEAD of the suspect frame;
* - the per-frame "No cached paint record" screenshot fallback captures the
* injected canvas — i.e. the LAST drawn drawElement frame, not this one.
* Any failure here throws; the caller treats that as verification failure and
* falls back the whole render (correct, never wrong-frame).
*/
export async function recaptureDrawElementFrameForVerify(
session: CaptureSession,
frameIndex: number,
time: number,
): Promise<Buffer> {
const { page, options } = session;
if (!session.isInitialized) {
throw new Error("[FrameCapture] Session not initialized");
}
await prepareFrameForCapture(session, frameIndex, time);
const { encodeResult } = await produceDrawElementFrame(
page,
options.width,
options.height,
options.quality ?? 80,
true,
);
return encodeResult;
}
/**
* P6 prototype (HF_DE_BATCH): capture N consecutive frames in one CDP
* round-trip via {@link produceDrawElementFrameBatch}. The caller pre-plans the
* batch (consecutive frame indices, none static-dedup'd, none opt-in
* boundary-screenshot). On a mid-batch in-page failure the remaining frames are
* re-captured through {@link captureFrameToBufferPipelined}, which owns the
* per-frame screenshot-fallback semantics — so failure behavior is identical to
* the unbatched path, just discovered at batch granularity.
*/
export async function captureFramesBatchPipelined(
session: CaptureSession,
frameIndices: number[],
times: number[],
): Promise<Array<{ frameIndex: number; encodeResult: Promise<Buffer> }>> {
const { page, options } = session;
if (!session.isInitialized) {
throw new Error("[FrameCapture] Session not initialized");
}
const startTime = Date.now();
const fps = fpsToNumber(options.fps);
const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(
page,
quantized,
options.width,
options.height,
options.quality ?? 80,
);
const okCount = failedAt === null ? frameIndices.length : failedAt;
const elapsed = Date.now() - startTime;
session.capturePerf.frames += okCount;
// Round-trips are fused — attribute the whole batch to produce time; each
// frame gets the batch mean for the per-frame sample series.
session.capturePerf.screenshotMs += elapsed;
session.capturePerf.totalMs += elapsed;
if (okCount > 0) {
const perFrame = elapsed / okCount;
for (let s2 = 0; s2 < okCount; s2++) session.capturePerf.frameMs.push(perFrame);
}
const results: Array<{ frameIndex: number; encodeResult: Promise<Buffer> }> = [];
for (let i = 0; i < okCount; i++) {
const frameIndex = frameIndices[i];
const encodeResult = encodeResults[i];
if (frameIndex === undefined || !encodeResult) break;
results.push({ frameIndex, encodeResult });
}
if (failedAt !== null) {
console.log(
`[engine] fast capture: batch produce failed at frame ` +
`${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
`re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`,
);
for (let i = failedAt; i < frameIndices.length; i++) {
const frameIndex = frameIndices[i];
const time = times[i];
if (frameIndex === undefined || time === undefined) break;
const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, time);
results.push({ frameIndex, encodeResult });
}
}
// Task B: retain the last encode result so a following static frame can reuse it.
const last = results[results.length - 1];
if (session.staticFrames && last) session.lastEncodeResult = last.encodeResult;
return results;
}
/**
* Type of the "inner capture" function consumed by
* {@link discardWarmupCapture}. Matches the real `captureFrameCore` signature
* with the buffer-bearing result trimmed to what the caller actually uses
* (the wrapper never inspects the buffer). Exposed so unit tests can inject
* a stub instead of driving Chrome end-to-end.
*/
export type DiscardWarmupInnerCapture = (
session: CaptureSession,
frameIndex: number,
time: number,
) => Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }>;
/**
* Perform one capture, throw away the buffer, and restore any session
* side-effects (perf counters, BeginFrame damage tallies) so downstream
* captures see state identical to a fresh session.
*
* Distributed chunk workers need this because Chrome's BeginFrame screenshot
* pipeline maintains a per-process `lastFrameCache`: when a captured frame's
* `hasDamage` reports `false`, the screenshot path returns the previously
* captured buffer. For chunk N (N > 0) the worker has no prior frame in its
* cache, so the very first capture's `hasDamage` reporting diverges from
* what an in-process render at the same absolute frame index would see (the
* in-process renderer always has frame N-1 cached). One discard capture
* before the first real capture primes the cache.
*
* The function intentionally restores perf state so the warmup capture does
* NOT bias `getCapturePerfSummary()`'s per-frame averages.
*
* No file is written; the buffer is discarded.
*
* @param session — initialized capture session
* @param frameIndex — frame index to warm up with (default 0). Chunk
* workers typically pass their chunk's first absolute frame index.
* @param time — time in seconds (default 0). Chunk workers typically pass
* the corresponding `frameIndex / fps`.
* @param innerCapture — injectable for tests; defaults to the real
* `captureFrameCore`.
*/
export async function discardWarmupCapture(
session: CaptureSession,
frameIndex: number = 0,
time: number = 0,
innerCapture: DiscardWarmupInnerCapture = captureFrameCore,
): Promise<void> {
// Snapshot the side-effect counters captureFrameCore mutates. We use a
// shallow `{...}` for capturePerf because all five fields are primitive
// numbers — no nested state to deep-copy.
const perfBefore = { ...session.capturePerf };
const hasDamageBefore = session.beginFrameHasDamageCount;
const noDamageBefore = session.beginFrameNoDamageCount;
const dedupCountBefore = session.staticDedupCount;
const lastFrameBufferBefore = session.lastFrameBuffer;
try {
await innerCapture(session, frameIndex, time);
} finally {
// Always restore — even on error. A failed warmup capture should not
// leak inflated perf counters, a phantom dedup reuse, or a warmup-era
// lastFrameBuffer anchor into the real capture summary/state.
session.capturePerf = perfBefore;
session.beginFrameHasDamageCount = hasDamageBefore;
session.beginFrameNoDamageCount = noDamageBefore;
session.staticDedupCount = dedupCountBefore;
session.lastFrameBuffer = lastFrameBufferBefore;
}
}
export async function closeCaptureSession(session: CaptureSession): Promise<void> {
// Realized static-dedup telemetry: how much the cache actually helped this
// render (vs the prediction logged at arm time). Both capture paths
// (sequential orchestrator + parallel workers) close their session here, so
// this is the one uniform emit point. Zero the count afterward so the
// idempotent re-close (HDR cleanup) doesn't double-log.
const reused = session.staticDedupCount ?? 0;
if (session.staticFrames && reused > 0) {
const captured = session.capturePerf.frames; // excludes reuses by design
const total = captured + reused;
const pct = total > 0 ? Math.round((reused / total) * 100) : 0;
const avgTotalMs = captured > 0 ? Math.round(session.capturePerf.totalMs / captured) : 0;
console.log(
`[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
`est. ~${reused * avgTotalMs}ms saved (avg ${avgTotalMs}ms/frame)`,
);
session.staticDedupCount = 0;
}
// INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR
// cleanup path tracks a `domSessionClosed` flag and may still re-call this
// in the outer finally if the inner cleanup raised before the flag flipped.
//
// Naive idempotency would be unsafe under pool semantics: releaseBrowser
// decrements pooledBrowserRefCount, so calling it twice for the same
// acquire could close a browser that another session still holds. We make
// it safe by gating each release behind a per-session "released" flag —
// the second call sees the flag already set and skips the release.
//
// We set the flag AFTER (not before) the await so that if a release throws
// midway, the unreleased resource is retried by the outer defensive call.
// Example: page release succeeds, browser release throws → pageReleased=true
// but browserReleased=false → second call no-ops on page and retries browser.
// This matches the orchestrator's intent for HDR cleanup.
if (session.workerEncodeEnabled && session.page && !session.pageReleased) {
cleanupDrawElementWorkerEncode(session.page);
}
if (!session.pageReleased && session.page) {
const pageClosed = await waitForCloseWithTimeout(session.page.close());
if (!pageClosed) {
console.warn("[FrameCapture] Timed out closing page; forcing browser process shutdown");
forceReleaseBrowser(session.browser);
session.browserReleased = true;
}
session.pageReleased = true;
}
if (!session.browserReleased && session.browser) {
const browserClosed = await waitForCloseWithTimeout(
releaseBrowser(session.browser, session.config),
);
if (!browserClosed) {
console.warn("[FrameCapture] Timed out closing browser; forcing browser process shutdown");
forceReleaseBrowser(session.browser);
}
session.browserReleased = true;
}
session.isInitialized = false;
}
export function prepareCaptureSessionForReuse(
session: CaptureSession,
outputDir: string,
onBeforeCapture: BeforeCaptureHook | null,
): void {
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
session.outputDir = outputDir;
session.onBeforeCapture = onBeforeCapture;
session.capturePerf = {
frames: 0,
seekMs: 0,
beforeCaptureMs: 0,
screenshotMs: 0,
totalMs: 0,
frameMs: [],
};
session.beginFrameHasDamageCount = 0;
session.beginFrameNoDamageCount = 0;
// Reset per-render dedup state so a buffer captured by the prior render/probe can't
// bleed into this render's first static frame. staticFrames (the armed set) is left
// intact: it's keyed in absolute frames and stays valid for a same-composition reuse;
// lastFrameBuffer must be re-seeded by this render's first fresh capture.
session.lastFrameBuffer = undefined;
session.staticDedupCount = 0;
}
export async function getCompositionDuration(session: CaptureSession): Promise<number> {
if (!session.isInitialized) throw new Error("[FrameCapture] Session not initialized");
return session.page.evaluate(() => {
return window.__hf?.duration ?? 0;
});
}
/**
* Ungated-release safety net, part 1: capture K screenshot ground-truth frames
* BEFORE the drawElement canvas is injected (the only window where a page
* screenshot shows the live DOM). The producer's drain compares each DE frame
* at these indices against its screenshot; a breach (or a blank frame that
* survives one retry) throws DrawElementVerificationError, and the orchestrator
* re-renders the whole job via the screenshot path.
*
* Env: HF_DE_VERIFY = sample count (default 4, clamp 0..8; 0 disables).
* Deterministic index selection: fixed fractions of the timeline, nudged off
* clip-cut boundaries (screenshot-vs-DE is legitimately ±1-frame desynced
* there — Lim 6). Skipped for tiny comps (<10 frames) and under
* HF_FORCE_DRAWELEMENT (debug escape hatch).
*
* False-positive bias is intentional: a nondeterministic comp may mismatch its
* init-time screenshot → the render falls back to the screenshot path (slower,
* never wrong). Cost when passing: ~K×(seek+screenshot) ≈ 150300ms at init.
*/
async function captureDeVerificationFrames(
session: CaptureSession,
page: Page,
logInitPhase: (phase: string) => void,
): Promise<void> {
const kRaw = Number(process.env.HF_DE_VERIFY ?? "4");
const k = Number.isFinite(kRaw) ? Math.max(0, Math.min(8, Math.floor(kRaw))) : 4;
if (k === 0 || process.env.HF_FORCE_DRAWELEMENT === "1") return;
if (session.options.format === "png") return; // worker-encode drain (the consumer) is jpeg-only
const fps = fpsToNumber(session.options.fps);
// Prefer the producer-resolved duration (the range that will actually be
// drained). The page's raw __hf.duration can exceed it — timelines outrun
// their data-duration, and infinite-repeat GSAP reports a huge sentinel —
// and indices derived from it would never be drained, silently disarming
// verification for exactly the comps that need it.
const duration =
session.options.compositionDurationSeconds ??
(await page.evaluate(
() => (window as unknown as { __hf?: { duration?: number } }).__hf?.duration ?? 0,
));
const totalFrames = Math.floor(duration * fps);
if (totalFrames < 10) return;
if (duration > 3600) {
// No producer duration and the page reports an implausible one.
logInitPhase(`drawElement self-verify skipped: implausible duration ${duration}s`);
return;
}
// Ground truth must show what the real capture paths would show: <video>
// pixels come from the onBeforeCapture injector. When this session has no
// injector (e.g. a probe session initialized before the render wires one
// up) a video comp's truth would screenshot black boxes and every sample
// would false-positive into the screenshot fallback — skip instead.
if (!session.onBeforeCapture) {
const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
if (hasVideos) {
logInitPhase("drawElement self-verify skipped: video comp without frame injector");
return;
}
}
const boundary = await computeClipBoundaryFrames(page, fps);
// Ascending order, and seek frame 0 first: GSAP .from()/overlapping tweens
// lazily record their start values on FIRST seek — scrubbing mid-timeline
// before the render's frame-0 seek corrupts those caches for the whole
// render (the detectCssEffectRisk lesson), and because DE frames and truth
// would share the corruption, PSNR would pass on the damaged output.
// Seeking 0 → ascending reproduces the render's own seek order.
const fractions = Array.from({ length: k }, (_, i) => (i + 1) / (k + 1));
const seekTo = async (t: number): Promise<void> => {
await page.evaluate((tt: number) => {
const hf = (window as unknown as { __hf?: { seek?: (x: number) => void } }).__hf;
if (hf && typeof hf.seek === "function") hf.seek(tt);
}, t);
};
await seekTo(quantizeTimeToFrame(0, fps));
// Force one frame so lazy tween initialization paints at t=0 state.
await pageScreenshotCapture(page, session.options);
const frames = new Map<number, Buffer>();
for (const f of fractions) {
let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
// Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
// if the nudge saturates on a boundary index, skip the sample entirely.
let guard = 0;
while (boundary.has(idx) && guard++ < 6) idx = Math.min(totalFrames - 1, idx + 2);
if (boundary.has(idx)) continue;
if (frames.has(idx)) continue;
const t = quantizeTimeToFrame(idx / fps, fps);
await seekTo(t);
// Video frame injection (same hook the real capture paths run) — without
// it, <video> elements screenshot black and every video comp would
// false-positive into the screenshot fallback.
if (session.onBeforeCapture) await session.onBeforeCapture(page, t);
// Double-capture: the first screenshot forces a frame, which is what runs
// rAF-driven callbacks (count-up text counters land a tick after seek()
// returns — a single immediate screenshot captures stale text and
// false-positives the verify: 3bea8c73 28.7dB vs a truth missing its stat
// values while the DE frame was correct). NOTE: waiting on rAF via
// evaluate instead deadlocks — headless only fires rAF when a frame is
// produced, and nothing produces one until a screenshot asks.
await pageScreenshotCapture(page, session.options);
frames.set(idx, await pageScreenshotCapture(page, session.options));
}
// Leave the page at frame 0 so the render's first seek starts from the
// same state as an unverified render.
await seekTo(quantizeTimeToFrame(0, fps));
session.deVerifyFrames = frames;
logInitPhase(
`drawElement self-verify armed: ${frames.size} ground-truth frame(s) @ [${[...frames.keys()].join(", ")}] of ${totalFrames}`,
);
}
function medianOf(samples: number[]): number {
if (samples.length === 0) return 0;
const sorted = [...samples].sort((a, b) => a - b);
return Math.round(sorted[Math.floor(sorted.length / 2)] ?? 0);
}
export function getCapturePerfSummary(session: CaptureSession): CapturePerfSummary {
const frames = Math.max(1, session.capturePerf.frames);
return {
frames: session.capturePerf.frames,
avgTotalMs: Math.round(session.capturePerf.totalMs / frames),
avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
p50TotalMs: medianOf(session.capturePerf.frameMs),
staticDedupReused: session.staticDedupCount ?? 0,
staticDedupEnabled: session.staticDedupEnabled ?? false,
// armed ⟺ a non-empty static set survived verification; predicted === its size.
staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
staticDedupPredicted: session.staticFrames?.size ?? 0,
staticDedupSkipReason: session.staticDedupSkipReason,
captureMode: session.captureMode,
deGateReason: session.deGateReason,
deWorkerEncode: session.workerEncodeEnabled ?? false,
deVerifyArmed: session.deVerifyFrames?.size ?? 0,
deVerifyInitMs: session.deVerifyInitMs ?? 0,
deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
deNcprFallbacks: session.deNcprFallbacks ?? 0,
};
}
// ── Transient browser error classification ─────────────────────────────────
// Puppeteer/Chrome can fail with transient errors that succeed on retry with a
// fresh browser session. These are infrastructure-level failures (frame
// detachment, connection drop, OOM kill, launch failure) — NOT composition bugs.
const TRANSIENT_BROWSER_ERROR_PATTERNS = [
/Navigating frame was detached/i,
/Target closed/i,
/Session closed/i,
/browser has disconnected/i,
/Page crashed/i,
/Execution context was destroyed/i,
/Cannot find context with specified id/i,
/Failed to launch the browser process/i,
/Navigation timeout of \d+ ms exceeded/i,
/ECONNREFUSED/i,
// pollHfReady's own timeout — thrown when window.__renderReady never flips
// true within playerReadyTimeout. "Runtime ready: false" means init simply
// didn't finish in time (commonly a slow/contended host, e.g. several
// concurrent renders), which a fresh session usually clears on retry. This
// is distinct from the "Runtime ready: true" fast-fail case a few lines up
// in pollHfReady (no timeline + no data-duration) — that's a genuine
// authoring bug and intentionally NOT matched here, so it still fails fast.
/Composition has zero duration[\s\S]*Runtime ready: false/,
];
export function isTransientBrowserError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return TRANSIENT_BROWSER_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}
// ── Memory-exhaustion classification ────────────────────────────────────────
// A render can run the Node process (or a page-side allocation) out of memory
// on an oversized composition — huge canvas, thousands of frames, or a very
// large frame cache. These surface as cryptic V8 RangeErrors ("Set maximum
// size exceeded", "Invalid array length"/"string length", "Array buffer
// allocation failed") or a hard V8 heap-limit abort. They are NOT transient
// (a retry re-hits the same ceiling) and NOT composition-logic bugs — they're
// resource limits. Classify them so the caller can surface actionable guidance
// (lower resolution / fps / duration, or enable low-memory mode) instead of a
// raw RangeError.
// Deliberately specific: each pattern is a distinct V8/Node allocation-failure
// signature. We intentionally do NOT match a bare /out of memory/ — that
// substring appears in benign browser-console noise (WebGL `CONTEXT_LOST … out
// of memory`, GPU driver notes) that gets carried into the error path, and
// misclassifying it would replace the real failure message with generic OOM
// guidance.
const MEMORY_EXHAUSTION_ERROR_PATTERNS = [
/Set maximum size exceeded/i,
/Map maximum size exceeded/i,
/Invalid (?:array|string) length/i,
/Array buffer allocation failed/i,
/Cannot create a string longer than/i,
/Reached heap limit/i,
/JavaScript heap out of memory/i,
];
export function isMemoryExhaustionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return MEMORY_EXHAUSTION_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}