Skip to content

Commit 1cd55e6

Browse files
feat(studio): drag keyframes with beat snapping
Keyframe diamonds are draggable with live preview and snap to the music beat grid (requires VITE_STUDIO_ENABLE_KEYFRAMES=1). Drag model: a tween start point trims the front (end fixed), an end point resizes (start fixed), an intermediate keyframe moves within the tween (adjacent segments resize, others untouched; start/end moves remap the intermediates to preserve their absolute times). The keyframe snaps to the nearest beat within ~8px, centered exactly on the dot. Reliability: the commit resolves the dragged element's selection + parsed animations on demand (awaited) instead of relying on the async DOM-edit session, picks the tween whose window contains the keyframe's original time among same-group tweens, and holds the dropped position optimistically until the cache round-trip lands. Cache clip% precision raised to 0.001% so the marker lands exactly where dropped. Pure match/plan logic + unit tests in editor/keyframeMove.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent b2fc792 commit 1cd55e6

9 files changed

Lines changed: 414 additions & 50 deletions

File tree

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { useDomEditContext } from "../contexts/DomEditContext";
1818
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
1919
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
2020
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
21+
import { fetchParsedAnimations } from "../hooks/useGsapTweenCache";
22+
import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove";
2123
import type { GestureRecordingState } from "./editor/GestureRecordControl";
2224

