feat(studio): add keyframe timeline state

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-25 21:40:44 +02:00
parent 3b3d4f559c
commit e4d7bde64f
21 changed files with 583 additions and 193 deletions
+36 -13
View File
@@ -5,14 +5,26 @@ import type {
} from "@hyperframes/core/gsap-parser";
import { PROPERTY_DEFAULTS } from "./gsapShared";
export function deduplicateKeyframes(
keyframes: GsapPercentageKeyframe[],
): GsapPercentageKeyframe[] {
const byPct = new Map<number, GsapPercentageKeyframe>();
export function deduplicateKeyframes<
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
>(keyframes: T[]): T[] {
const byPct = new Map<number, T>();
for (const kf of keyframes) {
const existing = byPct.get(kf.percentage);
if (existing) {
existing.properties = { ...existing.properties, ...kf.properties };
// Two DIFFERENT source animations with a keyframe at the same clip %: a
// single inline ease button can only target one of them, and which one is
// arbitrary (each may also inherit a different easeEach/animation ease, so
// comparing raw keyframe eases isn't enough). Flag it so the collapsed row
// hides the button there and the user edits per-lane instead.
if (
existing.animationId !== undefined &&
kf.animationId !== undefined &&
existing.animationId !== kf.animationId
) {
existing.easeAmbiguous = true;
}
if (kf.ease) existing.ease = kf.ease;
} else {
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
@@ -41,29 +53,40 @@ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframes
const fromProps = anim.fromProperties;
if (!toProps || Object.keys(toProps).length === 0) return null;
const startProps: Record<string, number | string> = {};
const endProps: Record<string, number | string> = {};
const rawStart: Record<string, number | string> = {};
const rawEnd: Record<string, number | string> = {};
if (anim.method === "from") {
for (const [k, v] of Object.entries(toProps)) {
startProps[k] = v;
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
rawStart[k] = v;
rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0;
}
} else if (anim.method === "fromTo" && fromProps) {
Object.assign(startProps, fromProps);
Object.assign(endProps, toProps);
Object.assign(rawStart, fromProps);
Object.assign(rawEnd, toProps);
} else {
for (const [k, v] of Object.entries(toProps)) {
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
endProps[k] = v;
rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0;
rawEnd[k] = v;
}
}
// Only numeric props are keyframe-interpolatable — a flat tween of a
// non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane.
const numericKeys = Object.keys(rawEnd).filter(
(k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number",
);
if (numericKeys.length === 0) return null;
const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]]));
const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]]));
return {
format: "percentage",
keyframes: [
{ percentage: 0, properties: startProps },
{ percentage: 100, properties: endProps },
// Segment ease lives on the destination keyframe (Figma/AE model) so the
// lane + cache surface it; also kept data-level for useGsapTweenCache.
{ percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) },
],
...(anim.ease ? { ease: anim.ease } : {}),
};