diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 297c8f8cc75..20a1f350281 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -482,3 +482,290 @@ describe("re-rendering leaves the rest as the seek left it", () => { expect(timeline.duration()).toBe(20); }); }); + +describe("gsap frame refresh from-state", () => { + it("keeps a from-state when the tween starts between output frames", () => { + const target = { x: 0, pivot: 50 }; + const timeline = gsap + .timeline({ paused: true }) + .fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 1, ease: "none", immediateRender: false }, + 0.0995, + ); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 3 / 30 }); + expect(target.pivot).toBe(0); + expect(target.x).toBeCloseTo(10.045, 6); + timeline.kill(); + }); + + it("keeps from-only values after a short tween ends within the redraw's rewind", () => { + const target = { x: 0, pivot: 50 }; + const timeline = gsap + .timeline({ paused: true }) + .fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 0.0002, ease: "none", immediateRender: false }, + 0.1, + ); + timeline.set({}, {}, 1); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 0.1005 }); + expect(target.pivot).toBe(0); + expect(target.x).toBe(100); + timeline.kill(); + }); + + it("restores the normal tween render when replaying a from-state throws", () => { + let pivot = 50; + let rejectFromState = false; + const target = { + x: 0, + get pivot() { + return pivot; + }, + set pivot(value: number) { + if (rejectFromState && value === 0) throw new Error("from-state rejected"); + pivot = value; + }, + }; + const timeline = gsap + .timeline({ paused: true }) + .fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 1, ease: "none", immediateRender: false }, + 0.1, + ); + timeline.totalTime(0.1, true); + const tween = timeline.getChildren(false, true, false)[0]!; + const renderDescriptor = Object.getOwnPropertyDescriptor(tween, "render"); + rejectFromState = true; + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + expect(() => adapter.seek({ time: 0.1 })).toThrow("from-state rejected"); + expect(Object.getOwnPropertyDescriptor(tween, "render")).toEqual(renderDescriptor); + expect(() => adapter.seek({ time: 0.2 })).not.toThrow(); + expect(target.x).toBeCloseTo(19, 6); + timeline.kill(); + }); + + it.each([false, true])("restores the tween's render descriptor (own=%s)", (own) => { + const target = { x: 0, pivot: 50 }; + const timeline = gsap.timeline({ paused: true }); + timeline.fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 1, ease: "none", immediateRender: false }, + 0.0995, + ); + const tween = timeline.getChildren(false, true, false)[0]!; + if (own) { + Object.defineProperty(tween, "render", { + value: tween.render, + writable: true, + configurable: true, + enumerable: false, + }); + } + const renderDescriptor = Object.getOwnPropertyDescriptor(tween, "render"); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 0.1 }); + expect(target.x).toBeCloseTo(10.045, 6); + expect(Object.getOwnPropertyDescriptor(tween, "render")).toEqual(renderDescriptor); + timeline.kill(); + }); + + it("keeps interpolation when a from-state setter reenters the refresh", () => { + let pivot = 50; + let armed = false; + let entered = false; + const target = { + x: 0, + get pivot() { + return pivot; + }, + set pivot(value: number) { + pivot = value; + if (armed && value === 0 && !entered) { + entered = true; + rerenderGsapTimelineAt(timeline, 0.1005); + } + }, + }; + const timeline = gsap + .timeline({ paused: true }) + .fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 1, ease: "none", immediateRender: false }, + 0.1, + ); + timeline.totalTime(0.1005, true); + armed = true; + rerenderGsapTimelineAt(timeline, 0.1005); + expect(entered).toBe(true); + expect(target.pivot).toBe(0); + expect(target.x).toBeCloseTo(10.045, 6); + timeline.kill(); + }); + + it("preserves repeatRefresh function evaluation for very short immediate tweens", () => { + let calls = 0; + const target = { x: 0, pivot: 50 }; + const timeline = gsap + .timeline({ paused: true }) + .fromTo( + target, + { x: () => 10 * ++calls, pivot: 0 }, + { + x: 100, + duration: 0.0002, + repeat: 2, + repeatRefresh: true, + immediateRender: true, + ease: "none", + }, + 0.1, + ) + .to({}, { duration: 1 }); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + for (const time of [0, 0.1, 0.1005]) adapter.seek({ time }); + expect(target.x).toBe(80); + expect(calls).toBe(6); + timeline.kill(); + }); + + it("reuses the initialized function-valued from-state on repeated seeks", () => { + let calls = 0; + const target = { x: 0, pivot: 50 }; + const timeline = gsap.timeline({ paused: true }).fromTo( + target, + { + x: 10, + pivot: () => { + calls++; + return 0; + }, + }, + { x: 100, duration: 1, ease: "none", immediateRender: false }, + 0.1, + ); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + for (const time of [0.1, 0.1, 0.1005]) adapter.seek({ time }); + expect(target.pivot).toBe(0); + expect(calls).toBe(1); + timeline.kill(); + }); + + it.each([ + { label: "exactly at the start", at: 0.1, x: 10 }, + { label: "within the redraw's rewind", at: 0.1005, x: 10.045 }, + { label: "after the redraw's rewind", at: 0.2, x: 19 }, + ])("keeps from-only values $label and restores them before the tween", ({ at, x }) => { + const target = { x: 0, pivot: 50 }; + const timeline = gsap + .timeline({ paused: true }) + .fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 1, ease: "none", immediateRender: false, autoRevert: true }, + 0.1, + ); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: at }); + adapter.seek({ time: at }); + expect(target.pivot).toBe(0); + expect(target.x).toBeCloseTo(x, 6); + adapter.seek({ time: 0.05 }); + expect(target.pivot).toBe(50); + expect(target.x).toBe(0); + timeline.kill(); + }); + + it.each([1, 2])("keeps from-only values in a scene playing at %sx", (speed) => { + const target = { x: 0, pivot: 50 }; + const scene = gsap + .timeline() + .fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 1, ease: "none", immediateRender: false }, + 0.1, + ) + .timeScale(speed); + const timeline = gsap.timeline({ paused: true }).add(scene, 1); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 1 + 0.1 / speed }); + expect(target.pivot).toBe(0); + expect(target.x).toBeCloseTo(10, 6); + timeline.kill(); + }); + + it.each([false, true])("keeps from-only writers in authored order (set last: %s)", (setLast) => { + const target = { x: 0, pivot: 50 }; + const timeline = gsap.timeline({ paused: true }); + const set = () => timeline.set(target, { pivot: 42 }, 0.1); + const fromTo = () => + timeline.fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 1, ease: "none", immediateRender: false }, + 0.1, + ); + if (setLast) { + fromTo(); + set(); + } else { + set(); + fromTo(); + } + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + for (const time of [0.1, 0.1, 0.2]) { + adapter.seek({ time }); + expect(target.pivot).toBe(setLast ? 42 : 0); + } + timeline.kill(); + }); + + it("leaves uninitialized scene siblings alone at the scene's opening", () => { + const target = { x: 0 }; + const other = { x: 0, pivot: 50 }; + const timeline = gsap + .timeline({ paused: true }) + .to(target, { x: 100, duration: 10, ease: "none" }); + const scene = gsap + .timeline() + .to(target, { x: 200, duration: 1, overwrite: "auto", ease: "none" }, 0); + scene.fromTo(other, { x: 10, pivot: 0 }, { x: 100, duration: 1, immediateRender: false }, 0); + timeline.add(scene, 2); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 2 }); + adapter.seek({ time: 1 }); + expect(target.x).toBe(10); + expect(other).toMatchObject({ x: 0, pivot: 50 }); + timeline.kill(); + }); +}); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 7a3695f43de..0ad186be1c0 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -5,8 +5,8 @@ type GsapAdapterDeps = { }; /** - * Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored - * order. That step skips a keyframed tween at its start and undoes a tween's from-values there, so both are redone. + * A from-below refresh keeps same-time steps in authored order; keyframes at their start need priming. + * Replay fromTo startAt in that order across the rewind: earlier writers must not override from-only values. */ export function rerenderGsapTimelineAt( timeline: { @@ -31,20 +31,30 @@ export function rerenderGsapTimelineAt( (parent) => [parent, parent._dur, parent._tDur, parent._end, parent._dirty] as const, ); for (const [child] of skipped) child._ts = 0; + const fromTweens = t >= 0.001 ? initializedFromTweensCrossedByRewind(children, t) : []; + const renders = fromTweens.map( + (child) => [child, child.render, Object.getOwnPropertyDescriptor(child, "render")] as const, + ); try { + for (const [child, render] of renders) { + child.render = renderWithFromState(child, render); + } timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true); primeTweensStartingAt(children, t); timeline.totalTime(t, true); } finally { + for (const [child, , descriptor] of renders) { + restoreRenderDescriptor(child, descriptor); + } for (const [child, timeScale] of skipped) child._ts = timeScale; for (const [parent, dur, tDur, end, dirty] of lengths) { Object.assign(parent, { _dur: dur, _tDur: tDur, _end: end, _dirty: dirty }); } - } - for (const [child, ratio, zTime, active] of marked) { - child.ratio = ratio; - child._zTime = zTime; - child._act = active; + for (const [child, ratio, zTime, active] of marked) { + child.ratio = ratio; + child._zTime = zTime; + child._act = active; + } } } @@ -94,17 +104,67 @@ type GsapAnimation = { timeScale: () => number; totalDuration: () => number; paused: () => boolean; - render: (totalTime: number, suppressEvents: boolean) => unknown; - vars?: { keyframes?: unknown }; + render: (totalTime: number, suppressEvents: boolean, force?: boolean) => unknown; + vars?: { keyframes?: unknown; startAt?: unknown }; + time?: () => number; + _tTime?: number; _startAt?: | 0 | { render: (totalTime: number, suppressEvents: boolean, force: boolean) => unknown }; getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[]; - timeline?: Pick; + timeline?: Pick; }; const BELOW_GSAP_TIME_RESOLUTION = 2e-8; +function restoreRenderDescriptor(child: GsapAnimation, descriptor?: PropertyDescriptor): void { + if (descriptor) Object.defineProperty(child, "render", descriptor); + else Reflect.deleteProperty(child, "render"); +} + +function renderWithFromState( + child: GsapAnimation, + render: GsapAnimation["render"], +): GsapAnimation["render"] { + return function (time, suppressEvents, force) { + if (time >= 0 && child._startAt) { + const renderedTime = child._tTime; + child._startAt.render(0, true, true); + force ||= child._tTime !== renderedTime; + } + return render.call(child, time, suppressEvents, force); + }; +} + +function hasInitializedFromState(child: GsapAnimation): boolean { + return Boolean( + child.vars?.startAt && child._startAt && typeof child._startAt.render === "function", + ); +} + +function initializedFromTweensCrossedByRewind( + children: unknown[], + time: number, + rewind = 0.001, + found: GsapAnimation[] = [], +): GsapAnimation[] { + for (const child of children.filter(playsForward)) { + const local = (time - child.startTime()) * child.timeScale(); + if (local < -PLAYHEAD_FLOAT_NOISE) continue; + const childRewind = rewind * child.timeScale(); + if (local < childRewind && hasInitializedFromState(child)) found.push(child); + const nested = child.getChildren ? child : child.timeline; + if (nested?.getChildren) + initializedFromTweensCrossedByRewind( + nested.getChildren(false, true, true), + nested.time ? nested.time() : local, + childRewind, + found, + ); + } + return found; +} + const playsForward = (value: unknown): value is GsapAnimation => { const animation = value as GsapAnimation | null; return ( diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index eafa8260d98..2328c1fcb0f 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1600,6 +1600,24 @@ describe("initSandboxRuntimeModular", () => { initSandboxRuntimeModular(); }; + it("keeps from-only values across export frames that land on a fromTo start", () => { + const target = { x: 0, pivot: 50 }; + const root = paused().fromTo( + target, + { x: 10, pivot: 0 }, + { x: 100, duration: 0.45, ease: "none", immediateRender: false }, + 0.0995, + ); + root.set({}, {}, 1); + initWithRoot("1", root); + expect(window.__player).toBeDefined(); + for (const frame of [0, 3, 4, 5]) { + window.__player?.renderSeek(frame / 30); + expect(target.pivot).toBe(frame === 0 ? 50 : 0); + } + root.kill(); + }); + it("skips the filler that pads a short timeline to the declared length", () => { const root = paused().to({ x: 0 }, { x: 1, duration: 4 }, 0); initWithRoot("10", root);