diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index e842514234..480ef20940 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -14,6 +14,7 @@ "packages/producer/src/runtime-conformance.ts", "packages/producer/src/benchmark.ts", "packages/producer/scripts/generate-font-data.ts", + "packages/producer/we-render.mjs", "packages/producer/scripts/validate-fast-video.ts", "packages/cli/scripts/generate-font-data.ts", "packages/engine/scripts/test-fitTextFontSize-browser.ts", diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 979368d379..d5dcd45f8c 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -361,11 +361,12 @@ export default defineCommand({ "experimental-fast-capture": { type: "boolean", description: - "EXPERIMENTAL. Capture frames via Chrome's drawElementImage API " + - "instead of Page.captureScreenshot — reads DOM paint records directly, " + - "~46% faster on GPU. Transparent (PNG) renders on SwiftShader (Docker) " + - "auto-fall back to screenshot capture. Incompatible with page-side " + - "shader compositing. Default: false. Env: PRODUCER_EXPERIMENTAL_FAST_CAPTURE.", + "Capture frames via Chrome's drawElementImage API instead of " + + "Page.captureScreenshot — reads DOM paint records directly, ~2x faster. " + + "Default: on where it can engage (macOS + hardware-GPU browser); " + + "incompatible compositions and self-verification failures fall back to " + + "screenshot capture automatically. Pass =false to disable. " + + "Env: PRODUCER_EXPERIMENTAL_FAST_CAPTURE.", // No `default` — an omitted flag must stay `undefined` so the `!= null` // guard below leaves PRODUCER_EXPERIMENTAL_FAST_CAPTURE untouched and the // env fallback survives (matches the --low-memory-mode idiom). diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 738876c251..e61504ffc1 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -222,17 +222,53 @@ describe("resolveConfig", () => { }); describe("useDrawElement (PRODUCER_EXPERIMENTAL_FAST_CAPTURE)", () => { - it("defaults to false", () => { + it("default is clamped off on software-GPU hosts (page-side compositing preserved)", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); + unsetEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE"); + unsetEnv("HF_DE_WORKER_ENCODE"); + const config = resolveConfig(); + expect(config.useDrawElement).toBe(false); + expect(config.enablePageSideCompositing).toBe(true); + }); + + it("default engages on macOS with a hardware-GPU browser", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware"); + unsetEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE"); + unsetEnv("HF_DE_WORKER_ENCODE"); + const config = resolveConfig(); + expect(config.useDrawElement).toBe(process.platform === "darwin"); + }); + + it("default engages on macOS with auto GPU mode (the stock CLI path)", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "auto"); + unsetEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE"); + unsetEnv("HF_DE_WORKER_ENCODE"); + const config = resolveConfig(); + expect(config.useDrawElement).toBe(process.platform === "darwin"); + }); + + it("default requires worker-encode (the verified drain)", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware"); + setEnv("HF_DE_WORKER_ENCODE", "false"); + unsetEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE"); const config = resolveConfig(); expect(config.useDrawElement).toBe(false); }); - it("enabled when PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true", () => { + it("explicit env opt-in skips the platform clamp", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "software"); setEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", "true"); const config = resolveConfig(); expect(config.useDrawElement).toBe(true); }); + it("env kill switch wins over the default", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware"); + setEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", "false"); + const config = resolveConfig(); + expect(config.useDrawElement).toBe(false); + }); + it("explicit override wins over the env var", () => { setEnv("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", "true"); const config = resolveConfig({ useDrawElement: false }); @@ -243,6 +279,15 @@ describe("resolveConfig", () => { const config = resolveConfig({ useDrawElement: true, enablePageSideCompositing: true }); expect(config.useDrawElement).toBe(true); expect(config.enablePageSideCompositing).toBe(false); + // The auto-disable is recorded so compile-time gates can restore it. + expect(config.pageSideCompositingAutoDisabled).toBe(true); + }); + + it("does NOT mark auto-disabled when the caller explicitly opted out of page-side compositing", () => { + const config = resolveConfig({ useDrawElement: true, enablePageSideCompositing: false }); + expect(config.enablePageSideCompositing).toBe(false); + // Explicit caller intent — a compile-time drawElement gate must not restore it. + expect(config.pageSideCompositingAutoDisabled).not.toBe(true); }); it("leaves page-side compositing on when fast capture is off", () => { diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index c8f80e5b6f..c675818f55 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -64,20 +64,30 @@ export interface EngineConfig { */ staticFrameDedup: boolean; /** - * EXPERIMENTAL. Use drawElementImage for frame capture (requires the - * CanvasDrawElement Chrome flag, added globally in buildChromeArgs). - * Surfaced via the CLI `--experimental-fast-capture` flag. - * Env fallback: `PRODUCER_EXPERIMENTAL_FAST_CAPTURE`. + * Use drawElementImage for frame capture (requires the CanvasDrawElement + * Chrome flag, added globally in buildChromeArgs). Default ON, clamped in + * `resolveConfig` to hosts where it can actually engage (macOS + hardware-GPU + * browser); compile/init gates and the runtime self-verification net route + * incompatible or damaged renders back to screenshot capture. + * Kill switch: `PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false` (or the CLI + * `--experimental-fast-capture=false`). */ useDrawElement: boolean; /** - * EXPERIMENTAL. Pipeline JPEG encode into an in-page OffscreenCanvas Worker - * for the drawElement fast-capture path (macOS hardware GPU only). The worker - * encodes frame N while the main thread seeks+paints frame N+1, targeting - * ~1.65–1.96× wall-time speedup. No-op unless `useDrawElement` is also true. - * Default: off. Env: `HF_DE_WORKER_ENCODE=true`. + * Pipeline JPEG encode into an in-page OffscreenCanvas Worker for the + * drawElement fast-capture path (macOS hardware GPU only). The worker + * encodes frame N while the main thread seeks+paints frame N+1 + * (~1.65–1.96× wall-time speedup). No-op unless `useDrawElement` is also + * true. Kill switch: `HF_DE_WORKER_ENCODE=false`. */ enableDrawElementWorkerEncode: boolean; + /** + * INTERNAL. Set by resolveConfig when it disabled enablePageSideCompositing + * solely because drawElement was on. Lets the producer's compile-time gates + * restore page-side compositing without overriding an explicit caller/env + * opt-out. Not intended to be set by callers. + */ + pageSideCompositingAutoDisabled?: boolean; /** * Low-memory render profile. When `true`, the orchestrator collapses the * pipeline to its cheapest shape on memory-constrained hosts: it skips the @@ -250,8 +260,8 @@ export const DEFAULT_CONFIG: EngineConfig = { protocolTimeout: 300_000, forceScreenshot: false, staticFrameDedup: true, - useDrawElement: false, - enableDrawElementWorkerEncode: false, + useDrawElement: true, + enableDrawElementWorkerEncode: true, // Auto-detected per host in `resolveConfig`; defaults off for the raw // DEFAULT_CONFIG (used directly by tests and worker-sizing fallbacks). lowMemoryMode: false, @@ -516,15 +526,48 @@ export function resolveConfig(overrides?: Partial): EngineConfig { ...overrides, }; + // Default-on drawElement is clamped to hosts where it can actually engage + // (macOS with a non-software-GPU browser; SwiftShader drops transparent + // sub-layers — crbug 521434899). "auto" passes the clamp: the stock CLI + // resolves GPU mode to auto, which probes to hardware on real Macs — and if + // it resolves to software after all, the SwiftShader init-time gate still + // routes the session to the screenshot baseline. Without the clamp, the + // default would needlessly disable page-side shader compositing (below) on + // Linux/Docker hosts where DE never runs. An EXPLICIT opt-in (env or caller override) + // skips the clamp and keeps the old semantics — attempt DE, let the + // init-time gates route away — which debugging relies on. + const explicitDrawElementOptIn = + env("PRODUCER_EXPERIMENTAL_FAST_CAPTURE") === "true" || overrides?.useDrawElement === true; + if ( + merged.useDrawElement && + !explicitDrawElementOptIn && + !(process.platform === "darwin" && merged.browserGpuMode !== "software") + ) { + merged.useDrawElement = false; + } + // The runtime self-verification net lives in the worker-encode drain — the + // serial drawElement path has only the blank guard. Default-on drawElement + // therefore requires worker-encode; disabling HF_DE_WORKER_ENCODE without an + // explicit drawElement opt-in falls back to the screenshot baseline rather + // than shipping unverified drawElement frames. + if (merged.useDrawElement && !explicitDrawElementOptIn && !merged.enableDrawElementWorkerEncode) { + merged.useDrawElement = false; + } + // drawElement capture and page-side shader compositing are mutually // incompatible capture strategies (drawElement reads paint records directly // and bypasses the page-side prepare→composite→resolve protocol). When - // experimental fast capture is on, force page-side compositing off so shader + // fast capture is on, force page-side compositing off so shader // transitions fall back to the Node-side layered blend rather than silently // dropping. This keeps the flag self-consistent and avoids a per-session // incompatibility warning on every fast-capture render. - if (merged.useDrawElement) { + if (merged.useDrawElement && merged.enablePageSideCompositing) { merged.enablePageSideCompositing = false; + // Record that THIS resolution (not the caller) turned page-side + // compositing off, so a later compile-time drawElement gate can restore + // it without clobbering an explicit enablePageSideCompositing:false from + // the programmatic API or HF_PAGE_SIDE_COMPOSITING=false. + merged.pageSideCompositingAutoDisabled = true; } return { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 20efb4cec7..94173a9d8a 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -84,6 +84,9 @@ export { captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, + DrawElementVerificationError, + isDrawElementVerificationError, + recaptureDrawElementFrameForVerify, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 19fb5016c5..a2b254f449 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -146,6 +146,42 @@ export interface CaptureSession { * worker-encode path (mirrors `lastFrameBuffer` on the screenshot path). Only set * when static-frame dedup is armed on the drawElement path. */ lastEncodeResult?: Promise; + /** 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; +} + +/** + * 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. @@ -581,6 +617,10 @@ async function initDrawElementOrTransparentBackground( // 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); + // Self-verification ground truth: must ALSO 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). + await captureDeVerificationFrames(session, page, logInitPhase); await injectDrawElementCanvas(page, session.options.width, session.options.height); if (transparent) { await initTransparentBackground(session.page); @@ -2176,7 +2216,7 @@ async function computeTimelineAtRiskFrames( * 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 { +function isNoCachedPaintRecordError(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); return msg.includes("No cached paint record"); } @@ -2512,6 +2552,38 @@ export async function captureFrameToBufferPipelined( } } +/** + * 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 { + 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 @@ -2740,6 +2812,114 @@ export async function getCompositionDuration(session: CaptureSession): Promise void, +): Promise { + 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: