Skip to content

fix(render): make WebGL video textures deterministic in headless render#1403

Merged
jrusso1020 merged 2 commits into
mainfrom
fix/webgl-video-texture-determinism-pr
Jun 13, 2026
Merged

fix(render): make WebGL video textures deterministic in headless render#1403
jrusso1020 merged 2 commits into
mainfrom
fix/webgl-video-texture-determinism-pr

Conversation

@jrusso1020

Copy link
Copy Markdown
Collaborator

Problem

WebGL compositions that sample a <video> as a texture — e.g. the HeyGen prism (a faceted crystal with HeyGen clips mapped onto its facets) — rendered with flickering, non-deterministic facets: a video facet would intermittently show a stale frame or go fully black, and the same frame differed byte-for-byte between two renders of the same composition.

Root cause

Two gaps in the headless-render video path:

  1. No WebGL analog of the WebGPU patchVideoTextureCompat. Chrome's headless compositor can't feed decoded <video> frames to the GPU, so the engine injects a pre-decoded <img class="__render_frame__"> sibling per video each frame. The WebGPU copyExternalImageToTexture path already substitutes it — but texImage2D / texSubImage2D did not, so WebGL uploaded a stale/black frame.

  2. Capture ordering. Per frame the runtime seeks first (GPU adapters render on hf-seek) and the engine injects the decoded frames after (onBeforeCapture). So the GPU render read a frame that didn't exist yet, and the injected frame never made it into the texture.

Fix

  1. patchWebGLVideoTextureCompat() — mirrors the WebGPU patch for texImage2D/texSubImage2D on WebGL2RenderingContext + WebGLRenderingContext, substituting the decoded __render_frame__ image when a <video> is the upload source. Shared resolveRenderFrameImage helper with the WebGPU path. Idempotent; only the trailing video-source overloads are touched (numeric/ArrayBufferView overloads pass through). No-op in preview (no injected sibling).

  2. After injecting frames, videoFrameInjector calls window.__hfReseekGpu(t) — a force-dispatch (forceDispatchSeekEvent) that bypasses the same-time hf-seek dedup — so GPU compositions re-upload their textures from the freshly-injected, decoded frames. Gated on injectedIds.size > 0 (no-op for compositions without active video).

Tests

  • video-texture-compat.test.tstexImage2D/texSubImage2D substitution, preview pass-through, undecoded-image skip, numeric-overload safety, idempotency.
  • seek-dispatch.test.ts — force-dispatch fires at the same time (post-injection re-render) while the normal path still dedups.
  • videoFrameInjector.test.ts — regression test: the post-injection GPU reseek fires only when frames were injected; existing cache-hygiene tests updated for the new page.evaluate call.

Verification

A WebGL prism with 8 live <video> facets renders byte-identical across independent runs with no facet flicker (every sampled frame matched by PNG md5 across two full renders).

Known tradeoff

The post-injection reseek re-renders all GPU adapters on any frame with an injected video, even a GPU scene that doesn't sample the video — only doubles GPU cost on frames that have both an active video and a GPU canvas (the comps that need it).

🤖 Generated with Claude Code

WebGL compositions that sample a `<video>` as a texture (e.g. a faceted
crystal with clips mapped onto its facets) rendered with flickering,
non-deterministic facets: a video would intermittently show a stale frame or
go black, and the same frame differed between two renders.

Two gaps caused this:

1. No WebGL analog of the WebGPU `patchVideoTextureCompat`. Chrome's headless
   compositor can't feed decoded `<video>` frames to the GPU, so the engine
   injects a decoded `<img class="__render_frame__">` sibling per video each
   frame. The WebGPU `copyExternalImageToTexture` path substitutes it, but
   `texImage2D` / `texSubImage2D` did not — so WebGL uploaded a stale/black
   frame. Add `patchWebGLVideoTextureCompat()` mirroring the WebGPU patch
   (shared `resolveRenderFrameImage` helper).

2. Capture ordering. Per frame the runtime seeks (GPU adapters render on
   `hf-seek`) BEFORE the engine injects the decoded frames, so the GPU render
   read a frame that didn't exist yet. After injecting, the engine now calls
   `window.__hfReseekGpu(t)` — a force-dispatch (`forceDispatchSeekEvent`) that
   bypasses the same-time `hf-seek` dedup — so GPU compositions re-upload their
   textures from the freshly-injected, decoded frames, deterministically.

Tests: unit tests for the texImage2D/texSubImage2D substitution and the
force-dispatch, plus a videoFrameInjector regression test asserting the
post-injection GPU reseek fires only when frames were injected. Verified
end-to-end: a WebGL prism with 8 live <video> facets renders byte-identical
across independent runs with no facet flicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@miguel-heygen

Copy link
Copy Markdown
Collaborator

@jrusso1020 good catch! can you add a producer regression test too?

…extures

A WebGL2 canvas samples a <video> as a texture every hf-seek (the natural
author pattern, distilled from the HeyGen prism). The render-compat harness
renders it and compares against the golden: with the video-texture fix the
render reproduces the decoded frames; revert the fix and the canvas renders
black, collapsing the comparison.

Golden verified to contain real, time-varying video content (not black), so a
regression is caught rather than passing vacuously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jrusso1020

Copy link
Copy Markdown
Collaborator Author

Added a producer-level regression test: packages/producer/tests/webgl-video-texture-render-compat/ (commit 47345ef).

It's a render-compat fixture — a WebGL2 canvas that samples a <video> as a texture on every hf-seek (the natural author pattern, distilled from the HeyGen prism into one small clip). The harness renders it through the full producer pipeline and compares against the golden:

  • With the fix → the render reproduces the decoded video frames (visual PASS, 0 failed frames).
  • Without the fix → the WebGL canvas renders black, so render-vs-golden PSNR collapses and the test fails.

I verified the golden actually contains real, time-varying video content (not black), so a regression is caught rather than passing vacuously. Kept it tiny (132 KB clip, 2 s @ 1280×720, 1 worker) so it's cheap in CI.

@jrusso1020 jrusso1020 merged commit d580f2a into main Jun 13, 2026
47 checks passed
@jrusso1020 jrusso1020 deleted the fix/webgl-video-texture-determinism-pr branch June 13, 2026 05:37
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