From 03d79278d36b90fba2bb094775b01057d108507a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 19 Jun 2026 18:56:28 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(studio):=20patchRuntimeTweenInPlace=20?= =?UTF-8?q?=E2=80=94=20update=20a=20tween's=20values=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. --- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 65 ++- .../studio/src/hooks/gsapRuntimePatch.test.ts | 382 ++++++++++++++++++ packages/studio/src/hooks/gsapRuntimePatch.ts | 144 +++++++ 3 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 packages/studio/src/hooks/gsapRuntimePatch.test.ts create mode 100644 packages/studio/src/hooks/gsapRuntimePatch.ts diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index a08cf2ff3..77d3f73a9 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -12,17 +12,19 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser- import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared"; import { roundTo3 } from "../utils/rounding"; -interface RuntimeTween { +export interface RuntimeTween { targets?: () => Element[]; vars?: Record; duration?: () => number; startTime?: () => number; + invalidate?: () => RuntimeTween; } -interface RuntimeTimeline { +export interface RuntimeTimeline { getChildren?: (deep: boolean) => RuntimeTween[]; duration?: () => number; time?: () => number; + invalidate?: () => RuntimeTimeline; } type Pct = { percentage: number; properties: Record }; @@ -56,7 +58,9 @@ const FLAT_SKIP_KEYS = new Set([ "keyframes", ]); -function timelinesOf(iframe: HTMLIFrameElement | null): Record | null { +export function timelinesOf( + iframe: HTMLIFrameElement | null, +): Record | null { if (!iframe?.contentWindow) return null; try { return ( @@ -161,6 +165,61 @@ function tweenTiming(tween: RuntimeTween): { start: number; duration: number } { }; } +export interface ResolvedRuntimeTween { + /** The live GSAP tween targeting the selector. */ + tween: RuntimeTween; + /** The composition timeline that owns it. */ + timeline: RuntimeTimeline; +} + +/** + * Resolve the live tween targeting `selector` using the SAME all-timelines scan + * `readRuntimeKeyframes` uses, so read and write agree on "which tween". With + * `kind: "keyframe"` it skips zero-duration `set`s and prefers the tween whose + * range contains the playhead (matching the reader). With `kind: "set"` it picks + * the zero-duration `set`/hold instead. Returns null when none matches. + */ +export function resolveRuntimeTween( + iframe: HTMLIFrameElement | null, + selector: string, + kind: "keyframe" | "set", + compositionId?: string, +): ResolvedRuntimeTween | null { + const timelines = timelinesOf(iframe); + if (!timelines) return null; + + let targetEl: Element | null = null; + try { + targetEl = iframe?.contentDocument?.querySelector(selector) ?? null; + } catch { + return null; + } + if (!targetEl) return null; + + const tlIds = compositionId + ? [compositionId] + : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function"); + + let first: ResolvedRuntimeTween | null = null; + for (const tlId of tlIds) { + const timeline = timelines[tlId]; + if (!timeline?.getChildren) continue; + const now = typeof timeline.time === "function" ? timeline.time() : null; + for (const tween of timeline.getChildren(true)) { + if (!tween.vars || !matchesElement(tween, targetEl)) continue; + const dur = typeof tween.duration === "function" ? tween.duration() : 0; + const isSet = !(dur > 0); + if (kind === "set" ? !isSet : isSet) continue; + if (first === null) first = { tween, timeline }; + if (kind === "keyframe" && now != null) { + const start = typeof tween.startTime === "function" ? tween.startTime() : 0; + if (now >= start - 1e-3 && now <= start + dur + 1e-3) return { tween, timeline }; + } + } + } + return first; +} + /** * Read keyframes (incl. motionPath arcs) for one selector from the live timeline. * Returns tween-relative percentages; callers convert to clip-relative. diff --git a/packages/studio/src/hooks/gsapRuntimePatch.test.ts b/packages/studio/src/hooks/gsapRuntimePatch.test.ts new file mode 100644 index 000000000..4f5119955 --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimePatch.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it, vi } from "vitest"; +import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch"; + +/** + * The helper patches ONE tween's values in `window.__timelines[compKey]` in place, + * `invalidate()`s it, and re-seeks via `__player.seek(currentTime)` so a value-only + * edit is reflected without re-running the composition. It must return `false` + * (caller falls back to a soft reload) whenever it can't confidently apply. + * + * The fixtures below mimic the runtime timeline shape the reader scans: + * a timeline with `getChildren(deep)`, child tweens with `vars`/`targets`/ + * `duration`/`startTime`/`invalidate`, and an `__player` with `getTime`/`seek`. + */ + +type TweenSpec = { + vars: Record; + targetIds: string[]; + duration: number; + startTime?: number; +}; + +function makeTween(spec: TweenSpec, el: { id: string }) { + const invalidate = vi.fn(); + return { + vars: spec.vars, + targets: () => spec.targetIds.map((id) => (id === el.id ? el : { id })), + duration: () => spec.duration, + startTime: () => spec.startTime ?? 0, + invalidate, + }; +} + +// A preview iframe whose runtime timeline holds the given tweens under `compKey`, +// resolves `#`, and exposes a `__player` clock that re-renders the timeline +// when `seek` is called (so we can assert the post-seek interpolated value). +function fakeIframe( + el: { id: string }, + tweens: ReturnType[], + opts: { + compKey?: string; + extraTimelines?: Record; + now?: number; + onSeek?: (t: number) => void; + } = {}, +): { + iframe: HTMLIFrameElement; + seek: ReturnType; + timeline: { getChildren: () => unknown[] }; +} { + const compKey = opts.compKey ?? "index.html"; + const now = opts.now ?? 0; + const timeline = { + getChildren: () => tweens, + duration: () => 14.6, + time: () => now, + }; + const seek = vi.fn((t: number) => opts.onSeek?.(t)); + const iframe = { + contentWindow: { + __timelines: { [compKey]: timeline, ...(opts.extraTimelines ?? {}) }, + __player: { getTime: () => now, seek }, + }, + contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) }, + } as unknown as HTMLIFrameElement; + return { iframe, seek, timeline }; +} + +describe("patchRuntimeTweenInPlace — set tweens", () => { + it("patches a tl.set x/y; a simulated re-seek reflects the NEW x/y (not the old)", () => { + const el = { id: "box" }; + // Model the runtime applying the set's vars to the element on seek. + const rendered: { x: number; y: number } = { x: 0, y: 0 }; + const setTween = makeTween( + { vars: { x: 0, y: 0 }, targetIds: ["box"], duration: 0, startTime: 0 }, + el, + ); + const { iframe, seek } = fakeIframe(el, [setTween], { + onSeek: () => { + rendered.x = setTween.vars.x as number; + rendered.y = setTween.vars.y as number; + }, + }); + + const ok = patchRuntimeTweenInPlace(iframe, "#box", { + kind: "set", + props: { x: 120, y: -40 }, + }); + + expect(ok).toBe(true); + expect(setTween.vars.x).toBe(120); + expect(setTween.vars.y).toBe(-40); + expect(setTween.invalidate).toHaveBeenCalled(); + expect(seek).toHaveBeenCalledTimes(1); + expect(rendered).toEqual({ x: 120, y: -40 }); + }); + + it("patches only the rotation channel, leaving x/y untouched", () => { + const el = { id: "knob" }; + const setTween = makeTween( + { vars: { x: 10, y: 20, rotation: 0 }, targetIds: ["knob"], duration: 0 }, + el, + ); + const { iframe } = fakeIframe(el, [setTween]); + + const ok = patchRuntimeTweenInPlace(iframe, "#knob", { + kind: "set", + props: { rotation: 45 }, + }); + + expect(ok).toBe(true); + expect(setTween.vars.rotation).toBe(45); + expect(setTween.vars.x).toBe(10); + expect(setTween.vars.y).toBe(20); + }); + + it("patches only the scale channels", () => { + const el = { id: "card" }; + const setTween = makeTween( + { vars: { scaleX: 1, scaleY: 1, opacity: 1 }, targetIds: ["card"], duration: 0 }, + el, + ); + const { iframe } = fakeIframe(el, [setTween]); + + const ok = patchRuntimeTweenInPlace(iframe, "#card", { + kind: "set", + props: { scaleX: 2, scaleY: 1.5 }, + }); + + expect(ok).toBe(true); + expect(setTween.vars.scaleX).toBe(2); + expect(setTween.vars.scaleY).toBe(1.5); + expect(setTween.vars.opacity).toBe(1); + }); +}); + +describe("patchRuntimeTweenInPlace — keyframe tweens", () => { + it("rebuilds the keyframes; a moved keyframe updates, others unchanged", () => { + const el = { id: "puck" }; + const kfTween = makeTween( + { + vars: { + keyframes: [ + { x: 0, y: 0 }, + { x: 100, y: 50 }, + { x: 200, y: 0 }, + ], + duration: 3, + ease: "power1.inOut", + }, + targetIds: ["puck"], + duration: 3, + startTime: 1, + }, + el, + ); + const { iframe, seek } = fakeIframe(el, [kfTween], { now: 2 }); + + const ok = patchRuntimeTweenInPlace(iframe, "#puck", { + kind: "keyframes", + keyframes: [ + { x: 0, y: 0 }, + { x: 140, y: 90 }, + { x: 200, y: 0 }, + ], + }); + + expect(ok).toBe(true); + const kfs = kfTween.vars.keyframes as Array>; + expect(kfs[1]).toEqual({ x: 140, y: 90 }); + expect(kfs[0]).toEqual({ x: 0, y: 0 }); + expect(kfs[2]).toEqual({ x: 200, y: 0 }); + expect(kfTween.invalidate).toHaveBeenCalled(); + expect(seek).toHaveBeenCalledTimes(1); + }); + + it("preserves the existing ease when rebuilding keyframes", () => { + const el = { id: "puck2" }; + const kfTween = makeTween( + { + vars: { + keyframes: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + duration: 2, + ease: "back.out", + }, + targetIds: ["puck2"], + duration: 2, + startTime: 0, + }, + el, + ); + const { iframe } = fakeIframe(el, [kfTween], { now: 1 }); + + const ok = patchRuntimeTweenInPlace(iframe, "#puck2", { + kind: "keyframes", + keyframes: [ + { x: 0, y: 0 }, + { x: 250, y: 10 }, + ], + }); + + expect(ok).toBe(true); + expect(kfTween.vars.ease).toBe("back.out"); + }); +}); + +describe("patchRuntimeTweenInPlace — defensive false returns", () => { + it("returns false when the selector has no matching tween", () => { + const el = { id: "lonely" }; + const otherTween = makeTween( + { vars: { x: 0 }, targetIds: ["someone-else"], duration: 0 }, + { id: "someone-else" }, + ); + const { iframe, seek } = fakeIframe(el, [otherTween]); + + const ok = patchRuntimeTweenInPlace(iframe, "#lonely", { kind: "set", props: { x: 50 } }); + + expect(ok).toBe(false); + expect(seek).not.toHaveBeenCalled(); + }); + + it("returns false when the selector resolves to no element", () => { + const el = { id: "present" }; + const setTween = makeTween({ vars: { x: 0 }, targetIds: ["present"], duration: 0 }, el); + const { iframe } = fakeIframe(el, [setTween]); + + const ok = patchRuntimeTweenInPlace(iframe, "#missing", { kind: "set", props: { x: 50 } }); + expect(ok).toBe(false); + }); + + it("returns false for a motionPath arc tween (defers to soft reload)", () => { + const el = { id: "flyer" }; + const arcTween = makeTween( + { + vars: { + motionPath: { + path: [ + { x: 0, y: 0 }, + { x: 100, y: -50 }, + { x: 200, y: 0 }, + ], + curviness: 1.5, + }, + duration: 4, + }, + targetIds: ["flyer"], + duration: 4, + startTime: 0, + }, + el, + ); + const { iframe, seek } = fakeIframe(el, [arcTween], { now: 1 }); + + const ok = patchRuntimeTweenInPlace(iframe, "#flyer", { + kind: "keyframes", + keyframes: [ + { x: 0, y: 0 }, + { x: 120, y: -30 }, + ], + }); + + expect(ok).toBe(false); + expect(arcTween.invalidate).not.toHaveBeenCalled(); + expect(seek).not.toHaveBeenCalled(); + }); + + it("returns false for a dynamic/computed keyframe value (string expression)", () => { + const el = { id: "dyn" }; + const kfTween = makeTween( + { + vars: { + keyframes: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + duration: 2, + }, + targetIds: ["dyn"], + duration: 2, + startTime: 0, + }, + el, + ); + const { iframe } = fakeIframe(el, [kfTween], { now: 1 }); + + // A non-finite/string value in the requested change can't be safely expressed + // as a static keyframe → defer to soft reload. + const ok = patchRuntimeTweenInPlace(iframe, "#dyn", { + kind: "keyframes", + keyframes: [ + { x: 0, y: 0 }, + // @ts-expect-error — intentionally dynamic/computed value + { x: "+=random(50,100)", y: 0 }, + ], + }); + + expect(ok).toBe(false); + }); + + it("returns false for a keyframes change against a set-only tween (shape mismatch)", () => { + const el = { id: "static" }; + const setTween = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["static"], duration: 0 }, el); + const { iframe } = fakeIframe(el, [setTween]); + + const ok = patchRuntimeTweenInPlace(iframe, "#static", { + kind: "keyframes", + keyframes: [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + ], + }); + expect(ok).toBe(false); + }); + + it("never throws — returns false on internal error", () => { + const el = { id: "boom" }; + const explodingTween = { + get vars() { + throw new Error("boom"); + }, + targets: () => [el], + duration: () => 0, + startTime: () => 0, + invalidate: vi.fn(), + }; + const timeline = { + getChildren: () => { + throw new Error("kaboom"); + }, + duration: () => 1, + time: () => 0, + }; + const iframe = { + contentWindow: { + __timelines: { "index.html": timeline }, + __player: { getTime: () => 0, seek: vi.fn() }, + }, + contentDocument: { querySelector: (sel: string) => (sel === "#boom" ? el : null) }, + } as unknown as HTMLIFrameElement; + void explodingTween; + + expect(() => + patchRuntimeTweenInPlace(iframe, "#boom", { kind: "set", props: { x: 1 } }), + ).not.toThrow(); + expect(patchRuntimeTweenInPlace(iframe, "#boom", { kind: "set", props: { x: 1 } })).toBe(false); + }); +}); + +describe("patchRuntimeTweenInPlace — composition isolation", () => { + it("patches only the tween in the element's owning timeline, not others", () => { + const el = { id: "owned" }; + const ownTween = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["owned"], duration: 0 }, el); + // Another composition's timeline holds a tween for a DIFFERENT element with the + // same channel — it must be left untouched. + const otherTween = makeTween( + { vars: { x: 999, y: 999 }, targetIds: ["someone-else"], duration: 0 }, + { id: "someone-else" }, + ); + const otherTimeline = { + getChildren: () => [otherTween], + duration: () => 5, + time: () => 0, + }; + + const { iframe } = fakeIframe(el, [ownTween], { + compKey: "subscene", + extraTimelines: { playground: otherTimeline, __proxied: true }, + }); + + const ok = patchRuntimeTweenInPlace(iframe, "#owned", { + kind: "set", + props: { x: 7, y: 8 }, + }); + + expect(ok).toBe(true); + expect(ownTween.vars).toMatchObject({ x: 7, y: 8 }); + expect(otherTween.vars).toMatchObject({ x: 999, y: 999 }); + expect(otherTween.invalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts new file mode 100644 index 000000000..b3fe8ddf8 --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -0,0 +1,144 @@ +/** + * Patch ONE tween's values in the preview iframe's runtime timeline in place, so a + * value-only manual edit (a `tl.set` position/rotation/scale, or a keyframe + * value/position change) is reflected INSTANTLY — without re-running the whole + * composition. + * + * Defensive by default: the caller (`runCommit`) falls back to the existing soft + * reload whenever this returns `false`. It returns `false` (never silently + * mis-patches) when the tween can't be confidently located or the requested change + * can't be safely expressed (dynamic/computed values, motionPath arcs, shape + * mismatch), and never throws. + * + * "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes` + * uses (`resolveRuntimeTween`), so read and write agree on the target. + */ +import { resolveRuntimeTween, type RuntimeTween } from "./gsapRuntimeKeyframes"; + +/** Value-only channels a `tl.set(...)` patch may touch. */ +export interface SetPatchProps { + x?: number; + y?: number; + rotation?: number; + scaleX?: number; + scaleY?: number; + scale?: number; + opacity?: number; +} + +/** A single keyframe step's numeric channels (the GSAP array-keyframe form). */ +export type KeyframeStep = Record; + +export type RuntimeTweenChange = + | { kind: "set"; props: SetPatchProps } + | { kind: "keyframes"; keyframes: KeyframeStep[] }; + +const SET_CHANNELS: Array = [ + "x", + "y", + "rotation", + "scaleX", + "scaleY", + "scale", + "opacity", +]; + +type IframeWindow = Window & { + __player?: { getTime?: () => number; seek?: (t: number) => void }; +}; + +function playerOf(iframe: HTMLIFrameElement): IframeWindow["__player"] | null { + try { + return (iframe.contentWindow as IframeWindow | null)?.__player ?? null; + } catch { + return null; + } +} + +/** Every step value must be a finite number — string/computed values can't round-trip. */ +function keyframesAreStatic(keyframes: KeyframeStep[]): boolean { + if (keyframes.length === 0) return false; + for (const step of keyframes) { + if (!step || typeof step !== "object") return false; + for (const v of Object.values(step)) { + if (typeof v !== "number" || !Number.isFinite(v)) return false; + } + } + return true; +} + +function patchSet(tween: RuntimeTween, props: SetPatchProps): boolean { + const vars = tween.vars; + if (!vars) return false; + // A `set` carrying a motionPath isn't a plain value set — defer. + if ("motionPath" in vars) return false; + let touched = false; + for (const ch of SET_CHANNELS) { + const next = props[ch]; + if (next === undefined) continue; + if (typeof next !== "number" || !Number.isFinite(next)) return false; + vars[ch] = next; + touched = true; + } + return touched; +} + +function patchKeyframes(tween: RuntimeTween, keyframes: KeyframeStep[]): boolean { + const vars = tween.vars; + if (!vars) return false; + // motionPath arc tweens express motion as a path, not channel keyframes — defer. + if ("motionPath" in vars) return false; + // Only the array-keyframe form is patched in place; the existing tween must + // already carry array keyframes (shape match), or this is a structural change. + if (!Array.isArray(vars.keyframes)) return false; + if (!keyframesAreStatic(keyframes)) return false; + vars.keyframes = keyframes.map((step) => ({ ...step })); + return true; +} + +/** + * Update one tween's values in `window.__timelines` in place + re-seek to the + * current playhead. Returns `true` on a confident patch, `false` otherwise + * (caller falls back to a soft reload). + */ +export function patchRuntimeTweenInPlace( + iframe: HTMLIFrameElement | null, + selector: string, + change: RuntimeTweenChange, + compositionId?: string, +): boolean { + if (!iframe) return false; + try { + const resolved = resolveRuntimeTween( + iframe, + selector, + change.kind === "set" ? "set" : "keyframe", + compositionId, + ); + if (!resolved) return false; + const { tween, timeline } = resolved; + + const applied = + change.kind === "set" + ? patchSet(tween, change.props) + : patchKeyframes(tween, change.keyframes); + if (!applied) return false; + + // Recompute the tween (and its timeline) from the new vars, then re-render at + // the current playhead. `invalidate()` makes GSAP re-read vars on the next render. + tween.invalidate?.(); + timeline.invalidate?.(); + + const player = playerOf(iframe); + const currentTime = + typeof player?.getTime === "function" + ? player.getTime() + : typeof timeline.time === "function" + ? timeline.time() + : 0; + player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0); + return true; + } catch { + return false; + } +} From 2a0df600d955d9d7486ae7685812ab36a3360e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 20 Jun 2026 17:00:20 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(studio):=20address=20#1612=20review=20?= =?UTF-8?q?=E2=80=94=20channel-aware=20set=20resolution=20+=20decline=20dy?= =?UTF-8?q?namic-expression=20patches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. --- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 36 ++++++++- .../studio/src/hooks/gsapRuntimePatch.test.ts | 80 +++++++++++++++++++ packages/studio/src/hooks/gsapRuntimePatch.ts | 15 ++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 77d3f73a9..b0db3184a 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -172,18 +172,39 @@ export interface ResolvedRuntimeTween { timeline: RuntimeTimeline; } +/** + * Whether a tween's `vars` carry at least one of `channels` as an OWN property. + * Used to disambiguate co-located `set`s: an element can have separate + * `tl.set("#el",{x,y})` and `tl.set("#el",{rotation})` tweens, and a position + * patch must land on the {x,y} set — never the rotation-only one. + */ +function varsCarryChannel(vars: Record | undefined, channels: string[]): boolean { + if (!vars) return false; + for (const ch of channels) { + if (Object.prototype.hasOwnProperty.call(vars, ch)) return true; + } + return false; +} + /** * Resolve the live tween targeting `selector` using the SAME all-timelines scan * `readRuntimeKeyframes` uses, so read and write agree on "which tween". With * `kind: "keyframe"` it skips zero-duration `set`s and prefers the tween whose * range contains the playhead (matching the reader). With `kind: "set"` it picks * the zero-duration `set`/hold instead. Returns null when none matches. + * + * `channels` disambiguates co-located `set`s (CHANNEL-BLIND otherwise): when + * provided with `kind: "set"`, a set carrying ONE of those channels wins, and a + * set carrying ONLY disjoint channels is skipped (so patching {x,y} never lands + * on a rotation-only set). With no channel-matching set, it falls back to the + * first matching set (back-compat). `channels` is ignored for `kind: "keyframe"`. */ export function resolveRuntimeTween( iframe: HTMLIFrameElement | null, selector: string, kind: "keyframe" | "set", compositionId?: string, + channels?: string[], ): ResolvedRuntimeTween | null { const timelines = timelinesOf(iframe); if (!timelines) return null; @@ -200,7 +221,10 @@ export function resolveRuntimeTween( ? [compositionId] : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function"); + const wantChannels = kind === "set" && channels && channels.length > 0 ? channels : null; + let first: ResolvedRuntimeTween | null = null; + let channelMatch: ResolvedRuntimeTween | null = null; for (const tlId of tlIds) { const timeline = timelines[tlId]; if (!timeline?.getChildren) continue; @@ -210,6 +234,16 @@ export function resolveRuntimeTween( const dur = typeof tween.duration === "function" ? tween.duration() : 0; const isSet = !(dur > 0); if (kind === "set" ? !isSet : isSet) continue; + if (wantChannels) { + if (varsCarryChannel(tween.vars, wantChannels)) { + if (channelMatch === null) channelMatch = { tween, timeline }; + } else if (first === null) { + // A set carrying only disjoint channels: remember as last-resort + // fallback, but never prefer it over a channel-matching set. + first = { tween, timeline }; + } + continue; + } if (first === null) first = { tween, timeline }; if (kind === "keyframe" && now != null) { const start = typeof tween.startTime === "function" ? tween.startTime() : 0; @@ -217,7 +251,7 @@ export function resolveRuntimeTween( } } } - return first; + return channelMatch ?? first; } /** diff --git a/packages/studio/src/hooks/gsapRuntimePatch.test.ts b/packages/studio/src/hooks/gsapRuntimePatch.test.ts index 4f5119955..bf999f9c8 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.test.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.test.ts @@ -133,6 +133,64 @@ describe("patchRuntimeTweenInPlace — set tweens", () => { }); }); +describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => { + it("patches the {x,y} set, not a co-located rotation-only set", () => { + const el = { id: "dual" }; + const posSet = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["dual"], duration: 0 }, el); + const rotSet = makeTween({ vars: { rotation: 0 }, targetIds: ["dual"], duration: 0 }, el); + // rotation set listed FIRST — channel-blind resolution would grab it. + const { iframe } = fakeIframe(el, [rotSet, posSet]); + + const ok = patchRuntimeTweenInPlace(iframe, "#dual", { + kind: "set", + props: { x: 33, y: 44 }, + }); + + expect(ok).toBe(true); + expect(posSet.vars).toMatchObject({ x: 33, y: 44 }); + // The rotation set must be untouched (no x/y written into it). + expect(rotSet.vars).toEqual({ rotation: 0 }); + expect(rotSet.invalidate).not.toHaveBeenCalled(); + expect(posSet.invalidate).toHaveBeenCalled(); + }); + + it("patches the rotation set, not a co-located {x,y} set", () => { + const el = { id: "dual2" }; + const posSet = makeTween({ vars: { x: 5, y: 6 }, targetIds: ["dual2"], duration: 0 }, el); + const rotSet = makeTween({ vars: { rotation: 0 }, targetIds: ["dual2"], duration: 0 }, el); + // position set listed FIRST. + const { iframe } = fakeIframe(el, [posSet, rotSet]); + + const ok = patchRuntimeTweenInPlace(iframe, "#dual2", { + kind: "set", + props: { rotation: 90 }, + }); + + expect(ok).toBe(true); + expect(rotSet.vars).toMatchObject({ rotation: 90 }); + expect(posSet.vars).toEqual({ x: 5, y: 6 }); + expect(posSet.invalidate).not.toHaveBeenCalled(); + expect(rotSet.invalidate).toHaveBeenCalled(); + }); + + it("falls back to the only set when none carries the requested channel", () => { + // Back-compat: a single {x,y} set, patched with {x,y} that obviously matches, + // plus a set lacking the channel entirely still resolves to a match. Here the + // only set carries opacity; patching opacity must still land on it. + const el = { id: "solo" }; + const set = makeTween({ vars: { opacity: 1 }, targetIds: ["solo"], duration: 0 }, el); + const { iframe } = fakeIframe(el, [set]); + + const ok = patchRuntimeTweenInPlace(iframe, "#solo", { + kind: "set", + props: { opacity: 0.5 }, + }); + + expect(ok).toBe(true); + expect(set.vars).toMatchObject({ opacity: 0.5 }); + }); +}); + describe("patchRuntimeTweenInPlace — keyframe tweens", () => { it("rebuilds the keyframes; a moved keyframe updates, others unchanged", () => { const el = { id: "puck" }; @@ -314,6 +372,28 @@ describe("patchRuntimeTweenInPlace — defensive false returns", () => { expect(ok).toBe(false); }); + it("returns false rather than overwriting a dynamic string set value", () => { + // The existing set value is a computed GSAP expression ("+=100"). Patching it + // with a plain number would silently drop the dynamic intent → defer. + const el = { id: "expr" }; + const setTween = makeTween( + { vars: { x: "+=100", y: 0 }, targetIds: ["expr"], duration: 0 }, + el, + ); + const { iframe, seek } = fakeIframe(el, [setTween]); + + const ok = patchRuntimeTweenInPlace(iframe, "#expr", { + kind: "set", + props: { x: 50, y: 10 }, + }); + + expect(ok).toBe(false); + // Declined → the dynamic expression survives, untouched. + expect(setTween.vars.x).toBe("+=100"); + expect(setTween.invalidate).not.toHaveBeenCalled(); + expect(seek).not.toHaveBeenCalled(); + }); + it("never throws — returns false on internal error", () => { const el = { id: "boom" }; const explodingTween = { diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index b3fe8ddf8..d8d9d5cbe 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -77,6 +77,10 @@ function patchSet(tween: RuntimeTween, props: SetPatchProps): boolean { const next = props[ch]; if (next === undefined) continue; if (typeof next !== "number" || !Number.isFinite(next)) return false; + // A string value here is a dynamic/computed GSAP expression (e.g. "+=100", + // "random(...)"). Overwriting it with a plain number would silently drop the + // dynamic intent — decline so the caller soft-reloads from the (edited) source. + if (typeof vars[ch] === "string") return false; vars[ch] = next; touched = true; } @@ -109,11 +113,22 @@ export function patchRuntimeTweenInPlace( ): boolean { if (!iframe) return false; try { + // For a `set` patch, hand the resolver the channels actually being written so + // it picks the set whose vars carry them — an element can have separate + // {x,y} and {rotation} sets, and a position patch must not corrupt the + // rotation set (channel-blind resolution would return the first match). + const channels = + change.kind === "set" + ? Object.keys(change.props).filter( + (k) => change.props[k as keyof SetPatchProps] !== undefined, + ) + : undefined; const resolved = resolveRuntimeTween( iframe, selector, change.kind === "set" ? "set" : "keyframe", compositionId, + channels, ); if (!resolved) return false; const { tween, timeline } = resolved;