Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts
Original file line number Diff line number Diff line change
@@ -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 }", () => {
Expand Down
43 changes: 38 additions & 5 deletions packages/studio/src/hooks/gsapRuntimeKeyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ interface RuntimeTween {
interface RuntimeTimeline {
getChildren?: (deep: boolean) => RuntimeTween[];
duration?: () => number;
time?: () => number;
}

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

export interface RuntimeKeyframeEntry {
keyframes: Pct[];
Expand Down Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -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)));
Expand Down
58 changes: 58 additions & 0 deletions packages/studio/src/hooks/gsapShared.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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<string, unknown>)).toBeNull();
expect(parsePercentageKeyframes({})).toBeNull();
});
});
38 changes: 28 additions & 10 deletions packages/studio/src/hooks/gsapShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, number | string> = {};
for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
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;
Expand Down
36 changes: 36 additions & 0 deletions packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
Loading
Loading