feat(studio): drag keyframes with live beat snapping#1439
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
168fd09 to
9f2a749
Compare
1ae886e to
b2fc792
Compare
9f2a749 to
bcc0c29
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Strengths
-
keyframeMove.ts:1–151— clean separation of pure logic. ExtractingpickKeyframeTween/computeKeyframeMovePlan/remapKeyframesinto a side-effect-free module with its own unit tests is exactly the right call for this level of complexity. The test coverage inkeyframeMove.test.tscovers the stale-cache no-op, same-group tween disambiguation, and the intermediate-remapping math — makes the behaviour reviewable without needing to spin up the full studio. -
TimelineClipDiamonds.tsx:624–651— mount-cleanup is thorough. Both the pending-hold timer and the in-flight drag listeners are covered byuseEffect/dragCleanupRef: the unmount effect firesdragCleanupRef.current?.()(removespointermove/pointerupfromdocument) andclearTimeout(pendingTimerRef.current), so mid-drag unmounts (comp switch, clip delete, zoom-out collapse) don't leak. -
timelineEditing.ts:796— snap-range math is solid. Converting 8px to seconds via8 / pixelsPerSecondmakes the snap radius stay perceptually constant across zoom levels, which is the right UX. TheMath.max(pixelsPerSecond, 1)guard avoids a division-by-zero on extreme zoom-out.
Blockers
None.
Important
-
StudioPreviewArea.tsx:37— no cancellation guard on the asynconMoveKeyframe.buildDomSelectionForTimelineElementis awaited inside auseCallback; if the component unmounts (or the active composition changes) while thePromise.allis in-flight, the continuation still runs and callshandleGsapRemoveKeyframe/handleGsapAddKeyframeon a stale selection. In practice the 2 s optimistic-hold timer is the only safeguard, but a silent stale-edit is possible. Consider anAbortControllerref (or a mounted-ref early-exit) after theawait:if (!mountedRef.current) return;
Low probability but worth guarding, especially since rapid drags on slow machines can queue multiple concurrent invocations.
-
keyframeMove.ts:335—isStartPointheuristic for flat tweens (tweenOldPct <= 50). For afrom/totween the synthesised diamonds are always at 0 % and 100 %, so the current heuristic is never actually wrong. But the comment describing the contract ("start point→ diamond at 0") is absent in the code, making the<= 50look like an arbitrary threshold to future readers. A// synthesised start/end are always 0 and 100; this is just a fallbacknote would prevent a wrong refactor.
Nits
-
keyframeMove.ts:246—clampPctrounds to 0.01 % butround3rounds to 0.001. Remapped intermediate keyframes useclampPct(2 dp) while the tweenposition/durationuseround3(3 dp). This is fine in practice since 0.01 % precision on a percentage is sub-millisecond, but it's worth a comment explaining why two different rounding levels exist. -
TimelineClipDiamonds.tsx:734—effPctuses strict===on float.const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
drag.origPctis set fromkf.percentageinhandlePointerDownandpis alsokf.percentage, so they are always the same JS value (no intermediate math), making the===safe here. Worth a brief inline comment to forestall the "should this be an epsilon compare?" question from the next person. -
keyframeMove.test.ts:45—elfixture is missingkey/id. ThepickKeyframeTweentest uses a fixture that only hasdomId/selector, which is fine for coverage, but theKeyframeCacheEntrylookup in the real handler usesel.key ?? el.id— a test that exercises theel.key-only path would complete the coverage.
Verdict: APPROVE
Reasoning: The core beat-snap and keyframe-move logic is well-structured, pure, and unit-tested. The resource-cleanup audit passes. CI is fully green (UNSTABLE is just Graphite's mergeability check still pending, not a code signal). The async race noted above is the most meaningful gap but is low-probability and bounded by the 2 s timeout; it doesn't block shipping.
— Magi
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Verdict: LGTM with concerns — keyframe drag-with-snap is built on the right primitives (pure plan logic + tested unit module), feature-flagged, deterministic, and the round-2/3 follow-ups in commits 2 + 3 already preempted the biggest holes I'd have flagged. Two UX concerns and a few coverage gaps below.
Verified at HEAD c99e9d9b
What ships
editor/keyframeMove.ts— purepickKeyframeTween+computeKeyframeMovePlan+remapKeyframes, with unit tests covering selector mismatch, group-aware pick, intermediate move, start-resize remap, and stale-cache no-op.timelineEditing.ts:snapKeyframePctToBeat— pure, ~8px window in time-domain (8 / max(pps, 1)seconds), returns input unchanged when no beat in range.TimelineClipDiamonds.tsx— pointer-down on diamond → document-levelpointermove/pointerup, 4px dead-zone, optimistic hold until cache reflects commit, fallback 2s timeout, unmount-safe cleanup.StudioPreviewArea.onMoveKeyframe— async commit that awaitsbuildDomSelectionForTimelineElement+fetchParsedAnimationsper drop, then dispatcheshandleGsapUpdateMeta/Remove/Addwith aselectionOverrideso the commit doesn't depend on the global DOM-edit session having caught up.useGsapAnimationsForElement+usePopulateKeyframeCacheForFile— clip-pct precision bumped 0.1% → 0.001% so beat-snapped keyframes center on the dot and the two caches agree.
Performance on drag-move hot path
handleMovedoes: one bounds clamp → one call tosnapKeyframePctToBeat→ onesetDrag. The snap is O(N) linear scan overbeatTimes. Worst-case bound is small (10-min song @ 180 BPM ≈ 1800 beats), so ~1800 float subs/compares per move event — trivially fine at 60-120 events/sec.- No DOM measurement, no layout reads, no allocation in the hot path beyond the
setDragstate object. fetchParsedAnimationsis at commit-time only (handleUp → onMoveKeyframe), NOT per move — one network round-trip per drop, which is correct.- Nit:
beatTimesis sorted (verified inbeatDetection.ts), so binary-search would be O(log N). Not load-bearing at current N, but a 1-line win if you're polishing. - Concern:
handleMovecloses overclipWidthPxcaptured at pointer-down. If the user zooms mid-drag thedx / clipWidthPxmapping skews. Edge case, butclipWidthPxis the only prop the closure depends on for math — consider aclipWidthPxRefif zoom-during-drag turns out to be a real workflow.
Wire contract from #1424
- Beat data flows store (
useMusicBeatAnalysis→playerStore.beatAnalysis) → memoizedremapBeatAnalysisToCompositioninTimeline.tsx→ passed toTimelineCanvas.beatAnalysisAND tosnapKeyframePctToBeatvia theonSnapKeyframePctclosure. Memo deps are[beatAnalysis, musicElement, beatEdits]— stable across move events. Consumer is on the right side of the contract; no high-freq subscription on the hot path. - Type
MusicBeatAnalysis.beatTimes: number[]confirmed against@hyperframes/core/beats. Correct field, correct units (seconds, composition-absolute after remap). - Beats are correctly treated as a global timeline reference — snap math uses
el.start + (pct/100)*el.durationto get composition-abs time, then nearest beat, then back to clip-pct. So a non-audio clip's keyframes can still snap to the music's beat grid, which is the expected behavior.
Determinism + clip-relative interaction with #1448
- No
Date.now,Math.random,performance.now, orfetchin any render-time code path in the new files (verified by grep). - The snap function is pure:
(el, pct, beatTimes, pps) → pct'. The commit math (computeKeyframeMovePlan) is pure. - Clip-relative interaction looks right: the snap operates in clip-% (which is the persisted GSAP keyframe space post-#1448), maps to absolute time only to find the nearest beat, then maps back. The persisted value is clip-relative throughout.
computeKeyframeMovePlanreconstructs absolute time fromel.start + (newPct/100)*el.durationand writes back tween-relativeposition/duration— that round-trip stays in author-time and doesn't leak into runtime/render.
Blockers
None.
Concerns
TimelineClipDiamonds.tsx:124-131— Re-drag during the optimistic-hold window uses stale origPct. If the user drops keyframe A at position B, then clicks A again before the cache round-trip lands (the optimistic hold is rendering at B butkf.percentagein props is still A),handlePointerDown(e, kf.percentage)is invoked withpct = A, sodragRef.origPct = A. The diamond is visually at B but the new drag tracks from A. Result: tiny mouse motion at the visual B position re-commits withorigPct=A, newPct=A+epsilon, whichonMoveKeyframethen tries to look up at A (the now-removed keyframe) and either no-ops via the stale-cache guard or hits the wrong one. Probably rare in practice (sub-2-second double-drag) but a real footgun. Fix idea: duringpendingRef.current === true, gatehandlePointerDown(return early) until the hold releases, or read frompendingHeldPctRefwhen present.TimelineClipDiamonds.tsx:130-140— No snap-disable modifier. Standard NLE convention (AE / Premiere / Resolve) is hold-Cmd or hold-Alt to bypass snap for precise placement. Right now once snap is on you can't place a keyframe between beats inside the ~8px window. Flag-gated feature so not blocking, but worth a follow-up —if (!me.altKey && snapPct) snapPct(rawPct) else rawPctreads natively.timelineEditing.ts:120—snapKeyframePctToBeathas no direct unit test. Pure math, easy to pin: snaps within window, doesn't snap outside, returns unchanged on empty beats, picks nearest when between two. Matters because the snap window math (8 / max(pps, 1)) is zoom-dependent and a regression here would silently break "feel" without breaking any test in the move/plan module.StudioPreviewArea.tsx:onMoveKeyframe— No error surface if the network fetch fails.fetchParsedAnimationsreturnsnullon error and the commit silently no-ops. Drop with no change → optimistic hold renders the new position for 2s → diamond snaps back to original on hold-timeout. Worth at least a console.warn on the early-return for the diagnostics trail.
Nits
timelineEditing.ts:131-137—beatTimesis sorted bybeatDetection.tsconstruction; O(log N) binary search would be cleaner than the linear scan. Not load-bearing at current N.TimelineClipDiamonds.tsx:158—setDrag({ origPct: d.origPct, pct: d.pct })in handleUp duplicates a setDrag from the last handleMove. Harmless re-render but redundant sinced.pctis what's already in state.keyframeMove.ts:34—clampPctrounds to 0.01 precision while the cache change bumps to 0.001 elsewhere — small precision asymmetry between the plan output and the cache. Worth aligning if you want exact round-trip parity.
Stack notes
- #1424 sibling under independent review; the wire contract this PR consumes (
MusicBeatAnalysis.beatTimes) is the right shape. - Three commits in this PR (initial + R2 follow-up + R3 beat-strip cosmetic). The R2 commit message lists exactly the issues a reviewer would have called out (selector-mismatch fallback removed, stale-cache no-op, optimistic-hold cache-match release, document-listener cleanup on unmount). Authors did the homework — appreciated.
CI
All required green at HEAD c99e9d9b: regression, preview-regression, player-perf, preflight. Graphite mergeability in_progress (expected — downstack #1424/#1430 open).
Review by Rames D Jusso
c99e9d9 to
897a1c2
Compare
2a80ae1 to
e001715
Compare
897a1c2 to
79c14d7
Compare
The base branch was changed.
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>
- 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>
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>
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>
79c14d7 to
3ac1da4
Compare

Summary
Keyframe diamonds in the Studio timeline are now draggable with live preview and snap to the music beat grid (requires
VITE_STUDIO_ENABLE_KEYFRAMES=1).Drag model
Works for both flat
from/to/settweens (whose synthesized start/end points are the diamonds) andkeyframes:[]tweens.Beat snapping
While dragging, the keyframe snaps to the nearest beat within ~8px (clip-% → time → nearest beat → clip-%), centered exactly on the beat dot — the keyframe-cache clip-% precision was raised to 0.001% and commit rounding to 3 decimals so the marker lands precisely where dropped (beats sit on a 1ms grid).
Reliability
Keyframe edits were tightly coupled to the async DOM-edit selection session, which caused intermittent reverts. Now:
buildDomSelectionForTimelineElement+fetchParsedAnimations) and mutates through a selection-param override on the GSAP handlers — no dependency on the global session being loaded.Structure
Pure match/plan logic (
pickKeyframeTween,computeKeyframeMovePlan,remapKeyframes) lives ineditor/keyframeMove.tswith unit tests; the timeline handler is a thin async orchestrator. Beat-snap math (snapKeyframePctToBeat) is intimelineEditing.ts.🤖 Generated with Claude Code