/** * Pure move-vs-resize decision + absolute-time remap for keyframe drag-to-retime. * * Keyframes live inside the ANIMATION's window (tween position + duration), which * is usually shorter than the clip. Dragging a keyframe is one of: * - MOVE: the drop stays within `[tweenStart, tweenEnd]` → re-key the tween-%. * - RESIZE: the drop crosses the tween boundary (the LAST keyframe past the end, * or the FIRST before the start, but still inside the clip — the gesture layer * already clamped to neighbours + clip). The tween's window grows so the * dragged keyframe lands exactly where dropped; every OTHER keyframe keeps its * ABSOLUTE time (its tween-% remaps onto the new, longer window). Value + ease * are preserved per keyframe. * * Kept pure (no React/store/GSAP) so the trickiest math is unit-testable. The * caller supplies the resolved tween window + the drop's absolute time. */ export interface RetimeKeyframe { /** Tween-relative percentage (the writer/runtime key on this). */ percentage: number; properties: Record; ease?: string; } /** One existing keyframe's old→new tween-% under a resize remap. */ export interface KeyframePctRemap { /** The existing keyframe's current tween-relative %. */ from: number; /** Its new tween-relative % on the resized window. */ to: number; } export interface KeyframeRetimeResult { kind: "noop" | "move" | "resize"; /** MOVE: tween-relative drop position. */ toTweenPct?: number; /** RESIZE: new tween position (absolute seconds). */ position?: number; /** RESIZE: new tween duration (seconds). */ duration?: number; /** * RESIZE: each existing keyframe's old→new tween-%. The commit re-keys each * keyframe IN PLACE (round-tripping its value node), so `_auto`, per-keyframe * `ease`, `easeEach`, and the outer tween `ease` all survive — unlike rebuilding * a fresh keyframes array. */ pctRemap?: KeyframePctRemap[]; } /** Below this (tween-%) a move resolves onto the source keyframe → skip the write. */ const NOOP_EPSILON_PCT = 0.1; /** Slack (seconds) for the within-tween boundary test. */ const EPSILON_TIME = 1e-4; const round3 = (n: number) => Math.round(n * 1000) / 1000; const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); /** * Decide move vs resize for a dragged keyframe and, for resize, return the new * tween window + remapped keyframes. * * - `keyframes`: the tween's keyframes (tween-relative %, with value + ease). * - `draggedTweenPct`: identifies which keyframe is being dragged (closest match). * - `tweenStart` / `tweenDuration`: the tween's resolved absolute window. * - `dropAbsTime`: the drop's absolute time (handler converts clip-% → seconds). */ export function resolveKeyframeRetime(opts: { keyframes: ReadonlyArray; draggedTweenPct: number; tweenStart: number; tweenDuration: number; dropAbsTime: number; }): KeyframeRetimeResult { const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime } = opts; if (tweenDuration <= 0) return { kind: "noop" }; const tweenEnd = tweenStart + tweenDuration; // Within the tween window → plain move (re-key the tween-%). This branch never // touches the keyframes array, so it still works for synthesized flat tweens. if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) { const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100); if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" }; return { kind: "move", toTweenPct }; } // Boundary resize needs the real keyframes to remap; a flat tween has none here. if (keyframes.length === 0) return { kind: "noop" }; const newStart = Math.min(dropAbsTime, tweenStart); const newEnd = Math.max(dropAbsTime, tweenEnd); const newDuration = Math.max(0.01, newEnd - newStart); // The dragged keyframe is the one whose tween-% is closest to draggedTweenPct. let draggedIdx = 0; let best = Infinity; keyframes.forEach((kf, i) => { const d = Math.abs(kf.percentage - draggedTweenPct); if (d < best) { best = d; draggedIdx = i; } }); // Map each existing keyframe to its new tween-% on the grown window, preserving // its absolute time (the dragged one lands at the drop). Carry only the old→new // percentages; the commit re-keys in place so value + ease + _auto + easeEach // survive verbatim (no rebuilt keyframes array). const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => { const absTime = i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration; return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) }; }); return { kind: "resize", position: round3(newStart), duration: round3(newDuration), pctRemap, }; }