mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes #1782) Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture to retime an existing keyframe while preserving its value and easing (delete+re-add bakes computed values and drops the explicit ease). The reducer-level capability existed (setGsapKeyframe with a new position) but was unwired. Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and recast, in parity) that re-keys a keyframe to a new percentage, carrying its properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to Playhead' entry on the keyframe context menu through both hosts (canvas MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the playhead's tween-relative percentage. Tests: parser correctness + recast/acorn parity (value+ease preserved, collision overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow health gate (parity-twin + wiring-layer duplication; extracted helper). * feat(studio): restore drag-to-retime on timeline keyframes Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic move-keyframe foundation so it's reliable. #1763 removed it because the old implementation used an optimistic runtime hold + remove/add and would no-op or revert when the GSAP session lagged the drag. This version: - previews visual-only (the dragged diamond follows the pointer; nothing touches the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves value + ease) — no optimistic hold, no lag race. - pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion, clamp [0,100], no-op when drop==origin (unit-tested). - wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline → TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the dragged keyframe's animation via resolveKeyframeTarget. tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate (same parity/wiring duplication as the rest of the branch). * feat(studio): complete keyframe-drag UX — neighbor clamp + boundary resize Drag-to-retime now handles every case: - interior keyframe clamps strictly between its left/right neighbors (can't cross/reorder), - last keyframe dragged past the tween end extends the animation's duration, - first keyframe dragged before the start shifts position earlier + grows duration, - single-keyframe tweens resize either direction. Boundary extends remap the other keyframes to preserve their absolute times (value + per-keyframe ease copied through) via the atomic replace-with-keyframes mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits on drop — no optimistic runtime hold. Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) + keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea resolves the tween window + clip timing and dispatches move vs resize. tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass (22 new helper tests). Flat keyframe-less tweens still move within window; boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate. * fix(studio): address #1784 review — keyframe retime correctness + resize fidelity Round 2 from Via + Rames: - (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on any tween shorter than its clip (Move to Playhead + the inherited Delete silently no-op'd). Menu now passes clip-%. - boundary resize preserved author intent: new record-preserving parser op resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes which dropped them. - resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent (retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget. - moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal (<0.05), collision only vs a different keyframe. - soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate resolves stale position-encoded ids). Tests: parser parity (small move + resize round-trip fidelity), studio-server resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths. 735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed fallow gate (branch-wide parity/wiring duplication).
122 lines
4.9 KiB
TypeScript
122 lines
4.9 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;
|
|
|
|
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<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;
|
|
|
|
// 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,
|
|
};
|
|
}
|