Repository navigation
fix(core): motion blur preview no longer slows down later in the timeline - #6865
Conversation
…line With data-motion-blur-events="on", which Studio's Subtle and Strong write, both blur tiers ran timeline callbacks at every per-frame pose read. Each frame's reads step back from half a shutter ahead to the frame, so a callback that keeps state and replays from zero on a backward seek (a ripple simulation, for example) redid its whole history most frames, and the cost grew with the playhead. The mover scan still runs callbacks, so motion set from a render(t) callback is still found. With events on, it also scans once without them, and the per-frame reads run callbacks only when the two scans disagree. The eventful scan runs first, so tweens take their start values after the callbacks before them and a one-off call fires as often as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits · reviewed 062715c, posted on ec91114 (comments only since then)
The callback check compares which elements each scan finds, so a callback that pans an ancestor some tween also touches drops out of the per-frame reads, and those movers get the wrong smear. ↓ inline
Scope
Read scanMovers, both tiers' readSeek/discover, readMoverFrames, and the runtime host's seekRootChildrenAndAdapters. Silent reads now take the same path that events-off reads already took, and the eventful scan → silent scan → eventful restore order leaves callback state as it was. Local: 183/183 captureMotionBlur tests pass. Mutation checks: swapping the scan order fails the toggle test, and forcing readEvents=false fails 2 tests. Dropping the dynamic comparison fails nothing. CI was still running when I posted. Comments is red, and it is optional. No prior reviews.
— Genesis
Edit accuracy: accurate 271 (base branch 271), smooth 120 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
Comparing which elements each scan finds missed a callback that pans a mover's ancestor while a tween also changes that ancestor: both scans found the same movers and the same changing elements, so the reads left the pan out. The scans now compare every changing element's box at each step, and the reads run callbacks when any step differs. Raises @hyperframes/core's unpacked size budget to the measured size plus the usual 5%: main already sat 0.98% over the old budget, and this change adds 1,232 bytes, which tipped it past the 1% tolerance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: APPROVE · 0 blocker · 0 important · 0 nits — 1 resolved, 0 open, 0 new · ec91114 → 59eee26
Checked
184/184 captureMotionBlur tests pass locally. CI and regression runs are green on 59eee26. The core unpacked budget bump follows the 5% headroom rule in verify-packed-manifests.mjs, and the reason is in the PR body.
— Genesis
… seek left it After silent pose reads, the restore seek back to the frame left GSAP in a different state than the frame's own eventful seek did: - A step exactly on the frame (a .call() or set) was re-armed, so the next frame fired it again. Calls fired 2-5 times per crossing, and a toggle could end inverted. - In renders, which keep no GSAP structure, landing back on the frame from later skipped same-time steps' authored order, so the frame showed the first of two sets instead of the last. Events-off blur always read silently and had both. This PR moves events-on roots without callback-driven motion (the Studio default) onto that path, so both are fixed here. The host's new holdFrame snapshots every landing mark on the root and scene timelines before the reads, and its restore seeks back silently, redraws in authored order where there is no structure, and puts the marks back. Each call now fires once per frame crossing, as without blur, and same-time sets land as authored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: REQUEST CHANGES · 1 blocker · 0 important · 0 nits — 0 open, 1 new · 59eee26 → acf5a2e
The .call re-arm and same-time set fixes check out (266/266 gsap.steps + captureMotionBlur tests pass locally). One new issue in the render redraw order ↓. CI was still running when this was posted.
— Genesis
…nds last holdFrame redrew the root and then the scenes, the reverse of the frame's own seek. When a scene and the root tween one property, the frame landed on the scene's value instead of the root's. The hold and landing move into the GSAP adapter as holdFrameMarks, which takes the scenes and the root separately, so the order lives in one place a real-GSAP test can pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What was wrong
With
data-motion-blur-events="on", both blur tiers (capture and filter) ran timeline callbacks at every per-frame pose read: 4 reads plus a restore per preview frame, 8 plus a restore per render frame. Studio's Subtle and Strong have written that attribute since #6255. The restore steps back from half a shutter ahead to the frame. So a callback that keeps state and replays from zero on a backward seek (here a WebGL ripple simulation driven fromonUpdate) redid its whole history most frames, and preview got slower the further into the timeline it played.Measured on a 158.5 s project in headless Chrome, 1.5 s of playback per point, Subtle:
__hfMotionBlur.report().meanMsstayed at 1–6 ms throughout, because the callback's GL work is async, so blur's own timing does not show this cost. The same project's render log shows the same shape: frames per progress tick fall from 573 to about 80 as the render advances.The fix
scanMovers(indiscover.ts) wraps discovery:render(t)callback is still found. Then it scans once more without callbacks.Landing back on the frame. Silent reads end with a silent seek back to the frame, and that seek used to leave GSAP in a different state from the frame's own eventful seek. Events-off blur has always read this way and had both problems; this PR moves events-on roots onto that path, so it fixes them here:
.call()orset()exactly on the frame was re-armed (GSAP sets_zTimeto_tinyNum), so the next frame fired it again. Calls fired 1–2 times per crossing with silent reads and 2–5 times with the old eventful reads, and a toggle could end inverted.The host's new
holdFrame(holdFrameMarksin the GSAP adapter) snapshots every landing mark on the root and scene timelines before the reads. Its restore seeks back silently, redraws in authored order where there is no structure, and puts the marks back. It redraws scenes first and the root last, as the frame's own seek does, so the root's values land last on a shared property. Each call now fires once per frame crossing, as without blur, and same-time sets land as authored.The eventful scan runs first on purpose. Tweens then take their start values after the callbacks before them, and a one-off call (a toggle) fires as many times as it did before. With the silent scan first, such a call fired once more and stayed flipped.
Tests
New: tween-driven movers read with callbacks off when events are on, in both tiers. Callback-driven movers still read with callbacks on.
New: a toggle call is fired an even number of times over a scan. This one fails on the reversed scan order.
New: a callback pans a mover's ancestor while a tween fades it; the reads keep callbacks. This one fails on the membership comparison.
New, real GSAP (
gsap.steps.test.ts,holdLandingMarks): a toggle call on a frame fires once across silent reads around it, in preview and render, and fires more than once without the hold. Two same-time sets land as authored after silent reads around a render frame; this one fails without the redraw. A scene and the root tween one property and the root lands last; this one fails with the root drawn first.New: silent reads land back through the host's
holdFrame, which is taken before the first read and replaces the plain seek back. Eventful reads don't use it.Updated: two existing tests assumed every seek is eventful when events are on.
All 2,681
packages/core/src/runtimetests pass. Typecheck, lint and format are clean.Browser check, on the final code, through the render path (
renderSeek, the same per-frame reads; 10 consecutive frames from each point; the playback-clock numbers above are less reliable because headless media stalls the clock):The one-time mover scan still replays the simulation once when blur first draws, as before. A box moved only by a
render(t)onUpdatestill smears in 20/20 frames (filter tier).Browser check, scene and root: the real runtime through
renderSeek, 46 frames, where a scene and the root tween one property and the scene holds two same-time sets at 1 s. Each blur mode is compared with no blur on every frame:Browser check, steps: the real runtime, events on, a toggle
.call()at 1 s and twoset()s at 1 s. Main fires the call 5 times (render) and 7 times (playback), and renders the frame at the first set. This PR fires it 3 times in both, and renders the last set. Of the 3, 2 come from the one-time eventful scan walking past the call and back, which is whatevents="on"has done since feat(studio): the blur panel turns on events with blur #6255; the pair leaves toggles as they were.Size budget
@hyperframes/core's unpacked budget goes from 4,024,585 to 4,268,445 bytes, which is the measured 4,065,185 × 1.05, the script's own suggestion. Main already measures 4,063,953, which is 0.98% over the old budget. This PR adds 1,232 bytes (the runtime bundle ships in several forms), which tipped it past the 1% tolerance. The packed size is unchanged in effect (1,675,457, still within its budget's tolerance).Known limits
Follow-ups for cases where the reads fall back to callbacks more often than needed, and for the scan cost, are in #6867.
🤖 Generated with Claude Code