mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension (#1919)
* feat(engine): drawElementImage capture service * chore(ci): ignore drawElementService exports pending upstack consumers Fallow's per-PR audit diffs against the merge base, so the bottom of the fast-capture stack (#1917) sees drawElementService's exports as unused — their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports entry documents this and can be dropped once #1919 merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(engine): 3D projection + compositor-effect risk gate * fix(engine): gate filter drop-shadow wherever blur gates (review) detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only detected blur( in its three scan paths — a drop-shadow comp stayed on the fast path despite the gate's own correctness contract. Detect drop-shadow( in computed styles, stylesheet rules, and GSAP tween vars, pinned by a focused test that runs the real page-side closure against a DOM shim (computed / stylesheet / tween coverage + blur regression + effect-free null). Addresses miguel-heygen's blocker on #1918. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension # Conflicts: # packages/engine/src/services/screenshotService.ts * fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review) Addresses miguel-heygen's blockers on #1919: - HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to a documented diagnostic flag: it exists for upstream-Chromium repro work (gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect classes; renders under it may be damaged BY DESIGN since it bypasses gates whose thresholds encode measured damage. Never production; the safety-net blank guard also stands down under it so diagnostic frames arrive unmodified. - armStaticDedup is now idempotent: the drawElement init path arms dedup before canvas injection, then initializeSession called it again — the second run overwrote the armed state with skipReason="capture_mode" (captureMode is "drawelement" by then), producing contradictory telemetry (armed frames + a skip reason), and re-ran the verification seeks on the fallback path. It now no-ops once staticFrames or a skip decision exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4749fe5716
commit
0e58344dca
@@ -82,6 +82,8 @@ export {
|
||||
closeCaptureSession,
|
||||
captureFrame,
|
||||
captureFrameToBuffer,
|
||||
captureFrameToBufferPipelined,
|
||||
writeCapturedFrame,
|
||||
discardWarmupCapture,
|
||||
getCompositionDuration,
|
||||
getCapturePerfSummary,
|
||||
@@ -101,6 +103,7 @@ export {
|
||||
injectVideoFramesBatch,
|
||||
syncVideoFrameVisibility,
|
||||
cdpSessionCache,
|
||||
probeBeginFrameLiveness,
|
||||
initTransparentBackground,
|
||||
captureAlphaPng,
|
||||
applyDomLayerMask,
|
||||
|
||||
@@ -98,7 +98,8 @@ async function probeHardwareWebGlInfo(
|
||||
|
||||
// "beginframe" = atomic compositor control via HeadlessExperimental.beginFrame (Linux only)
|
||||
// "screenshot" = renderSeek + Page.captureScreenshot (all platforms)
|
||||
export type CaptureMode = "beginframe" | "screenshot";
|
||||
// "drawelement" = BeginFrame compositor advance + canvas.drawElementImage capture
|
||||
export type CaptureMode = "beginframe" | "screenshot" | "drawelement";
|
||||
|
||||
export interface AcquiredBrowser {
|
||||
browser: Browser;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// fallow-ignore-file complexity
|
||||
// fallow-ignore-file complexity code-duplication
|
||||
/**
|
||||
* Frame Capture Service
|
||||
*
|
||||
@@ -30,6 +30,17 @@ import {
|
||||
initTransparentBackground,
|
||||
shouldDefaultCaptureBeyondViewport,
|
||||
} from "./screenshotService.js";
|
||||
import {
|
||||
detectSwiftShader,
|
||||
injectDrawElementCanvas,
|
||||
captureDrawElementFrame,
|
||||
resolveDrawElementCaptureMode,
|
||||
instrumentAcceleratedCanvases,
|
||||
initDrawElementWorkerEncode,
|
||||
cleanupDrawElementWorkerEncode,
|
||||
produceDrawElementFrame,
|
||||
} from "./drawElementService.js";
|
||||
import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
@@ -94,6 +105,13 @@ export interface CaptureSession {
|
||||
totalMs: 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;
|
||||
@@ -101,6 +119,32 @@ export interface CaptureSession {
|
||||
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>;
|
||||
}
|
||||
|
||||
// Circular buffer for browser console messages dumped on render failure diagnostics.
|
||||
@@ -342,6 +386,234 @@ async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boole
|
||||
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) {
|
||||
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) {
|
||||
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") {
|
||||
// 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 18–49 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) {
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
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");
|
||||
}
|
||||
}
|
||||
} else if (session.options.format === "png") {
|
||||
await initTransparentBackground(session.page);
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unit-size
|
||||
export async function createCaptureSession(
|
||||
serverUrl: string,
|
||||
@@ -357,9 +629,33 @@ export async function createCaptureSession(
|
||||
// `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
|
||||
@@ -367,7 +663,9 @@ export async function createCaptureSession(
|
||||
// the screenshot path for any DPR > 1.
|
||||
const supersampling = (options.deviceScaleFactor ?? 1) > 1;
|
||||
const preMode: CaptureMode =
|
||||
headlessShell && isLinux && !forceScreenshot && !supersampling ? "beginframe" : "screenshot";
|
||||
headlessShell && isLinux && !forceScreenshot && !supersampling && !drawElementTransparent
|
||||
? "beginframe"
|
||||
: "screenshot";
|
||||
const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
|
||||
const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
|
||||
chromePath: headlessShell ?? undefined,
|
||||
@@ -413,6 +711,44 @@ export async function createCaptureSession(
|
||||
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
|
||||
@@ -474,6 +810,7 @@ export async function createCaptureSession(
|
||||
totalMs: 0,
|
||||
},
|
||||
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
|
||||
@@ -691,11 +1028,16 @@ async function pollSubCompositionTimelines(
|
||||
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;
|
||||
@@ -723,6 +1065,7 @@ async function pollSubCompositionTimelines(
|
||||
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);
|
||||
}
|
||||
@@ -730,7 +1073,8 @@ async function pollSubCompositionTimelines(
|
||||
})()`);
|
||||
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 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1073,16 +1417,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
|
||||
await recordSessionInitTelemetry(session, initStart);
|
||||
|
||||
// For PNG captures, force the page background fully transparent so the
|
||||
// captured screenshots carry a real alpha channel. Must run AFTER
|
||||
// navigation (Chrome resets the override on every goto) and AFTER the
|
||||
// page is loaded (the injected stylesheet needs a real document.head).
|
||||
// The override is overridden by `body { background: ... }` and
|
||||
// `#root { background: ... }` rules — the helper handles that with a
|
||||
// `[data-composition-id]{background:transparent !important}` injection.
|
||||
if (session.options.format === "png") {
|
||||
await initTransparentBackground(session.page);
|
||||
}
|
||||
// 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;
|
||||
@@ -1228,15 +1564,15 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
|
||||
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
|
||||
|
||||
// For PNG captures, inject the transparent-background override + stylesheet
|
||||
// (see the screenshot-mode branch above for the rationale). BeginFrame mode
|
||||
// does not actually preserve alpha through its compositor — callers that
|
||||
// need transparent output should set `forceScreenshot: true` so this branch
|
||||
// is bypassed entirely. The call is left here as defense-in-depth for any
|
||||
// future BeginFrame alpha support.
|
||||
if (session.options.format === "png") {
|
||||
await initTransparentBackground(session.page);
|
||||
}
|
||||
// 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;
|
||||
@@ -1325,7 +1661,11 @@ async function prepareFrameForCapture(
|
||||
// 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") {
|
||||
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") {
|
||||
@@ -1651,6 +1991,13 @@ async function armStaticDedup(
|
||||
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
|
||||
@@ -1727,10 +2074,112 @@ async function armStaticDedup(
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal core: prepare, screenshot, and track perf.
|
||||
* Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
|
||||
* Returns the screenshot buffer, quantized time, and total capture time.
|
||||
* 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.
|
||||
*/
|
||||
export 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,
|
||||
@@ -1785,6 +2234,89 @@ async function captureFrameCore(
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
@@ -1820,21 +2352,34 @@ export async function captureFrame(
|
||||
frameIndex: number,
|
||||
time: number,
|
||||
): Promise<CaptureResult> {
|
||||
const { options, outputDir } = session;
|
||||
const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(
|
||||
session,
|
||||
frameIndex,
|
||||
time,
|
||||
);
|
||||
|
||||
const ext = options.format === "png" ? "png" : "jpg";
|
||||
const frameName = `frame_${String(frameIndex).padStart(6, "0")}.${ext}`;
|
||||
const framePath = join(outputDir, frameName);
|
||||
writeFileSync(framePath, buffer);
|
||||
|
||||
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.
|
||||
@@ -1849,6 +2394,123 @@ export async function captureFrameToBuffer(
|
||||
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;
|
||||
session.capturePerf.totalMs += Date.now() - startTime;
|
||||
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;
|
||||
|
||||
// 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)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of the "inner capture" function consumed by
|
||||
* {@link discardWarmupCapture}. Matches the real `captureFrameCore` signature
|
||||
@@ -1950,6 +2612,9 @@ export async function closeCaptureSession(session: CaptureSession): Promise<void
|
||||
// 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) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication complexity
|
||||
/**
|
||||
* Screenshot Service
|
||||
*
|
||||
@@ -43,6 +44,55 @@ export interface BeginFrameResult {
|
||||
hasDamage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a single no-output BeginFrame and race it against `timeoutMs`.
|
||||
*
|
||||
* On SwiftShader, compositions with many promoted layers (multi-group nested
|
||||
* opacity caption animations) can stall the FIRST BeginFrame indefinitely —
|
||||
* tested to 30 minutes without completion (style-7/8/10/15-prod). The
|
||||
* auto-worker calibration path catches this with its own capped protocol
|
||||
* timeout, but renders with an explicit `--workers N` skip calibration and
|
||||
* would hang for the full protocol timeout (and never succeed). This probe
|
||||
* gives the producer a cheap liveness signal right after session init:
|
||||
* `false` means route the render through screenshot capture instead.
|
||||
*
|
||||
* Healthy comps complete the probe in well under a second on GPU and within
|
||||
* a few seconds on SwiftShader. A protocol error also resolves `false` —
|
||||
* the safe direction (screenshot capture always works).
|
||||
*/
|
||||
export async function probeBeginFrameLiveness(
|
||||
page: Page,
|
||||
timeoutMs: number,
|
||||
// BeginFrame frameTimeTicks must be monotonic per session. The capture loop
|
||||
// sends `session.beginFrameTimeTicks + frameIndex * interval`, where the
|
||||
// base carries a 10-interval cushion above the warmup loop's last tick —
|
||||
// callers probing an initialized session should pass a tick INSIDE that
|
||||
// cushion (e.g. base − 5·interval) so warmup < probe < first capture stays
|
||||
// monotonic. Omit both params only for a session that will not issue
|
||||
// further BeginFrames.
|
||||
frameTimeTicks?: number,
|
||||
intervalMs?: number,
|
||||
): Promise<boolean> {
|
||||
const client = await getCdpSession(page);
|
||||
const params: { frameTimeTicks?: number; interval?: number } = {};
|
||||
if (typeof frameTimeTicks === "number") params.frameTimeTicks = frameTimeTicks;
|
||||
if (typeof intervalMs === "number") params.interval = intervalMs;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
client
|
||||
.send("HeadlessExperimental.beginFrame", params)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a frame using HeadlessExperimental.beginFrame.
|
||||
*
|
||||
@@ -545,6 +595,7 @@ export async function injectVideoFramesBatch(
|
||||
// shorter than the host's authored data-duration, where the runtime
|
||||
// truncates visibility but the replacement <img> must hold its last
|
||||
// frame) — those must NOT be skipped here.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
|
||||
let parent = el.parentElement;
|
||||
while (parent !== null && parent !== document.documentElement) {
|
||||
|
||||
Reference in New Issue
Block a user