Files
hyperframes/packages/studio/src/hooks/keyframeCacheAstLoad.ts
T

108 lines
4.4 KiB
TypeScript

/**
* Reading a composition file's GSAP tweens into the keyframe cache: fetch and
* selector -> element id resolution.
* 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 { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
import {
deduplicateKeyframes,
isStaticPositionHold,
synthesizeFlatTweenKeyframes,
type MergeableKeyframe,
} from "./gsapTweenSynth";
export { resolveSelectorElementIds };
/**
* The slice of the parse response callers actually read. The endpoint returns
* the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
* this fetch touches the source-text fields, so the guard below only has to
* vouch for what gets used.
*/
type ParsedGsapAnimations = Pick<
ParsedGsap,
"animations" | "multipleTimelines" | "unsupportedTimelinePattern"
>;
/**
* A proxy, an error page, or a stale server can answer 200 with something that
* has no `animations` array — the case where the old blind cast crashed on
* `.animations.filter`.
*/
function hasAnimations(value: unknown): value is ParsedGsapAnimations {
return (
typeof value === "object" &&
value !== null &&
"animations" in value &&
Array.isArray(value.animations)
);
}
export async function fetchParsedAnimations(
projectId: string,
sourceFile: string,
): Promise<ParsedGsapAnimations | 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: unknown = await res.json();
if (!hasAnimations(parsed)) return null;
// 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;
}
}
/**
* 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 { elements, domClipChildren } = usePlayerStore.getState();
const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
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 });
}
}
}
replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
}