diff --git a/packages/studio/src/player/components/BeatStrip.tsx b/packages/studio/src/player/components/BeatStrip.tsx index dc78e58fa..0caa0e326 100644 --- a/packages/studio/src/player/components/BeatStrip.tsx +++ b/packages/studio/src/player/components/BeatStrip.tsx @@ -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 (
- {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 && ( +
+ )}
); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 0c292abea..196678b3f 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -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({ )} {/* Faint beat lines in every track's background (behind the clips); - the active move-snap target is highlighted. */} + the active snap target is highlighted. */} {/* 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); diff --git a/packages/studio/src/player/components/timelineSnapTargets.test.ts b/packages/studio/src/player/components/timelineSnapTargets.test.ts new file mode 100644 index 000000000..534dded58 --- /dev/null +++ b/packages/studio/src/player/components/timelineSnapTargets.test.ts @@ -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 }); + }); +}); diff --git a/packages/studio/src/player/components/timelineSnapTargets.ts b/packages/studio/src/player/components/timelineSnapTargets.ts new file mode 100644 index 000000000..10bdfa963 --- /dev/null +++ b/packages/studio/src/player/components/timelineSnapTargets.ts @@ -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 = { + 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, + }; +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index 58d113ba5..0c71c0ffb 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -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, }); }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index f7c51d895..1c8aabeb3 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -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([]); 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(null); const draggedClipRef = useRef(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, );