Skip to content

feat: add browser GPU render mode#571

Merged
miguel-heygen merged 1 commit intomainfrom
feat/browser-gpu-render-mode
Apr 30, 2026
Merged

feat: add browser GPU render mode#571
miguel-heygen merged 1 commit intomainfrom
feat/browser-gpu-render-mode

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented Apr 29, 2026

Problem

HyperFrames already had --gpu, but that flag only controlled FFmpeg hardware encoding. The browser capture path still forced Chrome/WebGL through SwiftShader software GL via --use-angle=swiftshader, so WebGL-heavy local renders could leave the biggest bottleneck on the CPU path.

That made the existing flag naming easy to misread: --gpu sounded like it accelerated the whole render, but it did not change the browser frame-capture backend.

What this fixes

  • Enables host browser GPU acceleration automatically for local CLI renders.
  • Adds --no-browser-gpu as the local opt-out for software Chrome/WebGL capture.
  • Keeps --browser-gpu as an explicit local browser-GPU request.
  • Adds browserGpuMode: "software" | "hardware" to engine config, with PRODUCER_BROWSER_GPU_MODE env support for lower-level producer users.
  • Keeps Docker browser capture on the deterministic software path.
  • Maps hardware browser GPU mode to platform-native Chrome backends:
    • macOS: Metal-backed ANGLE
    • Windows: D3D11-backed ANGLE
    • Linux: EGL
  • Blocks explicit --browser-gpu --docker with a clear error because Docker browser GPU passthrough is not cross-platform.
  • Clarifies docs so --gpu means FFmpeg encoder GPU and browser GPU means Chrome/WebGL capture GPU.
  • Keeps encoder backend selection auto-detected from FFmpeg capabilities:
    • NVIDIA: NVENC
    • macOS: VideoToolbox
    • Linux: VAAPI
    • Intel: QSV

Why two flags

There are two separate GPU surfaces in the render pipeline:

  1. Browser GPU controls Chrome frame capture.

    • Affects WebGL, canvas, CSS rendering, compositing, and screenshot capture inside the browser.
    • This is enabled automatically for local CLI renders.
    • Use --no-browser-gpu when you want the software browser baseline.
  2. --gpu controls FFmpeg video encoding.

    • Affects the final encode step after frames have already been captured.
    • The concrete encoder is auto-detected from the host FFmpeg build and hardware.
    • It can be faster for some machines/codecs, but it is not equivalent to browser rendering acceleration.

The controls stay independent because users may want:

  • hyperframes render for the fast local default with browser GPU capture.
  • hyperframes render --no-browser-gpu for the software-browser local baseline.
  • hyperframes render --gpu for browser GPU capture plus hardware FFmpeg encoding.
  • hyperframes render --no-browser-gpu --gpu for software browser capture plus hardware FFmpeg encoding.
  • hyperframes render --docker for deterministic browser capture.

Why --gpu does not imply browser GPU

Keeping --gpu scoped to FFmpeg encoding avoids a semantic break and keeps the risk profile explicit:

  • --gpu already means encoder acceleration. Expanding it to also change Chrome capture would silently alter behavior for users who only wanted hardware encoding.
  • Browser GPU and encoder GPU have different portability. Encoder GPU can work in Docker when the host exposes the right devices; browser GPU passthrough is not cross-platform, so this PR intentionally blocks explicit --browser-gpu --docker.
  • The Apple presentation benchmark shows why the controls should stay separate: browser GPU capture was the useful improvement, while macOS VideoToolbox via --gpu was slower and produced larger output for this standard H.264 run.

If HyperFrames later wants a single umbrella acceleration control, it should be explicit, for example --acceleration browser|encoder|all or --gpu=browser|encoder|all, rather than changing the meaning of the existing boolean --gpu.

Root cause

buildChromeArgs() always injected --use-gl=angle --use-angle=swiftshader. disableGpu only appended --disable-gpu; it did not provide a hardware-GPU mode. That made the public --gpu flag look broader than it was, because render capture stayed software-backed even when encoder GPU was requested.

Verification