2325
export interface StudioPreviewAreaProps {
@@ -128,6 +130,7 @@ export function StudioPreviewArea({
128130
handleGsapAddKeyframe,
129131
handleGsapConvertToKeyframes,
130132
handleGsapDeleteAllForElement,
133+
buildDomSelectionForTimelineElement,
131134
} = useDomEditContext();
132135

133136
const [snapPrefs, setSnapPrefs] = useState(() => {
@@ -169,31 +172,43 @@ export function StudioPreviewArea({
169172
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
170173
}
171174
},
172-
onMoveKeyframe: (_el: TimelineElement, oldPct: number, newPct: number) => {
173-
const cacheKey = domEditSelection?.id ?? "";
174-
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
175+
// fallow-ignore-next-line complexity
176+
onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => {
177+
// Resolve the dragged element's selection + parsed animations on demand
178+
// (both awaited and cached) rather than relying on the async DOM-edit
179+
// session being loaded for this element — that coupling made the commit
180+
// intermittently no-op (revert) when dragging before the session caught up.
181+
if (!projectId) return;
182+
const sourceFile = _el.sourceFile || activeCompPath || "index.html";
183+
const [selection, parsed] = await Promise.all([
184+
buildDomSelectionForTimelineElement(_el),
185+
fetchParsedAnimations(projectId, sourceFile),
186+
]);
187+
if (!selection || !parsed) return;
188+
189+
const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id);
175190
const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
176-
const group = cachedKf?.propertyGroup;
177-
const anim =
178-
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
179-
selectedGsapAnimations.find((a) => a.keyframes);
180-
if (!anim?.keyframes) return;
181-
const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct;
182-
const kf = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - tweenOldPct) < 0.2);
183-
if (!kf) return;
184-
const tweenStart = anim.resolvedStart ?? 0;
185-
const tweenDur = anim.duration ?? 1;
186-
const newAbsTime = _el.start + (newPct / 100) * _el.duration;
187-
const tweenNewPct =
188-
tweenDur > 0
189-
? Math.max(
190-
0,
191-
Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10),
192-
)
193-
: 0;
194-
handleGsapRemoveKeyframe(anim.id, tweenOldPct);
195-
for (const [prop, val] of Object.entries(kf.properties)) {
196-
handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val);
191+
const origAbsTime = _el.start + (oldPct / 100) * _el.duration;
192+
const anim = pickKeyframeTween(
193+
parsed.animations,
194+
_el,
195+
origAbsTime,
196+
cachedKf?.propertyGroup,
197+
);
198+
if (!anim) return;
199+
200+
const plan = computeKeyframeMovePlan(
201+
anim,
202+
cachedKf?.tweenPercentage ?? oldPct,
203+
_el,
204+
newPct,
205+
);
206+
if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection);
207+
for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection);
208+
for (const add of plan.adds) {
209+
for (const [prop, val] of Object.entries(add.properties)) {
210+
handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection);
211+
}
197212
}
198213
},
199214
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
@@ -231,6 +246,9 @@ export function StudioPreviewArea({
231246
handleGsapUpdateMeta,
232247
handleGsapAddKeyframe,
233248
handleGsapConvertToKeyframes,
249+
buildDomSelectionForTimelineElement,
250+
projectId,
251+
activeCompPath,
234252
],
235253
);
236254

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect } from "vitest";
2+
import { pickKeyframeTween, computeKeyframeMovePlan } from "./keyframeMove";
3+
4+
const flat = (id: string, target: string, position: number, duration: number, group?: string) => ({
5+
id,
6+
targetSelector: target,
7+
position,
8+
duration,
9+
resolvedStart: position,
10+
propertyGroup: group,
11+
});
12+
13+
const el = { start: 0, duration: 10, domId: "box", selector: "#box" };
14+
15+
describe("pickKeyframeTween", () => {
16+
it("matches by the element's selector", () => {
17+
const anims = [flat("a", "#other", 0, 5), flat("b", "#box", 2, 3)];
18+
expect(pickKeyframeTween(anims, el, 3, undefined)?.id).toBe("b");
19+
});
20+
21+
it("prefers the dragged keyframe's property group", () => {
22+
const anims = [flat("pos", "#box", 0, 8, "position"), flat("vis", "#box", 0, 8, "visual")];
23+
expect(pickKeyframeTween(anims, el, 1, "visual")?.id).toBe("vis");
24+
});
25+
26+
it("among same-group tweens picks the one whose window contains the original time", () => {
27+
const fadeIn = flat("in", "#box", 1, 1, "visual");
28+
const fadeOut = flat("out", "#box", 8, 1, "visual");
29+
expect(pickKeyframeTween([fadeIn, fadeOut], el, 8.5, "visual")?.id).toBe("out");
30+
expect(pickKeyframeTween([fadeIn, fadeOut], el, 1.2, "visual")?.id).toBe("in");
31+
});
32+
33+
it("returns undefined when there are no tweens", () => {
34+
expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined();
35+
});
36+
});
37+
38+
describe("computeKeyframeMovePlan — flat tween", () => {
39+
const anim = flat("t", "#box", 2, 4); // window [2, 6]
40+
41+
it("start point trims the front, keeping the end fixed", () => {
42+
// newPct 30% → abs 3 → start moves to 3, duration shrinks to 3.
43+
const plan = computeKeyframeMovePlan(anim, 0, el, 30);
44+
expect(plan.meta).toEqual({ position: 3, duration: 3 });
45+
expect(plan.removes).toEqual([]);
46+
});
47+
48+
it("end point resizes, keeping the start", () => {
49+
// tweenOldPct 100 (end) → newPct 80% → abs 8 → duration 6, start unchanged.
50+
const plan = computeKeyframeMovePlan(anim, 100, el, 80);
51+
expect(plan.meta).toEqual({ position: 2, duration: 6 });
52+
});
53+
});
54+
55+
describe("computeKeyframeMovePlan — keyframe-array tween", () => {
56+
const anim = {
57+
id: "k",
58+
targetSelector: "#box",
59+
position: 0,
60+
duration: 10,
61+
resolvedStart: 0,
62+
keyframes: {
63+
keyframes: [
64+
{ percentage: 0, properties: { x: 0 } },
65+
{ percentage: 50, properties: { x: 50 } },
66+
{ percentage: 100, properties: { x: 100 } },
67+
],
68+
},
69+
};
70+
71+
it("moves an intermediate keyframe without touching the tween or others", () => {
72+
// mid keyframe (tweenPct 50) → newPct 70% → abs 7 → 70% of the tween.
73+
const plan = computeKeyframeMovePlan(anim, 50, el, 70);
74+
expect(plan.meta).toBeUndefined();
75+
expect(plan.removes).toEqual([50]);
76+
expect(plan.adds).toEqual([{ pct: 70, properties: { x: 50 } }]);
77+
});
78+
79+
it("start move remaps intermediates to preserve their absolute times", () => {
80+
// start (tweenPct 0) → newPct 20% → abs 2 → window [2,10]. The 50% keyframe
81+
// was at abs 5 → now (5-2)/8 = 37.5%.
82+
const plan = computeKeyframeMovePlan(anim, 0, el, 20);
83+
expect(plan.meta).toEqual({ position: 2, duration: 8 });
84+
expect(plan.removes).toContain(50);
85+
const mid = plan.adds.find((a) => a.properties.x === 50);
86+
expect(mid?.pct).toBeCloseTo(37.5, 1);
87+
});
88+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Pure helpers for committing a keyframe-diamond drag: pick the tween the
3+
* dragged keyframe belongs to, and compute the GSAP mutations (tween
4+
* position/duration and/or keyframe add/remove) for the move. Kept free of
5+
* React/store so the timeline drag handler stays a thin orchestrator.
6+
*/
7+
8+
interface TweenLike {
9+
id: string;
10+
targetSelector: string;
11+
position: number | string;
12+
duration?: number;
13+
resolvedStart?: number;
14+
propertyGroup?: string;
15+
keyframes?: { keyframes: { percentage: number; properties: Record<string, number | string> }[] };
16+
}
17+
18+
interface ElementWindow {
19+
start: number;
20+
duration: number;
21+
domId?: string;
22+
selector?: string;
23+
}
24+
25+
export interface KeyframeMovePlan {
26+
/** Tween timing change (start/end point drags). */
27+
meta?: { position: number; duration: number };
28+
/** Keyframe percentages to remove, then re-add (intermediate move / remap). */
29+
removes: number[];
30+
adds: { pct: number; properties: Record<string, number | string> }[];
31+
}
32+
33+
const round3 = (n: number) => Math.round(n * 1000) / 1000;
34+
const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100));
35+
const MIN_DUR = 0.05;
36+
37+
function tweenWindow(a: TweenLike): { start: number; dur: number } {
38+
return {
39+
start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0),
40+
dur: a.duration ?? 0,
41+
};
42+
}
43+
44+
type Kf = { percentage: number; properties: Record<string, number | string> };
45+
46+
/**
47+
* Remap every keyframe except `keepIdx` from the old tween window to the new one
48+
* so their absolute times stay fixed after a start/end resize. Returns the
49+
* remove/add ops (empty for flat tweens, which have no intermediates).
50+
*/
51+
function remapKeyframes(
52+
kfs: Kf[],
53+
keepIdx: number,
54+
oldStart: number,
55+
oldDur: number,
56+
newStart: number,
57+
newDur: number,
58+
): Pick<KeyframeMovePlan, "removes" | "adds"> {
59+
const removes: number[] = [];
60+
const adds: KeyframeMovePlan["adds"] = [];
61+
if (newDur <= 0) return { removes, adds };
62+
for (let i = 0; i < kfs.length; i++) {
63+
if (i === keepIdx) continue;
64+
const k = kfs[i]!;
65+
const absT = oldStart + (k.percentage / 100) * oldDur;
66+
const remapped = clampPct(((absT - newStart) / newDur) * 100);
67+
if (Math.abs(remapped - k.percentage) < 0.05) continue;
68+
removes.push(k.percentage);
69+
adds.push({ pct: remapped, properties: k.properties });
70+
}
71+
return { removes, adds };
72+
}
73+
74+
/**
75+
* Pick the tween the dragged keyframe belongs to: restrict to the element's
76+
* selector and (if known) the keyframe's property group, then choose the one
77+
* whose time window contains — or is nearest — the keyframe's original time.
78+
* An element can have several tweens in one group (e.g. fade-in + fade-out).
79+
*/
80+
export function pickKeyframeTween<T extends TweenLike>(
81+
anims: T[],
82+
el: ElementWindow,
83+
origAbsTime: number,
84+
group: string | undefined,
85+
): T | undefined {
86+
const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean);
87+
const forEl = anims.filter((a) => selectors.includes(a.targetSelector));
88+
const pool = forEl.length > 0 ? forEl : anims;
89+
const groupPool = group ? pool.filter((a) => a.propertyGroup === group) : [];
90+
const candidates = groupPool.length > 0 ? groupPool : pool;
91+
if (candidates.length === 0) return undefined;
92+
const dist = (a: T): number => {
93+
const { start, dur } = tweenWindow(a);
94+
if (origAbsTime >= start && origAbsTime <= start + dur) return 0;
95+
return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur)));
96+
};
97+
return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!);
98+
}
99+
100+
/**
101+
* Compute the mutations for moving a keyframe to `newPct` (clip-relative):
102+
* - start point → trim front (position moves, end fixed),
103+
* - end point → resize (duration changes, start fixed),
104+
* - intermediate → move only that keyframe; start/end moves remap the other
105+
* keyframes so their absolute times stay put.
106+
*/
107+
// fallow-ignore-next-line complexity
108+
export function computeKeyframeMovePlan(
109+
anim: TweenLike,
110+
tweenOldPct: number,
111+
el: ElementWindow,
112+
newPct: number,
113+
): KeyframeMovePlan {
114+
const newAbsTime = el.start + (newPct / 100) * el.duration;
115+
const tweenStart = tweenWindow(anim).start;
116+
const tweenDur = anim.duration ?? el.duration;
117+
const kfs = anim.keyframes
118+
? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage)
119+
: null;
120+
const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1;
121+
122+
if (kfs && idx > 0 && idx < kfs.length - 1) {
123+
const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0;
124+
return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] };
125+
}
126+
127+
const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50;
128+
let newStart = tweenStart;
129+
let newDur = tweenDur;
130+
if (isStartPoint) {
131+
const end = tweenStart + tweenDur;
132+
newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR));
133+
newDur = end - newStart;
134+
} else {
135+
newDur = Math.max(MIN_DUR, newAbsTime - tweenStart);
136+
}
137+
138+
const windowChanged = newStart !== tweenStart || newDur !== tweenDur;
139+
const remap =
140+
kfs && windowChanged
141+
? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur)
142+
: { removes: [], adds: [] };
143+
return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap };
144+
}

