import type { GsapAnimation, GsapKeyframesData, GsapPercentageKeyframe, } from "@hyperframes/core/gsap-parser"; import { PROPERTY_DEFAULTS } from "./gsapShared"; /** * A static position hold (only x/y, no real motion) is a `set`, not a keyframe — * it must not synthesize a diamond. Covers both `tl.set(...)` and the * `tl.to({ duration: 0, immediateRender: true })` hold that remove-all-keyframes * collapses to (otherwise shown as a stray 0% keyframe). * * Single owner: the collapsed keyframe cache and the expanded property lanes' * `gsapAnimations` map MUST agree on it, or a hold draws a phantom expanded lane * with no matching collapsed diamond. */ export function isStaticPositionHold(anim: GsapAnimation): boolean { if (anim.keyframes) return false; if (anim.method !== "set" && (anim.duration ?? 0) !== 0) return false; const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender"); return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y"); } export function deduplicateKeyframes< T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean }, >(keyframes: T[]): T[] { const byPct = new Map(); 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; } // Whichever tween iterated last used to win `ease`, so the merged // keyframe carried an arbitrary one of the colliding curves. Readers that // do not check easeAmbiguous (drag readouts, lane hints) then showed a // curve belonging to a different animation than the one an edit targets. // Drop it instead: ambiguous means "no single ease", and the flag is the // only honest answer. if (existing.easeAmbiguous) delete existing.ease; else if (kf.ease) existing.ease = kf.ease; } else { byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); } } return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); } // fallow-ignore-next-line complexity export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null { // Both parsers store extras as raw source text (`__raw:${code}`) so // non-editable config like `stagger: {...}` survives verbatim — a literal // `immediateRender: true` prints as exactly this string, not a boolean. const hasImmediateRenderHold = anim.extras?.immediateRender === "__raw:true"; if (anim.method === "set" || (anim.duration === 0 && hasImmediateRenderHold)) { // A `set` — or a `to()`/`from()` collapsed to a zero-duration // immediateRender hold (what removeAllKeyframesFromScript collapses a // keyframed tween to) — is a STATIC HOLD: a value applied at one point, // not an animated keyframe. It must NOT synthesize a keyframe, or the // timeline + panel show a phantom diamond for a value that doesn't // animate. This aligns the AST path with the runtime scan, which already // skips every zero-duration set. return null; } const toProps = anim.properties; const fromProps = anim.fromProperties; if (!toProps || Object.keys(toProps).length === 0) return null; const rawStart: Record = {}; const rawEnd: Record = {}; if (anim.method === "from") { for (const [k, v] of Object.entries(toProps)) { rawStart[k] = v; rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0; } } else if (anim.method === "fromTo" && fromProps) { Object.assign(rawStart, fromProps); Object.assign(rawEnd, toProps); } else { for (const [k, v] of Object.entries(toProps)) { 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 }, // 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 } : {}), }; }