diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 74abd1a044..9fed4a3636 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -921,6 +921,7 @@ How it uses the machine, and when it gives up: | `--low-memory-mode` / `--no-low-memory-mode` | auto at 8 GB RAM or less | Safe profile: one worker, screenshot capture, no calibration. See the note below. | | `--gpu` | off | Hardware FFmpeg encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV) | | `--browser-gpu` / `--no-browser-gpu` | auto locally, off in Docker | Host GPU for Chrome and WebGL capture, or software rendering. Auto probes WebGL once and falls back. | +| `--require-beginframe` | off | Fail instead of falling back to screenshot capture when BeginFrame can't run. Rejected with `--docker`. Env: `PRODUCER_REQUIRE_BEGINFRAME`. | | `--page-side-compositing` / `--no-page-side-compositing` | on | Page-side WebGL for SDR shader transitions in mp4, hls and gif, ~6× faster. webm and mov use the layered path; gif always uses page-side WebGL, even with `--no-page-side-compositing`. | | `--experimental-fast-capture` | on where it can engage | Chrome's draw-element capture, ~2× faster; falls back to screenshots on its own. Env: `PRODUCER_EXPERIMENTAL_FAST_CAPTURE`. | | `--frames-cache-dir` | `/hyperframes-extract-cache-` | Where extracted source frames cache. `off`, `none`, `false`, or `0` disables it. `doctor` reports the live value. | diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index df47984c8a..1f87d231ef 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -384,6 +384,13 @@ export default defineCommand({ "memory thrash on constrained machines. Default: auto-detected from " + "total RAM (<= 8 GB). Env: PRODUCER_LOW_MEMORY_MODE.", }, + "require-beginframe": { + type: "boolean", + description: + "Fail the render instead of falling back to screenshot capture when BeginFrame " + + "cannot run (Linux with chrome-headless-shell only; rejected with --docker). " + + "Env: PRODUCER_REQUIRE_BEGINFRAME.", + }, "experimental-fast-capture": { type: "boolean", description: diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index 632813a9f5..6f52d49ef5 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -229,6 +229,40 @@ describe("createRenderPlan", () => { } }); + it("maps --require-beginframe to the engine's environment switch", () => { + expect(createRenderPlan({ dir: projectDir, "require-beginframe": true }).environment).toEqual({ + PRODUCER_REQUIRE_BEGINFRAME: "true", + }); + expect(createRenderPlan({ dir: projectDir }).environment).not.toHaveProperty( + "PRODUCER_REQUIRE_BEGINFRAME", + ); + }); + + it("rejects --require-beginframe with --docker instead of dropping the requirement", () => { + expect(() => + createRenderPlan({ dir: projectDir, docker: true, "require-beginframe": true }), + ).toThrow(CliUsageError); + expect( + createRenderPlan({ dir: projectDir, docker: true, "require-beginframe": false }).environment, + ).toEqual({ PRODUCER_REQUIRE_BEGINFRAME: "false" }); + }); + + it("rejects PRODUCER_REQUIRE_BEGINFRAME=true with --docker", () => { + const previous = process.env.PRODUCER_REQUIRE_BEGINFRAME; + process.env.PRODUCER_REQUIRE_BEGINFRAME = "true"; + try { + expect(() => createRenderPlan({ dir: projectDir, docker: true })).toThrow(CliUsageError); + expect(() => createRenderPlan({ dir: projectDir })).not.toThrow(); + expect( + createRenderPlan({ dir: projectDir, docker: true, "require-beginframe": false }) + .environment, + ).toEqual({ PRODUCER_REQUIRE_BEGINFRAME: "false" }); + } finally { + if (previous === undefined) delete process.env.PRODUCER_REQUIRE_BEGINFRAME; + else process.env.PRODUCER_REQUIRE_BEGINFRAME = previous; + } + }); + it("resolves a relative frame-cache directory into the execution environment", () => { const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "./frame-cache" }); expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe(resolve("./frame-cache")); diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 4095ea99dd..f62832373f 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -104,6 +104,7 @@ export interface RenderCommandArgs { resume?: boolean; "keep-segments"?: boolean; "low-memory-mode"?: boolean; + "require-beginframe"?: boolean; "experimental-fast-capture"?: boolean; "frames-cache-dir"?: string; } @@ -384,6 +385,9 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren if (args["low-memory-mode"] != null) { environment.PRODUCER_LOW_MEMORY_MODE = args["low-memory-mode"] ? "true" : "false"; } + if (args["require-beginframe"] != null) { + environment.PRODUCER_REQUIRE_BEGINFRAME = args["require-beginframe"] ? "true" : "false"; + } if (args["experimental-fast-capture"] != null) { environment.PRODUCER_EXPERIMENTAL_FAST_CAPTURE = args["experimental-fast-capture"] ? "true" @@ -460,6 +464,16 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren ); failUsage(); } + const requireBeginFrame = + args["require-beginframe"] ?? process.env.PRODUCER_REQUIRE_BEGINFRAME === "true"; + if (useDocker && requireBeginFrame) { + errorBox( + "BeginFrame is local-only", + "--require-beginframe (or PRODUCER_REQUIRE_BEGINFRAME=true) needs host Chrome's BeginFrame capture. Docker mode always captures with screenshots on software GL, so the requirement could never hold.", + "Run without --docker, or drop --require-beginframe.", + ); + failUsage(); + } const videoBitrate = args["video-bitrate"]?.trim(); if (args.crf != null && videoBitrate) { diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 4098b4cc87..78a5f90108 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -85,6 +85,14 @@ describe("resolveConfig", () => { expect(config.enableBrowserPool).toBe(true); }); + it("requires BeginFrame only when PRODUCER_REQUIRE_BEGINFRAME is true", () => { + unsetEnv("PRODUCER_REQUIRE_BEGINFRAME"); + expect(resolveConfig().requireBeginFrame).toBe(false); + + setEnv("PRODUCER_REQUIRE_BEGINFRAME", "true"); + expect(resolveConfig().requireBeginFrame).toBe(true); + }); + it("lets env vars opt out of default streaming encode", () => { setEnv("PRODUCER_ENABLE_STREAMING_ENCODE", "false"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index fd6c4af07f..f120cea4f4 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -58,6 +58,11 @@ export interface EngineConfig { expectedChromiumMajor?: number; /** Force screenshot capture mode (skip BeginFrame even on Linux). */ forceScreenshot: boolean; + /** + * Fail a capture session that would not run BeginFrame, instead of + * falling back to screenshot capture. + */ + requireBeginFrame: boolean; /** * Static-frame dedup: reuse byte-identical frames instead of re-seeking + * re-screenshotting (anchor-verified at init). Default ON; disable via @@ -291,6 +296,7 @@ export const DEFAULT_CONFIG: EngineConfig = { browserTimeout: 120_000, protocolTimeout: 300_000, forceScreenshot: false, + requireBeginFrame: false, staticFrameDedup: true, useDrawElement: true, enableDrawElementWorkerEncode: true, @@ -342,6 +348,7 @@ const BOOLEAN_ENGINE_CONFIG_FIELDS = [ "disableGpu", "enableBrowserPool", "forceScreenshot", + "requireBeginFrame", "staticFrameDedup", "useDrawElement", "enableDrawElementWorkerEncode", @@ -851,6 +858,7 @@ export function resolveConfig(overrides?: Partial): EngineConfig { : undefined, forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot), + requireBeginFrame: envBool("PRODUCER_REQUIRE_BEGINFRAME", DEFAULT_CONFIG.requireBeginFrame), staticFrameDedup: resolveStaticFrameDedup(), useDrawElement: envBool("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", DEFAULT_CONFIG.useDrawElement), enableDrawElementWorkerEncode: envBool( diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index fb90a59be9..47354f1f17 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -87,6 +87,7 @@ export { compositionRequiresWebGpu, assertWebGpuAdapterAvailable, WebGpuUnavailableError, + BeginFrameRequiredError, ENABLE_BROWSER_POOL, BrowserLeasePool, type BuildChromeArgsOptions, diff --git a/packages/engine/src/services/browserLeasePool.test.ts b/packages/engine/src/services/browserLeasePool.test.ts index a5e2296b48..017cc70880 100644 --- a/packages/engine/src/services/browserLeasePool.test.ts +++ b/packages/engine/src/services/browserLeasePool.test.ts @@ -19,6 +19,7 @@ function fingerprint(args: string[] = ["--one"]): BrowserLaunchFingerprint { browserTimeoutMs: 1_000, protocolTimeoutMs: 2_000, requestedCaptureMode: "screenshot", + requireBeginFrame: false, }; } diff --git a/packages/engine/src/services/browserLeasePool.ts b/packages/engine/src/services/browserLeasePool.ts index 8a4b9e8141..88dc30508a 100644 --- a/packages/engine/src/services/browserLeasePool.ts +++ b/packages/engine/src/services/browserLeasePool.ts @@ -9,6 +9,7 @@ export interface BrowserLaunchFingerprint { readonly browserTimeoutMs: number; readonly protocolTimeoutMs: number; readonly requestedCaptureMode: CaptureMode; + readonly requireBeginFrame: boolean; } export interface BrowserLaunchResult { @@ -58,6 +59,7 @@ function fingerprintKey(fingerprint: Readonly): string fingerprint.browserTimeoutMs, fingerprint.protocolTimeoutMs, fingerprint.requestedCaptureMode, + fingerprint.requireBeginFrame, ]); } diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index d3f1af385a..5a2d3f6560 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -17,6 +17,7 @@ import { _probeBeginFrameSupportForTests, _setPuppeteerForTests, acquireBrowser, + BeginFrameRequiredError, buildChromeArgs, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, @@ -300,6 +301,65 @@ describe("browser launch capture-mode contract", () => { }); }); +describe.skipIf(process.platform !== "linux")("BeginFrame probe failure", () => { + let dir: string; + let chromePath: string; + let launch: ReturnType; + + function browserFailingProbe(): Browser { + return { + connected: true, + newPage: vi.fn().mockRejectedValue(new Error("renderer unavailable")), + version: vi.fn().mockResolvedValue("HeadlessChrome/152.0.0.0"), + close: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + process: () => ({ kill: vi.fn(), killed: false }), + } as unknown as Browser; + } + + beforeEach(() => { + _resetBrowserPoolForTests(); + dir = mkdtempSync(join(tmpdir(), "hf-require-beginframe-")); + chromePath = join(dir, "chrome-headless-shell"); + writeFileSync(chromePath, ""); + launch = vi.fn().mockImplementation(async () => browserFailingProbe()); + _setPuppeteerForTests({ launch } as unknown as PuppeteerNode); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(async () => { + await drainBrowserPool(); + _setPuppeteerForTests(undefined); + vi.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("relaunches in screenshot mode by default", async () => { + const lease = await acquireBrowser(["--enable-begin-frame-control"], { + chromePath, + enableBrowserPool: false, + }); + + expect(lease.captureMode).toBe("screenshot"); + expect(launch).toHaveBeenCalledTimes(2); + expect(launch.mock.calls[1]?.[0].args).not.toContain("--enable-begin-frame-control"); + await lease.release(); + }); + + it("fails without a screenshot relaunch when BeginFrame is required", async () => { + const acquired = acquireBrowser(["--enable-begin-frame-control"], { + chromePath, + enableBrowserPool: false, + requireBeginFrame: true, + }); + + await expect(acquired).rejects.toThrow(BeginFrameRequiredError); + await expect(acquired).rejects.toThrow(/beginFrame probe failed.*renderer unavailable/); + expect(launch).toHaveBeenCalledOnce(); + }); +}); + describe("resolveBrowserGpuMode", () => { const setMockWebGlProbe = (info: { hasWebGL: boolean; vendor: string; renderer: string }) => { const close = vi.fn().mockResolvedValue(undefined); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index dafe45173e..0b5fae84b8 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -652,13 +652,17 @@ function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: st function createBrowserLaunchFingerprint( chromeArgs: string[], config?: Partial< - Pick + Pick< + EngineConfig, + "browserTimeout" | "protocolTimeout" | "chromePath" | "forceScreenshot" | "requireBeginFrame" + > >, ): BrowserLaunchFingerprint { const launchConfig = { browserTimeout: DEFAULT_CONFIG.browserTimeout, protocolTimeout: DEFAULT_CONFIG.protocolTimeout, forceScreenshot: DEFAULT_CONFIG.forceScreenshot, + requireBeginFrame: DEFAULT_CONFIG.requireBeginFrame, ...config, }; const headlessShell = resolveHeadlessShellPath(launchConfig); @@ -681,6 +685,7 @@ function createBrowserLaunchFingerprint( browserTimeoutMs: launchConfig.browserTimeout, protocolTimeoutMs: launchConfig.protocolTimeout, requestedCaptureMode, + requireBeginFrame: launchConfig.requireBeginFrame, }; } @@ -692,7 +697,12 @@ export async function acquireBrowser( config?: Partial< Pick< EngineConfig, - "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot" + | "browserTimeout" + | "protocolTimeout" + | "enableBrowserPool" + | "chromePath" + | "forceScreenshot" + | "requireBeginFrame" > >, ): Promise { @@ -735,6 +745,13 @@ async function launchBrowser( if (!probe.supported) { await closeBrowserAfterFailedProbe(browser); browser = undefined; + if (fingerprint.requireBeginFrame) { + throw new BeginFrameRequiredError( + `the HeadlessExperimental.beginFrame probe failed after ${probe.durationMs}ms ` + + `(${probe.detail}). Browsers starting together on one GPU can miss this ` + + `deadline, so fewer --workers may help`, + ); + } console.warn( `[BrowserManager] HeadlessExperimental.beginFrame probe failed after ${probe.durationMs}ms: ` + `${probe.detail}; falling back to screenshot mode.`, @@ -988,6 +1005,14 @@ export function compositionRequiresWebGpu(html: string): boolean { return false; } +/** BeginFrame capture was required but this session would have used screenshot capture. */ +export class BeginFrameRequiredError extends Error { + constructor(reason: string) { + super(`BeginFrame capture is required, but ${reason}; not falling back to screenshot capture.`); + this.name = "BeginFrameRequiredError"; + } +} + /** No hardware WebGPU adapter on this host; distinct from a browser or navigation failure. */ export class WebGpuUnavailableError extends Error { constructor() { diff --git a/packages/engine/src/services/frameCapture-requireBeginFrame.test.ts b/packages/engine/src/services/frameCapture-requireBeginFrame.test.ts new file mode 100644 index 0000000000..fa60ab625a --- /dev/null +++ b/packages/engine/src/services/frameCapture-requireBeginFrame.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PuppeteerNode } from "puppeteer-core"; +import { + _resetBrowserPoolForTests, + _setPuppeteerForTests, + BeginFrameRequiredError, + drainBrowserPool, +} from "./browserManager.js"; +import { createCaptureSession } from "./frameCapture.js"; +import type { EngineConfig } from "../config.js"; + +describe.skipIf(process.platform !== "linux")("createCaptureSession with requireBeginFrame", () => { + let dir: string; + let chromePath: string; + let launch: ReturnType; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-require-beginframe-session-")); + chromePath = join(dir, "chrome-headless-shell"); + writeFileSync(chromePath, ""); + launch = vi.fn(); + _setPuppeteerForTests({ launch } as unknown as PuppeteerNode); + }); + + afterEach(async () => { + await drainBrowserPool(); + _resetBrowserPoolForTests(); + _setPuppeteerForTests(undefined); + rmSync(dir, { recursive: true, force: true }); + }); + + function create(config: Partial) { + return createCaptureSession( + "http://127.0.0.1:3000", + join(dir, "frames"), + { width: 320, height: 180, fps: { num: 30, den: 1 }, format: "jpeg", requiresWebGpu: false }, + null, + { chromePath, browserGpuMode: "software", requireBeginFrame: true, ...config }, + ); + } + + it("refuses a session forced to screenshot capture before launching Chrome", async () => { + const session = create({ forceScreenshot: true }); + + await expect(session).rejects.toThrow(BeginFrameRequiredError); + await expect(session).rejects.toThrow(/set to screenshot capture/); + expect(launch).not.toHaveBeenCalled(); + }); + + it("names the software GPU when that is what rules out BeginFrame", async () => { + await expect(create({ forceScreenshot: false })).rejects.toThrow( + /browser GPU resolved to software/, + ); + expect(launch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index dac4eaf3be..26e23619cd 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -44,6 +44,7 @@ import { resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, + BeginFrameRequiredError, type BrowserLease, type CaptureMode, } from "./browserManager.js"; @@ -1209,6 +1210,26 @@ export async function completeDeferredDrawElementInit(session: CaptureSession): session.deInitDeferred = false; } +function screenshotModeReason(input: { + headlessShell: boolean; + isLinux: boolean; + supersampling: boolean; + drawElementTransparent: boolean; + softwareGpu: boolean; +}): string { + if (!input.isLinux) return "BeginFrame capture runs only on Linux"; + if (!input.headlessShell) return "chrome-headless-shell was not found"; + if (input.supersampling) return "a deviceScaleFactor above 1 needs screenshot capture"; + if (input.drawElementTransparent) + return "transparent drawElement capture needs screenshot capture"; + if (input.softwareGpu) + return "the browser GPU resolved to software, which uses screenshot capture"; + return ( + "this session was set to screenshot capture (a transparent format, a render-mode hint, " + + "low-memory mode, or a retry after a capture failure)" + ); +} + // fallow-ignore-next-line unit-size export async function createCaptureSession( serverUrl: string, @@ -1284,6 +1305,17 @@ export async function createCaptureSession( !drawElementTransparent ? "beginframe" : "screenshot"; + if (preMode === "screenshot" && config?.requireBeginFrame) { + throw new BeginFrameRequiredError( + screenshotModeReason({ + headlessShell: Boolean(headlessShell), + isLinux, + supersampling, + drawElementTransparent, + softwareGpu: !forceScreenshot && effectiveForceScreenshot, + }), + ); + } // Callers that already have the HTML pass options.requiresWebGpu; others // fall back to fetching the server about to be navigated to anyway. A // fetch failure defaults to false — the real page.goto moments later