mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +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).
106 lines
4.3 KiB
TypeScript
106 lines
4.3 KiB
TypeScript
/**
|
|
* Pure math for the timeline keyframe-diamond drag-to-retime gesture. Kept free
|
|
* of React/store so the gesture handler stays a thin orchestrator and the
|
|
* click-vs-drag + neighbor clamp are unit-testable in isolation.
|
|
*
|
|
* The diamond is positioned by clip-relative % (same basis it's drawn with), so
|
|
* this layer works entirely in clip-%: it converts the pointer pixel delta to a
|
|
* clip-% drop and clamps it between the dragged keyframe's neighbours (and the
|
|
* clip bounds). The clip-%→tween-% conversion and the move-vs-resize decision
|
|
* happen in the studio handler (it has the tween window + clip timing this layer
|
|
* deliberately doesn't), see `keyframeRetime.ts`.
|
|
*/
|
|
|
|
/** Screen-px the pointer must travel before a press counts as a drag (else click). */
|
|
export const KEYFRAME_DRAG_THRESHOLD_PX = 4;
|
|
/** Clip-% movement below this is treated as no change (drop == original). */
|
|
const NOOP_EPSILON_PCT = 0.1;
|
|
/** Gap (clip-%) kept between a dragged interior keyframe and each neighbour so it
|
|
* can't equal/cross them (which would reorder the keyframes). */
|
|
const NEIGHBOR_EPSILON_PCT = 0.5;
|
|
|
|
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
|
|
/**
|
|
* Clamp a dragged keyframe's clip-% strictly between its immediate neighbours
|
|
* (with a small epsilon so it can't equal/cross them) and to the clip bounds.
|
|
*
|
|
* - Interior keyframe → bounded by both neighbours.
|
|
* - First keyframe (index 0) → left bound is the clip start (0%), so it's free to
|
|
* travel left toward/past the tween start (a boundary RESIZE the handler owns).
|
|
* - Last keyframe → right bound is the clip end (100%), free to travel right.
|
|
* - Lone keyframe → free across the whole clip [0, 100].
|
|
*/
|
|
export function clampToNeighbors(
|
|
clipPct: number,
|
|
sortedClipPcts: ReadonlyArray<number>,
|
|
draggedIndex: number,
|
|
): number {
|
|
const left =
|
|
draggedIndex > 0 ? (sortedClipPcts[draggedIndex - 1] ?? 0) + NEIGHBOR_EPSILON_PCT : 0;
|
|
const right =
|
|
draggedIndex < sortedClipPcts.length - 1
|
|
? (sortedClipPcts[draggedIndex + 1] ?? 100) - NEIGHBOR_EPSILON_PCT
|
|
: 100;
|
|
// Degenerate window (neighbours closer than 2·epsilon): pin to the midpoint so
|
|
// the result stays ordered between them.
|
|
if (left > right) return (left + right) / 2;
|
|
return clamp(clipPct, left, right);
|
|
}
|
|
|
|
export interface KeyframeDragResult {
|
|
/** `click`: under the drag threshold → seek. `noop`: moved but resolved onto
|
|
* the original keyframe → skip the commit. `move`: commit the retime. */
|
|
kind: "click" | "noop" | "move";
|
|
/** Clip-relative drop position, neighbour- and clip-clamped (only on `move`). */
|
|
toClipPct?: number;
|
|
}
|
|
|
|
/**
|
|
* Decide whether a diamond press was a click or a drag, and for a drag compute
|
|
* the neighbour-clamped clip-% drop position.
|
|
*
|
|
* - `draggedClipPct`: the dragged diamond's own clip-relative percentage.
|
|
* - `draggedIndex` / `sortedClipPcts`: index of the dragged keyframe within the
|
|
* clip's keyframes sorted by clip-%, used for the neighbour clamp.
|
|
*/
|
|
export function resolveKeyframeDrag(opts: {
|
|
pointerDownX: number;
|
|
pointerUpX: number;
|
|
clipWidthPx: number;
|
|
draggedClipPct: number;
|
|
draggedIndex: number;
|
|
sortedClipPcts: ReadonlyArray<number>;
|
|
}): KeyframeDragResult {
|
|
const dx = opts.pointerUpX - opts.pointerDownX;
|
|
if (Math.abs(dx) < KEYFRAME_DRAG_THRESHOLD_PX || opts.clipWidthPx <= 0) {
|
|
return { kind: "click" };
|
|
}
|
|
const rawClipPct = opts.draggedClipPct + (dx / opts.clipWidthPx) * 100;
|
|
const toClipPct = clampToNeighbors(rawClipPct, opts.sortedClipPcts, opts.draggedIndex);
|
|
if (Math.abs(toClipPct - opts.draggedClipPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
|
|
return { kind: "move", toClipPct };
|
|
}
|
|
|
|
/**
|
|
* Live drag preview: the dragged diamond's clip-% as it follows the pointer,
|
|
* neighbour- and clip-clamped to match where the commit will land. Visual only —
|
|
* no runtime/GSAP hold (the #1763 flake).
|
|
*/
|
|
export function previewClipPct(opts: {
|
|
pointerDownX: number;
|
|
pointerMoveX: number;
|
|
clipWidthPx: number;
|
|
draggedClipPct: number;
|
|
draggedIndex: number;
|
|
sortedClipPcts: ReadonlyArray<number>;
|
|
}): number {
|
|
if (opts.clipWidthPx <= 0) return opts.draggedClipPct;
|
|
const dx = opts.pointerMoveX - opts.pointerDownX;
|
|
return clampToNeighbors(
|
|
opts.draggedClipPct + (dx / opts.clipWidthPx) * 100,
|
|
opts.sortedClipPcts,
|
|
opts.draggedIndex,
|
|
);
|
|
}
|