feat(studio): drag keyframes with live beat snapping (#1439)

* 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>

* fix(studio): harden keyframe drag commit (review follow-ups)

- pickKeyframeTween no longer falls back to ALL animations on a selector
  mismatch — it only picks among the dragged element's own tweens, so a
  class/compound-selector mismatch can't edit a different element. No
  match → no-op.
- computeKeyframeMovePlan bails to a no-op when a keyframe-array tween's
  dragged keyframe can't be located (stale cache / precision drift) instead
  of falling through to an end-point resize that silently rescaled the
  whole tween and re-timed every keyframe.
- usePopulateKeyframeCacheForFile clipPct now uses 0.001% precision
  (matching useGsapAnimationsForElement) so beat-snapped keyframes from the
  file-wide cache also center on the dot and the two caches agree.
- The optimistic drag hold only releases once the cache reflects the
  committed position (a keyframe near the held %), so an unrelated cache
  rebuild no longer flashes the diamond back to its old spot.
- A drag's document listeners are cleaned up on unmount, so an unmount
  mid-drag (clip delete / comp switch / zoom-out) no longer leaks them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(studio): shrink + lower keyframe diamonds under the beat strip

When a clip's track shows the beat-dot strip (the top band), its keyframe
diamonds and connecting lines render at 45% size and centered in the
region below the band, so they don't collide with the dots. Full size and
vertically centered otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(studio): ignore keyframe re-drag during the optimistic-hold window

After a drop, the diamond is held at its dropped position (via effPct) until the
file round-trip lands, but `pct` passed to handlePointerDown still comes from
props (the pre-drop position). Re-grabbing the same keyframe in that window
would track the drag from a stale origin and commit against the wrong tween (or
no-op via the stale-cache guard). Skip starting a drag while a hold is pending;
it clears on the cache match (≤2s fallback). Click selection is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-14 17:23:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Miguel Ángel
parent d9f69f61e7
commit e6da47d8f8
10 changed files with 493 additions and 59 deletions
@@ -110,9 +110,14 @@ export function useGsapSelectionHandlers({
);
const handleGsapUpdateMeta = useCallback(
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
if (!domEditSelection) return;
updateGsapMeta(domEditSelection, animId, updates);
(
animId: string,
updates: { duration?: number; ease?: string; position?: number },
selectionOverride?: DomEditSelection | null,
) => {
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
updateGsapMeta(sel, animId, updates);
},
[domEditSelection, updateGsapMeta],
);
@@ -191,9 +196,16 @@ export function useGsapSelectionHandlers({
);
const handleGsapAddKeyframe = useCallback(
(animId: string, percentage: number, property: string, value: number | string) => {
if (!domEditSelection) return;
addKeyframe(domEditSelection, animId, percentage, property, value);
(
animId: string,
percentage: number,
property: string,
value: number | string,
selectionOverride?: DomEditSelection | null,
) => {
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
addKeyframe(sel, animId, percentage, property, value);
},
[domEditSelection, addKeyframe],
);
@@ -208,9 +220,10 @@ export function useGsapSelectionHandlers({
[domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
);
const handleGsapRemoveKeyframe = useCallback(
(animId: string, percentage: number) => {
if (!domEditSelection) return;
removeKeyframe(domEditSelection, animId, percentage);
(animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
removeKeyframe(sel, animId, percentage);
},
[domEditSelection, removeKeyframe],
);
@@ -283,9 +283,11 @@ export function useGsapAnimationsForElement(
const tweenDur = anim.duration ?? elDuration;
for (const k of kf.keyframes) {
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
// 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly
// on the beat dot, which is rendered at the true beat time.
const clipPct =
elDuration > 0
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
: k.percentage;
allKeyframes.push({
...k,
@@ -381,9 +383,12 @@ export function usePopulateKeyframeCacheForFile(
const elDuration = timelineEl?.duration ?? 1;
const clipKeyframes = kfData.keyframes.map((kf) => {
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
// 0.001% precision (matching useGsapAnimationsForElement above) so a
// beat-snapped keyframe centers exactly on the beat dot and the two
// caches agree on a keyframe's percentage.
const clipPct =
elDuration > 0
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
: kf.percentage;
return {
...kf,