Skip to content

Commit 8d4962b

Browse files
feat(studio): skip beat-snap on the music track, highlight move-snap target
The music track defines the beats, so moving or trimming it no longer snaps to its own beats (isMusicTrack guard on both the move and resize snap paths). Moving another clip snapped only on drop with no cue. snapMoveStartToBeat now also returns the beat it will snap to; BeatBackgroundLines draws that beat's line as a bright neon-green glow while the clip's edge is within the snap region, so the target is visible before drop. Also drops .commitmsg.tmp, accidentally committed via git add -A. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent e18a515 commit 8d4962b

4 files changed

Lines changed: 44 additions & 30 deletions

File tree

.commitmsg.tmp

Lines changed: 0 additions & 14 deletions
This file was deleted.

packages/studio/src/player/components/BeatStrip.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,32 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
2323
beatTimes,
2424
beatStrengths,
2525
pps,
26+
highlightTime,
2627
}: {
2728
beatTimes: number[] | undefined;
2829
beatStrengths: number[] | undefined;
2930
pps: number;
31+
/** Beat time a dragged clip will snap to — drawn as a bright neon line. */
32+
highlightTime?: number | null;
3033
}) {
3134
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
3235
return (
3336
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
3437
{beatTimes.map((t, i) => {
38+
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
3539
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
36-
const opacity = 0.06 + strength * 0.16;
40+
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
3741
return (
3842
<div
3943
key={`${t}-${i}`}
4044
className="absolute top-0 bottom-0"
41-
style={{ left: t * pps, width: 1, background: `rgba(34,197,94,${opacity.toFixed(3)})` }}
45+
style={{
46+
left: t * pps,
47+
width: isHighlight ? 2 : 1,
48+
background: `rgba(34,197,94,${opacity.toFixed(3)})`,
49+
boxShadow: isHighlight ? "0 0 6px rgba(34,197,94,0.9)" : undefined,
50+
zIndex: isHighlight ? 1 : undefined,
51+
}}
4252
/>
4353
);
4454
})}

packages/studio/src/player/components/TimelineCanvas.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,13 @@ export const TimelineCanvas = memo(function TimelineCanvas({
242242
</div>
243243
</div>
244244
<div style={{ width: trackContentWidth }} className="relative">
245-
{/* Faint beat lines in every track's background (behind the clips). */}
245+
{/* Faint beat lines in every track's background (behind the clips);
246+
the active move-snap target is highlighted. */}
246247
<BeatBackgroundLines
247248
beatTimes={beatAnalysis?.beatTimes}
248249
beatStrengths={beatAnalysis?.beatStrengths}
249250
pps={pps}
251+
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
250252
/>
251253
{/* Beat dots only on the active track (the one holding the selection). */}
252254
{els.some((e) => (e.key ?? e.id) === selectedElementId) && (
@@ -370,6 +372,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
370372
pointerOffsetY: e.clientY - rect.top,
371373
previewStart: el.start,
372374
previewTrack: el.track,
375+
snapBeatTime: null,
373376
started: false,
374377
});
375378
syncClipDragAutoScroll(e.clientX, e.clientY);

packages/studio/src/player/components/useTimelineClipDrag.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,35 +30,42 @@ function snapToNearestBeat(time: number, beatTimes: number[], thresholdSecs: num
3030

3131
/**
3232
* Snap a moved clip so whichever edge (start or end) is nearest a beat lands on
33-
* it, keeping the duration fixed. Returns the (clamped) start. No snap when
34-
* there are no beats or neither edge is within threshold.
33+
* it, keeping the duration fixed. Returns the (clamped) start plus the beat time
34+
* it snapped to (for the grid-line highlight), or `beat: null` when no edge is
35+
* within threshold.
3536
*/
3637
function snapMoveStartToBeat(
3738
start: number,
3839
duration: number,
3940
beatTimes: number[],
4041
pixelsPerSecond: number,
4142
timelineDuration: number,
42-
): number {
43-
if (beatTimes.length === 0) return start;
43+
): { start: number; beat: number | null } {
44+
if (beatTimes.length === 0) return { start, beat: null };
4445
const snapSecs = BEAT_SNAP_PX / Math.max(pixelsPerSecond, 1);
4546
const snappedStart = snapToNearestBeat(start, beatTimes, snapSecs);
4647
const snappedEnd = snapToNearestBeat(start + duration, beatTimes, snapSecs);
4748
const startMoved = snappedStart !== start;
4849
const endMoved = snappedEnd !== start + duration;
4950

5051
let candidate = start;
52+
let beat: number | null = null;
5153
if (
5254
startMoved &&
5355
(!endMoved || Math.abs(snappedStart - start) <= Math.abs(snappedEnd - (start + duration)))
5456
) {
5557
candidate = snappedStart;
58+
beat = snappedStart;
5659
} else if (endMoved) {
5760
candidate = snappedEnd - duration;
61+
beat = snappedEnd;
5862
}
5963

6064
const maxStart = Math.max(0, timelineDuration - duration);
61-
return Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000));
65+
const clamped = Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000));
66+
// If clamping pulled the clip off the snap target, drop the highlight.
67+
if (beat != null && Math.abs(clamped - candidate) > 1e-6) beat = null;
68+
return { start: clamped, beat };
6269
}
6370

6471
/* ── Shared state types ─────────────────────────────────────────── */
@@ -74,6 +81,8 @@ export interface DraggedClipState {
7481
pointerOffsetY: number;
7582
previewStart: number;
7683
previewTrack: number;
84+
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
85+
snapBeatTime: number | null;
7786
started: boolean;
7887
}
7988

@@ -199,19 +208,24 @@ export function useTimelineClipDrag({
199208
clientX,
200209
clientY,
201210
);
211+
// The music track defines the beats, so it must not snap to itself.
212+
const snap = isMusicTrack(drag.element)
213+
? { start: nextMove.start, beat: null }
214+
: snapMoveStartToBeat(
215+
nextMove.start,
216+
drag.element.duration,
217+
beatTimesRef.current,
218+
ppsRef.current,
219+
durationRef.current,
220+
);
202221
return {
203222
...drag,
204223
started: true,
205224
pointerClientX: clientX,
206225
pointerClientY: clientY,
207-
previewStart: snapMoveStartToBeat(
208-
nextMove.start,
209-
drag.element.duration,
210-
beatTimesRef.current,
211-
ppsRef.current,
212-
durationRef.current,
213-
),
226+
previewStart: snap.start,
214227
previewTrack: nextMove.track,
228+
snapBeatTime: snap.beat,
215229
};
216230
},
217231
[scrollRef, ppsRef, durationRef, trackOrderRef],
@@ -330,8 +344,9 @@ export function useTimelineClipDrag({
330344
// Snap edge to beat grid when beat analysis is available. The snap must
331345
// stay inside the same limits resolveTimelineResize enforces, or it would
332346
// push the edge past the available source media / composition end.
347+
// The music track defines the beats, so it must not snap to itself.
333348
const beatTimes = beatTimesRef.current;
334-
if (beatTimes.length > 0) {
349+
if (beatTimes.length > 0 && !isMusicTrack(resize.element)) {
335350
const snapSecs = BEAT_SNAP_PX / Math.max(ppsRef.current, 1);
336351
if (resize.edge === "end") {
337352
const edgeTime = nextResize.start + nextResize.duration;

0 commit comments

Comments
 (0)