Skip to content

perf(web-renderer): overlap HTML-in-canvas capture with worker frame construction - #11861

Closed
alec-watts wants to merge 7 commits into
remotion-dev:mainfrom
alec-watts:codex/html-in-canvas-worker-capture
Closed

alec-watts wants to merge 7 commits into
remotion-dev:mainfrom
alec-watts:codex/html-in-canvas-worker-capture

Conversation

@alec-watts

@alec-watts alec-watts commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Native HTML-in-canvas capture can keep the main thread occupied with snapshot drawing and VideoFrame construction while the next React frame is ready to be prepared. This PR moves that capture work to a worker and keeps bounded submission overlap through the existing encoder, muxer, and audio mixer.

Maintainer-requested comparison

The revision includes a standalone replay harness, generated-asset recipe, source hashes, original Electron launch flags, and every individual result in packages/web-renderer/benchmarks/html-in-canvas-capture. Current main is merged, preserving its Chrome 157 nested-capture capability check.

Fresh controlled measurements use the original generated two-video assets and source head f096ab1826cff02082e39c6f207964d0c84da419, with serial baseline c320056a980972de109ef27a40bede9660a46931. Host: Apple M2 Max, Electron 44.3.0 / Chromium 152.0.7977.78, ANGLE Metal, React 19.2.3, public Mediabunny 1.56.1. Every export is 1920×1080, 30 fps, 1,800 frames, H.264 MP4, very-high bitrate, muted audio, default hardware acceleration preference, and medium page responsiveness.

One discarded full-length warmup per variant and four balanced measured rounds, fresh hidden windows:

Path Mean export
Original serial capture 21.336 s
Persistent main-thread layout canvas, serial submission 21.185 s
Persistent main-thread layout canvas, overlapped submission 21.037 s
Worker capture and overlapped submission 13.372 s

The worker is 36.4% faster than persistent main-thread capture with submission overlap in this cohort. Copy removal and submission overlap alone do not explain the measured benefit on this host.

A second cohort isolates placement more closely. Both variants use the same transferred canvas, immutable ElementImage snapshots, reset/draw/VideoFrame sequence, conditional resizing, and submission overlap. One warmup per variant and four alternating measured pairs: 22.988 s main thread versus 14.895 s worker. An initial setup that reset main-thread canvas dimensions on every frame was stopped and excluded; the retained comparison matches the worker's resize behavior.

All 30 full-length outputs, including warmups, pass frame count, timestamps, composition markers, and both source-video markers: 54,000 verified output frames. Worker runs capture all 1,800 frames in the worker, keep at most two requests pending, and terminate the worker. Results include phase totals, which overlap and must not be interpreted as additive CPU time. No GPU trace or process-memory measurement is claimed.

The preserved Electron flags enable ANGLE Metal and CanvasDrawElement, and each window disables background throttling. They do not disable frame-rate limiting or GPU vsync. This differs from the maintainer's headless Chrome/M4 run and may help explain the different benefit. These measurements do not establish a universal speedup or contradict that host's roughly 0.25% incremental gain.

Behavior and validation

  • Uses the existing allowHtmlInCanvas opt-in. Requires transferable captureElementImage snapshots and workers; no new public option.
  • onFrame/onArtifact callbacks and still exports keep their serial capture path. URL masks and unsupported nesting keep existing fallbacks.
  • Worker failures reject the export. Cancellation terminates the worker, rejects pending requests, closes unsubmitted frames, and drains encoding/audio operations before disposal.
  • Earlier native validation on this implementation covered full-frame RGBA equality, identical decoded AAC PCM, callbacks, cancellation, and injected worker errors. Those historical checks remain separate from this fresh performance comparison.
  • After merging current main, the portable replay harness runs all four paths and verifies short native exports. Focused renderer typecheck and changed-file ESLint pass, with two existing TODO warnings. Formatting, package-content dry run, and git diff --check pass. The dry run verifies the replay tools do not ship in the npm package. Full upstream CI remains a separate check.
  • Historical paired estimates are superseded by the comparative evidence above; the individual new runs and hashes remain in the committed results file.

Why each file changes

File Reason
packages/web-renderer/.npmignore Exclude the replay harness from the published runtime package.
packages/web-renderer/src/html-in-canvas-worker.ts Own transferred capture, frame handoff, and worker cancellation/error cleanup.
packages/web-renderer/src/html-in-canvas.ts Share the paint wait and defer context acquisition until the serial path needs it.
packages/web-renderer/src/render-media-on-web.tsx Overlap bounded capture/submission and drain owned work before disposal.
packages/web-renderer/src/take-screenshot.ts Share fallback decisions and preserve current main's nested-capture support.
packages/docs/docs/client-side-rendering/html-in-canvas.mdx Document eligibility and worker failure behavior.
packages/web-renderer/benchmarks/html-in-canvas-capture/browser.tsx Replay the authored scene, native capture instrumentation, and output checks requested in review.
packages/web-renderer/benchmarks/html-in-canvas-capture/build.ts Produce comparison variants without mutating the measured checkout.
packages/web-renderer/benchmarks/html-in-canvas-capture/run.cjs Run balanced cohorts in fresh hidden Electron windows with original flags.
packages/web-renderer/benchmarks/html-in-canvas-capture/generate-fixtures.py Recreate the synthetic source assets and frame markers.
packages/web-renderer/benchmarks/html-in-canvas-capture/results.json Retain all individual timings, output checks, dependency/asset identity, and phase totals.
packages/web-renderer/benchmarks/html-in-canvas-capture/README.md Explain the methodology, host differences, excluded setup, and replay commands.

