Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"guides/hyperframes-vs-remotion",
"guides/gsap-animation",
"guides/rendering",
"guides/gpu-capture",
"guides/hdr",
"guides/performance",
"guides/timeline-editing",
Expand Down
109 changes: 109 additions & 0 deletions docs/guides/gpu-capture.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
title: GPU Frame Capture
description: Opt in to hardware GPU rendering in the headless-Chrome frame capture stage for faster renders on shader-heavy compositions.
---

HyperFrames renders in five stages. Stage 4 — **frame capture** — opens every
HTML frame in headless Chromium and takes a screenshot. By default Chromium
runs WebGL through the **SwiftShader** software rasterizer for maximum
cross-platform compatibility. On shader-heavy compositions that software
fallback becomes the render bottleneck.

Enable `--gpu-capture` to switch Chromium to a hardware ANGLE backend
(Direct3D 11 on Windows, Metal on macOS, OpenGL on Linux) and let your GPU
rasterize instead.

## When to use it

- Your render feels slow and the composition uses shader blocks from the
registry (`glitch`, `swirl-vortex`, `sdf-iris`, `light-leak`, etc.) or your
own WebGL canvases.
- You're on a workstation with a working GPU driver (NVIDIA / AMD / Intel on
Windows or Linux, any Apple Silicon or Intel Mac on macOS).
- You've already tried `--gpu` (stage-5 NVENC encoding) and still have
headroom to improve wall time.

Skip it when:

- You're in a CI or Docker environment without GPU passthrough. The default
SwiftShader path Just Works.
- Your composition is pure DOM/text with no WebGL. Expected speedup is
marginal (~10–25%) and the compatibility trade-off isn't worth it.

## Usage

```bash
hyperframes render --gpu --gpu-capture --output render.mp4
```

`--gpu-capture` is independent of `--gpu` — you can enable either, both, or
neither. `--gpu` drives stage-5 NVENC encoding; `--gpu-capture` drives the
stage-4 Chromium backend.

Set the env var `HYPERFRAMES_GPU_CAPTURE=1` (or `=true`) in your shell or CI
config to enable without passing the flag every invocation:

```bash
export HYPERFRAMES_GPU_CAPTURE=1
hyperframes render --output render.mp4
```

## Platform matrix

| OS | Backend selected | Default available out of the box |
| ----------- | ---------------- | --------------------------------------------------------- |
| Windows | `d3d11` | Yes on any machine with an installed display driver |
| macOS | `metal` | Yes on any Intel/Apple-Silicon Mac |
| Linux | `opengl` | Usually — needs Mesa/ANGLE libs. Headless CI may need extra setup. |

On Docker: the host needs `nvidia-container-runtime` (NVIDIA) or equivalent
for GPU passthrough, and the container image must include the GL libs. If GPU
capture silently falls back to software inside a container, leave `--gpu-capture`
off — the env var stays off and Chromium uses SwiftShader.

## Visual equivalence

GPU and software rasterization can disagree by 1–2 pixels at anti-aliased
edges. In practice:

- Average PSNR between a CPU-captured render and a GPU-captured render is
>50 dB on shader-heavy rigs, well above the 40 dB visual-equivalence
threshold.
- Deterministic output is preserved within the GPU backend — the same
composition on the same machine produces byte-identical frames across runs.

If pixel-exact reproducibility across different rendering machines matters
(e.g. regression tests with image diffs), stick with the SwiftShader default.
If wall-time matters more, opt in.

## Diagnosing silent fallback

If you suspect Chromium is still using SwiftShader despite the flag:

```js
// snippet for a one-off probe script
const puppeteer = require("puppeteer-core");
const browser = await puppeteer.launch({
headless: true,
args: ["--use-gl=angle", "--use-angle=d3d11", "--ignore-gpu-blocklist"],
});
const page = await browser.newPage();
await page.setContent('<canvas id="c"></canvas>');
const info = await page.evaluate(() => {
const gl = document.getElementById("c").getContext("webgl");
const ext = gl.getExtension("WEBGL_debug_renderer_info");
return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
});
console.log(info);
await browser.close();
```

A healthy result looks like `ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 …
Direct3D11 …)`. If you see `SwiftShader Device`, the hardware backend didn't
activate — usually a driver or Docker-passthrough issue.

