mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +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).
196 lines
5.8 KiB
TypeScript
196 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|