feat(studio): add timeline keyframe retiming interactions

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:05 +02:00
parent 4c7703ff8f
commit e36fb385bc
11 changed files with 772 additions and 77 deletions
@@ -1,3 +1,6 @@
// Boundary cases share an arrange/assert shape on purpose: each case states its
// own window, drag, and expected remap so a failure reads without cross-referencing.
// fallow-ignore-file code-duplication
import { describe, expect, it } from "vitest";
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
@@ -94,12 +97,12 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
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
// abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 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: 50, to: 33.333 },
{ from: 100, to: 100 },
]);
});
@@ -113,10 +116,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
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.
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
expect(r.pctRemap).toEqual([
{ from: 0, to: 0 },
{ from: 50, to: 63.6 },
{ from: 50, to: 63.636 },
{ from: 100, to: 100 },
]);
});
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
const MIN_TWEEN_DURATION = 0.01;
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));
/** Resolve timing for a flat tween's synthesized start/end diamond. */
@@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
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 { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
});
return {
@@ -152,13 +152,13 @@ export function useTimelineEditCallbacks({
// resizes the tween — position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => {
const target = resolveKeyframeTarget(fromClipPct);
const sel = domEditSelection;
if (!target || !sel) return;
if (!target || !sel) return false;
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return;
if (!anim || tweenStart === null) return false;
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
const { elements, domClipChildren } = usePlayerStore.getState();
@@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({
duration: decision.duration,
});
}
} else {
return false;
}
return true;
},
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
for (const anim of selectedGsapAnimations) {