Diff cleanup retains the render loop's try/finally indentation because it ensures outstanding work drains before encoder-source disposal. Benchmark variants are isolated evidence tools requested by the maintainer; they do not add a second production encoder, muxer, queue, or renderer.

Shared CI test repair

Includes the one-line caption-inspector track selector fix from #12179. Current main fails this test before it can exercise caption persistence. Both caption-inspector E2E cases pass in the actual Studio with this repair; restoring the old selector reproduces the failure.

File Reason
packages/example/e2e/captions-inspector.test.mts Repair the existing main-branch selector so shared CI can exercise caption persistence.

@vercel

vercel Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
bugs Ready Ready Preview Oct 9, 2026 9:11pm UTC
remotion Ready Ready Preview Oct 9, 2026 9:11pm UTC

Request Review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues found — a couple of rough edges worth a look, nothing blocking.

Reviewed changes

  • New worker capture path (html-in-canvas-worker.ts) — transfers the layout canvas offscreen, draws ElementImage snapshots and constructs VideoFrames in a worker; owns startup handshake, bounded pending requests, and cancellation/error cleanup.
  • Deferred context acquisition (html-in-canvas.ts) — setupHtmlInCanvas no longer obtains a 2D context up front; waitForPaint is now signal-aware and shared between the main-thread and worker paths.
  • Overlapped render loop (render-media-on-web.tsx) — single unsubmittedFrame slot, pendingEncoding barrier before current-frame audio mixing/submission, and a drain-on-exit finally that outlives encoder sources.
  • Shared fallback decision (take-screenshot.ts) — getHtmlInCanvasFallbackReason extracted and reused by both capture paths.
  • Docs (html-in-canvas.mdx) — eligibility, callback limits, and worker-error behavior.

The browser-API assumptions check out: OffscreenCanvasRenderingContext2D includes the drawElementImage/reset mixins, VideoFrame.close() on a transferred (detached) frame is a spec-defined no-op, and requestPaint()/paint remain usable on the main-thread canvas after transferControlToOffscreen(). Frame ownership on the worker path is also clean — the worker closes the transferred frame, the !request branch closes orphan responses, the unsubmittedFrame slot and addVideoSampleAndCloseFrame are mutually exclusive closers, and Promise.allSettled(activeEncodingPromises) keeps sources alive on early exit.

ℹ️ Worker startup failure is now fatal where the prior path recovered

The worker is created eagerly whenever captureElementImage exists (new lines 404–415), and this runs before the main try block. If new Worker(url) or transferControlToOffscreen() throws — for example a strict CSP that blocks blob: workers — the export rejects before the first frame, even though the DOM composer is fully available and nothing has been encoded yet. Before this change, native capture failures were per-frame and fell back to the DOM composer. This is documented as intentional, but the startup case is recoverable, unlike a mid-render worker failure.

Technical details
# Worker startup failure aborts the export instead of falling back

## Affected sites
- `packages/web-renderer/src/render-media-on-web.tsx:404-415` — worker created outside the `try` at line 505; a throw here rejects the whole render.
- `packages/web-renderer/src/html-in-canvas-worker.ts:47` — `new Worker(url)` (blob URL, subject to `worker-src` CSP).
- `packages/web-renderer/src/html-in-canvas-worker.ts:91` — `transferControlToOffscreen()` can throw.

## Required outcome
A failure to start the worker (before any frame is captured) should degrade to the DOM composer for the whole export, matching the previous best-effort behavior, rather than failing the render.

## Suggested approach (optional)
Wrap worker creation (or the `ready` handshake) and on startup rejection set `htmlInCanvasWorker = null` so the loop uses the existing fallback branch (which already handles `htmlInCanvasContext` correctly when the canvas has been transferred). Mid-render worker failures can continue to reject, since already-encoded frames cannot be replayed.

## Open questions for the human
Is the all-or-nothing behavior for startup intended for an opt-in, flag-gated feature, or is graceful fallback preferred?

ℹ️ Nitpicks

  • Worker creation could also check getHtmlInCanvasFallbackReason(div) === null, so compositions that always fall back (nested <HtmlInCanvas>, URL masks) don't start a worker or permanently transfer the canvas for zero benefit. URL masks can appear mid-render, so this is only safe for the statically-detectable nested-canvas case.
  • In the onFrame path, unsubmittedFrame still references the original frame when validateVideoFrame throws, so the new finally calls close() a second time. VideoFrame.close() is idempotent, so this is harmless, but nulling unsubmittedFrame before validation would remove the doubt.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since the prior pullfrog review

  • Brand CI lint fixes — packages/brand/src/AudioWaveformProgressComposition.tsx drops a redundant single-child fragment, and packages/brand/src/NewComposition.tsx switches to import type React since the file only references React as a type (it returns null, so there is no JSX/value use). Verified locally with the brand ESLint config: both files lint clean.

