fix(studio): populate keyframe lanes for every timeline composition

The keyframe cache was fetched for a single source file: the selected
element's, else the active composition. On open nothing is selected, so
a project whose clips live in sub-compositions loaded only index.html
and every property lane rendered empty until a clip was clicked.

Load the cache for each composition file the timeline has rows for, so
keyframed clips are expanded on open as intended. The AST load path
moves to keyframeCacheAstLoad.ts to keep useGsapTweenCache.ts under the
600-line cap.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:40:43 +02:00
parent 282cc883e5
commit 099d891fd3
3 changed files with 277 additions and 134 deletions
@@ -0,0 +1,175 @@
/**
* Reading a composition file's GSAP tweens into the keyframe cache: fetch,
* selector -> element id resolution, and the clip-relative timing basis.
* Split from useGsapTweenCache to keep that file under the 600-line limit.
*/
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import {
clearKeyframeCacheForFile,
writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers";
import { toAbsoluteTime } from "./gsapShared";
import {
deduplicateKeyframes,
isStaticPositionHold,
synthesizeFlatTweenKeyframes,
} from "./gsapTweenSynth";
function extractIdFromSelector(selector: string): string | null {
const match = selector.match(/^#([\w-]+)/);
return match ? match[1] : null;
}
/**
* Resolve a tween's target selector to the ids of the element(s) it animates.
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
* element they animate — not just one parsed from the string. Falls back to a
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
*/
// fallow-ignore-next-line complexity
export function resolveSelectorElementIds(
selector: string,
doc: Document | null | undefined,
): string[] {
const bareId = selector.match(/^#([\w-]+)$/);
if (bareId) return [bareId[1]];
if (!doc) {
const lead = extractIdFromSelector(selector);
return lead ? [lead] : [];
}
const ids = new Set<string>();
for (const part of selector.split(",")) {
const sel = part.trim();
if (!sel) continue;
try {
for (const el of Array.from(doc.querySelectorAll(sel))) {
if (el.id) ids.add(el.id);
}
} catch {
const lead = extractIdFromSelector(sel);
if (lead) ids.add(lead);
}
}
return Array.from(ids);
}
export async function fetchParsedAnimations(
projectId: string,
sourceFile: string,
): Promise<ParsedGsap | null> {
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
// Always re-read the freshly-parsed source; no per-call timestamp (which
// would defeat caching forever and is a deterministic-render no-no).
{ cache: "no-store" },
);
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;
}
}
/**
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
* inside a scene) aren't timeline clips themselves — they're derived at expand time
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
* data-composition-src is stripped in the rendered DOM, so we can't query it).
*/
export function resolveClipTimingBasis(
elementId: string,
sourceFile: string,
elements: ReadonlyArray<{
domId?: string;
key?: string;
id: string;
start: number;
duration: number;
}>,
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
): { elStart: number; elDuration: number } {
const direct = elements.find(
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
);
if (direct) return { elStart: direct.start, elDuration: direct.duration };
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
const host = hostId
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
: undefined;
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
}
/**
* Read one composition file's tweens into the keyframe cache. Split out of the
* hook so the effect can run it per file without re-nesting the whole body.
*/
// fallow-ignore-next-line complexity
export async function populateKeyframeCacheFromAst(
projectId: string,
sf: string,
doc: Document | null | undefined,
): Promise<void> {
const parsed = await fetchParsedAnimations(projectId, sf);
if (!parsed) return;
const { setKeyframeCache } = usePlayerStore.getState();
clearKeyframeCacheForFile(sf);
const { elements, domClipChildren } = usePlayerStore.getState();
const mergedByElement = new Map<string, GsapKeyframesData>();
const sourceByElement = new Map<string, GsapAnimation[]>();
for (const anim of parsed.animations) {
if (anim.hasUnresolvedKeyframes) continue;
if (isStaticPositionHold(anim)) continue;
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? 1;
// Attribute the tween to every element it animates (handles class /
// group / descendant selectors, not just `#id`).
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
// kfData is already resolved (real keyframes OR a synthesized flat
// tween), so a grouped flat tween joins the store like a keyframed one.
if (anim.propertyGroup) {
sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
}
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
const clipKeyframes = kfData.keyframes.map((kf) => {
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
// 0.001% precision (see useGsapAnimationsForElement) so a beat-snapped
// keyframe centers on the beat dot and both caches agree.
const clipPct =
elDuration > 0
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
: kf.percentage;
return {
...kf,
percentage: clipPct,
tweenPercentage: kf.percentage,
propertyGroup: anim.propertyGroup,
animationId: anim.id, // parity with other cache writers; inline ease needs it
};
});
const existing = mergedByElement.get(id);
if (existing) {
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
}
}
}
for (const [id, kfData] of mergedByElement) {
setKeyframeCache(`${sf}#${id}`, kfData);
setKeyframeCache(id, kfData);
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
}
}
+33 -133
View File
@@ -1,56 +1,29 @@
import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { GsapAnimation, GsapKeyframesData } from "@hyperframes/core/gsap-parser";
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore"; import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
import { import {
clearKeyframeCacheForElement, clearKeyframeCacheForElement,
clearKeyframeCacheForFile,
writeGsapAnimationsForElement, writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers"; } from "./gsapKeyframeCacheHelpers";
import { idFromSelector, toAbsoluteTime, toClipPercentage, toClipKeyframes } from "./gsapShared"; import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
import { import {
deduplicateKeyframes, deduplicateKeyframes,
isStaticPositionHold, isStaticPositionHold,
synthesizeFlatTweenKeyframes, synthesizeFlatTweenKeyframes,
} from "./gsapTweenSynth"; } from "./gsapTweenSynth";
import {
fetchParsedAnimations,
populateKeyframeCacheFromAst,
resolveClipTimingBasis,
} from "./keyframeCacheAstLoad";
/** // Re-exported so callers keep importing the GSAP cache surface from one module.
* Resolve a tween's target selector to the ids of the element(s) it animates. export {
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group fetchParsedAnimations,
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so resolveClipTimingBasis,
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every resolveSelectorElementIds,
* element they animate — not just one parsed from the string. Falls back to the } from "./keyframeCacheAstLoad";
* leading id when there's no DOM (so the cache still populates pre-iframe);
* `idFromSelector` reads both `#id` and the `[id="…"]` form writers emit for
* CSS-unsafe ids, so those elements resolve pre-iframe too.
*/
// fallow-ignore-next-line complexity
export function resolveSelectorElementIds(
selector: string,
doc: Document | null | undefined,
): string[] {
const bareId = selector.match(/^#([\w-]+)$/);
if (bareId) return [bareId[1]];
if (!doc) {
const lead = idFromSelector(selector);
return lead ? [lead] : [];
}
const ids = new Set<string>();
for (const part of selector.split(",")) {
const sel = part.trim();
if (!sel) continue;
try {
for (const el of Array.from(doc.querySelectorAll(sel))) {
if (el.id) ids.add(el.id);
}
} catch {
const lead = idFromSelector(sel);
if (lead) ids.add(lead);
}
}
return Array.from(ids);
}
/** The selected element's identity for matching tweens to it. */ /** The selected element's identity for matching tweens to it. */
export interface GsapElementTarget { export interface GsapElementTarget {
@@ -100,59 +73,6 @@ export function getAnimationsForElement(
); );
} }
export async function fetchParsedAnimations(
projectId: string,
sourceFile: string,
): Promise<ParsedGsap | null> {
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
// Always re-read the freshly-parsed source; no per-call timestamp (which
// would defeat caching forever and is a deterministic-render no-no).
{ cache: "no-store" },
);
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;
}
}
/**
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
* inside a scene) aren't timeline clips themselves — they're derived at expand time
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
* data-composition-src is stripped in the rendered DOM, so we can't query it).
*/
export function resolveClipTimingBasis(
elementId: string,
sourceFile: string,
elements: ReadonlyArray<{
domId?: string;
key?: string;
id: string;
start: number;
duration: number;
}>,
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
): { elStart: number; elDuration: number } {
const direct = elements.find(
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
);
if (direct) return { elStart: direct.start, elDuration: direct.duration };
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
const host = hostId
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
: undefined;
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
}
export function useGsapAnimationsForElement( export function useGsapAnimationsForElement(
projectId: string | null, projectId: string | null,
sourceFile: string, sourceFile: string,
@@ -426,6 +346,7 @@ export function useGsapCacheVersion() {
* elements. Called from the Timeline component so diamonds show without * elements. Called from the Timeline component so diamonds show without
* requiring a selection. * requiring a selection.
*/ */
export function usePopulateKeyframeCacheForFile( export function usePopulateKeyframeCacheForFile(
projectId: string | null, projectId: string | null,
sourceFile: string, sourceFile: string,
@@ -433,6 +354,16 @@ export function usePopulateKeyframeCacheForFile(
iframeRef?: React.RefObject<HTMLIFrameElement | null>, iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): void { ): void {
const elementCount = usePlayerStore((s) => s.elements.length); const elementCount = usePlayerStore((s) => s.elements.length);
// Every sub-composition file the timeline shows rows for. The cache is loaded
// for all of them up front, so keyframe lanes are populated on open instead of
// only once a clip from that file is selected (which is what switches
// `sourceFile`). Only files reachable from the store's elements are covered;
// a composition nested inside another still loads on first selection.
const compositionSrcKey = usePlayerStore((s) =>
Array.from(new Set(s.elements.map((el) => el.compositionSrc).filter((src) => !!src)))
.sort()
.join("|"),
);
// Re-run when sub-comp DOM children appear (they supply the host bounds the // Re-run when sub-comp DOM children appear (they supply the host bounds the
// clip-relative keyframe percentages are computed against; without this the // clip-relative keyframe percentages are computed against; without this the
// cache is computed once before they exist and the percentages stay wrong). // cache is computed once before they exist and the percentages stay wrong).
@@ -445,52 +376,21 @@ export function usePopulateKeyframeCacheForFile(
const astFetchDoneRef = useRef(""); const astFetchDoneRef = useRef("");
useEffect(() => { useEffect(() => {
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`; const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
if (fetchKey === lastFetchKeyRef.current) return; if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey; lastFetchKeyRef.current = fetchKey;
runtimeScanDoneRef.current = ""; runtimeScanDoneRef.current = "";
astFetchDoneRef.current = ""; astFetchDoneRef.current = "";
if (!projectId) return; if (!projectId) return;
const sf = sourceFile; // The active file first: it owns the selection, and each file clears only
// fallow-ignore-next-line complexity // its own cache entries, so the order just decides who writes the bare
fetchParsedAnimations(projectId, sf).then((parsed) => { // `id` alias last.
if (!parsed) return; const files = Array.from(
const { setKeyframeCache } = usePlayerStore.getState(); new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]),
clearKeyframeCacheForFile(sf); );
const { elements, domClipChildren } = usePlayerStore.getState();
const doc = iframeRef?.current?.contentDocument; const doc = iframeRef?.current?.contentDocument;
const mergedByElement = new Map<string, GsapKeyframesData>(); Promise.all(files.map((sf) => populateKeyframeCacheFromAst(projectId, sf, doc))).then(() => {
const sourceByElement = new Map<string, GsapAnimation[]>();
for (const anim of parsed.animations) {
if (anim.hasUnresolvedKeyframes) continue;
if (isStaticPositionHold(anim)) continue;
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
// Attribute the tween to every element it animates (handles class /
// group / descendant selectors, not just `#id`).
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
// kfData is already resolved (real keyframes OR a synthesized flat
// tween), so a flat tween joins the store like a keyframed one. No
// property-group filter: this map must cover every tween the cache
// below records, or expanded lanes have nothing to render.
sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
const clipKeyframes = toClipKeyframes(kfData.keyframes, anim, elStart, elDuration);
const existing = mergedByElement.get(id);
if (existing) {
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
}
}
}
for (const [id, kfData] of mergedByElement) {
setKeyframeCache(`${sf}#${id}`, kfData);
setKeyframeCache(id, kfData);
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
}
astFetchDoneRef.current = fetchKey; astFetchDoneRef.current = fetchKey;
}); });
// elementCount is in the deps because new timeline elements (e.g. after a // elementCount is in the deps because new timeline elements (e.g. after a
@@ -499,7 +399,7 @@ export function usePopulateKeyframeCacheForFile(
// iframeRef is read for DOM selector resolution but intentionally not a dep // iframeRef is read for DOM selector resolution but intentionally not a dep
// (it's a stable ref; the separate runtime-scan effect owns iframe timing). // (it's a stable ref; the separate runtime-scan effect owns iframe timing).
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey]); }, [projectId, sourceFile, version, elementCount, domClipChildrenKey, compositionSrcKey]);
// Separate effect for runtime keyframe discovery — polls until the iframe // Separate effect for runtime keyframe discovery — polls until the iframe
// has loaded GSAP timelines, independent of the AST fetch lifecycle. // has loaded GSAP timelines, independent of the AST fetch lifecycle.
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
import { usePlayerStore } from "../player/store/playerStore";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
function HookHost() {
usePopulateKeyframeCacheForFile("demo", "index.html", 1);
return null;
}
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
usePlayerStore.setState({
elements: [
{
id: "lab",
tag: "div",
start: 0,
duration: 12,
track: 1,
compositionSrc: "compositions/keyframe-lab.html",
},
] as never,
});
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
container?.remove();
vi.unstubAllGlobals();
});
describe("usePopulateKeyframeCacheForFile", () => {
it("loads every sub-composition file the timeline shows, not just the active one", async () => {
// Keyframe lanes must be populated when the project opens. Fetching only the
// active file left them empty until a clip from the sub-composition was
// selected (which is the only thing that switched `sourceFile`).
const urls: string[] = [];
const fetchMock = vi.fn((input: RequestInfo | URL) => {
urls.push(String(input));
return Promise.resolve({ ok: true, json: () => Promise.resolve({ animations: [] }) });
});
vi.stubGlobal("fetch", fetchMock);
act(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
root.render(<HookHost />);
});
await act(async () => {
await Promise.resolve();
});
expect(urls.some((u) => u.endsWith("/index.html"))).toBe(true);
expect(urls.some((u) => u.includes("keyframe-lab.html"))).toBe(true);
});
});