Files
hyperframes/packages/studio/src/player/components/BeatStrip.tsx
T
e6da47d8f8 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>
2026-06-14 17:23:54 -07:00

167 lines
6.4 KiB
TypeScript

import { memo, useRef, useState } from "react";
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y } from "./timelineLayout";
export const BEAT_BAND_H = 14; // dark band height at top of track
const BEAT_HIT_W = 12; // grab width per beat (px)
/** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
function beatsTooDense(beatTimes: number[], pps: number): boolean {
if (beatTimes.length < 2) return true;
const avgInterval = (beatTimes[beatTimes.length - 1]! - beatTimes[0]!) / (beatTimes.length - 1);
return avgInterval * pps < 5;
}
/**
* Faint full-height beat lines painted into a track lane's background. Rendered
* behind the clips so they only show through the empty track area (the dots in
* BeatStrip mark beats on the clips themselves). Brightness scales with beat
* loudness. Drawn on every track lane for a global beat grid.
*/
export const BeatBackgroundLines = memo(function BeatBackgroundLines({
beatTimes,
beatStrengths,
pps,
highlightTime,
}: {
beatTimes: number[] | undefined;
beatStrengths: number[] | undefined;
pps: number;
/** Beat time a dragged clip will snap to — drawn as a bright neon line. */
highlightTime?: number | null;
}) {
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
return (
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
{beatTimes.map((t, i) => {
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
return (
<div
key={`${t}-${i}`}
className="absolute top-0 bottom-0"
style={{
left: t * pps,
width: isHighlight ? 2 : 1,
background: `rgba(34,197,94,${opacity.toFixed(3)})`,
boxShadow: isHighlight ? "0 0 6px rgba(34,197,94,0.9)" : undefined,
zIndex: isHighlight ? 1 : undefined,
}}
/>
);
})}
</div>
);
});
/**
* Green beat dots on the music track's row. Drag a dot to move its beat,
* double-click to delete; both scrub the audio. Dot size/brightness scale with
* beat loudness (gamma-curved for contrast).
*/
export const BeatStrip = memo(function BeatStrip({
beatTimes,
beatStrengths,
pps,
}: {
beatTimes: number[] | undefined;
beatStrengths: number[] | undefined;
pps: number;
}) {
// Active drag: which beat and how far (px) it's been dragged.
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
const cy = BEAT_BAND_H / 2;
return (
<div
className="absolute left-0 right-0 pointer-events-none"
style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
>
{beatTimes.map((t, i) => {
// Louder beats → larger, brighter dot. Gamma curve widens the contrast.
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
const r = 1.5 + strength * 2.5;
const opacity = 0.25 + strength * 0.75;
const dxPx = drag?.index === i ? drag.dx : 0;
const x = t * pps + dxPx;
return (
<div
key={`${t}-${i}`}
className="absolute select-none"
title="Drag to move · double-click to delete"
draggable={false}
style={{
left: x - BEAT_HIT_W / 2,
top: 0,
width: BEAT_HIT_W,
height: BEAT_BAND_H,
cursor: "ew-resize",
pointerEvents: "auto",
touchAction: "none",
}}
onPointerDown={(e) => {
// preventDefault stops the browser starting a native text/drag
// selection (which otherwise "selects" the whole panel mid-drag).
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { index: i, startX: e.clientX, origTime: t };
setDrag({ index: i, dx: 0 });
usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.index !== i) return;
e.preventDefault();
const dx = e.clientX - d.startX;
setDrag({ index: i, dx });
// Scrub the audio (and move the playhead) to follow the dragged beat.
usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
}}
onPointerUp={(e) => {
const d = dragRef.current;
dragRef.current = null;
setDrag(null);
usePlayerStore.getState().setBeatDragging(false);
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
if (!d || d.index !== i) return;
const dx = e.clientX - d.startX;
if (Math.abs(dx) > 2) {
const newTime = Math.max(0, d.origTime + dx / pps);
moveBeatCompositionTime(d.origTime, newTime);
usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
deleteBeatAtCompositionTime(t);
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
}}
>
<div
className="absolute"
style={{
left: BEAT_HIT_W / 2 - r,
top: cy - r,
width: r * 2,
height: r * 2,
borderRadius: "50%",
background: `rgba(34,197,94,${opacity.toFixed(3)})`,
pointerEvents: "none",
}}
/>
</div>
);
})}
</div>
);
});