Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe("renderLocal browser GPU config", () => {
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
Expand All @@ -72,14 +72,33 @@ 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", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: true,
browserGpuMode: "hardware",
hdrMode: "auto",
quiet: true,
});
Expand All @@ -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 () => {
Expand All @@ -109,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 },
Expand All @@ -125,7 +150,7 @@ describe("renderLocal browser GPU config", () => {
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
Expand All @@ -147,7 +172,7 @@ describe("renderLocal browser GPU config", () => {
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: true,
browserGpuMode: "hardware",
hdrMode: "auto",
quiet: true,
exitAfterComplete: true,
Expand Down
55 changes: 41 additions & 14 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(" + ")));
}
Expand Down Expand Up @@ -397,7 +401,7 @@ export default defineCommand({
format,
workers,
gpu: useGpu,
browserGpu: useBrowserGpu,
browserGpuMode,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
crf,
videoBitrate,
Expand All @@ -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,
Expand All @@ -431,7 +435,12 @@ interface RenderOptions {
format: "mp4" | "webm" | "mov";
workers?: number;
gpu: boolean;
browserGpu: boolean;
/**
* 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";
hdrMode: "auto" | "force-hdr" | "force-sdr";
crf?: number;
videoBitrate?: string;
Expand Down Expand Up @@ -579,15 +588,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";
Expand Down Expand Up @@ -707,7 +734,7 @@ async function renderDocker(
format: options.format,
workers: options.workers,
gpu: options.gpu,
browserGpu: options.browserGpu,
browserGpu: options.browserGpuMode === "hardware",
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
Expand Down Expand Up @@ -777,7 +804,7 @@ export async function renderLocal(
workers: options.workers,
useGpu: options.gpu,
producerConfig: producer.resolveConfig({
browserGpuMode: options.browserGpu ? "hardware" : "software",
browserGpuMode: options.browserGpuMode ?? "software",
}),
hdrMode: options.hdrMode,
crf: options.crf,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions packages/engine/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
13 changes: 9 additions & 4 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -173,7 +178,7 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): 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;
};

Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {
acquireBrowser,
releaseBrowser,
resolveHeadlessShellPath,
resolveBrowserGpuMode,
buildChromeArgs,
ENABLE_BROWSER_POOL,
type BuildChromeArgsOptions,
Expand Down
84 changes: 82 additions & 2 deletions packages/engine/src/services/browserManager.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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");
});

Expand Down Expand Up @@ -48,6 +54,80 @@ 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");
});

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", () => {
it("kills the browser process and disconnects", () => {
const killFn = vi.fn(() => true);
Expand Down
Loading
Loading