feat(engine): browserGpuMode "auto" — probe WebGL once, fall back to software

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.
This commit is contained in:
James
2026-05-06 17:33:55 +00:00
parent 7174c4cdcd
commit 67bb56c703
8 changed files with 263 additions and 28 deletions
+31 -6
View File
@@ -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 () => {
+44 -14
View File
@@ -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,