mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user