From 062715c1780d29051af8bcc6d3f701bf9ed592f0 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 30 Oct 2026 15:59:06 -0700 Subject: [PATCH 1/5] fix(core): motion blur preview no longer slows down later in the timeline 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 --- docs/guides/motion-blur.mdx | 2 +- .../src/runtime/captureMotionBlur/discover.ts | 38 ++++++++ .../runtime/captureMotionBlur/filterTier.ts | 12 ++- .../runtime/captureMotionBlur/index.test.ts | 89 +++++++++++++++++-- .../src/runtime/captureMotionBlur/index.ts | 17 ++-- 5 files changed, 139 insertions(+), 19 deletions(-) diff --git a/docs/guides/motion-blur.mdx b/docs/guides/motion-blur.mdx index 0fe3c208eef..9183d792abc 100644 --- a/docs/guides/motion-blur.mdx +++ b/docs/guides/motion-blur.mdx @@ -29,7 +29,7 @@ That is all. The same attribute drives the browser editor preview and `hyperfram | `data-motion-blur` | none | `on` enables blur. `off` opts out, on the root or on any subtree. | | `data-motion-blur-shutter` | `240` | Shutter angle in degrees, 0–720. Larger is a longer smear. | | `data-motion-blur-samples` | at least 32 | A fixed number of samples per frame, 2–64. Left unset, each mover gets at least 32, and about one per 2 px of smear when that is more, so a fast move never shows steps. | -| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers and at each sample. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. | +| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers, and at each sample when a callback moves one. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. | | `data-motion-blur-tier` | `auto` | `capture` or `filter` forces one of the two ways blur is drawn (below). | ## In the browser editor diff --git a/packages/core/src/runtime/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index e397d360c2b..2850ae5d8bb 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.ts @@ -270,3 +270,41 @@ export function discoverMovers({ const translationOnly = movers.length > 0 && movers.every((el) => !notPlain.has(el)); return { movers, dynamic, pads, translationOnly }; } + +export interface ScanOptions extends Omit { + seek: (t: number, suppressEvents: boolean) => void; + /** The root lets blur run timeline callbacks (data-motion-blur-events="on"). */ + events: boolean; +} + +const sameMembers = (a: Iterable, b: Iterable): boolean => { + const x = new Set(a); + const y = new Set(b); + return x.size === y.size && [...x].every((el) => y.has(el)); +}; + +/** + * Discovery, and whether the per-frame pose reads must run timeline callbacks. With events on the + * scan runs them, so callback-driven movers are found; the reads run them only when a scan without + * them finds otherwise. Every frame's reads step back from half a shutter ahead, so a callback that + * keeps state (a simulation that replays from zero on a backward seek) would redo all of it per frame. + */ +export function scanMovers({ + seek, + events, + onStep, + ...scan +}: ScanOptions): Discovery & { readEvents: boolean } { + const quiet = (t: number): void => seek(t, true); + if (!events) return { ...discoverMovers({ ...scan, seek: quiet, onStep }), readEvents: false }; + // With callbacks first, as when this was the only scan: tweens first render (and take their start + // values) after the callbacks before them, and a one-off call fires as often as it did. Run second, + // it would start from the end the silent scan left the playhead at and fire such a call once more. + const found = discoverMovers({ ...scan, seek: (t) => seek(t, false), onStep }); + const without = discoverMovers({ ...scan, seek: quiet }); + // ponytail: compares what each scan finds, not each step's boxes, so a mover that a tween and a + // callback both move reads without callbacks; compare per-step boxes if that case shows up. + const same = + sameMembers(without.movers, found.movers) && sameMembers(without.dynamic, found.dynamic); + return { ...found, readEvents: !same }; +} diff --git a/packages/core/src/runtime/captureMotionBlur/filterTier.ts b/packages/core/src/runtime/captureMotionBlur/filterTier.ts index 53e06d6c590..f28c93a7f42 100644 --- a/packages/core/src/runtime/captureMotionBlur/filterTier.ts +++ b/packages/core/src/runtime/captureMotionBlur/filterTier.ts @@ -6,7 +6,7 @@ */ import { swallow } from "../diagnostics"; -import { discoverMovers } from "./discover"; +import { scanMovers } from "./discover"; import { planFilter, type FilterPlan } from "./filterPlan"; import { readMoverFrames } from "./frames"; import { createPoseReader } from "./pose"; @@ -105,6 +105,8 @@ export function createFilterTier( const width = Number(root.getAttribute("data-width")) || root.offsetWidth; const height = Number(root.getAttribute("data-height")) || root.offsetHeight; const seek = (t: number): void => host.seek(t, !config.events); + let readEvents = false; + const readSeek = (t: number): void => host.seek(t, !readEvents); let defs: SVGSVGElement | null = null; let movers: HTMLElement[] = []; let pads = new Map(); @@ -121,13 +123,15 @@ export function createFilterTier( function discover(restoreTo: number): void { scanned = host.timeline(); const s0 = performance.now(); - const found = discoverMovers({ + const found = scanMovers({ scene: root, durationSeconds: host.durationSeconds(), fps: host.fps, - seek, + seek: host.seek, + events: config.events, }); seek(restoreTo); + readEvents = found.readEvents; movers = found.movers; pads = found.pads; reader = createPoseReader(root, found.dynamic); @@ -178,7 +182,7 @@ export function createFilterTier( const read = readMoverFrames({ movers, reader, - seek, + seek: readSeek, t, reads: engineMode ? RENDER_READS : PREVIEW_READS, shutterSeconds: config.shutterDeg / 360 / host.fps, diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index 3979c6ebe6b..7c36b8cbf6e 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -889,10 +889,25 @@ describe("painting", () => { }).h; initCaptureMotionBlur(eventful); win.__hfMotionBlur?.probe(); - expect(eventful.seek.mock.calls.length).toBeGreaterThan(10); - expect(eventful.seek.mock.calls.every(([, suppress]) => suppress === false)).toBe(true); + const loud = eventful.seek.mock.calls.filter(([, suppress]) => suppress === false); + expect(loud.length).toBeGreaterThan(10); + // The scan ends with callbacks on, so their state is the frame's when it is done. + expect(eventful.seek.mock.calls.at(-1)?.[1]).toBe(false); }); + /** A slider that only a timeline callback moves, as a render(t) function would. */ + function callbackHost() { + const { root, el, h } = movingHost({ + "data-motion-blur": "on", + "data-motion-blur-events": "on", + }); + const slide = h.seek; + const seek = vi.fn((t: number, suppress: boolean) => { + if (!suppress) slide(t, suppress); + }); + return { root, el, h: { ...h, seek } }; + } + it("seeks pose reads with events suppressed unless the root allows them", () => { installHtmlInCanvas(); const quiet = host(makeRoot()); @@ -901,17 +916,60 @@ describe("painting", () => { expect(quiet.seek.mock.calls.every(([, suppress]) => suppress === true)).toBe(true); resetCaptureMotionBlurForTests(); - const eventful = movingHost({ - "data-motion-blur": "on", - "data-motion-blur-events": "on", - }).h; - initCaptureMotionBlur(eventful); + const { h } = callbackHost(); + initCaptureMotionBlur(h); void paintCaptureMotionBlur(0.5, PLAY); - const calls = eventful.seek.mock.calls; + const calls = h.seek.mock.calls; expect(calls.at(-1)?.[1]).toBe(false); expect(calls.some(([, suppress]) => suppress === false)).toBe(true); }); + // Each frame's reads step back to the frame from half a shutter ahead. A callback that keeps + // state (a simulation that replays from zero on a backward seek) would redo all of it per frame. + it("reads poses with callbacks off when callbacks move no mover, even with events on", () => { + installHtmlInCanvas(); + const { h } = movingHost({ "data-motion-blur": "on", "data-motion-blur-events": "on" }); + initCaptureMotionBlur(h); + win.__hfMotionBlur?.probe(); + expect(win.__hfMotionBlur?.report().movers).toBe(1); + h.seek.mockClear(); + void paintCaptureMotionBlur(0.5, PLAY); + expect(h.seek.mock.calls.length).toBeGreaterThan(0); + expect(h.seek.mock.calls.every(([, suppress]) => suppress === true)).toBe(true); + }); + + it("fires a one-off timeline call an even number of times over a scan, so a toggle ends as it began", () => { + installHtmlInCanvas(); + const { h } = movingHost({ "data-motion-blur": "on", "data-motion-blur-events": "on" }); + // A call at 0.7 s that flips state each time an eventful seek crosses it, either way. + let at = 0; + let flipped = false; + const slide = h.seek; + const seek = vi.fn((t: number, suppress: boolean) => { + slide(t, suppress); + if (!suppress && Math.min(at, t) < 0.7 && Math.max(at, t) >= 0.7) flipped = !flipped; + at = t; + }); + initCaptureMotionBlur({ ...h, seek }); + win.__hfMotionBlur?.probe(); + expect(seek.mock.calls.length).toBeGreaterThan(10); + expect(flipped).toBe(false); + }); + + it("reads poses with callbacks on when a callback moves a mover", () => { + installHtmlInCanvas(); + const { h } = callbackHost(); + initCaptureMotionBlur(h); + win.__hfMotionBlur?.probe(); + expect(win.__hfMotionBlur?.report().movers).toBe(1); + h.seek.mockClear(); + void paintCaptureMotionBlur(0.5, PLAY); + // Wrapping resyncs at host.now() (0) on its own; the shutter's reads are the rest. + const reads = h.seek.mock.calls.filter(([t]) => t > 0); + expect(reads.length).toBeGreaterThan(0); + expect(reads.every(([, suppress]) => suppress === false)).toBe(true); + }); + it("fires timeline callbacks during discovery only when the root allows events", () => { for (const events of [true, false]) { installHtmlInCanvas(); @@ -1788,6 +1846,21 @@ describe("filter fallback", () => { expect(el.style.filter).toBe("drop-shadow(0px 0px 4px red)"); }); + it("reads poses with callbacks off when callbacks move no mover in the filter tier too", () => { + const { h } = movingHost({ + "data-motion-blur": "on", + "data-motion-blur-events": "on", + "data-motion-blur-tier": "filter", + }); + initCaptureMotionBlur(h); + win.__hfMotionBlur?.probe(); + expect(win.__hfMotionBlur?.report()).toMatchObject({ tier: "filter", movers: 1 }); + h.seek.mockClear(); + void paintCaptureMotionBlur(0.5, PLAY); + expect(h.seek.mock.calls.length).toBeGreaterThan(0); + expect(h.seek.mock.calls.every(([, suppress]) => suppress === true)).toBe(true); + }); + it("removes its filters and its SVG on teardown", () => { const { el, h } = movingHost(); initCaptureMotionBlur(h); diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index dae1d42e554..d2058359b25 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -1,8 +1,9 @@ /** * Capture motion blur, in preview and render. Root attributes: data-motion-blur="on"|"off", * -shutter (0–720°, default 240), -samples (2–64; unset: at least 32, more for a long smear), - * -events="on" (run timeline callbacks at sub-frame samples). PAGE_MOTION_BLUR_OFF_GLOBAL (engine blur, or a preview that - * could not blur) turns all off. + * -events="on" (run timeline callbacks in the mover scan, and at sub-frame samples when they move + * a mover). PAGE_MOTION_BLUR_OFF_GLOBAL (engine blur, or a preview that could not blur) turns all + * off. * * Per frame: read movers' poses across the shutter (seeks, no paint), capture the root and crop * the movers, hide them and capture the background, then redraw each along its path. @@ -36,7 +37,7 @@ import { import { swallow } from "../diagnostics"; import { createCompositor, type MoverFrame } from "./composite"; import { readMotionBlurSettings } from "../../motionBlurSettings"; -import { discoverMovers } from "./discover"; +import { scanMovers } from "./discover"; import { createFilterTier } from "./filterTier"; import { readMoverFrames } from "./frames"; import { createPoseReader } from "./pose"; @@ -238,20 +239,24 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI } const seek = (t: number): void => host.seek(t, !config.events); + let readEvents = false; + const readSeek = (t: number): void => host.seek(t, !readEvents); function discover(restoreTo: number): void { scannedTimeline = host.timeline(); const duration = host.durationSeconds(); const defects = createDefectScan(root, isAccelerated); const s0 = performance.now(); - const found = discoverMovers({ + const found = scanMovers({ scene: root, durationSeconds: duration, fps: host.fps, - seek, + seek: host.seek, + events: config.events, onStep: (i, steps) => defects.sample(i, steps, (i / steps) * duration), }); seek(restoreTo); + readEvents = found.readEvents; scanDefect = defects.result(); faceNow = defects.facesNow; movers = found.movers; @@ -266,7 +271,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI return readMoverFrames({ movers, reader, - seek, + seek: readSeek, t, reads, shutterSeconds: config.shutterDeg / 360 / host.fps, From ec9111466ba005224c1d2daadc3de05d4abd287c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 30 Oct 2026 16:02:18 -0700 Subject: [PATCH 2/5] fix(core): keep the motion blur comments to the invariant Co-Authored-By: Claude Opus 5.5 --- .../core/src/runtime/captureMotionBlur/discover.ts | 14 ++------------ .../core/src/runtime/captureMotionBlur/index.ts | 5 ++--- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/core/src/runtime/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index 2850ae5d8bb..43b0b77a54a 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.ts @@ -273,7 +273,6 @@ export function discoverMovers({ export interface ScanOptions extends Omit { seek: (t: number, suppressEvents: boolean) => void; - /** The root lets blur run timeline callbacks (data-motion-blur-events="on"). */ events: boolean; } @@ -283,12 +282,7 @@ const sameMembers = (a: Iterable, b: Iterable): boolean => { return x.size === y.size && [...x].every((el) => y.has(el)); }; -/** - * Discovery, and whether the per-frame pose reads must run timeline callbacks. With events on the - * scan runs them, so callback-driven movers are found; the reads run them only when a scan without - * them finds otherwise. Every frame's reads step back from half a shutter ahead, so a callback that - * keeps state (a simulation that replays from zero on a backward seek) would redo all of it per frame. - */ +/** Discovery, and whether pose reads need callbacks: only when a callback moves a mover. */ export function scanMovers({ seek, events, @@ -297,13 +291,9 @@ export function scanMovers({ }: ScanOptions): Discovery & { readEvents: boolean } { const quiet = (t: number): void => seek(t, true); if (!events) return { ...discoverMovers({ ...scan, seek: quiet, onStep }), readEvents: false }; - // With callbacks first, as when this was the only scan: tweens first render (and take their start - // values) after the callbacks before them, and a one-off call fires as often as it did. Run second, - // it would start from the end the silent scan left the playhead at and fire such a call once more. + // Loud first: run after the silent scan, it starts at the end and fires a one-off call once more. const found = discoverMovers({ ...scan, seek: (t) => seek(t, false), onStep }); const without = discoverMovers({ ...scan, seek: quiet }); - // ponytail: compares what each scan finds, not each step's boxes, so a mover that a tween and a - // callback both move reads without callbacks; compare per-step boxes if that case shows up. const same = sameMembers(without.movers, found.movers) && sameMembers(without.dynamic, found.dynamic); return { ...found, readEvents: !same }; diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index d2058359b25..93199264663 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -1,9 +1,8 @@ /** * Capture motion blur, in preview and render. Root attributes: data-motion-blur="on"|"off", * -shutter (0–720°, default 240), -samples (2–64; unset: at least 32, more for a long smear), - * -events="on" (run timeline callbacks in the mover scan, and at sub-frame samples when they move - * a mover). PAGE_MOTION_BLUR_OFF_GLOBAL (engine blur, or a preview that could not blur) turns all - * off. + * -events="on" (callbacks run in the scan, and in pose reads when they move a mover). + * PAGE_MOTION_BLUR_OFF_GLOBAL (engine blur, or a preview that could not blur) turns all off. * * Per frame: read movers' poses across the shutter (seeks, no paint), capture the root and crop * the movers, hide them and capture the background, then redraw each along its path. From 59eee26d7e22e0aa4c1e8cadf04159e7b25d8524 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 30 Oct 2026 16:19:55 -0700 Subject: [PATCH 3/5] fix(core): motion blur reads run callbacks when a callback moves any box 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 --- .../src/runtime/captureMotionBlur/discover.ts | 45 ++++++++++++++----- .../runtime/captureMotionBlur/index.test.ts | 34 ++++++++++++++ scripts/package-size-budgets.json | 2 +- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index 43b0b77a54a..08343730c28 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.ts @@ -38,6 +38,7 @@ export interface DiscoverOptions { /** Seek the composition (timeline + adapters) to a time. */ seek: (t: number) => void; onStep?: (i: number, steps: number) => void; + onStepBoxes?: (i: number, boxes: ReadonlyArray) => void; } export interface Discovery { @@ -187,6 +188,7 @@ export function discoverMovers({ fps, seek, onStep, + onStepBoxes, }: DiscoverOptions): Discovery { seek(0); const all = [...scene.querySelectorAll("*")].filter(isHtmlElement); @@ -230,6 +232,7 @@ export function discoverMovers({ dynamic.add(el); rects[j] = el.getBoundingClientRect(); }); + onStepBoxes?.(i, rects); const away = fresh.filter( (j) => edgeShift(baseRects[j], rects[j]) / framesPerStep >= MIN_SPEED, ); @@ -276,13 +279,20 @@ export interface ScanOptions extends Omit { events: boolean; } -const sameMembers = (a: Iterable, b: Iterable): boolean => { - const x = new Set(a); - const y = new Set(b); - return x.size === y.size && [...x].every((el) => y.has(el)); -}; +/** Every changing element's box, at 1/64 px, as one number per step. */ +function boxesKey(boxes: ReadonlyArray): number { + let h = 0x811c9dc5; + const mix = (v: number): void => { + h = Math.imul(h ^ v, 0x01000193); + }; + boxes.forEach((b, j) => { + if (!b) return; + for (const v of [j, b.left, b.top, b.width, b.height]) mix(Math.round(v * 64)); + }); + return h; +} -/** Discovery, and whether pose reads need callbacks: only when a callback moves a mover. */ +/** Discovery, and whether pose reads need callbacks: only when a callback moves any box. */ export function scanMovers({ seek, events, @@ -292,9 +302,22 @@ export function scanMovers({ const quiet = (t: number): void => seek(t, true); if (!events) return { ...discoverMovers({ ...scan, seek: quiet, onStep }), readEvents: false }; // Loud first: run after the silent scan, it starts at the end and fires a one-off call once more. - const found = discoverMovers({ ...scan, seek: (t) => seek(t, false), onStep }); - const without = discoverMovers({ ...scan, seek: quiet }); - const same = - sameMembers(without.movers, found.movers) && sameMembers(without.dynamic, found.dynamic); - return { ...found, readEvents: !same }; + const loud: number[] = []; + const found = discoverMovers({ + ...scan, + seek: (t) => seek(t, false), + onStep, + onStepBoxes: (i, boxes) => { + loud[i] = boxesKey(boxes); + }, + }); + let differs = false; + discoverMovers({ + ...scan, + seek: quiet, + onStepBoxes: (i, boxes) => { + differs ||= boxesKey(boxes) !== loud[i]; + }, + }); + return { ...found, readEvents: differs }; } diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index 7c36b8cbf6e..57777991164 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -956,6 +956,40 @@ describe("painting", () => { expect(flipped).toBe(false); }); + // Both scans find the same mover and the same changing elements; only the mover's box differs. + it("reads poses with callbacks on when a callback pans a mover's tweened ancestor", () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost({ + "data-motion-blur": "on", + "data-motion-blur-events": "on", + }); + const rig = document.createElement("div"); + root.append(rig); + rig.append(el); + const pan = () => Number(/,\s*([-\d.]+),\s*0\)$/.exec(rig.style.transform)?.[1] ?? 0); + const own = el.getBoundingClientRect.bind(el); + el.getBoundingClientRect = () => { + const b = own(); + return { ...b, left: b.left + pan(), right: b.right + pan() } as DOMRect; + }; + rig.getBoundingClientRect = () => + ({ left: pan(), top: 0, right: pan() + 10, bottom: 10, width: 10, height: 10 }) as DOMRect; + const slide = h.seek; + const seek = vi.fn((t: number, suppress: boolean) => { + slide(t, suppress); + rig.style.opacity = String(1 - t / 2); + if (!suppress) rig.style.transform = `matrix(1, 0, 0, 1, ${-30 * t}, 0)`; + }); + initCaptureMotionBlur({ ...h, seek }); + win.__hfMotionBlur?.probe(); + expect(win.__hfMotionBlur?.report().movers).toBe(1); + seek.mockClear(); + void paintCaptureMotionBlur(0.5, PLAY); + const reads = seek.mock.calls.filter(([t]) => t > 0); + expect(reads.length).toBeGreaterThan(0); + expect(reads.every(([, suppress]) => suppress === false)).toBe(true); + }); + it("reads poses with callbacks on when a callback moves a mover", () => { installHtmlInCanvas(); const { h } = callbackHost(); diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 068dcbaef77..ae1bc58ca2d 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -9,7 +9,7 @@ }, "@hyperframes/core": { "packed": 1661748, - "unpacked": 4024585 + "unpacked": 4268445 }, "@hyperframes/engine": { "packed": 456397, From acf5a2e31f450c14de4a30a6b736bd1a369b53fd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 30 Oct 2026 17:35:25 -0700 Subject: [PATCH 4/5] fix(core): silent motion blur reads land back on the frame as its own 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 --- docs/guides/motion-blur.mdx | 2 +- .../src/runtime/adapters/gsap.steps.test.ts | 56 ++++++++++++++++++- packages/core/src/runtime/adapters/gsap.ts | 18 ++++++ .../src/runtime/captureMotionBlur/discover.ts | 2 +- .../runtime/captureMotionBlur/filterTier.ts | 1 + .../src/runtime/captureMotionBlur/frames.ts | 3 +- .../runtime/captureMotionBlur/index.test.ts | 40 ++++++++++++- .../src/runtime/captureMotionBlur/index.ts | 1 + .../src/runtime/captureMotionBlur/tier.ts | 1 + packages/core/src/runtime/init.ts | 23 ++++++++ 10 files changed, 140 insertions(+), 7 deletions(-) diff --git a/docs/guides/motion-blur.mdx b/docs/guides/motion-blur.mdx index 9183d792abc..4289b185005 100644 --- a/docs/guides/motion-blur.mdx +++ b/docs/guides/motion-blur.mdx @@ -29,7 +29,7 @@ That is all. The same attribute drives the browser editor preview and `hyperfram | `data-motion-blur` | none | `on` enables blur. `off` opts out, on the root or on any subtree. | | `data-motion-blur-shutter` | `240` | Shutter angle in degrees, 0–720. Larger is a longer smear. | | `data-motion-blur-samples` | at least 32 | A fixed number of samples per frame, 2–64. Left unset, each mover gets at least 32, and about one per 2 px of smear when that is more, so a fast move never shows steps. | -| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers, and at each sample when a callback moves one. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. | +| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers, and at each sample when a callback changes an element's inline style or its box. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. | | `data-motion-blur-tier` | `auto` | `capture` or `filter` forces one of the two ways blur is drawn (below). | ## In the browser editor diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 7d3250bb4e4..6d2a2207f42 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -1,7 +1,12 @@ // @vitest-environment happy-dom import gsap from "gsap"; import { describe, expect, it } from "vitest"; -import { createGsapAdapter, rerenderGsapTimelineAt, seekGsapTimeline } from "./gsap"; +import { + createGsapAdapter, + holdLandingMarks, + rerenderGsapTimelineAt, + seekGsapTimeline, +} from "./gsap"; import type { GsapStructureKey } from "./gsap"; import type { RuntimeTimelineLike } from "../types"; @@ -771,3 +776,52 @@ describe("rerenderGsapTimelineAt with a structure key", () => { } }); }); + +describe("holdLandingMarks", () => { + const FPS = 30; + // Motion blur's pose reads seek silently across the shutter, then land back on the frame. + function readsAround(timeline: gsap.core.Timeline, t: number, render: boolean, hold: boolean) { + const tl = timeline as unknown as RuntimeTimelineLike; + const key: GsapStructureKey | undefined = render + ? undefined + : { owner: timeline, version: "1" }; + const put = hold ? holdLandingMarks(tl, key) : () => {}; + for (const at of [t - 0.011, t - 0.004, t + 0.004, t + 0.011]) + seekGsapTimeline(tl, at, true, key); + seekGsapTimeline(tl, t, true, key); + if (render) rerenderGsapTimelineAt(timeline, t); + put(); + } + function play(timeline: gsap.core.Timeline, render: boolean, hold: boolean, frames: number) { + for (let frame = 0; frame <= frames; frame++) { + const t = frame / FPS; + seekGsapTimeline(timeline as unknown as RuntimeTimelineLike, t, false); + readsAround(timeline, t, render, hold); + } + } + + it.each([false, true])( + "fires a call on a frame once across silent reads around it (render %s)", + (render) => { + const run = (hold: boolean) => { + let fires = 0; + let on = false; + const timeline = gsap.timeline({ paused: true }); + timeline.to({}, { duration: 2 }, 0); + timeline.call(() => ((on = !on), fires++), [], 1); + play(timeline, render, hold, 45); + return { fires, on }; + }; + expect(run(false).fires).toBeGreaterThan(1); + expect(run(true)).toEqual({ fires: 1, on: true }); + }, + ); + + it("keeps two same-time sets in authored order after silent reads around a render frame", () => { + const value = { v: 0 }; + const timeline = gsap.timeline({ paused: true }); + timeline.to({}, { duration: 2 }, 0).set(value, { v: 1 }, 1).set(value, { v: 2 }, 1); + play(timeline, true, true, FPS); + expect(value.v).toBe(2); + }); +}); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 5b2516454de..31e505f5ecc 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -383,6 +383,24 @@ function stepsAt( return steps; } +export function holdLandingMarks( + timeline: { getChildren?: RuntimeTimelineLike["getChildren"] }, + structure?: GsapStructureKey, +): () => void { + const placed = structure ? placedLandingMarks(timeline, structure) : null; + const marks = placed + ? [...placed.always, ...placed.points.map((point) => point.mark)] + : childrenWithLandingMarksIn(timeline); + const held = marks.map((mark) => [mark, mark.ratio, mark._zTime, mark._act] as const); + return () => { + for (const [mark, ratio, zTime, active] of held) { + mark.ratio = ratio; + mark._zTime = zTime; + mark._act = active; + } + }; +} + function landingMarksOf( timeline: { getChildren?: RuntimeTimelineLike["getChildren"] }, structure: GsapStructureKey | undefined, diff --git a/packages/core/src/runtime/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index 08343730c28..8fb6f36a827 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.ts @@ -292,7 +292,7 @@ function boxesKey(boxes: ReadonlyArray): number { return h; } -/** Discovery, and whether pose reads need callbacks: only when a callback moves any box. */ +/** Discovery, and whether pose reads need callbacks: when they change an element's style or box. */ export function scanMovers({ seek, events, diff --git a/packages/core/src/runtime/captureMotionBlur/filterTier.ts b/packages/core/src/runtime/captureMotionBlur/filterTier.ts index f28c93a7f42..a56aad1e2fc 100644 --- a/packages/core/src/runtime/captureMotionBlur/filterTier.ts +++ b/packages/core/src/runtime/captureMotionBlur/filterTier.ts @@ -183,6 +183,7 @@ export function createFilterTier( movers, reader, seek: readSeek, + restore: readEvents ? undefined : host.holdFrame?.(), t, reads: engineMode ? RENDER_READS : PREVIEW_READS, shutterSeconds: config.shutterDeg / 360 / host.fps, diff --git a/packages/core/src/runtime/captureMotionBlur/frames.ts b/packages/core/src/runtime/captureMotionBlur/frames.ts index 624c48a3ea2..651d25a3949 100644 --- a/packages/core/src/runtime/captureMotionBlur/frames.ts +++ b/packages/core/src/runtime/captureMotionBlur/frames.ts @@ -14,6 +14,7 @@ export interface FrameRequest { movers: HTMLElement[]; reader: ReturnType; seek: (t: number) => void; + restore?: (t: number) => void; t: number; /** Pose reads across the shutter; the taps between them are interpolated. */ reads: number; @@ -40,7 +41,7 @@ export function readMoverFrames(req: FrameRequest): { els: HTMLElement[]; frames r.reset(); visible.forEach((el, i) => poses[i]?.push(r.pose(el))); } - seek(t); + (req.restore ?? seek)(t); r.reset(); const frames = visible.map((el, i) => { const pad = req.pads?.get(el) ?? 0; diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index 57777991164..62030e8f3c8 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -891,7 +891,7 @@ describe("painting", () => { win.__hfMotionBlur?.probe(); const loud = eventful.seek.mock.calls.filter(([, suppress]) => suppress === false); expect(loud.length).toBeGreaterThan(10); - // The scan ends with callbacks on, so their state is the frame's when it is done. + // The page goes back to the frame with callbacks on after the scan. expect(eventful.seek.mock.calls.at(-1)?.[1]).toBe(false); }); @@ -934,8 +934,42 @@ describe("painting", () => { expect(win.__hfMotionBlur?.report().movers).toBe(1); h.seek.mockClear(); void paintCaptureMotionBlur(0.5, PLAY); - expect(h.seek.mock.calls.length).toBeGreaterThan(0); - expect(h.seek.mock.calls.every(([, suppress]) => suppress === true)).toBe(true); + const reads = h.seek.mock.calls.filter(([t]) => t > 0); + expect(reads.length).toBeGreaterThan(0); + expect(reads.every(([, suppress]) => suppress === true)).toBe(true); + }); + + it("lands silent reads back on the frame through the host's holdFrame, taken before the first read", () => { + installHtmlInCanvas(); + const { h } = movingHost({ "data-motion-blur": "on", "data-motion-blur-events": "on" }); + const order: string[] = []; + const seek = vi.fn((t: number, suppress: boolean) => { + h.seek(t, suppress); + order.push(`seek ${t.toFixed(3)}`); + }); + const hold = vi.fn(() => { + order.push("hold"); + return (t: number) => void order.push(`restore ${t}`); + }); + initCaptureMotionBlur({ ...h, seek, holdFrame: hold }); + win.__hfMotionBlur?.probe(); + order.length = 0; + void paintCaptureMotionBlur(0.5, PLAY); + const first = order.findIndex((step) => step.startsWith("seek 0.4")); + expect(order.indexOf("hold")).toBeGreaterThanOrEqual(0); + expect(order.indexOf("hold")).toBeLessThan(first); + expect(order.at(-1)).toBe("restore 0.5"); + expect(order.filter((step) => step === "seek 0.500")).toEqual([]); + }); + + it("does not hold the page when callbacks run in the reads", () => { + installHtmlInCanvas(); + const { h } = callbackHost(); + const hold = vi.fn(() => () => {}); + initCaptureMotionBlur({ ...h, holdFrame: hold }); + win.__hfMotionBlur?.probe(); + void paintCaptureMotionBlur(0.5, PLAY); + expect(hold).not.toHaveBeenCalled(); }); it("fires a one-off timeline call an even number of times over a scan, so a toggle ends as it began", () => { diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index 93199264663..7750c6fc7ee 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -271,6 +271,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI movers, reader, seek: readSeek, + restore: readEvents ? undefined : host.holdFrame?.(), t, reads, shutterSeconds: config.shutterDeg / 360 / host.fps, diff --git a/packages/core/src/runtime/captureMotionBlur/tier.ts b/packages/core/src/runtime/captureMotionBlur/tier.ts index 0270dbaa778..04e0a315b90 100644 --- a/packages/core/src/runtime/captureMotionBlur/tier.ts +++ b/packages/core/src/runtime/captureMotionBlur/tier.ts @@ -11,6 +11,7 @@ export interface CaptureMotionBlurHost { now: () => number; /** Seek the timeline and adapters (not media) for a pose read. */ seek: (t: number, suppressEvents: boolean) => void; + holdFrame?: () => (t: number) => void; ignoreMutations?: (write: () => void) => void; } diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index dbb3a59b1d3..e7445058e29 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -54,6 +54,7 @@ import { injectCompositionCssVariables } from "./getVariables"; import { createCssAdapter } from "./adapters/css"; import { createGsapAdapter, + holdLandingMarks, rerenderGsapTimelineAt, seekGsapTimeline, type GsapStructureKey, @@ -3989,6 +3990,28 @@ export function initSandboxRuntimeModular(): void { seek: (t, suppressEvents) => { seekRootChildrenAndAdapters(state.capturedTimeline, t, { suppressEvents }); }, + // Silent pose reads re-arm GSAP's landing marks (a step at t would fire again), and a render + // seek, which has no structure, lands back on t without same-time steps' authored order. + holdFrame: () => { + const timelines = [state.capturedTimeline, ...sceneTimelines().map(([, tl]) => tl)]; + const marks = timelines.map((tl) => tl && holdLandingMarks(tl, gsapStructureOf(tl))); + return (t) => { + seekRootChildrenAndAdapters(state.capturedTimeline, t, { suppressEvents: true }); + for (const tl of timelines) { + if (tl?.totalTime && !gsapStructureOf(tl)) { + rerenderGsapTimelineAt( + { + totalTime: tl.totalTime.bind(tl), + totalDuration: tl.totalDuration?.bind(tl), + getChildren: tl.getChildren?.bind(tl), + }, + Number(tl.totalTime()), + ); + } + } + for (const put of marks) put?.(); + }; + }, ignoreMutations: withoutOwnCompositionRecords, }); } From 6d587e136c5864739b2bcc0e0cd2fecc36b1e88a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 30 Oct 2026 18:05:20 -0700 Subject: [PATCH 5/5] fix(core): a held render frame draws its scenes first, so the root lands 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 --- .../src/runtime/adapters/gsap.steps.test.ts | 24 +++++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 29 +++++++++++++++++++ packages/core/src/runtime/init.ts | 29 +++++++------------ 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 6d2a2207f42..fc40d97c5d6 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -3,6 +3,7 @@ import gsap from "gsap"; import { describe, expect, it } from "vitest"; import { createGsapAdapter, + holdFrameMarks, holdLandingMarks, rerenderGsapTimelineAt, seekGsapTimeline, @@ -817,6 +818,29 @@ describe("holdLandingMarks", () => { }, ); + it("lands a render frame with the root drawn after its scenes, as the frame's own seek does", () => { + const shared = { x: 0 }; + const scene = gsap.timeline({ paused: true }); + scene.fromTo(shared, { x: 0 }, { x: 100, duration: 2, ease: "none" }, 0); + const root = gsap.timeline({ paused: true }); + root.fromTo(shared, { x: 1000 }, { x: 2000, duration: 2, ease: "none" }, 0); + const tl = (timeline: gsap.core.Timeline) => timeline as unknown as RuntimeTimelineLike; + const seekBoth = (t: number, silent: boolean) => { + seekGsapTimeline(tl(scene), t, silent); + seekGsapTimeline(tl(root), t, silent); + }; + seekBoth(0.5, false); + expect(shared.x).toBe(1250); + const land = holdFrameMarks({ + scenes: [tl(scene)], + root: tl(root), + structureOf: () => undefined, + }); + for (const at of [0.489, 0.496, 0.504, 0.511]) seekBoth(at, true); + land(() => seekBoth(0.5, true)); + expect(shared.x).toBe(1250); + }); + it("keeps two same-time sets in authored order after silent reads around a render frame", () => { const value = { v: 0 }; const timeline = gsap.timeline({ paused: true }); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 31e505f5ecc..83a333a63e6 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -401,6 +401,35 @@ export function holdLandingMarks( }; } +export function holdFrameMarks({ + scenes, + root, + structureOf, +}: { + scenes: RuntimeTimelineLike[]; + root: RuntimeTimelineLike | null; + structureOf: (timeline: RuntimeTimelineLike) => GsapStructureKey | undefined; +}): (landBack: () => void) => void { + const rootLandsLast = root ? [...scenes, root] : scenes; + const marks = rootLandsLast.map((timeline) => holdLandingMarks(timeline, structureOf(timeline))); + return (landBack) => { + landBack(); + for (const timeline of rootLandsLast) { + if (timeline.totalTime && !structureOf(timeline)) { + rerenderGsapTimelineAt( + { + totalTime: timeline.totalTime.bind(timeline), + totalDuration: timeline.totalDuration?.bind(timeline), + getChildren: timeline.getChildren?.bind(timeline), + }, + Number(timeline.totalTime()), + ); + } + } + for (const put of marks) put(); + }; +} + function landingMarksOf( timeline: { getChildren?: RuntimeTimelineLike["getChildren"] }, structure: GsapStructureKey | undefined, diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index e7445058e29..e7a315fa49a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -54,7 +54,7 @@ import { injectCompositionCssVariables } from "./getVariables"; import { createCssAdapter } from "./adapters/css"; import { createGsapAdapter, - holdLandingMarks, + holdFrameMarks, rerenderGsapTimelineAt, seekGsapTimeline, type GsapStructureKey, @@ -3993,24 +3993,15 @@ export function initSandboxRuntimeModular(): void { // Silent pose reads re-arm GSAP's landing marks (a step at t would fire again), and a render // seek, which has no structure, lands back on t without same-time steps' authored order. holdFrame: () => { - const timelines = [state.capturedTimeline, ...sceneTimelines().map(([, tl]) => tl)]; - const marks = timelines.map((tl) => tl && holdLandingMarks(tl, gsapStructureOf(tl))); - return (t) => { - seekRootChildrenAndAdapters(state.capturedTimeline, t, { suppressEvents: true }); - for (const tl of timelines) { - if (tl?.totalTime && !gsapStructureOf(tl)) { - rerenderGsapTimelineAt( - { - totalTime: tl.totalTime.bind(tl), - totalDuration: tl.totalDuration?.bind(tl), - getChildren: tl.getChildren?.bind(tl), - }, - Number(tl.totalTime()), - ); - } - } - for (const put of marks) put?.(); - }; + const land = holdFrameMarks({ + scenes: sceneTimelines().map(([, timeline]) => timeline), + root: state.capturedTimeline, + structureOf: gsapStructureOf, + }); + return (t) => + land(() => + seekRootChildrenAndAdapters(state.capturedTimeline, t, { suppressEvents: true }), + ); }, ignoreMutations: withoutOwnCompositionRecords, });