Skip to content

fix(core): motion blur preview no longer slows down later in the timeline - #6865

Merged
vanceingalls merged 5 commits into
mainfrom
fix/motion-blur-events-stateful-callbacks
Oct 31, 2026
Merged

vanceingalls merged 5 commits into
mainfrom
fix/motion-blur-events-stateful-callbacks

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

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 from onUpdate) 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:

playhead events off events on, before events on, after
50 s 0 resets, 54 sim steps 2 resets, 2,422 steps, worst frame 3.6 s 0 resets, 54 steps
90 s 0 resets, 55 steps 3 resets, 7,201 steps, worst frame 4.3 s 0 resets, 58 steps
130 s 0 resets, 55 steps 3 resets, 10,801 steps, worst frame 4.9 s 0 resets, 48 steps

__hfMotionBlur.report().meanMs stayed 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 (in discover.ts) wraps discovery:

  • Events off: unchanged.
  • Events on: the scan runs callbacks first, exactly as before, so motion set from a render(t) callback is still found. Then it scans once more without callbacks.
  • Per-frame reads: they run callbacks only when, at any scan step, a changing element's box differs between the two scans. Boxes are compared rather than which elements move, so a callback that pans a mover's ancestor while a tween also changes that ancestor still keeps callbacks in the reads (Genesis's blocker on the first version).

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:

  • Calls fired again. A .call() or set() exactly on the frame was re-armed (GSAP sets _zTime to _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.
  • Steps out of order in renders. Renders keep no GSAP structure, so landing back on the frame from later skipped same-time steps' authored order, and the frame showed the first of two sets instead of the last.

The host's new holdFrame (holdFrameMarks in 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/runtime tests 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):

    from main this PR
    20 s 10 resets, 3,034 sim steps 0 resets, 9 steps
    90 s 10 resets, 24,034 steps 0 resets, 9 steps
    130 s 10 resets, 36,034 steps 0 resets, 9 steps

    The one-time mover scan still replays the simulation once when blur first draws, as before. A box moved only by a render(t) onUpdate still 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:

    main this PR
    events off 1 frame differs 0
    events on 16 frames differ 0
  • Browser check, steps: the real runtime, events on, a toggle .call() at 1 s and two set()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 what events="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.

  • Only box changes count. A callback that moves things only through a class or a non-style attribute is not seen; the scan does not treat those as motion today either.
  • With events on, each rescan (once per timeline, i.e. per Studio edit) walks the timeline twice. The second walk is silent, so it never re-runs callbacks. Both together took 32 ms on the project above.
  • A composition where a stateful callback positions DOM movers still reads with callbacks, so it still pays the per-frame cost.

🤖 Generated with Claude Code

…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>
@mintlify

mintlify Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Oct 31, 2026, 12:36 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 · 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

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

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

  • seqplainkeys-none-pct-r0-root-z100: 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 / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

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>

@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 · 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>

@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 — 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

Comment thread packages/core/src/runtime/init.ts Resolved
…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>

@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 · 0 blocker · 0 important · 0 nits — 1 resolved, 0 open, 0 new · acf5a2e → 6d587e1

— Genesis

@vanceingalls
vanceingalls merged commit 5e4f5ae into main Oct 31, 2026
144 of 145 checks passed
@vanceingalls
vanceingalls deleted the fix/motion-blur-events-stateful-callbacks branch October 31, 2026 01:21
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