mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
/**
|
|
* 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<string, number | string>;
|
|
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;
|
|
/** Smallest authored tween window; avoids sub-millisecond/round-to-zero durations. */
|
|
const MIN_TWEEN_DURATION = 0.01;
|
|
|
|
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
|
|
/** Resolve timing for a flat tween's synthesized start/end diamond. */
|
|
function resolveFlatTweenBoundaryRetime(opts: {
|
|
keyframeCount: number;
|
|
draggedTweenPct: number;
|
|
tweenStart: number;
|
|
tweenEnd: number;
|
|
dropAbsTime: number;
|
|
}): KeyframeRetimeResult | null {
|
|
const { keyframeCount, draggedTweenPct, tweenStart, tweenEnd, dropAbsTime } = opts;
|
|
if (keyframeCount > 0) return null;
|
|
// Flat tweens have only synthesized boundary diamonds. Never delegate an
|
|
// unexpected interior percentage to the authored-keyframe move path.
|
|
if (draggedTweenPct !== 0 && draggedTweenPct !== 100) return { kind: "noop" };
|
|
const draggedTime = draggedTweenPct === 0 ? tweenStart : tweenEnd;
|
|
if (Math.abs(dropAbsTime - draggedTime) <= EPSILON_TIME) return { kind: "noop" };
|
|
const newStart = draggedTweenPct === 0 ? dropAbsTime : tweenStart;
|
|
const newEnd = draggedTweenPct === 100 ? dropAbsTime : tweenEnd;
|
|
const newDuration = newEnd - newStart;
|
|
if (newDuration < MIN_TWEEN_DURATION) return { kind: "noop" };
|
|
return {
|
|
kind: "resize",
|
|
position: round3(newStart),
|
|
duration: round3(newDuration),
|
|
pctRemap: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<RetimeKeyframe>;
|
|
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;
|
|
|
|
// A flat tween's synthesized diamonds are its real start/end boundaries —
|
|
// there is no authored keyframe node to re-key. Moving either boundary must
|
|
// therefore resize the tween window while keeping the opposite endpoint at
|
|
// its absolute time. Returning `resize` with an empty remap lets the caller
|
|
// update the flat tween's position/duration instead of sending a keyframe
|
|
// mutation that would silently no-op.
|
|
const flatBoundary = resolveFlatTweenBoundaryRetime({
|
|
keyframeCount: keyframes.length,
|
|
draggedTweenPct,
|
|
tweenStart,
|
|
tweenEnd,
|
|
dropAbsTime,
|
|
});
|
|
if (flatBoundary) return flatBoundary;
|
|
|
|
// Within the tween window → plain move (re-key the tween-%).
|
|
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. Flat start/end boundaries
|
|
// were handled above.
|
|
if (keyframes.length === 0) return { kind: "noop" };
|
|
|
|
const newStart = Math.min(dropAbsTime, tweenStart);
|
|
const newEnd = Math.max(dropAbsTime, tweenEnd);
|
|
const newDuration = Math.max(MIN_TWEEN_DURATION, 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: round3(((absTime - newStart) / newDuration) * 100) };
|
|
});
|
|
|
|
return {
|
|
kind: "resize",
|
|
position: round3(newStart),
|
|
duration: round3(newDuration),
|
|
pctRemap,
|
|
};
|
|
}
|