Skip to content
Open
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
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.
9 changes: 8 additions & 1 deletion packages/cli/src/capture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ export async function captureWebsite(
const { ensureBrowser } = await import("../browser/manager.js");
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const { resolveAngleBackend } = await import("@hyperframes/engine");
// Honor HYPERFRAMES_GPU_CAPTURE for parity with the render command's
// --gpu-capture flag. Backend selection lives in the engine so capture
// and the render pipeline pick the same flag on every platform.
const gpuCapture =
process.env.HYPERFRAMES_GPU_CAPTURE === "true" || process.env.HYPERFRAMES_GPU_CAPTURE === "1";
const angleBackend = resolveAngleBackend(gpuCapture);
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
Expand All @@ -81,7 +88,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
81 changes: 72 additions & 9 deletions 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,20 +115,71 @@ describe("buildDockerRunArgs", () => {
expect(args).not.toContain("--hdr");
});

it("requests host GPU passthrough only when gpu is enabled", () => {
// 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 and --gpu forward independently into the container", () => {
// The two CLI flags do separate things (NVENC encode vs hardware frame
// capture) — each is forwarded on its own so the containerized CLI can
// re-enable just the one the user asked for.
const captureOnly = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpuCapture: true },
});
expect(captureOnly).toContain("--gpu-capture");
expect(captureOnly).not.toContain("--gpu");

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

// Footgun caught in PR #471 review: --gpu-capture without --gpu used to
// forward the flag into the container but never request host GPU passthrough,
// so Chromium silently fell back to swiftshader. Either flag on its own
// implies `--gpus all` now.
it("requests host GPU passthrough when --gpu OR --gpu-capture is enabled", () => {
const off = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(off).not.toContain("--gpus");
expect(off).not.toContain("--gpu");

const on = buildDockerRunArgs({
const encodeOnly = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpu: true },
});
// `--gpus all` is a docker run flag (host passthrough); `--gpu` is the
// hyperframes CLI flag forwarded into the container — both must be set.
expect(on).toContain("--gpus");
expect(on).toContain("all");
expect(on).toContain("--gpu");
expect(encodeOnly).toContain("--gpus");
expect(encodeOnly).toContain("all");

const captureOnly = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpuCapture: true },
});
expect(captureOnly).toContain("--gpus");
expect(captureOnly).toContain("all");

// `--gpus all` should appear exactly once even when both flags are set.
const both = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, gpu: true, gpuCapture: true },
});
expect(both.filter((a) => a === "--gpus")).toHaveLength(1);
});

it("forwards every renderer-shaped option (regression tripwire for silent drops)", () => {
Expand All @@ -130,6 +191,7 @@ describe("buildDockerRunArgs", () => {
format: "webm",
workers: 8,
gpu: true,
gpuCapture: true,
hdr: true,
crf: 16,
videoBitrate: undefined,
Expand All @@ -147,6 +209,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
12 changes: 10 additions & 2 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 All @@ -38,8 +39,11 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
"--platform",
"linux/amd64",
"--shm-size=2g",
// GPU encoding requires host GPU passthrough.
...(options.gpu ? ["--gpus", "all"] : []),
// GPU encoding (--gpu) needs NVENC; hardware frame capture (--gpu-capture)
// needs ANGLE talking to the host GPU. Either flag, on its own, requires
// `--gpus all` — without it, Chromium silently falls back to swiftshader
// inside the container and the user gets no perf benefit.
...(options.gpu || options.gpuCapture ? ["--gpus", "all"] : []),
"-v",
`${projectDir}:/project:ro`,
"-v",
Expand All @@ -60,6 +64,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"] : []),
];
}
Loading
Loading