fix(render): make WebGL video textures deterministic in headless render#1403
Merged
Conversation
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>
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>
Collaborator
Author
|
Added a producer-level regression test: It's a render-compat fixture — a WebGL2 canvas that samples a
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. |
miguel-heygen
approved these changes
Jun 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 WebGPUcopyExternalImageToTexturepath already substitutes it — buttexImage2D/texSubImage2Ddid not, so WebGL uploaded a stale/black frame.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
patchWebGLVideoTextureCompat()— mirrors the WebGPU patch fortexImage2D/texSubImage2DonWebGL2RenderingContext+WebGLRenderingContext, substituting the decoded__render_frame__image when a<video>is the upload source. SharedresolveRenderFrameImagehelper with the WebGPU path. Idempotent; only the trailing video-source overloads are touched (numeric/ArrayBufferViewoverloads pass through). No-op in preview (no injected sibling).After injecting frames,
videoFrameInjectorcallswindow.__hfReseekGpu(t)— a force-dispatch (forceDispatchSeekEvent) that bypasses the same-timehf-seekdedup — so GPU compositions re-upload their textures from the freshly-injected, decoded frames. Gated oninjectedIds.size > 0(no-op for compositions without active video).Tests
video-texture-compat.test.ts—texImage2D/texSubImage2Dsubstitution, 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 newpage.evaluatecall.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