## Related

- `--gpu` — hardware NVENC encoding in stage 5 (see [Rendering](./rendering)).
- `--disable-gpu` — force-disable GPU entirely, including the capture stage
and any driver initialisation.
13 changes: 12 additions & 1 deletion packages/cli/src/capture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ export async function captureWebsite(
const { ensureBrowser } = await import("../browser/manager.js");
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
// Honor HYPERFRAMES_GPU_CAPTURE for parity with the render command's
// --gpu-capture flag. On Windows use D3D11, macOS Metal, Linux OpenGL.
const gpuCapture =
process.env.HYPERFRAMES_GPU_CAPTURE === "true" || process.env.HYPERFRAMES_GPU_CAPTURE === "1";
const angleBackend = !gpuCapture
? "--use-angle=swiftshader"
: process.platform === "win32"
? "--use-angle=d3d11"
: process.platform === "darwin"
? "--use-angle=metal"
: "--use-angle=opengl";
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
Expand All @@ -81,7 +92,7 @@ export async function captureWebsite(
"--enable-webgl",
"--ignore-gpu-blocklist",
"--use-gl=angle",
"--use-angle=swiftshader",
angleBackend,
"--disable-blink-features=AutomationControlled",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export default defineCommand({
description: "Target video bitrate such as 10M. Mutually exclusive with --crf.",
},
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
"gpu-capture": {
type: "boolean",
description:
"Use hardware GPU for frame capture (ANGLE backend: D3D11/Metal/OpenGL). " +
"Default is SwiftShader software GL. Opt in for faster captures on shader-heavy " +
"compositions; requires working GPU drivers. Env fallback: HYPERFRAMES_GPU_CAPTURE=1.",
default: false,
},
quiet: {
type: "boolean",
description: "Suppress verbose output",
Expand Down Expand Up @@ -158,6 +166,14 @@ export default defineCommand({
workers = parsed;
}

// ── Thread --gpu-capture into engine config via env var ────────────
// Matches the pattern used for PRODUCER_MAX_CONCURRENT_RENDERS below:
// resolveConfig() picks up HYPERFRAMES_GPU_CAPTURE in the engine worker
// processes, avoiding per-worker RenderJob plumbing.
if (args["gpu-capture"]) {
process.env.HYPERFRAMES_GPU_CAPTURE = "true";
}

// ── Validate max-concurrent-renders ─────────────────────────────────
if (args["max-concurrent-renders"] != null) {
const parsed = parseInt(args["max-concurrent-renders"], 10);
Expand Down Expand Up @@ -309,6 +325,7 @@ export default defineCommand({
format,
workers: workerCount,
gpu: useGpu,
gpuCapture: Boolean(args["gpu-capture"]),
hdr: args.hdr ?? false,
crf,
videoBitrate,
Expand All @@ -321,6 +338,7 @@ export default defineCommand({
format,
workers: workerCount,
gpu: useGpu,
gpuCapture: Boolean(args["gpu-capture"]),
hdr: args.hdr ?? false,
crf,
videoBitrate,
Expand All @@ -337,6 +355,7 @@ interface RenderOptions {
format: "mp4" | "webm" | "mov";
workers: number;
gpu: boolean;
gpuCapture: boolean;
hdr: boolean;
crf?: number;
videoBitrate?: string;
Expand Down Expand Up @@ -461,6 +480,7 @@ async function renderDocker(
format: options.format,
workers: options.workers,
gpu: options.gpu,
gpuCapture: options.gpuCapture,
hdr: options.hdr,
crf: options.crf,
videoBitrate: options.videoBitrate,
Expand Down
49 changes: 48 additions & 1 deletion packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const BASE: DockerRenderOptions = {
format: "mp4",
workers: 4,
gpu: false,
gpuCapture: false,
hdr: false,
crf: undefined,
videoBitrate: undefined,
Expand Down Expand Up @@ -53,7 +54,15 @@ describe("buildDockerRunArgs", () => {
expect(
buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpu: true, hdr: true, crf: 18, videoBitrate: undefined, quiet: true },
options: {
...BASE,
gpu: true,
gpuCapture: true,
hdr: true,
crf: 18,
videoBitrate: undefined,
quiet: true,
},
}),
).toMatchInlineSnapshot(`
[
Expand Down Expand Up @@ -84,6 +93,7 @@ describe("buildDockerRunArgs", () => {
"18",
"--quiet",
"--gpu",
"--gpu-capture",
"--hdr",
]
`);
Expand All @@ -105,6 +115,41 @@ describe("buildDockerRunArgs", () => {
expect(args).not.toContain("--hdr");
});

// Forwarding gap caught in PR #471 review: --gpu-capture is plumbed
// through host env (process.env.HYPERFRAMES_GPU_CAPTURE), which the
// container never sees. The containerized CLI must receive the flag
// explicitly so it can re-export the env var inside the container before
// its engine workers spawn.
it("forwards --gpu-capture to the container when gpuCapture is enabled", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpuCapture: true },
});
expect(args).toContain("--gpu-capture");
});

it("omits --gpu-capture when gpuCapture is disabled", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--gpu-capture");
});

it("--gpu-capture is independent of --gpu (each forwards on its own)", () => {
const captureOnly = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpuCapture: true },
});
expect(captureOnly).toContain("--gpu-capture");
expect(captureOnly).not.toContain("--gpu");
expect(captureOnly).not.toContain("--gpus");

const encodeOnly = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpu: true },
});
expect(encodeOnly).toContain("--gpu");
expect(encodeOnly).not.toContain("--gpu-capture");
});

it("requests host GPU passthrough only when gpu is enabled", () => {
const off = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(off).not.toContain("--gpus");
Expand All @@ -130,6 +175,7 @@ describe("buildDockerRunArgs", () => {
format: "webm",
workers: 8,
gpu: true,
gpuCapture: true,
hdr: true,
crf: 16,
videoBitrate: undefined,
Expand All @@ -147,6 +193,7 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("16");
expect(args).toContain("--quiet");
expect(args).toContain("--gpu");
expect(args).toContain("--gpu-capture");
expect(args).toContain("--hdr");
});

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface DockerRenderOptions {
format: "mp4" | "webm" | "mov";
workers: number;
gpu: boolean;
gpuCapture: boolean;
hdr: boolean;
crf?: number;
videoBitrate?: string;
Expand Down Expand Up @@ -60,6 +61,10 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
...(options.quiet ? ["--quiet"] : []),
...(options.gpu ? ["--gpu"] : []),
// The containerized CLI re-runs `hyperframes render` with this flag,
// which exports HYPERFRAMES_GPU_CAPTURE=true inside the container before
// the engine workers spawn — same code path as a local render.
...(options.gpuCapture ? ["--gpu-capture"] : []),
...(options.hdr ? ["--hdr"] : []),
];
}
33 changes: 33 additions & 0 deletions packages/engine/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,39 @@ describe("resolveConfig", () => {
expect(config.disableGpu).toBe(false);
});

it("defaults gpuCapture to false (software GL fallback)", () => {
const config = resolveConfig();
expect(config.gpuCapture).toBe(false);
});

it("reads HYPERFRAMES_GPU_CAPTURE=true as gpuCapture on", () => {
setEnv("HYPERFRAMES_GPU_CAPTURE", "true");

const config = resolveConfig();
expect(config.gpuCapture).toBe(true);
});

it("reads HYPERFRAMES_GPU_CAPTURE=1 as gpuCapture on (local-dev shorthand)", () => {
setEnv("HYPERFRAMES_GPU_CAPTURE", "1");

const config = resolveConfig();
expect(config.gpuCapture).toBe(true);
});

it("treats other HYPERFRAMES_GPU_CAPTURE values as false", () => {
setEnv("HYPERFRAMES_GPU_CAPTURE", "yes");

const config = resolveConfig();
expect(config.gpuCapture).toBe(false);
});

it("explicit gpuCapture override beats env var", () => {
setEnv("HYPERFRAMES_GPU_CAPTURE", "true");

const config = resolveConfig({ gpuCapture: false });
expect(config.gpuCapture).toBe(false);
});

it("explicit overrides take precedence over env vars", () => {
setEnv("PRODUCER_CORES_PER_WORKER", "5");

Expand Down
Loading
Loading