Skip to content

fix(core): a multi-worker or chunked render lands every frame where an in-order render does - #6707

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/gsap-seek-order-independent
Oct 29, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/gsap-seek-order-independent

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

Description

A multi-worker export can twitch where the preview plays clean. Reported on a music video exported from the Desktop app: for most of a second, three of every eight frames showed the background at a different zoom and framing, so the picture jumped back and forth. The preview never did.

GSAP's state at a frame can depend on which frames rendered before it. A to() tween records its start values the first time it renders, and a set that lands while another tween on the same property is running wins or loses depending on whether a render falls just after it. In the reported film, a zoom punch is still running when a cut sets the zoom and a slower move starts from wherever the zoom is. Playback renders every frame in order. An export with 8 workers interleaves frames (since #5952 each worker captures every 8th frame so encoding can start during capture), so each worker skips over those moments differently and records different values. Workers 5, 6 and 7 disagreed with the rest, which is the period-8 twitch. A contiguous range or a cloud chunk that starts mid-film has the same problem once, as a jump at its first frame.

That history only matters where two tweens write the same property of the same target at the same time. Anywhere else, jumping straight to a frame gives the same result as rendering every frame before it. So a render seek now first renders, in order, the frames it skipped since that timeline's previous render seek, but only those inside such an overlap (plus one frame on each side). The overlap spans are computed once per timeline structure. Tweens under a paused child timeline (each scene, which the runtime nests paused in the root and seeks on its own clock, with its own replay) are left out of the root's spans; tweens under a repeating child timeline count over that timeline's whole span. Frame times are counted back from the target, so a scene whose host starts between two frames, or a scene held at 0 that jumps to a chunk start, replays the frames an in-order render would have shown. This covers the root timeline and every scene timeline. The preview is unchanged: only the render runtime passes its fps.

Who it affects: every multi-worker export since #5952 of a composition with overlapping tweens on one property, and every chunked render of one starting inside such an overlap.

Cost

The replayed frames are timeline renders only: no capture, no screenshot.

Film Base timeline renders With fix Time
Reported film (3.3 min, dense overlapping stage effects), one of 8 interleaved workers 1,503 3,180 17 ms → 25 ms per worker over the whole export
Same film, chunk starting at 90% 1,203 2,950 11 ms → 14 ms
10-minute 60 fps film laid out like the runtime (root with 2,000 tweens on 2,000 targets plus 10 paused nested scenes with overlapping tweens inside), one of 8 interleaved workers 4,500 root renders 4,500 unchanged
Same film, chunk starting at minute 9 3,600 root renders 3,600 unchanged

A 10-minute film is why the replay is restricted to overlaps. One render of a 2,000-tween DOM timeline costs 1.4 ms in Chrome, so replaying every skipped frame would cost a chunk at minute 9 about 46 s.

Testing

fails without the fix: gives every frame the same value when 8 workers each capture every 8th frame, gives every frame the same value when a chunk starts inside the overlap, gives every frame the same value in a scene whose host starts between two frames and gives every frame the same value when a scene held at 0 jumps to a chunk start between two frames in packages/core/src/runtime/adapters/gsap.order.test.ts. On main, workers and chunks record a different zoom from the frame-by-frame render. replays no frame when no two tweens write the same property at once pins that a film without overlaps pays nothing, including one with a paused nested scene and a repeating nested timeline; it fails on 1dcb484.

  • packages/core/src/runtime/adapters/: 310 tests passed, 3 runs in a row, including every existing step, keyframe and from-value adapter test, unchanged.
  • Core tsc --noEmit and the runtime typecheck: clean. Comment ratchet: clean.
  • The reported film's own stage timeline replayed in Node, all 12 animated parameters, frames 125-133 s: 8 interleaved workers differ from a frame-by-frame walk on 99 frames with plain seeks and 0 with the fix. Chunks starting at 125.0, 130.0, 130.817, 130.883 and 131.667 s: 0 differ.
  • Full 8-worker export of the reported film on an M4 Pro Mac, 130-133 s, with the first version of this fix (replay every skipped frame): the base build shows the jump-and-return at the same frames as the reported export (61, 69, 77, 85, 93); the fix shows none. Export wall 235 s vs 230 s.

What I did NOT exercise

  • The full export on this head (overlap-only replay); the Node replay above covers the same timeline on this head.
  • hyperframes snapshot: its page server passes no render fps, so stills are not covered by this change.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 139 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)

@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 · 1 blocker · 0 important · 0 nits · head 1dcb484

The ordering fix and its four red-on-main tests hold up. The cost guarantee does not: a root that holds a paused scene (the runtime's normal layout) replays every skipped frame even when no tweens overlap. ↓ inline

Scope

Read gsap.ts, init.ts and gsap.order.test.ts at 1dcb484. The gsap.order.test.ts suite passes locally (6/6). I measured the replay with a throwaway vitest probe that counts totalTime(t) calls. Note: under capture gsapStructureOf returns undefined, so the spans are cached once per timeline under version "". That is fine only while the structure stays frozen during capture. CI not checked, since this is a request for changes.

for (const child of timeline.getChildren?.(true, true, false) ?? []) {
const tween = child as PlacedTween;
const write = writeOf(tween, timeline);
if (!write) return [[-Infinity, Infinity]];

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.

blocker: one tween that startOnOwner can't place makes the whole timeline replay every skipped frame. startOnOwner returns null whenever an ancestor has _repeat or _ts === 0. The runtime nests each scene in the root at its host start (nestAtHostStart, init.ts:1670), and seekSceneTimeline pauses it on its own clock (init.ts:5086, "each paused on its own clock, so the root's pass skips them"). So for any multi-scene film, the root's getChildren(true, true, false) yields scene tweens under a _ts = 0 parent, and the root falls back to [-Infinity, Infinity].

I measured it: a root with one 300 s tween on a and a paused nested scene on c, with no shared target. A seek to 270 s followed by the next interleaved frame costs 8,109 root renders, against 2 with the scene active. That is the full-replay cost (~46 s per chunk on the 10-minute film) that the PR body says only overlaps pay. A nested repeat/yoyo timeline does the same: 8,101.

Fix: skip descendants of a paused child timeline, since the root's render doesn't drive them. For a repeating ancestor, give its tweens a write that spans the ancestor's window on the owner and keep them in the per-target overlap check, rather than returning [-Infinity, Infinity] for the whole timeline. Then add a paused nested scene, and a repeating one, to replays no frame when no two tweens write the same property at once.

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.

✅ Fixed in 5cc3246. A paused ancestor is now skipped, and a repeating one widens the tween's write to the ancestor's window. My probe now counts 2 renders for a paused scene, a repeat:3 timeline and a repeat:-1 timeline, and 13 when a loop does overlap a root tween on the same property. The extended call-count test fails at 1dcb484.

@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 — 1 resolved, 0 open, 0 new · 1dcb484 → 5cc3246. CI and Windows render verification are green on this head.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit fa9acbd Oct 29, 2026
130 checks passed
@miguel-heygen
miguel-heygen deleted the fix/gsap-seek-order-independent branch October 29, 2026 05:19
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