Repository navigation
fix(core): a multi-worker or chunked render lands every frame where an in-order render does - #6707
Conversation
…n in-order render does
Edit accuracy: accurate 271 (base branch 271), smooth 139 of thoseThe gate passes. Quarantined, measured but not gated (0) |
There was a problem hiding this comment.
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]]; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ 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.
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 asetthat 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.
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 framesandgives every frame the same value when a scene held at 0 jumps to a chunk start between two framesinpackages/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 oncepins 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.tsc --noEmitand the runtime typecheck: clean. Comment ratchet: clean.What I did NOT exercise
hyperframes snapshot: its page server passes no render fps, so stills are not covered by this change.