Files
hyperframes/packages/studio/src/hooks/useGsapTweenCache.ts
T
Miguel Ángel 7a0883264d feat(studio): keyframe hooks wiring — session, cache, toolbar [5/6] (#1171)
* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.

* feat(studio): GSAP runtime bridge + optimistic update pattern

* feat(studio): keyframe diamonds, navigation controls, context menu

* feat(studio): keyframe hooks wiring — session, commits, cache, toolbar toggle
2026-06-05 11:51:52 -04:00

160 lines
5.2 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
function extractIdFromSelector(selector: string): string | null {
const match = selector.match(/^#([\w-]+)/);
return match ? match[1] : null;
}
/** The selected element's identity for matching tweens to it. */
export interface GsapElementTarget {
id?: string | null;
selector?: string | null;
}
/**
* A tween belongs to the selected element when its target selector addresses
* that element — by id (`#id`), by the exact CSS selector the element was
* selected through (`.kicker`), or as one member of a group selector
* (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
* compositions target tweens by class via `querySelector`, so id-only matching
* misses them.
*/
export function getAnimationsForElement(
animations: GsapAnimation[],
target: GsapElementTarget,
): GsapAnimation[] {
const matchers = new Set<string>();
if (target.id) matchers.add(`#${target.id}`);
if (target.selector) matchers.add(target.selector);
if (matchers.size === 0) return [];
return animations.filter((a) =>
a.targetSelector.split(",").some((part) => matchers.has(part.trim())),
);
}
export async function fetchParsedAnimations(
projectId: string,
sourceFile: string,
): Promise<ParsedGsap | null> {
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
);
return res.ok ? ((await res.json()) as ParsedGsap) : null;
} catch {
return null;
}
}
export function useGsapAnimationsForElement(
projectId: string | null,
sourceFile: string,
target: GsapElementTarget | null,
version: number,
): {
animations: GsapAnimation[];
multipleTimelines: boolean;
unsupportedTimelinePattern: boolean;
} {
const [allAnimations, setAllAnimations] = useState<GsapAnimation[]>([]);
const [multipleTimelines, setMultipleTimelines] = useState(false);
const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
const lastFetchKeyRef = useRef("");
useEffect(() => {
const fetchKey = `${projectId}:${sourceFile}:${version}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
if (!projectId) {
setAllAnimations([]);
setMultipleTimelines(false);
setUnsupportedTimelinePattern(false);
return;
}
let cancelled = false;
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
if (cancelled) return;
if (!parsed) {
setAllAnimations([]);
setMultipleTimelines(false);
setUnsupportedTimelinePattern(false);
return;
}
setAllAnimations(parsed.animations);
setMultipleTimelines(parsed.multipleTimelines === true);
setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
});
return () => {
cancelled = true;
};
}, [projectId, sourceFile, version]);
const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
const animations = useMemo(
() =>
targetId || targetSelector
? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
: [],
[allAnimations, targetId, targetSelector],
);
// Populate keyframe cache for the selected element.
// Key format must match timeline element keys: "sourceFile#domId".
const elementId = target?.id ?? null;
useEffect(() => {
if (!elementId) return;
const { setKeyframeCache } = usePlayerStore.getState();
const withKeyframes = animations.find((a) => a.keyframes);
setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined);
}, [elementId, sourceFile, animations]);
return { animations, multipleTimelines, unsupportedTimelinePattern };
}
export function useGsapCacheVersion() {
const [version, setVersion] = useState(0);
const bump = useCallback(() => setVersion((v) => v + 1), []);
return { version, bump };
}
/**
* Fetch GSAP animations for a file and populate the keyframe cache for all
* elements. Called from the Timeline component so diamonds show without
* requiring a selection.
*/
export function usePopulateKeyframeCacheForFile(
projectId: string | null,
sourceFile: string,
version: number,
): void {
const lastFetchKeyRef = useRef("");
useEffect(() => {
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
if (!projectId) return;
let cancelled = false;
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
if (cancelled || !parsed) return;
const { setKeyframeCache } = usePlayerStore.getState();
for (const anim of parsed.animations) {
if (!anim.keyframes) continue;
const id = extractIdFromSelector(anim.targetSelector);
if (id) setKeyframeCache(`${sourceFile}#${id}`, anim.keyframes);
}
});
return () => {
cancelled = true;
};
}, [projectId, sourceFile, version]);
}