diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts index 91cda720f4..e227e0bc92 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts @@ -1,5 +1,101 @@ import { describe, expect, it } from "vitest"; -import { arcPathFromMotionPathValue } from "./gsapRuntimeKeyframes"; +import { arcPathFromMotionPathValue, readRuntimeKeyframes } from "./gsapRuntimeKeyframes"; + +// Build a fake preview iframe whose runtime timeline holds the given child tweens +// and resolves `selector` to `el`. +function fakeIframe(el: { id: string }, children: unknown[], now?: number): HTMLIFrameElement { + const timeline = { + getChildren: () => children, + duration: () => 14.6, + ...(now != null ? { time: () => now } : {}), + }; + return { + contentWindow: { __timelines: { "index.html": timeline } }, + contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) }, + } as unknown as HTMLIFrameElement; +} + +describe("readRuntimeKeyframes — zero-duration set must not shadow the keyframed tween", () => { + const el = { id: "puck-b" }; + const holdSet = { + targets: () => [el], + // `data` is the STUDIO_HOLD_MARKER sentinel ("hf-hold") from core's gsapParser. + // TODO(core follow-up): re-export STUDIO_HOLD_MARKER via the @hyperframes/core/ + // gsap-parser subpath so this fixture can import the const instead of the literal. + vars: { x: 0, y: 0, data: "hf-hold" }, + duration: () => 0, + startTime: () => 0, + }; + const kfTween = { + targets: () => [el], + vars: { + keyframes: [ + { x: 0, y: 0 }, + { x: -180, y: -60 }, + { x: -320, y: 40 }, + { x: -460, y: -20 }, + ], + duration: 3.4, + ease: "power1.inOut", + }, + duration: () => 3.4, + startTime: () => 1.0, + }; + + it("reads all 4 keyframes from the to() even when a hold-set precedes it", () => { + const read = readRuntimeKeyframes(fakeIframe(el, [holdSet, kfTween]), "#puck-b"); + expect(read?.keyframes).toHaveLength(4); + }); + + it("returns null when the element only has a zero-duration set (no real motion)", () => { + expect(readRuntimeKeyframes(fakeIframe(el, [holdSet]), "#puck-b")).toBeNull(); + }); +}); + +describe("readRuntimeKeyframes — multiple tweens pick the one under the playhead", () => { + const el = { id: "puck-a" }; + // Two non-overlapping gesture recordings → two separate keyframed tweens. + const gestureA = { + targets: () => [el], + vars: { + keyframes: [ + { x: 0, y: 0 }, + { x: -100, y: 50 }, + ], + duration: 2.03, + }, + duration: () => 2.03, + startTime: () => 1.033, // range [1.033, 3.063] + }; + const gestureB = { + targets: () => [el], + vars: { + keyframes: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + { x: 30, y: 30 }, + ], + duration: 1.129, + }, + duration: () => 1.129, + startTime: () => 3.342, // range [3.342, 4.471] + }; + + it("playhead inside the SECOND tween reads the second tween (not the first)", () => { + const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 3.373), "#puck-a"); + expect(read?.keyframes).toHaveLength(3); // gestureB + }); + + it("playhead inside the FIRST tween reads the first tween", () => { + const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 2.0), "#puck-a"); + expect(read?.keyframes).toHaveLength(2); // gestureA + }); + + it("playhead outside every range falls back to the first keyframed tween", () => { + const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 9.0), "#puck-a"); + expect(read?.keyframes).toHaveLength(2); // gestureA (first) + }); +}); describe("arcPathFromMotionPathValue", () => { it("builds arc config from object form { path, curviness }", () => { diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 3b1ac272ce..fc7fc4fae0 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -22,10 +22,11 @@ interface RuntimeTween { interface RuntimeTimeline { getChildren?: (deep: boolean) => RuntimeTween[]; duration?: () => number; + time?: () => number; } type Pct = { percentage: number; properties: Record }; -type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig }; +export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig }; export interface RuntimeKeyframeEntry { keyframes: Pct[]; @@ -71,6 +72,17 @@ function isXY(p: unknown): p is { x: number; y: number } { return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number"; } +/** + * A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl. + * the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`). + * These sit before the real keyframed tween and otherwise shadow it — `readTween` + * would fall back to a degenerate 2-point flat path from the set's values, hiding + * the actual multi-keyframe motion. `!(duration > 0)` also rejects NaN durations. + */ +function isZeroDurationSet(duration: number): boolean { + return !(duration > 0); +} + /** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */ function coordsFromMotionPath(mp: unknown): { coords: Array<{ x: number; y: number }>; @@ -160,7 +172,11 @@ export function readRuntimeKeyframes( ): ReadTween | null { const timelines = timelinesOf(iframe); if (!timelines) return null; - const tlId = compositionId || Object.keys(timelines)[0]; + // Skip non-timeline markers (e.g. the studio's `__proxied` flag) when no + // explicit composition id is given — picking those yields no getChildren. + const tlId = + compositionId || + Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function"); if (!tlId) return null; const timeline = timelines[tlId]; if (!timeline?.getChildren) return null; @@ -173,12 +189,28 @@ export function readRuntimeKeyframes( } if (!targetEl) return null; + // The element can have MORE THAN ONE keyframed tween at disjoint time ranges + // (e.g. two non-overlapping gesture recordings → two separate `to()`s). The + // overlay must draw the segment under the PLAYHEAD, not blindly the first one + // — otherwise recording a second gesture leaves the path stuck on the first. + const now = typeof timeline.time === "function" ? timeline.time() : null; + let firstRead: ReadTween | null = null; for (const tween of timeline.getChildren(true)) { if (!tween.vars || !matchesElement(tween, targetEl)) continue; + const dur = typeof tween.duration === "function" ? tween.duration() : 0; + if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet) const read = readTween(tween.vars); - if (read) return read; + if (!read) continue; + if (firstRead === null) firstRead = read; + // Prefer the tween whose [start, start+dur] contains the playhead. + if (now != null) { + const start = typeof tween.startTime === "function" ? tween.startTime() : 0; + if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read; + } } - return null; + // Playhead outside every tween's range (or timeline has no clock): the element + // still has motion, so fall back to the first keyframed tween. + return firstRead; } /** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */ @@ -217,9 +249,10 @@ function addScanEntry( clipById?: ClipDims, ): void { if (!tween.targets || !tween.vars) return; + const { start, duration } = tweenTiming(tween); + if (isZeroDurationSet(duration)) return; // skip hold/set tweens (see isZeroDurationSet) const read = readTween(tween.vars); if (!read) return; - const { start, duration } = tweenTiming(tween); for (const target of tween.targets()) { const id = (target as HTMLElement).id; if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id))); diff --git a/packages/studio/src/hooks/gsapShared.test.ts b/packages/studio/src/hooks/gsapShared.test.ts new file mode 100644 index 0000000000..b31c377534 --- /dev/null +++ b/packages/studio/src/hooks/gsapShared.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { parsePercentageKeyframes } from "./gsapShared"; + +describe("parsePercentageKeyframes", () => { + it("parses the object/percentage form", () => { + const out = parsePercentageKeyframes({ "0%": { x: 0, y: 0 }, "100%": { x: 9, y: 4 } }); + expect(out?.keyframes).toEqual([ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 100, properties: { x: 9, y: 4 } }, + ]); + }); + + it("parses GSAP array-form keyframes as evenly-distributed steps", () => { + // Regression: a multi-point shuttle path authored as `keyframes: [...]` used to + // read as null (no `N%` keys) → no motion path. Steps map to i/(n-1)*100%. + const out = parsePercentageKeyframes([ + { x: 0, y: 0 }, + { x: 520, y: 120 }, + { x: 1040, y: 0 }, + { x: 1480, y: 160 }, + ] as unknown as Record); + expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]); + expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 }); + }); + + it("strips a per-entry ease without shifting the even index-spacing of the others", () => { + // GSAP positions array keyframes by array index, so a `{ ease }` carried on an + // entry is a segment ease (skipped as a property) — it must not change where + // the surrounding keyframes land. 3 entries → 0 / 50 / 100, even though the + // middle entry also carries an ease. + const out = parsePercentageKeyframes([ + { x: 0 }, + { x: 100, ease: "power2.in" }, + { x: 200 }, + ] as unknown as Record); + expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 50, 100]); + expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 100 }, { x: 200 }]); + }); + + it("keeps even spacing when an interior array slot has no animatable prop", () => { + // A degenerate `{ ease }`-only slot contributes no output keyframe, but it is + // still an array slot GSAP allocates a position to — so the remaining entries + // keep their original i/(n-1) percentages (0 and 100 for a 3-slot array), not + // 0/100 collapsed onto a 2-entry spacing. + const out = parsePercentageKeyframes([ + { x: 0 }, + { ease: "power2.in" }, + { x: 200 }, + ] as unknown as Record); + expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]); + expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 200 }]); + }); + + it("returns null for keyframes with no positional/animatable props", () => { + expect(parsePercentageKeyframes([] as unknown as Record)).toBeNull(); + expect(parsePercentageKeyframes({})).toBeNull(); + }); +}); diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts index e299f15a53..0b5fccfb0b 100644 --- a/packages/studio/src/hooks/gsapShared.ts +++ b/packages/studio/src/hooks/gsapShared.ts @@ -97,16 +97,6 @@ export function queryIframeElement( } } -/** Safely access an iframe's contentDocument, returning null on cross-origin errors. */ -export function getIframeDocument(iframe: HTMLIFrameElement | null): Document | null { - if (!iframe) return null; - try { - return iframe.contentDocument; - } catch { - return null; - } -} - // ── Keyframe parsing ────────────────────────────────────────────────────────── export interface ParsedPercentageKeyframes { @@ -125,6 +115,34 @@ export function parsePercentageKeyframes( const keyframes: ParsedPercentageKeyframes["keyframes"] = []; let easeEach: string | undefined; + // GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — are spread + // evenly across the tween by default: GSAP gives each entry an equal share of + // the duration unless an entry carries its own `duration`/`delay`, which the + // studio never emits. So entry i of n maps to i/(n-1)*100% (n=4 → 0/33.3/66.7/100). + // Index spacing counts EVERY array slot, including a degenerate entry that + // contributes no animatable prop (it's still a slot GSAP allocates a position + // to), so dropping such an entry from the output below must NOT shift the others. + // A per-entry `ease` is a segment ease, not a keyframe value, so it's skipped as + // a property; there is no array-form `easeEach` (that's an object-form sibling key). + // (The object form further down uses explicit "0%" keys instead.) Without this + // branch, array-keyframed tweens (e.g. a multi-point shuttle) read as null → no + // motion path. + if (Array.isArray(kfObj)) { + const steps = kfObj as unknown[]; + steps.forEach((entry, i) => { + if (!entry || typeof entry !== "object") return; + const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0; + const properties: Record = {}; + for (const [pk, pv] of Object.entries(entry as Record)) { + if (pk === "ease") continue; + if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; + else if (typeof pv === "string") properties[pk] = pv; + } + if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties }); + }); + return keyframes.length > 0 ? { keyframes } : null; + } + for (const [key, val] of Object.entries(kfObj)) { if (key === "easeEach") { if (typeof val === "string") easeEach = val; diff --git a/packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts b/packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts new file mode 100644 index 0000000000..6bba06f913 --- /dev/null +++ b/packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { selectElementAnimationsOrRetry } from "./useGsapAnimationFetchFallback"; + +const anim = (targetSelector: string): GsapAnimation => + ({ id: targetSelector, targetSelector, properties: {} }) as unknown as GsapAnimation; +const parsed = (anims: GsapAnimation[]): ParsedGsap => ({ animations: anims }) as ParsedGsap; +const target = { id: "puck-a", selector: "#puck-a" }; + +describe("selectElementAnimationsOrRetry", () => { + it("signals fetch-error (short retry) when the fetch itself failed (null)", () => { + // A null parse means fetchParsedAnimations hit a 404/network/JSON failure — + // not a parse-warming race, so it must NOT be conflated with a cold parse. + expect(selectElementAnimationsOrRetry(null, target)).toEqual({ kind: "fetch-error" }); + }); + + it("signals cold (full retry budget) when the parse is reachable but has zero total animations", () => { + expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({ kind: "cold" }); + }); + + it("resolves the matching animations from a warm parse", () => { + const outcome = selectElementAnimationsOrRetry( + parsed([anim("#puck-a"), anim("#other")]), + target, + ); + expect(outcome.kind).toBe("resolved"); + expect(outcome.kind === "resolved" && outcome.animations.map((a) => a.targetSelector)).toEqual([ + "#puck-a", + ]); + }); + + it("resolves to [] (no retry) for a warm parse with no match — element genuinely has no animation", () => { + const outcome = selectElementAnimationsOrRetry(parsed([anim("#other")]), target); + expect(outcome).toEqual({ kind: "resolved", animations: [] }); + }); +}); diff --git a/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts index f995d0ee6b..e1fcded7d2 100644 --- a/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts +++ b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts @@ -1,18 +1,74 @@ import { useCallback } from "react"; +import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditing"; import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache"; +// A cold parse is the initial-load race: the endpoint is reachable but its parse +// isn't warm yet (zero total animations). It's worth waiting out (~600ms). +const COLD_PARSE_RETRIES = 5; +const COLD_PARSE_DELAY_MS = 120; +// A hard fetch error (404/403/network/JSON failure → `fetchParsedAnimations` +// returns null) is NOT a parse-warming race, so it shouldn't burn the full +// cold-parse budget. One short retry covers a transient blip; beyond that the +// endpoint genuinely isn't serving this file, so fall through to "no animation". +const FETCH_ERROR_RETRIES = 1; +const FETCH_ERROR_DELAY_MS = 120; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Outcome of resolving an element's animations from a single parse result. + * - `resolved`: a definitive answer (matched animations, or `[]` when the parse + * is warm but this element has no animation — create a new one, don't retry). + * - `fetch-error`: `fetchParsedAnimations` returned null (HTTP/network/JSON + * failure) — retry briefly, not for the full cold-parse budget. + * - `cold`: the parse came back reachable but with zero total animations — the + * initial-load warming race, worth the full cold-parse retry budget. + */ +export type ElementAnimationsOutcome = + | { kind: "resolved"; animations: GsapAnimation[] } + | { kind: "fetch-error" } + | { kind: "cold" }; + +/** + * Classify a parse result for one element. Differentiates a hard fetch failure + * (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`) + * so the caller can apply the right retry budget to each. + */ +export function selectElementAnimationsOrRetry( + parsed: ParsedGsap | null, + target: { id: string | null; selector: string | null }, +): ElementAnimationsOutcome { + if (!parsed) return { kind: "fetch-error" }; + if (parsed.animations.length === 0) return { kind: "cold" }; + return { kind: "resolved", animations: getAnimationsForElement(parsed.animations, target) }; +} + export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) { return useCallback( - (selection: DomEditSelection) => async () => { - const pid = projectId; - if (!pid) return []; - const parsed = await fetchParsedAnimations(pid, gsapSourceFile); - if (!parsed) return []; - return getAnimationsForElement(parsed.animations, { - id: selection.id ?? null, - selector: selection.selector ?? null, - }); + (selection: DomEditSelection) => async (): Promise => { + if (!projectId) return []; + const target = { id: selection.id ?? null, selector: selection.selector ?? null }; + // A drag can fire before the async parse is warm; a cold parse must retry + // rather than fall through to the no-animation path (which duplicates the + // tween). A hard fetch error is a different failure — retry only briefly. + let coldAttempts = 0; + let errorAttempts = 0; + for (;;) { + const parsed = await fetchParsedAnimations(projectId, gsapSourceFile); + const outcome = selectElementAnimationsOrRetry(parsed, target); + if (outcome.kind === "resolved") return outcome.animations; + if (outcome.kind === "fetch-error") { + if (errorAttempts >= FETCH_ERROR_RETRIES) return []; + errorAttempts++; + await delay(FETCH_ERROR_DELAY_MS); + continue; + } + // cold + if (coldAttempts >= COLD_PARSE_RETRIES) return []; + coldAttempts++; + await delay(COLD_PARSE_DELAY_MS); + } }, [projectId, gsapSourceFile], ); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 246f53526c..2cbcf4c8b6 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; +import { isStudioHoldSet } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge"; import { @@ -107,7 +108,12 @@ export async function fetchParsedAnimations( const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`, ); - return res.ok ? ((await res.json()) as ParsedGsap) : null; + if (!res.ok) return null; + const parsed = (await res.json()) as ParsedGsap; + // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they + // hold an element's first keyframe before its tween). They must not surface as + // user animations — otherwise they pollute the keyframe cache / timeline diamonds. + return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) }; } catch { return null; }