diff --git a/docs/guides/motion-blur.mdx b/docs/guides/motion-blur.mdx index 0fe3c208eef..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. 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..fc40d97c5d6 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -1,7 +1,13 @@ // @vitest-environment happy-dom import gsap from "gsap"; import { describe, expect, it } from "vitest"; -import { createGsapAdapter, rerenderGsapTimelineAt, seekGsapTimeline } from "./gsap"; +import { + createGsapAdapter, + holdFrameMarks, + holdLandingMarks, + rerenderGsapTimelineAt, + seekGsapTimeline, +} from "./gsap"; import type { GsapStructureKey } from "./gsap"; import type { RuntimeTimelineLike } from "../types"; @@ -771,3 +777,75 @@ 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("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 }); + 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..83a333a63e6 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -383,6 +383,53 @@ 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; + } + }; +} + +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/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index e397d360c2b..8fb6f36a827 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, ); @@ -270,3 +273,51 @@ 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; + events: boolean; +} + +/** 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: when they change an element's style or box. */ +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 }; + // Loud first: run after the silent scan, it starts at the end and fires a one-off call once more. + 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/filterTier.ts b/packages/core/src/runtime/captureMotionBlur/filterTier.ts index 53e06d6c590..a56aad1e2fc 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,8 @@ export function createFilterTier( const read = readMoverFrames({ movers, reader, - seek, + 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 3979c6ebe6b..62030e8f3c8 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 page goes back to the frame with callbacks on after the scan. + 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,128 @@ 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); + 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", () => { + 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); + }); + + // 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(); + 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 +1914,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..7750c6fc7ee 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -1,8 +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 at sub-frame samples). 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. @@ -36,7 +36,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 +238,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 +270,8 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI return readMoverFrames({ movers, reader, - seek, + 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..e7a315fa49a 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, + holdFrameMarks, rerenderGsapTimelineAt, seekGsapTimeline, type GsapStructureKey, @@ -3989,6 +3990,19 @@ 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 land = holdFrameMarks({ + scenes: sceneTimelines().map(([, timeline]) => timeline), + root: state.capturedTimeline, + structureOf: gsapStructureOf, + }); + return (t) => + land(() => + seekRootChildrenAndAdapters(state.capturedTimeline, t, { suppressEvents: true }), + ); + }, ignoreMutations: withoutOwnCompositionRecords, }); } 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,