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).
This commit is contained in:
@@ -72,6 +72,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
selectedGsapAnimations,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleGsapMoveKeyframeToPlayhead,
|
||||
} = useDomEditContext();
|
||||
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
|
||||
iframeRef,
|
||||
@@ -490,6 +491,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
onClose={() => setKfMenu(null)}
|
||||
onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
|
||||
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
||||
onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveKeyframeDrag,
|
||||
previewClipPct,
|
||||
clampToNeighbors,
|
||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
||||
} from "./keyframeDrag";
|
||||
|
||||
// Three keyframes at clip-% 20 / 40 / 60. The dragged one (index 1) is bounded
|
||||
// by its neighbours at 20 and 60; first/last are bounded only by the clip.
|
||||
const CLIP_PCTS = [20, 40, 60];
|
||||
|
||||
describe("resolveKeyframeDrag — click vs drag threshold", () => {
|
||||
const base = {
|
||||
clipWidthPx: 200,
|
||||
draggedClipPct: 40,
|
||||
draggedIndex: 1,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
};
|
||||
|
||||
it("treats sub-threshold movement as a click", () => {
|
||||
const r = resolveKeyframeDrag({
|
||||
...base,
|
||||
pointerDownX: 100,
|
||||
pointerUpX: 100 + (KEYFRAME_DRAG_THRESHOLD_PX - 1),
|
||||
});
|
||||
expect(r.kind).toBe("click");
|
||||
});
|
||||
|
||||
it("treats movement at/over the threshold as a drag", () => {
|
||||
const r = resolveKeyframeDrag({
|
||||
...base,
|
||||
pointerDownX: 100,
|
||||
pointerUpX: 100 + KEYFRAME_DRAG_THRESHOLD_PX + 1,
|
||||
});
|
||||
expect(r.kind).toBe("move");
|
||||
});
|
||||
|
||||
it("guards a zero-width clip (no division blowup) → click", () => {
|
||||
const r = resolveKeyframeDrag({ ...base, clipWidthPx: 0, pointerDownX: 0, pointerUpX: 50 });
|
||||
expect(r.kind).toBe("click");
|
||||
});
|
||||
|
||||
it("no-ops a past-threshold drag that resolves to the source percentage", () => {
|
||||
// Over the px threshold, but on a huge clip the 5px maps to ~0.00125 clip%
|
||||
// away — under the noop epsilon, so don't commit a churn write.
|
||||
const r = resolveKeyframeDrag({
|
||||
...base,
|
||||
clipWidthPx: 1_000_000,
|
||||
pointerDownX: 80,
|
||||
pointerUpX: 80 + KEYFRAME_DRAG_THRESHOLD_PX + 1,
|
||||
});
|
||||
expect(r.kind).toBe("noop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyframeDrag — pixel delta → clip%", () => {
|
||||
// 200px wide clip → 2px per clip-%. Dragged diamond at clip 40%, pointer-down
|
||||
// anchored at its pixel position (80px) for a clean delta.
|
||||
const base = {
|
||||
clipWidthPx: 200,
|
||||
draggedClipPct: 40,
|
||||
draggedIndex: 1,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
pointerDownX: 80,
|
||||
};
|
||||
|
||||
it("maps a rightward drag to clip-%", () => {
|
||||
// +20px → +10 clip% → clip 50% (within [20, 60]).
|
||||
const r = resolveKeyframeDrag({ ...base, pointerUpX: 100 });
|
||||
expect(r.kind).toBe("move");
|
||||
expect(r.toClipPct).toBeCloseTo(50, 5);
|
||||
});
|
||||
|
||||
it("maps a leftward drag", () => {
|
||||
// -20px → -10 clip% → clip 30%.
|
||||
const r = resolveKeyframeDrag({ ...base, pointerUpX: 60 });
|
||||
expect(r.toClipPct).toBeCloseTo(30, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyframeDrag — neighbour + clip clamp", () => {
|
||||
const base = { clipWidthPx: 200, pointerDownX: 80 };
|
||||
|
||||
it("an interior keyframe cannot pass its right neighbour", () => {
|
||||
// Drag the middle (clip 40, index 1) far right → clamps just inside 60.
|
||||
const r = resolveKeyframeDrag({
|
||||
...base,
|
||||
draggedClipPct: 40,
|
||||
draggedIndex: 1,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
pointerUpX: 5000,
|
||||
});
|
||||
expect(r.toClipPct).toBeLessThan(60);
|
||||
expect(r.toClipPct).toBeGreaterThan(59); // epsilon inside, not equal/crossed
|
||||
});
|
||||
|
||||
it("an interior keyframe cannot pass its left neighbour", () => {
|
||||
const r = resolveKeyframeDrag({
|
||||
...base,
|
||||
draggedClipPct: 40,
|
||||
draggedIndex: 1,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
pointerUpX: -5000,
|
||||
});
|
||||
expect(r.toClipPct).toBeGreaterThan(20);
|
||||
expect(r.toClipPct).toBeLessThan(21);
|
||||
});
|
||||
|
||||
it("the first keyframe is free to the clip start (0%) but bounded by the 2nd", () => {
|
||||
// Index 0 dragged left past 0 → clamps to 0.
|
||||
const left = resolveKeyframeDrag({
|
||||
...base,
|
||||
draggedClipPct: 20,
|
||||
draggedIndex: 0,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
pointerUpX: -5000,
|
||||
});
|
||||
expect(left.toClipPct).toBe(0);
|
||||
// Dragged right past the 2nd keyframe (40) → clamps just inside it.
|
||||
const right = resolveKeyframeDrag({
|
||||
...base,
|
||||
draggedClipPct: 20,
|
||||
draggedIndex: 0,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
pointerUpX: 5000,
|
||||
});
|
||||
expect(right.toClipPct).toBeLessThan(40);
|
||||
expect(right.toClipPct).toBeGreaterThan(39);
|
||||
});
|
||||
|
||||
it("the last keyframe is free to the clip end (100%) but bounded by the 2nd-to-last", () => {
|
||||
const right = resolveKeyframeDrag({
|
||||
...base,
|
||||
draggedClipPct: 60,
|
||||
draggedIndex: 2,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
pointerUpX: 5000,
|
||||
});
|
||||
expect(right.toClipPct).toBe(100);
|
||||
const left = resolveKeyframeDrag({
|
||||
...base,
|
||||
draggedClipPct: 60,
|
||||
draggedIndex: 2,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
pointerUpX: -5000,
|
||||
});
|
||||
expect(left.toClipPct).toBeGreaterThan(40);
|
||||
expect(left.toClipPct).toBeLessThan(41);
|
||||
});
|
||||
|
||||
it("a lone keyframe moves freely across the whole clip", () => {
|
||||
const r = resolveKeyframeDrag({
|
||||
...base,
|
||||
draggedClipPct: 50,
|
||||
draggedIndex: 0,
|
||||
sortedClipPcts: [50],
|
||||
pointerUpX: 5000,
|
||||
});
|
||||
expect(r.toClipPct).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampToNeighbors", () => {
|
||||
it("pins to the midpoint when neighbours are tighter than 2·epsilon", () => {
|
||||
// Neighbours at 10 and 10.5 → window [10.5, 10] inverts → midpoint 10.25.
|
||||
expect(clampToNeighbors(50, [10, 10.2, 10.5], 1)).toBeCloseTo(10.25, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewClipPct", () => {
|
||||
it("follows the pointer in clip-% and clamps to neighbours", () => {
|
||||
expect(
|
||||
previewClipPct({
|
||||
pointerDownX: 80,
|
||||
pointerMoveX: 100,
|
||||
clipWidthPx: 200,
|
||||
draggedClipPct: 40,
|
||||
draggedIndex: 1,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
}),
|
||||
).toBeCloseTo(50, 5);
|
||||
// Far right → clamps just inside the right neighbour (60), not the clip edge.
|
||||
expect(
|
||||
previewClipPct({
|
||||
pointerDownX: 80,
|
||||
pointerMoveX: 5000,
|
||||
clipWidthPx: 200,
|
||||
draggedClipPct: 40,
|
||||
draggedIndex: 1,
|
||||
sortedClipPcts: CLIP_PCTS,
|
||||
}),
|
||||
).toBeLessThan(60);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
|
||||
|
||||
// Tween window [2, 6] (start 2s, duration 4s). Keyframes at tween-% 0/50/100 →
|
||||
// absolute times 2 / 4 / 6. First + last carry an ease to prove it's preserved.
|
||||
const KEYFRAMES: RetimeKeyframe[] = [
|
||||
{ percentage: 0, properties: { x: 0 }, ease: "power1.out" },
|
||||
{ percentage: 50, properties: { x: 50 } },
|
||||
{ percentage: 100, properties: { x: 100 }, ease: "power2.in" },
|
||||
];
|
||||
const WINDOW = { tweenStart: 2, tweenDuration: 4 };
|
||||
|
||||
describe("resolveKeyframeRetime — move (within the tween window)", () => {
|
||||
it("re-keys an interior keyframe to the tween-% of the drop", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...WINDOW,
|
||||
keyframes: KEYFRAMES,
|
||||
draggedTweenPct: 50,
|
||||
dropAbsTime: 3, // (3-2)/4 = 25%
|
||||
});
|
||||
expect(r.kind).toBe("move");
|
||||
expect(r.toTweenPct).toBeCloseTo(25, 5);
|
||||
});
|
||||
|
||||
it("no-ops a drop that resolves onto the source keyframe", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...WINDOW,
|
||||
keyframes: KEYFRAMES,
|
||||
draggedTweenPct: 50,
|
||||
dropAbsTime: 4, // exactly 50%
|
||||
});
|
||||
expect(r.kind).toBe("noop");
|
||||
});
|
||||
|
||||
it("moves a flat (keyframe-less) tween without needing the keyframes array", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...WINDOW,
|
||||
keyframes: [],
|
||||
draggedTweenPct: 100,
|
||||
dropAbsTime: 5, // (5-2)/4 = 75%
|
||||
});
|
||||
expect(r.kind).toBe("move");
|
||||
expect(r.toTweenPct).toBeCloseTo(75, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
|
||||
it("extends the LAST keyframe past the end, keeping others' absolute times", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...WINDOW,
|
||||
keyframes: KEYFRAMES,
|
||||
draggedTweenPct: 100,
|
||||
dropAbsTime: 8, // past end (6) → grow duration
|
||||
});
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(2, 5); // start unchanged
|
||||
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
|
||||
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
|
||||
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
|
||||
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
|
||||
expect(r.pctRemap).toEqual([
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 33.3 },
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("extends the FIRST keyframe before the start, shifting position earlier", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...WINDOW,
|
||||
keyframes: KEYFRAMES,
|
||||
draggedTweenPct: 0,
|
||||
dropAbsTime: 0.5, // before start (2) → move position back + grow duration
|
||||
});
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(0.5, 5);
|
||||
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
|
||||
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
|
||||
expect(r.pctRemap).toEqual([
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 63.6 },
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyframeRetime — single keyframe (both first and last)", () => {
|
||||
const lone: RetimeKeyframe[] = [{ percentage: 100, properties: { x: 9 } }];
|
||||
|
||||
it("resizes right past the end", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...WINDOW, // lone keyframe sits at abs 6
|
||||
keyframes: lone,
|
||||
draggedTweenPct: 100,
|
||||
dropAbsTime: 9,
|
||||
});
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(2, 5);
|
||||
expect(r.duration).toBeCloseTo(7, 5);
|
||||
expect(r.pctRemap).toEqual([{ from: 100, to: 100 }]);
|
||||
});
|
||||
|
||||
it("resizes left before the start", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...WINDOW,
|
||||
keyframes: lone,
|
||||
draggedTweenPct: 100,
|
||||
dropAbsTime: 0.5,
|
||||
});
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(0.5, 5);
|
||||
expect(r.duration).toBeCloseTo(5.5, 5);
|
||||
expect(r.pctRemap).toEqual([{ from: 100, to: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyframeRetime — guards", () => {
|
||||
it("no-ops a zero-duration tween", () => {
|
||||
expect(
|
||||
resolveKeyframeRetime({
|
||||
tweenStart: 2,
|
||||
tweenDuration: 0,
|
||||
keyframes: KEYFRAMES,
|
||||
draggedTweenPct: 50,
|
||||
dropAbsTime: 3,
|
||||
}).kind,
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("no-ops a boundary drop on a flat tween (nothing to remap)", () => {
|
||||
expect(
|
||||
resolveKeyframeRetime({
|
||||
...WINDOW,
|
||||
keyframes: [],
|
||||
draggedTweenPct: 100,
|
||||
dropAbsTime: 8,
|
||||
}).kind,
|
||||
).toBe("noop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user