Local checks

  • bun install
  • bun run build:hyperframes-runtime
  • bun run --filter @hyperframes/engine test src/config.test.ts src/services/browserManager.test.ts
  • bun run --filter @hyperframes/cli test src/utils/dockerRunArgs.test.ts src/commands/render.test.ts
  • bun run --filter @hyperframes/cli typecheck
  • bun run --filter @hyperframes/engine typecheck
  • bun run --filter @hyperframes/producer typecheck
  • cd packages/producer && bunx vitest run src/services/renderOrchestrator.test.ts
  • bunx oxlint packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts packages/cli/src/utils/dockerRunArgs.ts packages/cli/src/utils/dockerRunArgs.test.ts packages/engine/src/config.ts packages/engine/src/config.test.ts packages/engine/src/services/browserManager.ts packages/engine/src/services/browserManager.test.ts packages/producer/src/services/renderOrchestrator.test.ts
  • bunx oxfmt --check ... on changed source/docs files
  • git diff --check
  • bun packages/cli/src/cli.ts render --help | rg -n "browser-gpu|no-browser-gpu|GPU"
  • bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --output /tmp/hf-auto-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict
    • Render plan prints GPU: browser GPU (auto).
  • bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --no-browser-gpu --output /tmp/hf-software-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict
    • Render plan does not print browser GPU.
  • bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --docker --browser-gpu --output /tmp/should-not-render.mp4
    • Exits 1 with Browser GPU is local-only.
  • buildDockerRunArgs() regression coverage asserts Docker container args include --no-browser-gpu, preventing nested container renders from re-enabling browser GPU through the local CLI default.
  • resolveBrowserGpuForCli() regression coverage asserts PRODUCER_BROWSER_GPU_MODE=software opts out when no CLI browser-GPU flag is supplied, while explicit --browser-gpu / --no-browser-gpu still win.
  • ffmpeg -v error -i /tmp/hf-auto-browser-gpu-smoke.mp4 -f null -
  • ffmpeg -v error -i /tmp/hf-software-browser-gpu-smoke.mp4 -f null -
  • ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of json /tmp/hf-browser-gpu-smoke.mp4 -> H.264, 1920x1080, 24fps, 5.0s

Apple presentation benchmark

Rendered /Users/miguel07code/Downloads/apple-presentation.zip as supplied after extracting to /tmp/hf-apple-profile/apple-presentation.

Fixed settings:

  • 1920x1080
  • 30fps
  • standard quality
  • 4240 frames
  • 141.32s duration
  • 8-worker cap; render auto-calibration used 6 capture workers
  • macOS host detected FFmpeg GPU encoder: videotoolbox
Mode Equivalent flags after this PR Wall time vs software-browser baseline Speed Capture Encode Output
Software browser + CPU encode --no-browser-gpu 120.77s baseline 1.17x 97.87s 10.04s 8.38MB
Browser GPU + CPU encode default local render 70.10s 42.0% faster 2.02x 50.72s 9.91s 8.39MB
Software browser + encoder GPU --no-browser-gpu --gpu 133.16s 10.3% slower 1.06x 103.58s 18.31s 25.43MB
Browser GPU + encoder GPU --gpu 74.12s 38.6% faster 1.91x 46.69s 17.93s 25.45MB

Result: browser GPU capture is the meaningful improvement for this WebGL/browser-capture-heavy presentation. VideoToolbox encoding was slower and produced larger files for this current standard H.264 path, so --gpu should stay separate and opt-in.

Why --gpu plus browser GPU was slower than browser GPU alone: the combined run captured about 4.0s faster than browser GPU alone, but VideoToolbox encoding was about 8.0s slower than CPU x264 encoding, so the encode loss outweighed the capture gain.

VideoToolbox flag check

I also isolated the encode stage against the already-captured Apple frames to check whether macOS GPU encoding only needed special flags.

ffmpeg -h encoder=h264_videotoolbox does not expose a CRF/CQ-style quality option like x264. It exposes bitrate-oriented and VideoToolbox-specific options such as -b:v, -realtime, -profile, -coder, -prio_speed, -power_efficient, and -allow_sw. That means our current -q:v mapping is not equivalent to x264 CRF and can produce very different bitrate/size behavior.

Measured full-frame encode variants on this host:

VideoToolbox variant Encode wall time Output size Bitrate
Current -q:v 64 -allow_sw 1 18.76s 25.31MB 1.43 Mbps
Current without -allow_sw 1 18.21s 25.31MB 1.43 Mbps
-b:v 500k -maxrate 750k -bufsize 1000k -profile high -coder cabac -realtime 1 -prio_speed 1 -power_efficient 0 20.58s 7.42MB 0.42 Mbps
Same with -b:v 1500k 20.84s 16.70MB 0.95 Mbps
-b:v 500k -profile baseline -coder cavlc -realtime 1 -prio_speed 1 -power_efficient 0 18.11s 8.94MB 0.51 Mbps

Conclusion: VideoToolbox can be made size/bitrate-predictable with explicit --video-bitrate, but the tested speed-oriented flags did not make it faster than CPU x264 wall time for this render. That reinforces keeping --gpu encoder acceleration explicit and separate from browser GPU capture.

Artifacts from the local benchmark:

  • /tmp/hf-apple-profile/results/cpu.mp4
  • /tmp/hf-apple-profile/results/browser-gpu.mp4
  • /tmp/hf-apple-profile/results/encoder-gpu.mp4
  • /tmp/hf-apple-profile/results/full-gpu.mp4
  • /tmp/hf-apple-profile/results/summary.json

