Skip to content

fix(engine): a video held past its media end keeps following its animation - #6424

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/video-style-after-media-end
Oct 26, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/video-style-after-media-end

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

What

A <video> whose clip runs longer than its media now keeps following its animation after the media ends. Before, once the last frame was held, the replacement image kept the opacity, transform and filter it had at that moment, so a fade-out or a move stopped dead for the rest of the clip.

Why

#4049: a 2 s clip in a 4 s slot fading to black froze at the opacity it had at 2 s (reproduced on 0.8.193, R = 133 from frame 61 on, while a sibling div reached 3).

The frame injector skipped a video whose decoded frame index had not changed. That skip saved resending the picture, but it also skipped copying the video's style onto the replacement image. Past the end of the media the index never changes again, so the style never updated. The same skip also left the style one frame behind whenever a slower source repeats a frame.

Related work

Fixes #4049. Builds on the review of #4087 (closed), which found the right cause but re-sent and re-decoded the frame on every held frame and broke motion blur over video (#5150).

How

The skip now separates the picture from the style:

  • videoFrameInjector.ts: an unchanged frame index no longer drops the video. It goes into restyleIds unless the call is a motion-blur sub-sample (heldVideoTime !== time), which must keep the style injected at the output frame's time.
  • screenshotService.ts: injectVideoFramesBatch(page, updates, restyleIds). A restyle item has no picture, so it skips the frame transport, the src write and the decode, and runs the same style copy as a new frame (one owner for that copy). It is not reported as injected, so the GPU reseek and the cache bookkeeping are unchanged.

Design call, options considered:

  1. Re-inject the whole frame on every held frame (fix(engine): keep video tweens active past media EOF #4087): simplest, but a full frame transfer and decode per frame, and it smears motion blur.
  2. Copy the style inside syncVideoFrameVisibility: no extra round trip, but a second copy of the style-copy code in another page function.
  3. Picked: restyle-only items in the existing batch. One owner for the style copy, no frame bytes or decode; the cost is one small page.evaluate on frames where a held video is the only change.

Test plan

  • Real render, motionBlurVideo.integration.test.ts (Chrome + ffmpeg): a 0.2 s white clip in a 0.5 s slot fading out on black. Every frame after the media ends is darker than the one before and the last one is under 15% of the first (plain levels 255, 238, ... 34, 17). With motion blur on, every frame's pixels match the plain render. 7 of 7 tests passed, 3 runs in a row.

  • Mutation: without the restyle, the fade test fails (frame 7: expected 170 to be less than 170). Restyling motion-blur sub-samples too fails both the new blur test and the existing "keeps the video rows identical to the unblurred render" test.

  • Unit tests updated: an unchanged frame now asks for a restyle with no picture ([], ["pip"]), and a motion-blur sub-sample asks for nothing. videoFrameInjector.test.ts + screenshotService.test.ts: 59 passed, 3 runs.

  • tsc --noEmit for engine and producer, oxfmt, oxlint on the changed files: clean.

  • Render cost, measured (640x360, 30 fps, 11 s slot, draft MP4, one worker, software GPU; branch and main engine interleaved, 5 rounds each, one build):

    composition restyle calls capture p50 wall, median of 5 (range)
    normal (11 s video in an 11 s slot) branch 0 50 ms, both branch 20.34 s (19.71-22.31), main 19.95 s (18.14-24.16)
    10 s hold (1 s video in an 11 s slot) branch 300, one per held frame 50 ms, both branch 18.93 s (18.37-20.18), main 17.77 s (17.62-18.09)

    A normal render never takes the new path (0 restyle calls), so its per-frame work is unchanged; the wall difference there is within run-to-run spread. A held frame costs about 4 ms more (capture average 56 ms vs 53 ms), which is the style copy main skips and gets wrong.

  • Comments follow CONTRIBUTING.md "Comments".

@heygengenesis heygengenesis 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.

Verdict: REQUEST CHANGES · 2 blocker · 0 important · 0 nits · head b69b5c48

The engine fix looks right to me: restyle-only items skip the transport, the src write and the decode, they aren't reported as injected, and the sub-sample guard keeps blurred video rows matching the plain render. Two things block it, both inline:

  • The squash reverts #6416, which merged as this PR's base commit dc56b3d2. It deletes isClipHiddenByRuntime, the layer-walk prune and its tests, and puts CLIP_HIDDEN_ATTR back in clipReveal.ts. This revert is also what trips the PR captures gate (the PR now touches studio) and half of Comments (clipReveal.ts).
  • Comments comment-ratchet: videoFrameInjector.ts goes from 19.1% to 19.2% (+2 comment lines).
Scope

Read in full: videoFrameInjector.ts, injectResolvedVideoFramesBatch, withVideoFrameSources (an empty updates passes straight through), and every onBeforeCapture caller. captureAccumulatedFrame injects at (frameTime, frameTime) and sub-samples come through prepareFrameForCapture with heldVideoTime. HDR passes no held time, so it restyles. Held hosts behind a hidden ancestor still get their img hidden. When the host shows again, the held frame is now published (before this change it was skipped). CI at review time: Comments and PR captures failed, the rest were still running. No prior reviews.

@miguel-heygen
miguel-heygen force-pushed the fix/video-style-after-media-end branch from b69b5c4 to f65fca9 Compare October 26, 2026 14:02
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 130 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (2)

  • seqplainkeys-none-px-r0-nested-z100: tracking 0.07, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.07, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.07, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • seqplainkeys-none-pct-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Both blockers are fixed at 35b4086: the #6416 revert is gone (rebuilt on main; the diff is the 5 engine/producer files only), the two added comment lines in videoFrameInjector.ts are cut, and the description now has render-cost numbers (no extra work on normal renders, about 4 ms per held frame).

@heygengenesis heygengenesis 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.

Verdict: APPROVE — 2 resolved, 0 open, 0 new · b69b5c4 → 35b4086

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit d8398c9 Oct 26, 2026
77 checks passed
@miguel-heygen
miguel-heygen deleted the fix/video-style-after-media-end branch October 26, 2026 16:32
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Oct 26, 2026
Merge 20 more upstream commits (acd0bc3..ad4d3fa, v0.8.205: heygen-com#6403,
heygen-com#6363, heygen-com#6209, heygen-com#6395, heygen-com#6424, heygen-com#6426, heygen-com#6442, heygen-com#6430, heygen-com#6402, heygen-com#6435, heygen-com#6439,
heygen-com#6449, heygen-com#5436, heygen-com#6455 and docs). No conflicts. Record the 11 open PRs
heygen-com#6440-heygen-com#6458 and issue heygen-com#6443 in docs/DECISIONS.md (none adopted), re-check
the earlier open PRs, and advance the commit watermark to ad4d3fa, the
PR watermark to heygen-com#6458 and the issue watermark to heygen-com#6443.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
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.

<video> clip silently ignores GSAP tweens once its source media ends (data-duration > media duration)

1 participant