Files
hyperframes/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts
T
Miguel Angel Simon Sierra e34e529720 fix(studio): keep gsapAnimations in sync with the keyframe cache
An ungrouped tween (mixed property groups classify to propertyGroup
undefined) fed keyframeCache but was skipped by every gsapAnimations
writer, so the collapsed row drew diamonds the expanded lanes had no
source animation to render. Drop the property-group gate at all three
writers; lane consumers already filter by group.

Also route the same-percentage merge in updateKeyframeCacheFromParsed
through deduplicateKeyframes so the easeAmbiguous rule has one owner.
2026-07-25 22:48:00 +02:00

166 lines
6.8 KiB
TypeScript

/**
* Helpers for reading/writing the GSAP keyframe cache in the player store.
* Extracted from useGsapScriptCommits to keep file sizes under the 600-line limit.
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
import { toAbsoluteTime } from "./gsapShared";
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
export function updateKeyframeCacheFromParsed(
animations: GsapAnimation[],
targetPath: string,
selectionId: string | undefined,
mutation: Record<string, unknown>,
): void {
const { setKeyframeCache, elements } = usePlayerStore.getState();
const idsWithKeyframes = new Set<string>();
const merged = new Map<string, KeyframeCacheEntry>();
const sourceAnimations = new Map<string, GsapAnimation[]>();
for (const anim of animations) {
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
const kfSource =
anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
if (!id || kfSource.length === 0) continue;
idsWithKeyframes.add(id);
// Every tween that fed keyframeCache also lands in gsapAnimations, group or
// not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
// cache diamonds with no source animation behind them, so the collapsed row
// drew keyframes the expanded lanes couldn't render. Lane consumers do the
// group filtering themselves (animationContributesLane).
sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip.
const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? 1;
const timelineEl = elements.find(
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
);
const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 1;
const clipKeyframes = kfSource.map((kf) => {
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
const clipPct =
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
return {
...kf,
percentage: clipPct,
tweenPercentage: kf.percentage,
propertyGroup: anim.propertyGroup,
animationId: anim.id,
};
});
const existing = merged.get(id);
if (existing) {
// deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
// flag downstream lanes read); a second copy of that rule here is how the
// two writers drift.
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
merged.set(id, {
...anim.keyframes,
format: anim.keyframes?.format ?? "percentage",
keyframes: clipKeyframes,
});
}
}
for (const [id, entry] of merged) {
setKeyframeCache(`${targetPath}#${id}`, entry);
setKeyframeCache(id, entry);
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
}
const targetId =
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
selectionId;
if (targetId && !idsWithKeyframes.has(targetId)) {
clearKeyframeCacheForElement(targetPath, targetId);
}
}
/**
* Clear every keyframe-cache key variant written for an element: the
* source-prefixed key, the index.html fallback, and the bare element id.
* Writes set all three (see updateKeyframeCacheFromParsed and
* usePopulateKeyframeCacheForFile). PropertyPanel's keyframe nav reads the bare
* id directly (`element.id`), and other consumers (timeline diamonds, the
* preview overlay) fall back to the bare id when an element has no
* source-prefixed key — so a clear that drops only the prefixed keys leaves the
* bare entry behind and those readers keep showing keyframes the element no
* longer has. Each delete is guarded by `has` so an absent key doesn't allocate
* a new cache map and re-render every subscriber.
*/
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
usePlayerStore.getState();
const keys = elementCacheKeys(sourceFile, elementId);
for (const key of keys) {
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
}
}
/**
* Clear every cached element of `sourceFile` before a full re-scan repopulates
* it. Collects the element ids that currently have a prefixed or index.html
* fallback key for the file and drops each through clearKeyframeCacheForElement
* so the bare key goes too — an element whose keyframes were removed (and so is
* absent from the re-scan) leaves no stale bare entry behind.
*/
export function clearKeyframeCacheForFile(sourceFile: string): void {
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
const sfPrefix = `${sourceFile}#`;
const fallbackPrefix = "index.html#";
const ids = new Set<string>();
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
const matchesFile =
key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
if (!matchesFile) continue;
const hashIdx = key.indexOf("#");
if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1));
}
for (const id of ids) {
clearKeyframeCacheForElement(sourceFile, id);
}
}
function elementCacheKeys(sourceFile: string, elementId: string): string[] {
return sourceFile === "index.html"
? [`index.html#${elementId}`, elementId]
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
}
export function writeGsapAnimationsForElement(
sourceFile: string,
elementId: string,
animations: GsapAnimation[] | undefined,
): void {
const { setGsapAnimations } = usePlayerStore.getState();
for (const key of elementCacheKeys(sourceFile, elementId)) {
setGsapAnimations(key, animations);
}
}
function buildCacheKey(sourceFile: string, elementId: string): string {
return `${sourceFile}#${elementId}`;
}
export function readKeyframeSnapshot(
sourceFile: string,
elementId: string | null | undefined,
): KeyframeCacheEntry | undefined {
if (!elementId) return undefined;
return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
}
export function writeKeyframeCache(
sourceFile: string,
elementId: string | null | undefined,
data: KeyframeCacheEntry | undefined,
): void {
if (!elementId) return;
usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
}