No changes to the web-renderer worker/loop code or docs since the previous review; the findings there stand as previously reported.

Pullfrog  | View workflow run | Using DeepSeek Flash | 𝕏

Co-authored-by: Cursor <cursoragent@cursor.com>
@JonnyBurger

Copy link
Copy Markdown
Member

We benchmarked this against a simpler main-thread variant to understand how much of the gain requires a worker. We are still a bit sceptical that introducing a capture worker is justified by the performance evidence so far.

We compared three paths:

  1. Existing native capture: draw into the layout canvas, allocate an OffscreenCanvas and copy into it, construct a VideoFrame, then await submission.
  2. Persistent main-thread canvas: return the existing layout canvas context directly, eliminating the intermediate allocation/copy while keeping submission serial. This was a benchmark-only change at scale 1.
  3. This PR's worker path, including its submission overlap.

On an Apple M4 with 24 GiB RAM, headless Chrome 152.0.7977.42 / ANGLE Metal, Remotion 4.0.532 and Mediabunny 1.56.1:

Path Mean export Median export
Existing 13.120 s 13.375 s
Persistent main-thread canvas 12.644 s 12.682 s
Worker + overlap 12.613 s 12.493 s

The worker's mean advantage over the persistent main-thread canvas was only 31 ms, or about 0.25%, with much larger variation between runs. The worker beat the existing path in four of six rounds; the persistent canvas beat it in three of six.

Individual rounds and methodology
Round Existing Persistent main thread Worker
1 11.304 s 10.903 s 10.564 s
2 13.226 s 13.627 s 11.991 s
3 12.972 s 13.904 s 13.780 s
4 13.525 s 10.737 s 14.614 s
5 13.972 s 11.738 s 11.731 s
6 13.723 s 14.954 s 12.995 s

Each export was 1920×1080, 30 fps, 1,800 frames, H.264 MP4, very-high bitrate, muted audio, allowHtmlInCanvas=true, no callbacks, default hardware acceleration preference, and medium page responsiveness. Two generated H.264 videos at distinct URLs filled the frame and a 960×540 region at (960,0), respectively; GOP 60 and two B-frames.

One full-length warmup per variant was discarded. Six measured rounds used all permutations of the three variants, with fresh browser contexts/pages and serial execution. Timers included source loading, render setup, worker startup, capture, encoding and finalization, and excluded bundling, page creation, verification and disk saving. Frame-rate limiting, GPU vsync and background throttling were disabled. Detailed profiling was excluded from these export timings.

All 21 full-length outputs, including warmups, used native capture for every frame and passed decoded frame-count, timestamp, composition-marker and both source-video-marker checks. Separate exports also produced matching full-frame RGBA hashes at twelve positions across all three paths, for both two-video content and moving semitransparent graphics. Worker requests never exceeded two pending captures.

Separate instrumented 300-frame profiles of the two-video workload measured average synchronous VideoFrame construction at roughly 0.13 ms for the existing path, 0.52 ms for the persistent main-thread canvas, and 1.79 ms in the worker. These are call durations, potentially including deferred drawing/synchronization, rather than GPU traces. The canvas backings differ, and these profiling exports are excluded from the throughput comparison. The existing full-length path also spent only about 0.14 s awaiting video/audio submission, averaged across the six runs.

This does not disprove the reported 31.1% reduction on your M2 Max / hidden Electron setup: our hardware, host, launch settings and generated assets differ. We checked the baseline revisions, and the relevant renderer/media implementation is unchanged between your benchmark baseline and ours. Your reported saving corresponds to about 4.36 ms per frame, versus about 0.28 ms here, so identifying which work or waiting is being hidden would help explain the difference.

Our concern is whether the incremental benefit warrants the additional worker lifecycle, transfers, frame ownership, queue and failure/cancellation handling. Removing the extra canvas copy looks like a simpler candidate to evaluate first, although our results do not establish a consistent speedup for that either. The three-path comparison also does not isolate thread placement from the PR's changes to capture and submission scheduling.

Could you share the exact benchmark harness, generated assets and Electron launch flags, and compare against a persistent main-thread canvas? Ideally, stage measurements or a trace would also separate the benefit of submission overlap from moving capture into a worker. That would give us a stronger basis for deciding whether the worker itself is worth introducing.

# Conflicts:
#	packages/docs/docs/client-side-rendering/html-in-canvas.mdx
#	packages/web-renderer/src/take-screenshot.ts

This branch was successfully deployed

2 active deployments
Preview – remotion — 1132a973 Deployed Oct 9, 2026 by vercel[bot]
Preview – bugs — 1132a973 Deployed Oct 9, 2026 by vercel[bot]
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.

3 participants