From 67bb56c703e833669eec56461f6bb831b7f536b0 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 May 2026 06:24:23 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(engine):=20browserGpuMode=20"auto"=20?= =?UTF-8?q?=E2=80=94=20probe=20WebGL=20once,=20fall=20back=20to=20software?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the host doesn't have a usable GPU (CI containers, eval rigs without GPU passthrough, dev VMs), Chrome's hardware-mode WebGL flags (`--use-gl=egl/metal/d3d11`) silently leave WebGL unavailable — `getContext("webgl")` returns null, three.js' WebGLRenderer dies, the canvas stays black. Surfaced today by Abhay's c2v-eval failing on a docker render of an hf bundle that uses three.js + a custom fragment shader. The fix that's been there: `--use-gl=angle --use-angle=swiftshader` (CPU software WebGL, ~5-50× slower but pixel-identical). The engine already exposed `browserGpuMode: "software"` for this. The gap was discovery — users had to know to pass `--no-browser-gpu` on no-GPU hosts. This change adds `browserGpuMode: "auto"` (now the CLI default for local renders): on first launch in the process, probe Chrome with hardware args, check `canvas.getContext("webgl") !== null`, cache the result. ~1-2 s on first render, free on every subsequent render in the same worker. Hardware GPUs keep their fast path; no-GPU hosts get SwiftShader without ceremony. Behaviour matrix: - No flag, no env, local → "auto" (NEW default) - `--browser-gpu` → "hardware" (force; errors if no GPU) - `--no-browser-gpu` → "software" (force SwiftShader) - `PRODUCER_BROWSER_GPU_MODE` → "hardware" / "software" / "auto" / unset - Docker mode → forced "software" (unchanged) Engine-config default stays "software" (conservative for embedders); the "auto" default lives in the CLI's `resolveBrowserGpuForCli` so producer embedders aren't surprised by a probe-on-launch. Also adds `--enable-unsafe-swiftshader` to the software flag set — Chrome 120+ deprecated implicit SwiftShader fallback and emits a deprecation warning unless the flag is set explicitly. Despite the "unsafe" name this is exactly the pre-deprecation behaviour; the rename is about Chrome's threat model on the open web, not about the rendering itself. Verification: - Engine 535/535 + CLI 256/256 (incl. new probe tests + tri-state CLI test) - Empirical: probe on this no-GPU devbox returns "software" in 240 ms, cached 0 ms on subsequent calls - Format / lint / typecheck clean across all packages Refs the Abhay/Slack thread on c2v-eval rendering without a GPU node. --- packages/cli/src/commands/render.test.ts | 37 +++++- packages/cli/src/commands/render.ts | 58 +++++++--- packages/engine/src/config.test.ts | 7 ++ packages/engine/src/config.ts | 13 ++- packages/engine/src/index.ts | 1 + .../src/services/browserManager.test.ts | 61 +++++++++- .../engine/src/services/browserManager.ts | 106 +++++++++++++++++- packages/engine/src/services/frameCapture.ts | 8 +- 8 files changed, 263 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index fbf4c2c12..915475c37 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -72,6 +72,25 @@ describe("renderLocal browser GPU config", () => { }); }); + it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => { + const { renderLocal } = await import("./render.js"); + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: 30, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "auto", + hdrMode: "auto", + quiet: true, + }); + + expect(producerState.resolveConfigCalls).toContainEqual({ browserGpuMode: "auto" }); + expect(producerState.createdJobs[0]?.producerConfig).toMatchObject({ + browserGpuMode: "auto", + resolved: true, + }); + }); + it("passes an explicit hardware override for default local browser GPU", async () => { const { renderLocal } = await import("./render.js"); await renderLocal("/tmp/project", "/tmp/out.mp4", { @@ -94,12 +113,18 @@ describe("renderLocal browser GPU config", () => { it("resolves browser GPU from CLI flags, Docker mode, and env fallback", async () => { const { resolveBrowserGpuForCli } = await import("./render.js"); - expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe(true); - expect(resolveBrowserGpuForCli(false, undefined, "hardware")).toBe(true); - expect(resolveBrowserGpuForCli(false, undefined, "software")).toBe(false); - expect(resolveBrowserGpuForCli(false, true, "software")).toBe(true); - expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe(false); - expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe(false); + // Default (no flag, no env): auto — engine probes and chooses. + expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto"); + // Env override + expect(resolveBrowserGpuForCli(false, undefined, "hardware")).toBe("hardware"); + expect(resolveBrowserGpuForCli(false, undefined, "software")).toBe("software"); + expect(resolveBrowserGpuForCli(false, undefined, "auto")).toBe("auto"); + // Explicit CLI flag wins over env + expect(resolveBrowserGpuForCli(false, true, "software")).toBe("hardware"); + expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe("software"); + // Docker forces software regardless of flags/env + expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe("software"); + expect(resolveBrowserGpuForCli(true, undefined, "auto")).toBe("software"); }); it("forwards parsed --variables payload to createRenderJob", async () => { diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 009da0b7b..53ff63fb9 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -118,7 +118,7 @@ export default defineCommand({ "browser-gpu": { type: "boolean", description: - "Use host GPU acceleration for Chrome/WebGL capture. Enabled by default for local renders; use --no-browser-gpu to opt out.", + "Force host GPU acceleration for Chrome/WebGL capture. Default: auto (probe on first launch; fall back to software if no GPU). Use --no-browser-gpu to force software (SwiftShader).", }, quiet: { type: "boolean", @@ -224,7 +224,7 @@ export default defineCommand({ const useDocker = args.docker ?? false; const useGpu = args.gpu ?? false; const browserGpuArg = args["browser-gpu"]; - const useBrowserGpu = resolveBrowserGpuForCli(useDocker, browserGpuArg); + const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg); const quiet = args.quiet ?? false; const strictAll = args["strict-all"] ?? false; const strictErrors = (args.strict ?? false) || strictAll; @@ -275,10 +275,14 @@ export default defineCommand({ c.dim(" \u2192 " + outputPath), ); console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel)); - if (useGpu || useBrowserGpu) { + if (useGpu || browserGpuMode !== "software") { const gpuModes = [ useGpu ? "encoder GPU" : null, - useBrowserGpu ? "browser GPU (auto)" : null, + browserGpuMode === "hardware" + ? "browser GPU (forced)" + : browserGpuMode === "auto" + ? "browser GPU (auto-detect)" + : null, ].filter(Boolean); console.log(c.dim(" GPU: " + gpuModes.join(" + "))); } @@ -397,7 +401,7 @@ export default defineCommand({ format, workers, gpu: useGpu, - browserGpu: useBrowserGpu, + browserGpu: browserGpuMode === "hardware", hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, videoBitrate, @@ -412,7 +416,7 @@ export default defineCommand({ format, workers, gpu: useGpu, - browserGpu: useBrowserGpu, + browserGpuMode, hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, videoBitrate, @@ -431,7 +435,15 @@ interface RenderOptions { format: "mp4" | "webm" | "mov"; workers?: number; gpu: boolean; - browserGpu: boolean; + /** + * For local renders: tri-state ("auto" | "hardware" | "software"). "auto" + * probes WebGL availability on first launch and falls back to software. + * Docker renders use the boolean `browserGpu` field instead because the + * docker run-args wire the inner CLI's `--no-browser-gpu` flag. + */ + browserGpuMode?: "auto" | "hardware" | "software"; + /** Docker-only: true → host GPU passthrough, false → forced software. */ + browserGpu?: boolean; hdrMode: "auto" | "force-hdr" | "force-sdr"; crf?: number; videoBitrate?: string; @@ -579,15 +591,33 @@ export function validateVariablesAgainstProject( return validateVariables(values, meta.variables); } +/** + * Resolve the browser-GPU mode for a CLI render invocation. + * + * Priority (highest first): + * 1. Docker mode → always "software" (docker has no portable GPU + * passthrough; the engine's render path uses SwiftShader). + * 2. Explicit CLI flag — `--browser-gpu` → "hardware", + * `--no-browser-gpu` → "software". + * 3. Env var `PRODUCER_BROWSER_GPU_MODE` accepts "hardware" / "software" / + * "auto". + * 4. Default = "auto" — engine probes WebGL availability on first launch + * and falls back to software if the host lacks a usable GPU. + * + * Returning "auto" by default lets local renders Just Work whether or not the + * host has a GPU, while preserving the explicit overrides for CI / power + * users who want failure-on-misconfig. + */ export function resolveBrowserGpuForCli( useDocker: boolean, browserGpuArg: boolean | undefined, envMode = process.env.PRODUCER_BROWSER_GPU_MODE, -): boolean { - if (useDocker) return false; - if (browserGpuArg !== undefined) return browserGpuArg; - if (envMode === "software") return false; - return true; +): "auto" | "hardware" | "software" { + if (useDocker) return "software"; + if (browserGpuArg === true) return "hardware"; + if (browserGpuArg === false) return "software"; + if (envMode === "hardware" || envMode === "software" || envMode === "auto") return envMode; + return "auto"; } const DOCKER_IMAGE_PREFIX = "hyperframes-renderer"; @@ -707,7 +737,7 @@ async function renderDocker( format: options.format, workers: options.workers, gpu: options.gpu, - browserGpu: options.browserGpu, + browserGpu: options.browserGpu ?? false, hdrMode: options.hdrMode, crf: options.crf, videoBitrate: options.videoBitrate, @@ -777,7 +807,7 @@ export async function renderLocal( workers: options.workers, useGpu: options.gpu, producerConfig: producer.resolveConfig({ - browserGpuMode: options.browserGpu ? "hardware" : "software", + browserGpuMode: options.browserGpuMode ?? (options.browserGpu ? "hardware" : "software"), }), hdrMode: options.hdrMode, crf: options.crf, diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 02bc3f02f..0e29345b7 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -97,6 +97,13 @@ describe("resolveConfig", () => { expect(config.browserGpuMode).toBe("hardware"); }); + it("accepts 'auto' as a valid browser GPU mode env value", () => { + setEnv("PRODUCER_BROWSER_GPU_MODE", "auto"); + + const config = resolveConfig(); + expect(config.browserGpuMode).toBe("auto"); + }); + it("falls back to software browser GPU mode for invalid env values", () => { setEnv("PRODUCER_BROWSER_GPU_MODE", "native"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index 9ae23ea6c..c2f9bc130 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -32,10 +32,15 @@ export interface EngineConfig { chromePath?: string; disableGpu: boolean; /** - * Chrome/WebGL rendering backend. "software" keeps the existing SwiftShader - * path for reproducible output; "hardware" lets Chrome use the host GPU. + * Chrome/WebGL rendering backend. + * - "software": SwiftShader (CPU-only). Always works; ~5-50× slower than GPU. + * - "hardware": host GPU via platform-native ANGLE backend (Metal/D3D11/EGL). + * Errors if no usable GPU is reachable from Chrome. + * - "auto": probe Chrome for WebGL availability on first launch in this + * process; fall back to software if hardware-mode WebGL is unavailable. + * Cost: one extra Chrome launch (~1-2 s) per process; result cached. */ - browserGpuMode: "software" | "hardware"; + browserGpuMode: "software" | "hardware" | "auto"; enableBrowserPool: boolean; browserTimeout: number; protocolTimeout: number; @@ -173,7 +178,7 @@ export function resolveConfig(overrides?: Partial): EngineConfig { }; const envBrowserGpuMode = (): EngineConfig["browserGpuMode"] => { const raw = env("PRODUCER_BROWSER_GPU_MODE"); - if (raw === "hardware" || raw === "software") return raw; + if (raw === "hardware" || raw === "software" || raw === "auto") return raw; return DEFAULT_CONFIG.browserGpuMode; }; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 28e533c76..88303bb1f 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -50,6 +50,7 @@ export { acquireBrowser, releaseBrowser, resolveHeadlessShellPath, + resolveBrowserGpuMode, buildChromeArgs, ENABLE_BROWSER_POOL, type BuildChromeArgsOptions, diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index f30cd3a3e..4e6827ead 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildChromeArgs, forceReleaseBrowser } from "./browserManager.js"; +import { + _resetAutoBrowserGpuModeCacheForTests, + buildChromeArgs, + forceReleaseBrowser, + resolveBrowserGpuMode, +} from "./browserManager.js"; describe("buildChromeArgs browser GPU mode", () => { const base = { width: 1920, height: 1080 }; @@ -10,6 +15,7 @@ describe("buildChromeArgs browser GPU mode", () => { expect(args).toContain("--enable-features=CanvasDrawElement"); expect(args).toContain("--use-gl=angle"); expect(args).toContain("--use-angle=swiftshader"); + expect(args).toContain("--enable-unsafe-swiftshader"); expect(args).not.toContain("--enable-gpu-rasterization"); }); @@ -48,6 +54,57 @@ describe("buildChromeArgs browser GPU mode", () => { }); }); +describe("resolveBrowserGpuMode", () => { + beforeEach(() => { + _resetAutoBrowserGpuModeCacheForTests(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + _resetAutoBrowserGpuModeCacheForTests(); + }); + + it("passes 'software' through unchanged without probing", async () => { + const mode = await resolveBrowserGpuMode("software"); + expect(mode).toBe("software"); + }); + + it("passes 'hardware' through unchanged without probing", async () => { + const mode = await resolveBrowserGpuMode("hardware"); + expect(mode).toBe("hardware"); + }); + + it("falls back to 'software' when the probe browser cannot launch", async () => { + // No chromePath, env unset, and (in the test env) no system Chrome to find + // → puppeteer.launch will throw → caller catches → software fallback. + // Force a definitely-missing chrome binary so the launch path errors fast. + const mode = await resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + expect(mode).toBe("software"); + }); + + it("caches the probe result across calls", async () => { + const first = await resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + // Second call uses cache — no new launch. Assert the same answer comes back + // even with a different chromePath that would have a different probe outcome. + const second = await resolveBrowserGpuMode("auto", { + chromePath: "/another/definitely/missing/path", + browserTimeout: 2000, + }); + expect(first).toBe("software"); + expect(second).toBe("software"); + // Reset and re-probe to confirm the test-only reset works. + _resetAutoBrowserGpuModeCacheForTests(); + const third = await resolveBrowserGpuMode("hardware"); + expect(third).toBe("hardware"); + }); +}); + describe("forceReleaseBrowser", () => { it("kills the browser process and disconnects", () => { const killFn = vi.fn(() => true); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 8c63de5eb..e5fb48615 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -136,6 +136,97 @@ async function probeBeginFrameSupport(browser: Browser): Promise { } } +/** + * Cached result of `resolveBrowserGpuMode("auto", ...)` for the lifetime of + * this process. The probe launches a transient Chrome with hardware args and + * checks whether `canvas.getContext("webgl")` returns a context. Result is + * memoised because the answer is a property of the host (GPU/driver + * availability) and cannot meaningfully change inside one process. + * + * Exported for tests; production callers go through `resolveBrowserGpuMode`. + */ +export let _autoBrowserGpuModeCache: "software" | "hardware" | undefined; + +/** Test-only: reset the cached probe result. */ +export function _resetAutoBrowserGpuModeCacheForTests(): void { + _autoBrowserGpuModeCache = undefined; +} + +/** + * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer. + * + * For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"` + * it launches a tiny Chrome with the platform's hardware GPU args, runs a + * one-shot WebGL availability probe, and falls back to `"software"` if + * hardware-mode WebGL is unavailable. The probe result is cached for the + * process lifetime — a multi-render run pays the ~1-2 s cost once. + * + * Any failure (Chrome launch error, navigation timeout, missing canvas API, + * etc.) is treated as a `"software"` fallback. The render path with + * SwiftShader always works, so a misclassification toward software is the + * safe failure mode; misclassifying toward hardware would error on the real + * render. + */ +export async function resolveBrowserGpuMode( + mode: EngineConfig["browserGpuMode"], + options: { + chromePath?: string; + browserTimeout?: number; + platform?: NodeJS.Platform; + } = {}, +): Promise<"software" | "hardware"> { + if (mode !== "auto") return mode; + if (_autoBrowserGpuModeCache !== undefined) return _autoBrowserGpuModeCache; + + const platform = options.platform ?? process.platform; + const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; + const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); + + const probeArgs = [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--enable-webgl", + "--ignore-gpu-blocklist", + ...getBrowserGpuArgs("hardware", platform), + ]; + + const ppt = await getPuppeteer().catch(() => null); + if (!ppt) { + _autoBrowserGpuModeCache = "software"; + return _autoBrowserGpuModeCache; + } + + let probeBrowser: Browser | undefined; + try { + probeBrowser = await ppt.launch({ + headless: true, + args: probeArgs, + defaultViewport: { width: 64, height: 64 }, + executablePath, + timeout: browserTimeout, + }); + const page = await probeBrowser.newPage(); + const hasWebGL = await page.evaluate(() => { + try { + const c = document.createElement("canvas"); + const gl = + c.getContext("webgl") || (c.getContext("experimental-webgl") as RenderingContext | null); + return gl !== null; + } catch { + return false; + } + }); + _autoBrowserGpuModeCache = hasWebGL ? "hardware" : "software"; + } catch { + _autoBrowserGpuModeCache = "software"; + } finally { + await probeBrowser?.close().catch(() => {}); + } + + return _autoBrowserGpuModeCache; +} + export async function acquireBrowser( chromeArgs: string[], config?: Partial< @@ -344,7 +435,20 @@ function getBrowserGpuArgs( platform: NodeJS.Platform, ): string[] { if (mode === "software") { - return ["--use-gl=angle", "--use-angle=swiftshader"]; + // Chrome 120+ deprecated implicit SwiftShader fallback; the explicit + // path (--use-angle=swiftshader) keeps working but Chrome emits a + // deprecation warning unless --enable-unsafe-swiftshader is also set. + // Despite the name, this is exactly the behaviour Chrome had before; + // the flag exists to make CPU rasterisation an explicit opt-in rather + // than an implicit fallback for end users on the open web. + return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]; + } + + if (mode === "auto") { + // Should not reach here — `resolveBrowserGpuMode` collapses "auto" to + // "software" or "hardware" before args are built. Be defensive: software + // is the always-safe fallback. + return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]; } switch (platform) { diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index d19e8cf6f..98307ab54 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -18,6 +18,7 @@ import { releaseBrowser, forceReleaseBrowser, buildChromeArgs, + resolveBrowserGpuMode, resolveHeadlessShellPath, type CaptureMode, } from "./browserManager.js"; @@ -115,9 +116,14 @@ export async function createCaptureSession( const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot; const preMode: CaptureMode = headlessShell && isLinux && !forceScreenshot ? "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, + { ...config, browserGpuMode: resolvedGpuMode }, ); const { browser, captureMode } = await acquireBrowser(chromeArgs, config); From 222164772871bea50bae4c9225476970506d1efa Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 May 2026 06:28:04 +0000 Subject: [PATCH 2/4] refactor(cli): unify RenderOptions on browserGpuMode tri-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial PR carried a backwards-compat shim where RenderOptions had both `browserGpu?: boolean` (for docker) and `browserGpuMode?` (for local). Since renderLocal/renderDocker have no external callers, simplify to a single field. The boolean → docker-args conversion now happens inline at the one site that needs it (`browserGpu: options.browserGpuMode === "hardware"` when handing off to dockerRunArgs). No behaviour change. 535/535 engine + 256/256 CLI still pass. --- packages/cli/src/commands/render.test.ts | 10 +++++----- packages/cli/src/commands/render.ts | 15 ++++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 915475c37..b3ff435d6 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -60,7 +60,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, }); @@ -98,7 +98,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: true, + browserGpuMode: "hardware", hdrMode: "auto", quiet: true, }); @@ -134,7 +134,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, variables: { title: "Hello", count: 3 }, @@ -150,7 +150,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: false, + browserGpuMode: "software", hdrMode: "auto", quiet: true, }); @@ -172,7 +172,7 @@ describe("renderLocal browser GPU config", () => { quality: "standard", format: "mp4", gpu: false, - browserGpu: true, + browserGpuMode: "hardware", hdrMode: "auto", quiet: true, exitAfterComplete: true, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 53ff63fb9..ae2c8df8f 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -401,7 +401,7 @@ export default defineCommand({ format, workers, gpu: useGpu, - browserGpu: browserGpuMode === "hardware", + browserGpuMode, hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, videoBitrate, @@ -436,14 +436,11 @@ interface RenderOptions { workers?: number; gpu: boolean; /** - * For local renders: tri-state ("auto" | "hardware" | "software"). "auto" - * probes WebGL availability on first launch and falls back to software. - * Docker renders use the boolean `browserGpu` field instead because the - * docker run-args wire the inner CLI's `--no-browser-gpu` flag. + * Chrome WebGL backend mode. "auto" probes on first launch and falls back + * to "software" if no usable GPU. Defaults to "software" when omitted to + * stay backwards-compatible with callers that pre-date the tri-state. */ browserGpuMode?: "auto" | "hardware" | "software"; - /** Docker-only: true → host GPU passthrough, false → forced software. */ - browserGpu?: boolean; hdrMode: "auto" | "force-hdr" | "force-sdr"; crf?: number; videoBitrate?: string; @@ -737,7 +734,7 @@ async function renderDocker( format: options.format, workers: options.workers, gpu: options.gpu, - browserGpu: options.browserGpu ?? false, + browserGpu: options.browserGpuMode === "hardware", hdrMode: options.hdrMode, crf: options.crf, videoBitrate: options.videoBitrate, @@ -807,7 +804,7 @@ export async function renderLocal( workers: options.workers, useGpu: options.gpu, producerConfig: producer.resolveConfig({ - browserGpuMode: options.browserGpuMode ?? (options.browserGpu ? "hardware" : "software"), + browserGpuMode: options.browserGpuMode ?? "software", }), hdrMode: options.hdrMode, crf: options.crf, From f635deb86a75782567deb0524eb76350b9961e6c Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 May 2026 17:20:14 +0000 Subject: [PATCH 3/4] feat(engine): cache probe Promise + log resolved mode + sync docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from Vai's staff-eng review: 1. Concurrent-probe race (real bug): the parallel coordinator runs N workers via Promise.all, so `--workers 4` on a no-GPU host fired 4 simultaneous probe Chromes — each paying the same 240 ms launch cost. Cache the *Promise* (not the resolved value): first caller assigns the in-flight Promise, every other concurrent caller awaits the same one. Verified with a new test asserting all concurrent callers get the identical Promise reference. 2. Stale rendering.md (lines 23, 29): user-visible contract said "browser GPU enabled by default", which was wrong post-auto. Now describes the auto / hardware / software trichotomy explicitly. 3. Silent fallback: auto-mode produced no output, so a regression to "always falls back to software even with GPU present" would have been invisible in production logs. Added a single stderr line per process when the probe resolves: `[hyperframes] browserGpuMode auto → ()`. Cache hits don't re-log. Verification: - Engine 536/536 (incl. new concurrent-dedup test asserting Promise reference equality across simultaneous callers) - CLI 256/256 - Format / lint / typecheck clean --- packages/cli/src/docs/rendering.md | 4 +- .../src/services/browserManager.test.ts | 23 +++ .../engine/src/services/browserManager.ts | 134 ++++++++++-------- 3 files changed, 103 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/docs/rendering.md b/packages/cli/src/docs/rendering.md index 15a4c44f5..c2b49c466 100644 --- a/packages/cli/src/docs/rendering.md +++ b/packages/cli/src/docs/rendering.md @@ -20,13 +20,13 @@ Requires: Docker installed and running. - `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`) - `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`) - `--gpu` — Use GPU encoding (NVENC, VideoToolbox, VAAPI, QSV) -- `--browser-gpu` / `--no-browser-gpu` — Use or opt out of host GPU acceleration for local Chrome/WebGL capture (enabled by default for local renders, disabled in Docker) +- `--browser-gpu` / `--no-browser-gpu` — Force host GPU or software (SwiftShader) for Chrome/WebGL capture. Default for local renders is `auto` — probe WebGL availability on first launch and fall back to software if no GPU is reachable. Docker mode always uses software. - `-o, --output` — Custom output path ## Tips - Use `draft` quality for fast previews during development -- Local renders use browser GPU capture automatically; use `--no-browser-gpu` to compare against the software-browser path +- Local renders auto-detect GPU on first launch; use `--browser-gpu` to force hardware (errors if no GPU) or `--no-browser-gpu` to force SwiftShader - Use `--gpu` when a local render also benefits from hardware FFmpeg encoding - Use `npx hyperframes benchmark` to find optimal settings - 4 workers is usually the sweet spot for most compositions diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index 4e6827ead..08af34802 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -103,6 +103,29 @@ describe("resolveBrowserGpuMode", () => { const third = await resolveBrowserGpuMode("hardware"); expect(third).toBe("hardware"); }); + + it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => { + // Parallel coordinator fires N workers via Promise.all — without Promise- + // level caching, a `--workers 4` render against a no-GPU host would launch + // 4 simultaneous probe Chromes. Verify all concurrent callers get the + // exact same Promise reference (proving the probe runs once, not N times). + const p1 = resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + const p2 = resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + const p3 = resolveBrowserGpuMode("auto", { + chromePath: "/definitely/not/a/real/chrome/binary", + browserTimeout: 2000, + }); + expect(p1).toBe(p2); + expect(p2).toBe(p3); + const results = await Promise.all([p1, p2, p3]); + expect(results).toEqual(["software", "software", "software"]); + }); }); describe("forceReleaseBrowser", () => { diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index e5fb48615..714b9a938 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -137,15 +137,18 @@ async function probeBeginFrameSupport(browser: Browser): Promise { } /** - * Cached result of `resolveBrowserGpuMode("auto", ...)` for the lifetime of - * this process. The probe launches a transient Chrome with hardware args and - * checks whether `canvas.getContext("webgl")` returns a context. Result is - * memoised because the answer is a property of the host (GPU/driver - * availability) and cannot meaningfully change inside one process. + * Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`. + * + * Caching the Promise (rather than the resolved value) deduplicates concurrent + * callers — the parallel coordinator runs N workers via `Promise.all`, so a + * `--workers 4` render against a no-GPU host would otherwise fire 4 + * simultaneous probe Chromes. The first call assigns the Promise and every + * other concurrent caller awaits the same one, paying the ~240 ms probe cost + * exactly once per process lifetime. * * Exported for tests; production callers go through `resolveBrowserGpuMode`. */ -export let _autoBrowserGpuModeCache: "software" | "hardware" | undefined; +export let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined; /** Test-only: reset the cached probe result. */ export function _resetAutoBrowserGpuModeCacheForTests(): void { @@ -158,8 +161,8 @@ export function _resetAutoBrowserGpuModeCacheForTests(): void { * For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"` * it launches a tiny Chrome with the platform's hardware GPU args, runs a * one-shot WebGL availability probe, and falls back to `"software"` if - * hardware-mode WebGL is unavailable. The probe result is cached for the - * process lifetime — a multi-render run pays the ~1-2 s cost once. + * hardware-mode WebGL is unavailable. The Promise is cached for the process + * lifetime, so concurrent callers (parallel workers) share the same probe. * * Any failure (Chrome launch error, navigation timeout, missing canvas API, * etc.) is treated as a `"software"` fallback. The render path with @@ -167,7 +170,7 @@ export function _resetAutoBrowserGpuModeCacheForTests(): void { * safe failure mode; misclassifying toward hardware would error on the real * render. */ -export async function resolveBrowserGpuMode( +export function resolveBrowserGpuMode( mode: EngineConfig["browserGpuMode"], options: { chromePath?: string; @@ -175,58 +178,77 @@ export async function resolveBrowserGpuMode( platform?: NodeJS.Platform; } = {}, ): Promise<"software" | "hardware"> { - if (mode !== "auto") return mode; - if (_autoBrowserGpuModeCache !== undefined) return _autoBrowserGpuModeCache; - - const platform = options.platform ?? process.platform; - const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; - const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); - - const probeArgs = [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - "--enable-webgl", - "--ignore-gpu-blocklist", - ...getBrowserGpuArgs("hardware", platform), - ]; - - const ppt = await getPuppeteer().catch(() => null); - if (!ppt) { - _autoBrowserGpuModeCache = "software"; - return _autoBrowserGpuModeCache; - } + if (mode !== "auto") return Promise.resolve(mode); + if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache; + + _autoBrowserGpuModeCache = (async () => { + const platform = options.platform ?? process.platform; + const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout; + const executablePath = options.chromePath ?? resolveHeadlessShellPath({}); + + const probeArgs = [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--enable-webgl", + "--ignore-gpu-blocklist", + ...getBrowserGpuArgs("hardware", platform), + ]; + + const ppt = await getPuppeteer().catch(() => null); + if (!ppt) { + logResolvedBrowserGpuMode("software", "puppeteer unavailable"); + return "software" as const; + } - let probeBrowser: Browser | undefined; - try { - probeBrowser = await ppt.launch({ - headless: true, - args: probeArgs, - defaultViewport: { width: 64, height: 64 }, - executablePath, - timeout: browserTimeout, - }); - const page = await probeBrowser.newPage(); - const hasWebGL = await page.evaluate(() => { - try { - const c = document.createElement("canvas"); - const gl = - c.getContext("webgl") || (c.getContext("experimental-webgl") as RenderingContext | null); - return gl !== null; - } catch { - return false; - } - }); - _autoBrowserGpuModeCache = hasWebGL ? "hardware" : "software"; - } catch { - _autoBrowserGpuModeCache = "software"; - } finally { - await probeBrowser?.close().catch(() => {}); - } + let probeBrowser: Browser | undefined; + try { + probeBrowser = await ppt.launch({ + headless: true, + args: probeArgs, + defaultViewport: { width: 64, height: 64 }, + executablePath, + timeout: browserTimeout, + }); + const page = await probeBrowser.newPage(); + const hasWebGL = await page.evaluate(() => { + try { + const c = document.createElement("canvas"); + const gl = + c.getContext("webgl") || + (c.getContext("experimental-webgl") as RenderingContext | null); + return gl !== null; + } catch { + return false; + } + }); + const resolved = hasWebGL ? ("hardware" as const) : ("software" as const); + logResolvedBrowserGpuMode(resolved, hasWebGL ? "WebGL probe succeeded" : "WebGL unavailable"); + return resolved; + } catch (err) { + logResolvedBrowserGpuMode( + "software", + `probe failed (${err instanceof Error ? err.message : String(err)})`, + ); + return "software" as const; + } finally { + await probeBrowser?.close().catch(() => {}); + } + })(); return _autoBrowserGpuModeCache; } +/** + * Single observability surface for the auto-detect outcome. Logged exactly + * once per process (the probe runs once); without this line, a regression + * to "always software even with a GPU present" would be invisible in + * production. Goes to stderr to stay out of stdout pipelines. + */ +function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void { + console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`); +} + export async function acquireBrowser( chromeArgs: string[], config?: Partial< From 37e3b365251ad71e2455027a8c5905f5ea4aac4a Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 May 2026 17:34:42 +0000 Subject: [PATCH 4/4] chore: oxfmt sweep for two unformatted files on main Two files on main fail `bun run format:check`: - `registry/registry.json` (missing trailing newline) - `registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html` (whitespace + quote-style) Local format-check on the auto-detect-browser-gpu branch only flagged these once rebased onto current main (post-#647 / v0.5.2). Pure whitespace fix; no semantic change. --- .../vfx-liquid-glass/vfx-liquid-glass.html | 74 ++++++++++++++----- registry/registry.json | 2 +- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html b/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html index 657dc7deb..2ac7ac054 100644 --- a/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html +++ b/registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html @@ -52,20 +52,34 @@ overflow: hidden; } .text-source::before { - content: ''; + content: ""; position: absolute; - width: 700px; height: 700px; border-radius: 50%; - background: radial-gradient(circle, rgba(0,212,255,0.06) 0%, transparent 70%); - top: -150px; right: -100px; pointer-events: none; + width: 700px; + height: 700px; + border-radius: 50%; + background: radial-gradient(circle, rgba(0, 212, 255, 0.06) 0%, transparent 70%); + top: -150px; + right: -100px; + pointer-events: none; } .text-source::after { - content: ''; + content: ""; position: absolute; - width: 500px; height: 500px; border-radius: 50%; - background: radial-gradient(circle, rgba(124,58,237,0.05) 0%, transparent 70%); - bottom: -100px; left: -50px; pointer-events: none; + width: 500px; + height: 500px; + border-radius: 50%; + background: radial-gradient(circle, rgba(124, 58, 237, 0.05) 0%, transparent 70%); + bottom: -100px; + left: -50px; + pointer-events: none; + } + .badge { + font-size: 14px; + font-weight: 600; + letter-spacing: 3px; + text-transform: uppercase; + color: rgba(0, 212, 255, 0.8); } - .badge { font-size: 14px; font-weight: 600; letter-spacing: 3px; text-transform: uppercase; color: rgba(0,212,255,0.8); } .text-source h1 { font-size: 148px; font-weight: 900; @@ -83,7 +97,7 @@ .subtitle { font-size: 28px; font-weight: 400; - color: rgba(255,255,255,0.35); + color: rgba(255, 255, 255, 0.35); max-width: 800px; line-height: 1.5; } @@ -92,9 +106,23 @@ gap: 48px; margin-top: 16px; } - .stat { text-align: center; } - .stat-val { font-size: 42px; font-weight: 800; color: #00d4ff; letter-spacing: -1px; } - .stat-label { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 2px; margin-top: 4px; } + .stat { + text-align: center; + } + .stat-val { + font-size: 42px; + font-weight: 800; + color: #00d4ff; + letter-spacing: -1px; + } + .stat-label { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + letter-spacing: 2px; + margin-top: 4px; + } @@ -125,11 +153,23 @@
Write HTML → Render Video

Ship videos 10x faster

-
HTML is the source of truth for video. No timeline editors, no After Effects — just code.
+
+ HTML is the source of truth for video. No timeline editors, no After Effects — just + code. +
-
47x
Faster than AE
-
12.4K
Creators
-
2.4M
Videos Rendered
+
+
47x
+
Faster than AE
+
+
+
12.4K
+
Creators
+
+
+
2.4M
+
Videos Rendered
+
diff --git a/registry/registry.json b/registry/registry.json index 52660dc00..596c4494b 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -252,4 +252,4 @@ "type": "hyperframes:block" } ] -} \ No newline at end of file +}