feat(studio): per-keyframe ease presets, velocity fitting, gesture smoothing (#1694)

Per-keyframe speed-curve editing, velocity-based ease fitting, and Gaussian gesture smoothing. Easy Ease presets, per-segment KeyframeEaseList with a bezier editor, AE-convention ease fitting, position-only set-tween rows, and AnimationCard extraction.
This commit is contained in:
Miguel Ángel
2026-06-24 18:43:37 -04:00
committed by GitHub
parent 8ae010bf51
commit 97db811a2f
18 changed files with 779 additions and 322 deletions
+32 -4
View File
@@ -24,6 +24,7 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
}
// fallow-ignore-next-line complexity
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
if (anim.method === "set") {
return {
@@ -107,6 +108,9 @@ export async function fetchParsedAnimations(
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;
@@ -156,7 +160,9 @@ export function useGsapAnimationsForElement(
let cancelled = false;
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
if (cancelled) return;
if (cancelled) {
return;
}
if (!parsed) {
setAllAnimations([]);
setMultipleTimelines(false);
@@ -169,7 +175,7 @@ export function useGsapAnimationsForElement(
// Retry once if initial fetch returned 0 animations — handles
// cold-load race where the sourceFile isn't resolved yet.
if (parsed.animations.length === 0 && target) {
if (parsed.animations.length === 0 && targetKey) {
retryTimerRef.current = setTimeout(() => {
if (cancelled) return;
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
@@ -189,7 +195,7 @@ export function useGsapAnimationsForElement(
retryTimerRef.current = null;
}
};
}, [projectId, sourceFile, version, target]);
}, [projectId, sourceFile, version, target?.id, target?.selector]);
const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
@@ -201,6 +207,7 @@ export function useGsapAnimationsForElement(
[allAnimations, targetId, targetSelector],
);
// fallow-ignore-next-line complexity
const animations = useMemo(() => {
const iframe = iframeRef?.current;
let result = rawAnimations;
@@ -268,6 +275,7 @@ export function useGsapAnimationsForElement(
// Merges keyframes from ALL animations targeting this element and synthesizes
// flat tweens so the cache is never downgraded vs the bulk populate.
const elementId = target?.id ?? null;
// fallow-ignore-next-line complexity
useEffect(() => {
if (!elementId) return;
@@ -287,6 +295,11 @@ export function useGsapAnimationsForElement(
let ease: string | undefined;
let easeEach: string | undefined;
for (const anim of animations) {
if (
anim.method === "set" &&
Object.keys(anim.properties).every((k) => k === "x" || k === "y")
)
continue;
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kf) continue;
// Convert tween-relative percentages to clip-relative so diamonds
@@ -366,6 +379,7 @@ export function usePopulateKeyframeCacheForFile(
if (!projectId) return;
const sf = sourceFile;
// fallow-ignore-next-line complexity
fetchParsedAnimations(projectId, sf).then((parsed) => {
if (!parsed) return;
const { setKeyframeCache } = usePlayerStore.getState();
@@ -376,6 +390,14 @@ export function usePopulateKeyframeCacheForFile(
const id = extractIdFromSelector(anim.targetSelector);
if (!id) continue;
if (anim.hasUnresolvedKeyframes) continue;
// Position-only set tweens are static holds (created by drag), not
// keyframed animations — skip them so they don't show timeline diamonds.
if (anim.method === "set") {
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
if (propKeys.every((k) => k === "x" || k === "y")) {
continue;
}
}
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
const tweenPos =
@@ -430,6 +452,7 @@ export function usePopulateKeyframeCacheForFile(
let attempts = 0;
const maxAttempts = 10;
// fallow-ignore-next-line complexity
const tryRuntimeScan = () => {
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
const iframe =
@@ -449,7 +472,12 @@ export function usePopulateKeyframeCacheForFile(
const fallbackKey = `index.html#${id}`;
const alreadyCached =
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
if (alreadyCached) {
if (alreadyCached) continue;
// Skip position-only set tweens from runtime too — same filter as AST path
const isPosOnly =
data.keyframes.length === 1 &&
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
if (isPosOnly) {
continue;
}
const entry = {