/** * 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, } from "./screenshotService.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; export interface CaptureSession { browser: Browser; page: Page; options: CaptureOptions; serverUrl: string; outputDir: string; onBeforeCapture: BeforeCaptureHook | null; isInitialized: boolean; // 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[]; capturePerf: { frames: number; seekMs: number; beforeCaptureMs: number; screenshotMs: number; totalMs: number; }; captureMode: CaptureMode; // BeginFrame state beginFrameTimeTicks: number; beginFrameIntervalMs: number; beginFrameHasDamageCount: number; beginFrameNoDamageCount: number; /** Optional producer config — when set, overrides module-level env var constants. */ config?: Partial; } // 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; /** * 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; /** Injectable so tests can advance "time" without real setTimeout. */ sleep?: (ms: number) => Promise; } const realSleep = (ms: number): Promise => 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 { 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); } } async function waitForCloseWithTimeout(promise: Promise): Promise { let timedOut = false; let timer: ReturnType | undefined; await Promise.race([ promise.then( () => undefined, () => undefined, ), new Promise((resolve) => { timer = setTimeout(() => { timedOut = true; resolve(); }, CAPTURE_SESSION_CLOSE_TIMEOUT_MS); }), ]); if (timer) clearTimeout(timer); return !timedOut; } export async function createCaptureSession( serverUrl: string, outputDir: string, options: CaptureOptions, onBeforeCapture: BeforeCaptureHook | null = null, config?: Partial, ): Promise { 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). const headlessShell = resolveHeadlessShellPath(config); const isLinux = process.platform === "linux"; const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot; // 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 ? "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?: (fn: T, _name: string) => T }; if (typeof w.__name !== "function") { w.__name = (fn: T, _name: string): T => fn; } }); // 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 }; 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 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: options.width, height: options.height, deviceScaleFactor: options.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, serverUrl, outputDir, onBeforeCapture, isInitialized: false, browserConsoleBuffer: [], capturePerf: { frames: 0, seekMs: 0, beforeCaptureMs: 0, screenshotMs: 0, totalMs: 0, }, 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}`, ); } async function pollPageExpression( page: Page, expression: string, timeoutMs: number, intervalMs: number = 100, ): Promise { 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)); } async function applyVideoMetadataHints( page: Page, hints: readonly CaptureVideoMetadataHint[] | undefined, ): Promise { if (!hints || hints.length === 0) return; 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 { 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, new Promise((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.`, ); } } export async function initializeSession(session: CaptureSession): Promise { const { page, serverUrl } = session; // Forward browser console to host with [Browser] prefix page.on("console", (msg: ConsoleMessage) => { const type = msg.type(); const text = msg.text(); const locationUrl = msg.location()?.url ?? ""; const isFontLoadError = isFontResourceError(type, text, locationUrl); // 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") && !isFontLoadError; const prefix = isResourceLoadError ? "[non-blocking]" : type === "error" ? "[Browser:ERROR]" : type === "warn" ? "[Browser:WARN]" : "[Browser]"; if (!isFontLoadError) { console.log(`${prefix} ${text}`); } session.browserConsoleBuffer.push(`${prefix} ${text}`); if (session.browserConsoleBuffer.length > BROWSER_CONSOLE_BUFFER_SIZE) { session.browserConsoleBuffer.shift(); } }); 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); } session.browserConsoleBuffer.push(text); if (session.browserConsoleBuffer.length > BROWSER_CONSOLE_BUFFER_SIZE) { session.browserConsoleBuffer.shift(); } }); // Navigate to the file server const url = `${serverUrl}/index.html`; if (session.captureMode === "screenshot") { // Screenshot mode: standard navigation, rAF works normally await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 }); const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout; const pageReady = await pollPageExpression( page, `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`, pageReadyTimeout, ); if (!pageReady) { throw new Error( `[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`, ); } await applyVideoMetadataHints(page, session.options.videoMetadataHints); // Wait for all video elements to have decoded their CURRENT frame, not // just metadata. readyState >= 2 (HAVE_CURRENT_DATA) means a frame is // actually rasterized and ready to paint — at >= 1 (HAVE_METADATA) we // only know the dimensions, and the first