Skip to content

Commit 06aecb2

Browse files
committed
feat(studio): GSAP runtime read layer + shared helpers
readRuntimeKeyframes skips zero-duration hf-hold sets and picks the first real timeline; shared tween/selector helpers, tween cache, and fetch fallback.
1 parent f6a0cbb commit 06aecb2

7 files changed

Lines changed: 245 additions & 26 deletions

packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,98 @@
11
import { describe, expect, it } from "vitest";
2-
import { arcPathFromMotionPathValue } from "./gsapRuntimeKeyframes";
2+
import { arcPathFromMotionPathValue, readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
3+
4+
// Build a fake preview iframe whose runtime timeline holds the given child tweens
5+
// and resolves `selector` to `el`.
6+
function fakeIframe(el: { id: string }, children: unknown[], now?: number): HTMLIFrameElement {
7+
const timeline = {
8+
getChildren: () => children,
9+
duration: () => 14.6,
10+
...(now != null ? { time: () => now } : {}),
11+
};
12+
return {
13+
contentWindow: { __timelines: { "index.html": timeline } },
14+
contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) },
15+
} as unknown as HTMLIFrameElement;
16+
}
17+
18+
describe("readRuntimeKeyframes — zero-duration set must not shadow the keyframed tween", () => {
19+
const el = { id: "puck-b" };
20+
const holdSet = {
21+
targets: () => [el],
22+
vars: { x: 0, y: 0, data: "hf-hold" },
23+
duration: () => 0,
24+
startTime: () => 0,
25+
};
26+
const kfTween = {
27+
targets: () => [el],
28+
vars: {
29+
keyframes: [
30+
{ x: 0, y: 0 },
31+
{ x: -180, y: -60 },
32+
{ x: -320, y: 40 },
33+
{ x: -460, y: -20 },
34+
],
35+
duration: 3.4,
36+
ease: "power1.inOut",
37+
},
38+
duration: () => 3.4,
39+
startTime: () => 1.0,
40+
};
41+
42+
it("reads all 4 keyframes from the to() even when a hold-set precedes it", () => {
43+
const read = readRuntimeKeyframes(fakeIframe(el, [holdSet, kfTween]), "#puck-b");
44+
expect(read?.keyframes).toHaveLength(4);
45+
});
46+
47+
it("returns null when the element only has a zero-duration set (no real motion)", () => {
48+
expect(readRuntimeKeyframes(fakeIframe(el, [holdSet]), "#puck-b")).toBeNull();
49+
});
50+
});
51+
52+
describe("readRuntimeKeyframes — multiple tweens pick the one under the playhead", () => {
53+
const el = { id: "puck-a" };
54+
// Two non-overlapping gesture recordings → two separate keyframed tweens.
55+
const gestureA = {
56+
targets: () => [el],
57+
vars: {
58+
keyframes: [
59+
{ x: 0, y: 0 },
60+
{ x: -100, y: 50 },
61+
],
62+
duration: 2.03,
63+
},
64+
duration: () => 2.03,
65+
startTime: () => 1.033, // range [1.033, 3.063]
66+
};
67+
const gestureB = {
68+
targets: () => [el],
69+
vars: {
70+
keyframes: [
71+
{ x: 10, y: 10 },
72+
{ x: 20, y: 20 },
73+
{ x: 30, y: 30 },
74+
],
75+
duration: 1.129,
76+
},
77+
duration: () => 1.129,
78+
startTime: () => 3.342, // range [3.342, 4.471]
79+
};
80+
81+
it("playhead inside the SECOND tween reads the second tween (not the first)", () => {
82+
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 3.373), "#puck-a");
83+
expect(read?.keyframes).toHaveLength(3); // gestureB
84+
});
85+
86+
it("playhead inside the FIRST tween reads the first tween", () => {
87+
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 2.0), "#puck-a");
88+
expect(read?.keyframes).toHaveLength(2); // gestureA
89+
});
90+
91+
it("playhead outside every range falls back to the first keyframed tween", () => {
92+
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 9.0), "#puck-a");
93+
expect(read?.keyframes).toHaveLength(2); // gestureA (first)
94+
});
95+
});
396

