// fallow-ignore-file code-duplication complexity /** * Screenshot Service * * BeginFrame-based deterministic screenshot capture and video frame injection. */ // fallow-ignore-file code-duplication import { type Page } from "puppeteer-core"; import { type CaptureOptions } from "../types.js"; import { HF_COLOR_GRADING_CANVAS_ID_PREFIX, MEDIA_VISUAL_STYLE_PROPERTIES, } from "@hyperframes/core"; export const cdpSessionCache = new WeakMap(); const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; export async function getCdpSession(page: Page): Promise { let client = cdpSessionCache.get(page); if (!client) { client = await page.createCDPSession(); cdpSessionCache.set(page, client); } return client; } export function shouldDefaultCaptureBeyondViewport( browserVersion: string, platform: NodeJS.Platform = process.platform, ): boolean { // Regular Chrome's viewport-bound screenshot path can expose a compositor // surface shorter than the page viewport on affected macOS builds. In that // case Chrome fills the clipped area with the page background. Headless shell // reports as HeadlessChrome and keeps the faster viewport-bound path. return platform === "darwin" && browserVersion.startsWith("Chrome/"); } /** * BeginFrame result with screenshot data and damage detection. */ export interface BeginFrameResult { buffer: Buffer; 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 { 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 | undefined; try { return await Promise.race([ client .send("HeadlessExperimental.beginFrame", params) .then(() => true) .catch(() => false), new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); } } /** * Capture a frame using HeadlessExperimental.beginFrame. * * This is an atomic operation: one CDP call runs a single layout-paint-composite * cycle and returns the screenshot + hasDamage boolean. Replaces the separate * settle → screenshot pipeline with a single deterministic render cycle. * * Requires chrome-headless-shell with --enable-begin-frame-control and * --deterministic-mode flags. */ // Cache the last valid screenshot buffer per page for hasDamage=false frames. // When Chrome reports no visual change, we reuse the previous frame rather than // attempting Page.captureScreenshot (which times out in beginFrame mode since // the compositor is paused). const lastFrameCache = new WeakMap(); const PENDING_FRAME_RETRIES = 5; async function sendBeginFrame( client: import("puppeteer-core").CDPSession, params: Parameters>[1], ) { for (let attempt = 0; ; attempt++) { try { return await client.send("HeadlessExperimental.beginFrame", params); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); const isPending = msg.includes("Another frame is pending"); if (isPending && attempt < PENDING_FRAME_RETRIES) { await new Promise((r) => setTimeout(r, 50 * 2 ** attempt)); continue; } if (isPending) { throw new Error( `[BeginFrame] Frame still pending after ${PENDING_FRAME_RETRIES} retries — CPU overloaded by parallel renders. ` + `Reduce concurrent renders or use --docker for isolation.`, ); } throw err; } } } export async function beginFrameCapture( page: Page, options: CaptureOptions, frameTimeTicks: number, interval: number, ): Promise { const client = await getCdpSession(page); const isPng = options.format === "png"; const screenshot = { format: isPng ? "png" : "jpeg", quality: isPng ? undefined : (options.quality ?? 80), optimizeForSpeed: true, } as const; const result = await sendBeginFrame(client, { frameTimeTicks, interval, screenshot }); let buffer: Buffer; if (result.screenshotData) { buffer = Buffer.from(result.screenshotData, "base64"); lastFrameCache.set(page, buffer); } else { const cached = lastFrameCache.get(page); if (cached) { buffer = cached; } else { // Frame 0 always has damage, so this path is near-unreachable. // Force a composite with a tiny time advance. const fallback = await sendBeginFrame(client, { frameTimeTicks: frameTimeTicks + 0.001, interval, screenshot, }); buffer = fallback.screenshotData ? Buffer.from(fallback.screenshotData, "base64") : Buffer.alloc(0); if (buffer.length > 0) lastFrameCache.set(page, buffer); } } return { buffer, hasDamage: result.hasDamage, }; } /** * True if the page's actual rendered content is taller than the requested * capture height. `captureBeyondViewport` exists for exactly one reason * (#1094): a native `