packages/studio/src/hooks/useGsapSelectionHandlers.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,14 @@ export function useGsapSelectionHandlers({
110110
);
111111

112112
const handleGsapUpdateMeta = useCallback(
113-
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
114-
if (!domEditSelection) return;
115-
updateGsapMeta(domEditSelection, animId, updates);
113+
(
114+
animId: string,
115+
updates: { duration?: number; ease?: string; position?: number },
116+
selectionOverride?: DomEditSelection | null,
117+
) => {
118+
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
119+
if (!sel) return;
120+
updateGsapMeta(sel, animId, updates);
116121
},
117122
[domEditSelection, updateGsapMeta],
118123
);
@@ -191,9 +196,16 @@ export function useGsapSelectionHandlers({
191196
);
192197

193198
const handleGsapAddKeyframe = useCallback(
194-
(animId: string, percentage: number, property: string, value: number | string) => {
195-
if (!domEditSelection) return;
196-
addKeyframe(domEditSelection, animId, percentage, property, value);
199+
(
200+
animId: string,
201+
percentage: number,
202+
property: string,
203+
value: number | string,
204+
selectionOverride?: DomEditSelection | null,
205+
) => {
206+
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
207+
if (!sel) return;
208+
addKeyframe(sel, animId, percentage, property, value);
197209
},
198210
[domEditSelection, addKeyframe],
199211
);
@@ -208,9 +220,10 @@ export function useGsapSelectionHandlers({
208220
[domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
209221
);
210222
const handleGsapRemoveKeyframe = useCallback(
211-
(animId: string, percentage: number) => {
212-
if (!domEditSelection) return;
213-
removeKeyframe(domEditSelection, animId, percentage);
223+
(animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
224+
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
225+
if (!sel) return;
226+
removeKeyframe(sel, animId, percentage);
214227
},
215228
[domEditSelection, removeKeyframe],
216229
);

packages/studio/src/hooks/useGsapTweenCache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,11 @@ export function useGsapAnimationsForElement(
283283
const tweenDur = anim.duration ?? elDuration;
284284
for (const k of kf.keyframes) {
285285
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
286+
// 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly
287+
// on the beat dot, which is rendered at the true beat time.
286288
const clipPct =
287289
elDuration > 0
288-
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
290+
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
289291
: k.percentage;
290292
allKeyframes.push({
291293
...k,

0 commit comments

Comments
 (0)