497
describe("arcPathFromMotionPathValue", () => {
598
it("builds arc config from object form { path, curviness }", () => {

packages/studio/src/hooks/gsapRuntimeKeyframes.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ interface RuntimeTween {
2222
interface RuntimeTimeline {
2323
getChildren?: (deep: boolean) => RuntimeTween[];
2424
duration?: () => number;
25+
time?: () => number;
2526
}
2627

2728
type Pct = { percentage: number; properties: Record<string, number | string> };
28-
type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
29+
export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
2930

3031
export interface RuntimeKeyframeEntry {
3132
keyframes: Pct[];
@@ -160,7 +161,11 @@ export function readRuntimeKeyframes(
160161
): ReadTween | null {
161162
const timelines = timelinesOf(iframe);
162163
if (!timelines) return null;
163-
const tlId = compositionId || Object.keys(timelines)[0];
164+
// Skip non-timeline markers (e.g. the studio's `__proxied` flag) when no
165+
// explicit composition id is given — picking those yields no getChildren.
166+
const tlId =
167+
compositionId ||
168+
Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function");
164169
if (!tlId) return null;
165170
const timeline = timelines[tlId];
166171
if (!timeline?.getChildren) return null;
@@ -173,12 +178,32 @@ export function readRuntimeKeyframes(
173178
}
174179
if (!targetEl) return null;
175180

181+
// The element can have MORE THAN ONE keyframed tween at disjoint time ranges
182+
// (e.g. two non-overlapping gesture recordings → two separate `to()`s). The
183+
// overlay must draw the segment under the PLAYHEAD, not blindly the first one
184+
// — otherwise recording a second gesture leaves the path stuck on the first.
185+
const now = typeof timeline.time === "function" ? timeline.time() : null;
186+
let firstRead: ReadTween | null = null;
176187
for (const tween of timeline.getChildren(true)) {
177188
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
189+
// Skip zero-duration tweens (`tl.set(...)`, incl. the studio position-hold
190+
// `data:"hf-hold"`). They sit before the real keyframed tween and otherwise
191+
// shadow it — `readTween` falls back to a degenerate 2-point flat path from
192+
// the set's values, hiding the actual multi-keyframe motion.
193+
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
194+
if (!(dur > 0)) continue;
178195
const read = readTween(tween.vars);
179-
if (read) return read;
196+
if (!read) continue;
197+
if (firstRead === null) firstRead = read;
198+
// Prefer the tween whose [start, start+dur] contains the playhead.
199+
if (now != null) {
200+
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
201+
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read;
202+
}
180203
}
181-
return null;
204+
// Playhead outside every tween's range (or timeline has no clock): the element
205+
// still has motion, so fall back to the first keyframed tween.
206+
return firstRead;
182207
}
183208

184209
/** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
@@ -217,9 +242,12 @@ function addScanEntry(
217242
clipById?: ClipDims,
218243
): void {
219244
if (!tween.targets || !tween.vars) return;
245+
const { start, duration } = tweenTiming(tween);
246+
// Skip zero-duration sets/holds — they shadow the real keyframed tween (see
247+
// readRuntimeKeyframes).
248+
if (!(duration > 0)) return;
220249
const read = readTween(tween.vars);
221250
if (!read) return;
222-
const { start, duration } = tweenTiming(tween);
223251
for (const target of tween.targets()) {
224252
const id = (target as HTMLElement).id;
225253
if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parsePercentageKeyframes } from "./gsapShared";
3+
4+
describe("parsePercentageKeyframes", () => {
5+
it("parses the object/percentage form", () => {
6+
const out = parsePercentageKeyframes({ "0%": { x: 0, y: 0 }, "100%": { x: 9, y: 4 } });
7+
expect(out?.keyframes).toEqual([
8+
{ percentage: 0, properties: { x: 0, y: 0 } },
9+
{ percentage: 100, properties: { x: 9, y: 4 } },
10+
]);
11+
});
12+
13+
it("parses GSAP array-form keyframes as evenly-distributed steps", () => {
14+
// Regression: a multi-point shuttle path authored as `keyframes: [...]` used to
15+
// read as null (no `N%` keys) → no motion path. Steps map to i/(n-1)*100%.
16+
const out = parsePercentageKeyframes([
17+
{ x: 0, y: 0 },
18+
{ x: 520, y: 120 },
19+
{ x: 1040, y: 0 },
20+
{ x: 1480, y: 160 },
21+
] as unknown as Record<string, unknown>);
22+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]);
23+
expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
24+
});
25+
26+
it("returns null for keyframes with no positional/animatable props", () => {
27+
expect(parsePercentageKeyframes([] as unknown as Record<string, unknown>)).toBeNull();
28+
expect(parsePercentageKeyframes({})).toBeNull();
29+
});
30+
});

packages/studio/src/hooks/gsapShared.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,6 @@ export function queryIframeElement(
9797
}
9898
}
9999

100-
/** Safely access an iframe's contentDocument, returning null on cross-origin errors. */
101-
export function getIframeDocument(iframe: HTMLIFrameElement | null): Document | null {
102-
if (!iframe) return null;
103-
try {
104-
return iframe.contentDocument;
105-
} catch {
106-
return null;
107-
}
108-
}
109-
110100
// ── Keyframe parsing ──────────────────────────────────────────────────────────
111101

112102
export interface ParsedPercentageKeyframes {
@@ -125,6 +115,26 @@ export function parsePercentageKeyframes(
125115
const keyframes: ParsedPercentageKeyframes["keyframes"] = [];
126116
let easeEach: string | undefined;
127117

118+
// GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — are evenly
119+
// distributed across the tween, so step i of n maps to i/(n-1)*100%. (The object
120+
// form below uses explicit "0%" keys.) Without this, array-keyframed tweens (e.g.
121+
// a multi-point shuttle path) read as null → no motion path.
122+
if (Array.isArray(kfObj)) {
123+
const steps = kfObj as unknown[];
124+
steps.forEach((entry, i) => {
125+
if (!entry || typeof entry !== "object") return;
126+
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
127+
const properties: Record<string, number | string> = {};
128+
for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
129+
if (pk === "ease") continue;
130+
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
131+
else if (typeof pv === "string") properties[pk] = pv;
132+
}
133+
if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
134+
});
135+
return keyframes.length > 0 ? { keyframes } : null;
136+
}
137+
128138
for (const [key, val] of Object.entries(kfObj)) {
129139
if (key === "easeEach") {
130140
if (typeof val === "string") easeEach = val;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3+
import { selectElementAnimationsOrRetry } from "./useGsapAnimationFetchFallback";
4+
5+
const anim = (targetSelector: string): GsapAnimation =>
6+
({ id: targetSelector, targetSelector, properties: {} }) as unknown as GsapAnimation;
7+
const parsed = (anims: GsapAnimation[]): ParsedGsap => ({ animations: anims }) as ParsedGsap;
8+
const target = { id: "puck-a", selector: "#puck-a" };
9+
10+
describe("selectElementAnimationsOrRetry", () => {
11+
it("returns null (retry) when the parse is cold — null or zero total animations", () => {
12+
expect(selectElementAnimationsOrRetry(null, target)).toBeNull();
13+
expect(selectElementAnimationsOrRetry(parsed([]), target)).toBeNull();
14+
});
15+
16+
it("returns the matching animations from a warm parse", () => {
17+
const result = selectElementAnimationsOrRetry(
18+
parsed([anim("#puck-a"), anim("#other")]),
19+
target,
20+
);
21+
expect(result?.map((a) => a.targetSelector)).toEqual(["#puck-a"]);
22+
});
23+
24+
it("returns [] (no retry) for a warm parse with no match — element genuinely has no animation", () => {
25+
expect(selectElementAnimationsOrRetry(parsed([anim("#other")]), target)).toEqual([]);
26+
});
27+
});

packages/studio/src/hooks/useGsapAnimationFetchFallback.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,43 @@
11
import { useCallback } from "react";
2+
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
23
import type { DomEditSelection } from "../components/editor/domEditing";
34
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
45

6+
const COLD_PARSE_RETRIES = 5;
7+
const COLD_PARSE_DELAY_MS = 120;
8+
9+
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
10+
11+
/**
12+
* Decide an element's animations from a parse result, or signal a retry.
13+
*
14+
* Returns `null` only when the parse is *cold* (missing or zero total animations)
15+
* — the initial-load race where the endpoint isn't ready yet, so the caller should
16+
* retry. A warm parse with no match for this element returns `[]` (the element
17+
* genuinely has no animation — create a new one, don't retry).
18+
*/
19+
export function selectElementAnimationsOrRetry(
20+
parsed: ParsedGsap | null,
21+
target: { id: string | null; selector: string | null },
22+
): GsapAnimation[] | null {
23+
if (!parsed || parsed.animations.length === 0) return null;
24+
return getAnimationsForElement(parsed.animations, target);
25+
}
26+
527
export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
628
return useCallback(
7-
(selection: DomEditSelection) => async () => {
8-
const pid = projectId;
9-
if (!pid) return [];
10-
const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
11-
if (!parsed) return [];
12-
return getAnimationsForElement(parsed.animations, {
13-
id: selection.id ?? null,
14-
selector: selection.selector ?? null,
15-
});
29+
(selection: DomEditSelection) => async (): Promise<GsapAnimation[]> => {
30+
if (!projectId) return [];
31+
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
32+
// A drag can fire before the async parse is warm; a cold parse must retry
33+
// rather than fall through to the no-animation path (which duplicates the tween).
34+
for (let attempt = 0; ; attempt++) {
35+
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
36+
const resolved = selectElementAnimationsOrRetry(parsed, target);
37+
if (resolved !== null) return resolved;
38+
if (attempt >= COLD_PARSE_RETRIES) return [];
39+
await delay(COLD_PARSE_DELAY_MS);
40+
}
1641
},
1742
[projectId, gsapSourceFile],
1843
);

packages/studio/src/hooks/useGsapTweenCache.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
22
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
33
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
4+
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
45
import { usePlayerStore } from "../player/store/playerStore";
56
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
67
import {
@@ -107,7 +108,12 @@ export async function fetchParsedAnimations(
107108
const res = await fetch(
108109
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
109110
);
110-
return res.ok ? ((await res.json()) as ParsedGsap) : null;
111+
if (!res.ok) return null;
112+
const parsed = (await res.json()) as ParsedGsap;
113+
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
114+
// hold an element's first keyframe before its tween). They must not surface as
115+
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
116+
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
111117
} catch {
112118
return null;
113119
}

0 commit comments

Comments
 (0)