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,
);