All four benchmark MP4s completed ffprobe and full ffmpeg -f null decode checks.

Pixel comparison

Compared decoded MP4 output between software-browser and browser-GPU renders:

  • Apple presentation:
    • 4240 frames compared
    • 636 exact matching decoded frame hashes
    • 3604 different decoded frame hashes
    • Average PSNR: 57.79 dB
  • css-spinner-render-compat clean fixture:
    • 120 frames compared
    • 0 exact matching decoded frame hashes
    • Average PSNR: 61.57 dB

Interpretation: browser GPU output is not strict hash/pixel-identical to the software-browser path after lossy H.264 encode, but the measured deltas are visually tiny. Above 50 dB PSNR is typically visually indistinguishable for normal video review. Use --no-browser-gpu or Docker when strict cross-run/cross-machine reproducibility matters more than local speed.

Browser verification

  • Started HyperFrames Studio preview for packages/producer/tests/css-spinner-render-compat/src.
  • Used agent-browser to open http://localhost:5191#project/src and verify the composition loaded in Studio.
  • Screenshots:
    • /tmp/hf-gpu-browser-proof/preview-loaded.png
    • /tmp/hf-gpu-browser-proof/preview-playing.png
    • /tmp/hf-gpu-browser-proof/preview-frame-60.png
  • Agent-browser recordings:
    • /tmp/hf-gpu-browser-proof/preview-playback.webm
    • /tmp/hf-gpu-browser-proof/preview-seek.webm

Notes

  • Browser GPU is enabled automatically for local CLI renders and disabled in Docker.
  • --no-browser-gpu is the opt-out for software Chrome/WebGL capture.
  • --gpu remains encoder-only and opt-in.
  • The Apple presentation zip has existing lint errors around unmanaged nested videos and imperative media play() calls. The benchmark still compares the same supplied source across modes, but it should not be treated as a clean deterministic-composition fixture.

@mintlify
Copy link
Copy Markdown

mintlify Bot commented Apr 29, 2026

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hyperframes 🟢 Ready View Preview Apr 29, 2026, 11:36 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff re-review on the updated branch (50406c90):

  1. P2: --no-browser-gpu does not reliably opt out when the environment enables browser GPU.

    In packages/cli/src/commands/render.ts, producerConfig is only passed when options.browserGpu is true:

    producerConfig: options.browserGpu
      ? producer.resolveConfig({ browserGpuMode: "hardware" })
      : undefined,

    With PRODUCER_BROWSER_GPU_MODE=hardware hyperframes render --no-browser-gpu, the CLI sets options.browserGpu=false, but then passes producerConfig: undefined, so the producer later reads the env var and still launches Chrome with hardware browser GPU. The documented opt-out should pass an explicit software override for the false path, and this needs a CLI regression test for env + --no-browser-gpu.

  2. P3: Some docs still describe browser GPU as opt-in even though this version makes it the local default.

    docs/guides/rendering.mdx labels hyperframes render --gpu as “Hardware FFmpeg encoder only,” but that command now also uses browser GPU unless --no-browser-gpu is present. packages/cli/src/docs/rendering.md also documents --browser-gpu without the new default/opt-out behavior.

I did a static re-review only; I did not run the test suite.

@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

@vanceingalls ready for re-review. I addressed both discussion items: --no-browser-gpu now passes an explicit software override even when PRODUCER_BROWSER_GPU_MODE=hardware, added the focused CLI regression test, and updated the stale browser-GPU docs.

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Docker renders accidentally inherit browser GPU mode.

The outer CLI sets useBrowserGpu false for --docker, but renderDocker() drops that value when building the inner container command, and the Docker entrypoint runs hyperframes render "$@" without --docker. Inside the container, the new local default turns browser GPU back on, which becomes Linux EGL flags and undermines the deterministic software-GL Docker path.

Thread browserGpu through buildDockerRunArgs() and append --no-browser-gpu for Docker renders, or make CONTAINER=true default browser GPU off.

Open question: should PRODUCER_BROWSER_GPU_MODE=software be honored by the CLI default path? The engine reads it, but renderLocal() always passes an explicit override from the CLI boolean, so the env var cannot opt out unless the user also passes --no-browser-gpu.

@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

@vanceingalls fixed the Docker re-entry case too. Docker container args now always include --no-browser-gpu, with dockerRunArgs regression coverage. I also handled the open env question: PRODUCER_BROWSER_GPU_MODE=software opts out when no CLI browser-GPU flag is supplied, while explicit --browser-gpu / --no-browser-gpu still win.

@miguel-heygen miguel-heygen merged commit 395fb9c into main Apr 30, 2026
42 of 51 checks passed
Copy link
Copy Markdown
Collaborator Author

Merge activity

@miguel-heygen miguel-heygen deleted the feat/browser-gpu-render-mode branch April 30, 2026 04:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants