feat(studio): magnetic snapping to clip edges, playhead, and bounds

Timeline moves and resizes previously snapped only to the beat grid; they
now also snap the dragged edge to other clips' start/end, the playhead,
and the composition bounds, whichever is nearest within the existing
threshold. Beats fold in as one target kind, so beat snapping is
unchanged. Snap logic lives in a new pure timelineSnapTargets module with
unit tests; a snapped edge shows a guide line (beat highlight, else a thin
accent line) for both drag and resize.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:27 -04:00
parent 6b4870246c
commit d7e7fca8ec
6 changed files with 385 additions and 116 deletions
@@ -28,13 +28,17 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
beatTimes: number[] | undefined;
beatStrengths: number[] | undefined;
pps: number;
/** Beat time a dragged clip will snap to — drawn as a bright neon line. */
/** Snap guide time — drawn as a bright line even when it is not a beat. */
highlightTime?: number | null;
}) {
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null;
const highlightIsBeat =
highlightTime != null &&
visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true;
if (!visibleBeatTimes && highlightTime == null) return null;
return (
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
{beatTimes.map((t, i) => {
{visibleBeatTimes?.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;
@@ -52,6 +56,18 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
/>
);
})}
{highlightTime != null && !highlightIsBeat && (
<div
className="absolute top-0 bottom-0"
style={{
left: highlightTime * pps,
width: 2,
background: "rgba(34,197,94,1)",
boxShadow: "0 0 6px rgba(34,197,94,0.9)",
zIndex: 1,
}}
/>
)}
</div>
);
});
@@ -120,7 +120,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
selectedElementId,
hoveredClip,
draggedClip,
resizingClip: _resizingClip,
resizingClip,
blockedClipRef,
suppressClickRef,
scrollRef,
@@ -158,6 +158,11 @@ export const TimelineCanvas = memo(function TimelineCanvas({
onRazorSplitAll,
} = useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging);
const activeSnapGuideTime = draggedClip?.started
? (draggedClip.snapBeatTime ?? draggedClip.snapGuideTime)
: resizingClip?.started
? resizingClip.snapGuideTime
: null;
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
@@ -312,12 +317,12 @@ export const TimelineCanvas = memo(function TimelineCanvas({
<TimelineDropInsertionLine edge={dropIndicator.edge} accentColor={ts.accent} />
)}
{/* Faint beat lines in every track's background (behind the clips);
the active move-snap target is highlighted. */}
the active snap target is highlighted. */}
<BeatBackgroundLines
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
highlightTime={activeSnapGuideTime}
/>
{/* Beat dots on the active track (the one holding the selection),
falling back to the music track when nothing is selected. */}
@@ -394,6 +399,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
previewStart: el.start,
previewDuration: el.duration,
previewPlaybackStart: el.playbackStart,
snapGuideTime: null,
snapGuideKind: null,
started: false,
});
}}
@@ -452,6 +459,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
previewLayerIndex: rowIndex,
previewStackingReorder: null,
snapBeatTime: null,
snapGuideTime: null,
snapGuideKind: null,
started: false,
});
syncClipDragAutoScroll(e.clientX, e.clientY);
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import {
buildTimelineSnapTargets,
snapEdgesToTargets,
snapResizeEdgeToTargets,
} from "./timelineSnapTargets";
function timelineElement(input: {
id: string;
key?: string;
start: number;
duration: number;
}): TimelineElement {
return {
id: input.id,
key: input.key,
tag: "div",
start: input.start,
duration: input.duration,
track: 0,
};
}
describe("buildTimelineSnapTargets", () => {
it("excludes the dragged clip's own edges", () => {
const dragged = timelineElement({
id: "dragged-id",
key: "dragged-key",
start: 1,
duration: 2,
});
const other = timelineElement({ id: "other", start: 4, duration: 2 });
const targets = buildTimelineSnapTargets({
elements: [dragged, other],
draggedKey: "dragged-key",
playhead: 8,
compDuration: 10,
beats: [1.5],
});
const times = targets.map((target) => target.time);
expect(times).not.toContain(1);
expect(times).not.toContain(3);
expect(times).toContain(4);
expect(times).toContain(6);
});
it("dedupes near-equal times from different sources", () => {
const dragged = timelineElement({ id: "dragged", start: 2, duration: 2 });
const other = timelineElement({ id: "other", start: 0.0004, duration: 10 });
const targets = buildTimelineSnapTargets({
elements: [dragged, other],
draggedKey: "dragged",
playhead: 5,
compDuration: 10,
beats: [0.0002, 10.0002],
});
expect(targets.filter((target) => Math.abs(target.time) < 0.001)).toHaveLength(1);
expect(targets.filter((target) => Math.abs(target.time - 10) < 0.001)).toHaveLength(1);
});
});
describe("snapEdgesToTargets", () => {
it("snaps the start edge to another clip's end", () => {
const snap = snapEdgesToTargets(3.95, 2, [{ time: 4, kind: "edge" }], 100);
expect(snap).toEqual({ start: 4, snapTime: 4, snapKind: "edge" });
});
it("snaps the end edge to another clip's start", () => {
const snap = snapEdgesToTargets(2.96, 2, [{ time: 5, kind: "edge" }], 100);
expect(snap).toEqual({ start: 3, snapTime: 5, snapKind: "edge" });
});
it("snaps to the playhead", () => {
const snap = snapEdgesToTargets(2.94, 1, [{ time: 3, kind: "playhead" }], 100);
expect(snap).toEqual({ start: 3, snapTime: 3, snapKind: "playhead" });
});
it("snaps the start edge to the lower composition bound", () => {
const snap = snapEdgesToTargets(0.04, 1, [{ time: 0, kind: "bound" }], 100);
expect(snap).toEqual({ start: 0, snapTime: 0, snapKind: "bound" });
});
it("snaps the end edge to the upper composition bound", () => {
const snap = snapEdgesToTargets(7.96, 2, [{ time: 10, kind: "bound" }], 100);
expect(snap).toEqual({ start: 8, snapTime: 10, snapKind: "bound" });
});
it("does not snap when targets are beyond the pixel threshold", () => {
const snap = snapEdgesToTargets(3.9, 1, [{ time: 4, kind: "edge" }], 100);
expect(snap).toEqual({ start: 3.9, snapTime: null, snapKind: null });
});
});
describe("snapResizeEdgeToTargets", () => {
it("does not apply end-edge snaps past maxEnd or below minDuration", () => {
expect(
snapResizeEdgeToTargets("end", 4, 2.95, [{ time: 7.01, kind: "edge" }], 100, {
minDuration: 0.05,
maxEnd: 7,
}),
).toEqual({ start: 4, duration: 2.95, snapTime: null, snapKind: null });
expect(
snapResizeEdgeToTargets("end", 4, 0.1, [{ time: 4.03, kind: "edge" }], 100, {
minDuration: 0.05,
maxEnd: 10,
}),
).toEqual({ start: 4, duration: 0.1, snapTime: null, snapKind: null });
});
});
@@ -0,0 +1,163 @@
import type { TimelineElement } from "../store/playerStore";
export type TimelineSnapKind = "beat" | "edge" | "playhead" | "bound";
export interface TimelineSnapTarget {
time: number;
kind: TimelineSnapKind;
}
interface NearestSnap {
target: TimelineSnapTarget;
distance: number;
}
const SNAP_PX = 8;
const DEDUPE_EPSILON_SECONDS = 0.001;
const ROUND_FACTOR = 1000;
const KIND_PRIORITY: Record<TimelineSnapKind, number> = {
bound: 0,
playhead: 1,
edge: 2,
beat: 3,
};
function roundToMillis(value: number): number {
return Math.round(value * ROUND_FACTOR) / ROUND_FACTOR;
}
function addTarget(targets: TimelineSnapTarget[], candidate: TimelineSnapTarget) {
if (!Number.isFinite(candidate.time)) return;
const existingIndex = targets.findIndex(
(target) => Math.abs(target.time - candidate.time) < DEDUPE_EPSILON_SECONDS,
);
if (existingIndex === -1) {
targets.push(candidate);
return;
}
const existing = targets[existingIndex];
if (!existing || KIND_PRIORITY[candidate.kind] >= KIND_PRIORITY[existing.kind]) return;
targets[existingIndex] = candidate;
}
export function buildTimelineSnapTargets(input: {
elements: TimelineElement[];
draggedKey: string;
playhead: number;
compDuration: number;
beats: number[];
}): TimelineSnapTarget[] {
const targets: TimelineSnapTarget[] = [];
addTarget(targets, { time: 0, kind: "bound" });
addTarget(targets, { time: Math.max(0, input.compDuration), kind: "bound" });
addTarget(targets, { time: Math.max(0, input.playhead), kind: "playhead" });
for (const element of input.elements) {
const elementKey = element.key ?? element.id;
if (elementKey === input.draggedKey || element.id === input.draggedKey) continue;
addTarget(targets, { time: element.start, kind: "edge" });
addTarget(targets, { time: element.start + element.duration, kind: "edge" });
}
for (const beat of input.beats) {
addTarget(targets, { time: beat, kind: "beat" });
}
return targets.sort((a, b) => a.time - b.time || KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind]);
}
function nearestSnap(
time: number,
targets: TimelineSnapTarget[],
thresholdSeconds: number,
): NearestSnap | null {
let best: NearestSnap | null = null;
let bestDistance = thresholdSeconds;
for (const target of targets) {
if (target.time === time) continue;
const distance = Math.abs(target.time - time);
if (distance < bestDistance) {
bestDistance = distance;
best = { target, distance };
}
}
return best;
}
export function snapEdgesToTargets(
start: number,
duration: number,
targets: TimelineSnapTarget[],
pixelsPerSecond: number,
options?: { maxStart?: number },
): { start: number; snapTime: number | null; snapKind: TimelineSnapKind | null } {
const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1);
const startSnap = nearestSnap(start, targets, thresholdSeconds);
const endSnap = nearestSnap(start + duration, targets, thresholdSeconds);
let candidate = start;
let snapTarget: TimelineSnapTarget | null = null;
if (startSnap && (!endSnap || startSnap.distance <= endSnap.distance)) {
candidate = startSnap.target.time;
snapTarget = startSnap.target;
} else if (endSnap) {
candidate = endSnap.target.time - duration;
snapTarget = endSnap.target;
}
const maxStart = options?.maxStart ?? Number.POSITIVE_INFINITY;
const upperStart = Number.isFinite(maxStart) ? Math.max(0, maxStart) : Number.POSITIVE_INFINITY;
const clamped = Math.max(0, Math.min(upperStart, roundToMillis(candidate)));
if (snapTarget && Math.abs(clamped - candidate) > 1e-6) {
return { start: clamped, snapTime: null, snapKind: null };
}
return {
start: clamped,
snapTime: snapTarget?.time ?? null,
snapKind: snapTarget?.kind ?? null,
};
}
export function snapResizeEdgeToTargets(
edge: "start" | "end",
start: number,
duration: number,
targets: TimelineSnapTarget[],
pixelsPerSecond: number,
limits: { minDuration: number; maxEnd: number; maxLeftDelta?: number },
): { start: number; duration: number; snapTime: number | null; snapKind: TimelineSnapKind | null } {
const thresholdSeconds = SNAP_PX / Math.max(pixelsPerSecond, 1);
if (edge === "end") {
const snap = nearestSnap(start + duration, targets, thresholdSeconds);
if (!snap) return { start, duration, snapTime: null, snapKind: null };
const snappedDuration = roundToMillis(snap.target.time - start);
if (snap.target.time > limits.maxEnd + 1e-6 || snappedDuration < limits.minDuration) {
return { start, duration, snapTime: null, snapKind: null };
}
return {
start,
duration: snappedDuration,
snapTime: snap.target.time,
snapKind: snap.target.kind,
};
}
const snap = nearestSnap(start, targets, thresholdSeconds);
if (!snap) return { start, duration, snapTime: null, snapKind: null };
const snappedStart = roundToMillis(snap.target.time);
const delta = start - snappedStart;
const snappedDuration = roundToMillis(duration + delta);
const maxLeftDelta = limits.maxLeftDelta ?? Number.POSITIVE_INFINITY;
if (snappedStart < 0 || delta > maxLeftDelta + 1e-6 || snappedDuration < limits.minDuration) {
return { start, duration, snapTime: null, snapKind: null };
}
return {
start: snappedStart,
duration: snappedDuration,
snapTime: snap.target.time,
snapKind: snap.target.kind,
};
}
@@ -102,6 +102,8 @@ function renderDragHarness(elements: TimelineElement[]) {
previewLayerIndex: layerIndex,
previewStackingReorder: null,
snapBeatTime: null,
snapGuideTime: null,
snapGuideKind: null,
started: false,
});
});
@@ -115,6 +117,8 @@ function renderDragHarness(elements: TimelineElement[]) {
previewStart: element.start,
previewDuration: element.duration,
previewPlaybackStart: element.playbackStart,
snapGuideTime: null,
snapGuideKind: null,
started: false,
});
});
@@ -13,63 +13,15 @@ import { TRACK_H } from "./timelineLayout";
import { isMusicTrack } from "../../utils/timelineInspector";
import { mergeUserBeats } from "../../utils/beatEditing";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
import {
buildTimelineSnapTargets,
snapEdgesToTargets,
snapResizeEdgeToTargets,
type TimelineSnapKind,
} from "./timelineSnapTargets";
const BEAT_SNAP_PX = 8;
const EMPTY_BEAT_TIMES: number[] = [];
function snapToNearestBeat(time: number, beatTimes: number[], thresholdSecs: number): number {
let best = time;
let bestDist = thresholdSecs;
for (const bt of beatTimes) {
const d = Math.abs(bt - time);
if (d < bestDist) {
bestDist = d;
best = bt;
}
}
return best;
}
/**
* Snap a moved clip so whichever edge (start or end) is nearest a beat lands on
* it, keeping the duration fixed. Returns the (clamped) start plus the beat time
* it snapped to (for the grid-line highlight), or `beat: null` when no edge is
* within threshold.
*/
function snapMoveStartToBeat(
start: number,
duration: number,
beatTimes: number[],
pixelsPerSecond: number,
timelineDuration: number,
): { start: number; beat: number | null } {
if (beatTimes.length === 0) return { start, beat: null };
const snapSecs = BEAT_SNAP_PX / Math.max(pixelsPerSecond, 1);
const snappedStart = snapToNearestBeat(start, beatTimes, snapSecs);
const snappedEnd = snapToNearestBeat(start + duration, beatTimes, snapSecs);
const startMoved = snappedStart !== start;
const endMoved = snappedEnd !== start + duration;
let candidate = start;
let beat: number | null = null;
if (
startMoved &&
(!endMoved || Math.abs(snappedStart - start) <= Math.abs(snappedEnd - (start + duration)))
) {
candidate = snappedStart;
beat = snappedStart;
} else if (endMoved) {
candidate = snappedEnd - duration;
beat = snappedEnd;
}
const maxStart = Math.max(0, timelineDuration - duration);
const clamped = Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000));
// If clamping pulled the clip off the snap target, drop the highlight.
if (beat != null && Math.abs(clamped - candidate) > 1e-6) beat = null;
return { start: clamped, beat };
}
/* ── Shared state types ─────────────────────────────────────────── */
export interface DraggedClipState {
element: TimelineElement;
@@ -87,6 +39,8 @@ export interface DraggedClipState {
previewLayerIndex: number;
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
snapBeatTime: number | null;
snapGuideTime: number | null;
snapGuideKind: TimelineSnapKind | null;
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
previewStackingReorder: TimelineStackingReorderIntent | null;
started: boolean;
@@ -99,6 +53,8 @@ export interface ResizingClipState {
previewStart: number;
previewDuration: number;
previewPlaybackStart?: number;
snapGuideTime: number | null;
snapGuideKind: TimelineSnapKind | null;
started: boolean;
}
@@ -149,6 +105,8 @@ export function useTimelineClipDrag({
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
const beatEdits = usePlayerStore((s) => s.beatEdits);
const playhead = usePlayerStore((s) => s.currentTime);
const compositionDuration = usePlayerStore((s) => s.duration);
const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
const musicPlaybackStart = usePlayerStore(
(s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
@@ -176,6 +134,22 @@ export function useTimelineClipDrag({
const beatTimesRef = useRef<number[]>([]);
beatTimesRef.current = adjustedBeatTimes;
const playheadRef = useRef(0);
playheadRef.current = playhead;
const compositionDurationRef = useRef(0);
compositionDurationRef.current = compositionDuration;
const buildSnapTargets = useCallback(
(element: TimelineElement) =>
buildTimelineSnapTargets({
elements: timelineElementsRef.current,
draggedKey: element.key ?? element.id,
playhead: playheadRef.current,
compDuration: compositionDurationRef.current,
beats: isMusicTrack(element) ? EMPTY_BEAT_TIMES : beatTimesRef.current,
}),
[timelineElementsRef],
);
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
const draggedClipRef = useRef<DraggedClipState | null>(null);
@@ -222,16 +196,13 @@ export function useTimelineClipDrag({
clientX,
clientY,
);
// The music track defines the beats, so it must not snap to itself.
const snap = isMusicTrack(drag.element)
? { start: nextMove.start, beat: null }
: snapMoveStartToBeat(
nextMove.start,
drag.element.duration,
beatTimesRef.current,
ppsRef.current,
Number.POSITIVE_INFINITY,
);
const snap = snapEdgesToTargets(
nextMove.start,
drag.element.duration,
buildSnapTargets(drag.element),
ppsRef.current,
{ maxStart: Number.POSITIVE_INFINITY },
);
return {
...drag,
started: true,
@@ -242,10 +213,12 @@ export function useTimelineClipDrag({
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
previewStackingReorder: nextMove.stackingReorder ?? null,
snapBeatTime: snap.beat,
snapBeatTime: snap.snapKind === "beat" ? snap.snapTime : null,
snapGuideTime: snap.snapTime,
snapGuideKind: snap.snapKind,
};
},
[scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef],
[scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef, buildSnapTargets],
);
const stopClipDragAutoScroll = useCallback(() => {
@@ -359,51 +332,32 @@ export function useTimelineClipDrag({
e.clientX,
);
// Snap edge to beat grid when beat analysis is available. The snap must
// stay inside the same limits resolveTimelineResize enforces, or it would
// push the edge past the available source media / composition end.
// The music track defines the beats, so it must not snap to itself.
const beatTimes = beatTimesRef.current;
if (beatTimes.length > 0 && !isMusicTrack(resize.element)) {
const snapSecs = BEAT_SNAP_PX / Math.max(ppsRef.current, 1);
if (resize.edge === "end") {
const edgeTime = nextResize.start + nextResize.duration;
const snapped = snapToNearestBeat(edgeTime, beatTimes, snapSecs);
// Stay within [start+minDuration, maxEnd] so the snap can't create a
// degenerate clip or run past the source/composition limit.
const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
nextResize = { ...nextResize, duration: snappedDuration };
}
} else {
const snapped = snapToNearestBeat(nextResize.start, beatTimes, snapSecs);
const delta = nextResize.start - snapped; // >0 when snapping left
// Leftward snap reveals more source; cap so playbackStart can't go < 0.
const maxLeftDelta =
nextResize.playbackStart != null
const snap = snapResizeEdgeToTargets(
resize.edge,
nextResize.start,
nextResize.duration,
buildSnapTargets(resize.element),
ppsRef.current,
{
minDuration: 0.05,
maxEnd,
maxLeftDelta:
resize.edge === "start" && nextResize.playbackStart != null
? nextResize.playbackStart / playbackRate
: Number.POSITIVE_INFINITY;
// Also require the resulting duration to stay >= minDuration so a
// rightward snap (delta < 0) can't collapse the clip to zero/negative.
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
if (
snapped !== nextResize.start &&
snapped >= 0 &&
delta <= maxLeftDelta + 1e-6 &&
snappedDuration >= 0.05
) {
nextResize = {
...nextResize,
start: snapped,
duration: snappedDuration,
playbackStart:
nextResize.playbackStart != null
? Math.round(
Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000,
) / 1000
: undefined,
};
}
: Number.POSITIVE_INFINITY,
},
);
if (snap.snapTime != null) {
const unsnappedStart = nextResize.start;
nextResize = { ...nextResize, start: snap.start, duration: snap.duration };
if (resize.edge === "start" && nextResize.playbackStart != null) {
const delta = unsnappedStart - snap.start;
nextResize = {
...nextResize,
playbackStart:
Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
1000,
};
}
}
@@ -415,6 +369,8 @@ export function useTimelineClipDrag({
previewStart: nextResize.start,
previewDuration: nextResize.duration,
previewPlaybackStart: nextResize.playbackStart,
snapGuideTime: snap.snapTime,
snapGuideKind: snap.snapKind,
}
: prev,
);