Skip to content

fix(engine): pre-create __render_frame__ siblings in initializeSession#1853

Open
varo-yang wants to merge 1 commit into
heygen-com:mainfrom
varo-yang:fix/engine-precreate-render-frame-siblings
Open

fix(engine): pre-create __render_frame__ siblings in initializeSession#1853
varo-yang wants to merge 1 commit into
heygen-com:mainfrom
varo-yang:fix/engine-precreate-render-frame-siblings

Conversation

@varo-yang

@varo-yang varo-yang commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Hit a periodic single-frame near-black flash in chunk-lambda renders — every chunk_frames / worker_count frames one frame comes out as body background + previously-composed overlays only. On a single-video 4-worker chunk that's every 60 frames; signalstats reports YAVG ~22 with YMAX ~240. Single-process local renders don't see it because they don't run under BeginFrame control.

It's the isNewImage branch of injectVideoFramesBatch at screenshotService.ts:473. First inject for a given videoId per session sees hasImg === false and creates the replacement <img> on the fly — document.createElement("img") + insertBefore at lines 482-487. captureFrameCore fires beginFrameCapture immediately after: no intermediate BeginFrame, and no paint flush either, since the flush at frameCapture.ts:1328 is gated on captureMode !== "beginframe".

Under HeadlessExperimental.beginFrame deterministic mode, chrome's compositor doesn't include the freshly-inserted <img> layer in that immediately-next BeginFrame — it lands a tick later. So the captured PNG shows just what was already in there: body background + persistent overlays. That's the YAVG ~22 signature.

Pre-creating the __render_frame__ sibling once at the end of initializeSession — right before session.isInitialized = true in both branches — fixes it. By the time the first beginFrameCapture fires, warmup + GSAP flush + readiness polls have driven several BeginFrame ticks, so the layers are already committed. injectVideoFramesBatch sees hasImg === true every time and just updates img.src. The isNewImage branch stays as a fallback.

FWIW things we tried before landing here: willChange / translateZ promotion hints on the freshly-inserted <img>, dataUri preload, awaiting img.decode() on the pending frame, and a second BeginFrame at the same frameTimeTicks. Chrome refuses two BeginFrames at the same tick — the CDP call just protocol-times-out at 60s. Only avoiding the on-the-fly DOM mutation resolved it.

Tests cover: creates a hidden sibling for every video[data-start] on first call, no-op for videos that already have one, leaves plain <video> (no data-start) alone.

Under chrome-headless-shell + HeadlessExperimental.beginFrame deterministic
mode, injectVideoFramesBatch's `isNewImage` branch creates the replacement
<img> on the fly (createElement + insertBefore). The immediately-next
beginFrame captures before the new layer lands in the compositor's tree,
so that frame paints only body background + already-composed overlays.

Pre-create the __render_frame__ sibling once at the end of
initializeSession. By the time the first capture begins, warmup + GSAP
flush + readiness polls have driven several BeginFrame ticks, so the
layers are committed and every subsequent inject takes the `hasImg=true`
path (just an img.src update).
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.

1 participant