Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions packages/studio/src/player/components/timelineSnapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
TIMELINE_SNAP_PX,
collectTimelineSnapTargets,
snapMoveToTargets,
snapTimelineTime,
} from "./timelineSnapping";

describe("collectTimelineSnapTargets", () => {
const elements = [
{ start: 2, duration: 3, key: "a", id: "a" },
{ start: 10, duration: 1.5, key: "b", id: "b" },
];

it("collects clip starts and ends, playhead, and beats with types", () => {
const targets = collectTimelineSnapTargets({
elements,
playheadTime: 7.25,
beatTimes: [0.5, 1.0],
});
expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
expect(targets).toContainEqual({ time: 5, type: "clip-edge" });
expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
expect(targets).toContainEqual({ time: 11.5, type: "clip-edge" });
expect(targets).toContainEqual({ time: 7.25, type: "playhead" });
expect(targets).toContainEqual({ time: 0.5, type: "beat" });
});

it("excludes the dragged element's own edges", () => {
const targets = collectTimelineSnapTargets({
elements,
playheadTime: null,
beatTimes: [],
excludeElementKey: "a",
});
expect(targets.some((t) => t.time === 2)).toBe(false);
expect(targets.some((t) => t.time === 5)).toBe(false);
expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
});

it("omits playhead when null and dedupes identical times preferring playhead > clip-edge > beat", () => {
const targets = collectTimelineSnapTargets({
elements: [{ start: 1, duration: 1, key: "x", id: "x" }],
playheadTime: 2,
beatTimes: [2],
});
const atTwo = targets.filter((t) => t.time === 2);
expect(atTwo).toEqual([{ time: 2, type: "playhead" }]);
});

it("dedupes a coincident clip-edge and beat preferring clip-edge (no playhead)", () => {
// A beat and a clip edge land on the same time (2). clip-edge has higher
// priority than beat, so the deduped target is clip-edge — not beat.
const targets = collectTimelineSnapTargets({
elements: [{ start: 2, duration: 3, key: "x", id: "x" }],
playheadTime: null,
beatTimes: [2],
});
const atTwo = targets.filter((t) => t.time === 2);
expect(atTwo).toEqual([{ time: 2, type: "clip-edge" }]);
});
});

describe("snapTimelineTime", () => {
const targets = [
{ time: 5, type: "clip-edge" as const },
{ time: 5.3, type: "playhead" as const },
];

it("snaps to the nearest target within threshold", () => {
expect(snapTimelineTime(5.05, targets, 0.1)).toEqual({
time: 5,
target: { time: 5, type: "clip-edge" },
});
});

it("returns input unchanged when nothing is within threshold", () => {
expect(snapTimelineTime(6, targets, 0.1)).toEqual({ time: 6, target: null });
});
});

describe("snapMoveToTargets", () => {
// pps=100 → threshold = TIMELINE_SNAP_PX/100 = 0.08s
const targets = [{ time: 5, type: "playhead" as const }];

it("snaps the start edge when it is the closer edge", () => {
const r = snapMoveToTargets(5.05, 2, targets, 100, 60);
expect(r).toEqual({ start: 5, snapTime: 5, snapType: "playhead" });
});

it("snaps the end edge, shifting start so the end lands on the target", () => {
const r = snapMoveToTargets(3.03, 2, targets, 100, 60);
expect(r.start).toBeCloseTo(3, 5);
expect(r.snapTime).toBe(5);
expect(r.snapType).toBe("playhead");
});

it("drops the snap when clamping to timeline bounds pulls it off target", () => {
// duration 2, timeline 6 → maxStart 4; target at 5.05 wants start 5.05 → clamped to 4
const r = snapMoveToTargets(5.0, 2, [{ time: 5.05, type: "beat" }], 100, 6);
expect(r.snapTime).toBeNull();
});

it("threshold scales with pixels-per-second", () => {
// pps=10 → threshold 0.8s: 5.5 snaps; pps=1000 → threshold 0.008s: it does not
expect(snapMoveToTargets(5.5, 2, targets, 10, 60).snapTime).toBe(5);
expect(snapMoveToTargets(5.5, 2, targets, 1000, 60).snapTime).toBeNull();
});

it("TIMELINE_SNAP_PX matches the historical beat-snap threshold", () => {
expect(TIMELINE_SNAP_PX).toBe(8);
});

it("keeps the snap indicator for a frame-quantized duration (ms-rounding residue, no clamp)", () => {
// duration 10/3 ≈ 3.3333…; end-snap onto a clip-edge at 5 gives a candidate
// start of 5 - 10/3 = 1.6666…, whose ms-rounding residue (~3.3e-4) exceeds the
// old 1e-6 tolerance. With a huge timeline (no bounds clamp), the snap must
// survive — the clip snaps AND the indicator shows.
const duration = 10 / 3;
const edge = [{ time: 5, type: "clip-edge" as const }];
const r = snapMoveToTargets(5 - duration + 0.001, duration, edge, 100, 1000);
expect(r.snapTime).toBe(5);
expect(r.snapType).toBe("clip-edge");
});

it("still drops the snap when the bounds clamp genuinely moves a frame-quantized clip off target", () => {
// Same 10/3 duration, but the timeline is short enough that clamping to maxStart
// pulls the clip off the target — the indicator must still vanish (the residue
// widening must not mask a real clamp).
const duration = 10 / 3;
const r = snapMoveToTargets(5.0, duration, [{ time: 5.05, type: "beat" }], 100, 6);
expect(r.snapTime).toBeNull();
});
});
110 changes: 110 additions & 0 deletions packages/studio/src/player/components/timelineSnapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { TimelineElement } from "../store/playerStore";

export type TimelineSnapType = "beat" | "playhead" | "clip-edge";

export interface TimelineSnapTarget {
time: number;
type: TimelineSnapType;
}

/** Pixel radius within which a time snaps to a target (matches historical beat snap). */
export const TIMELINE_SNAP_PX = 8;

const TYPE_PRIORITY: Record<TimelineSnapType, number> = {
playhead: 0,
"clip-edge": 1,
beat: 2,
};

export function collectTimelineSnapTargets(input: {
elements: ReadonlyArray<Pick<TimelineElement, "start" | "duration" | "key" | "id">>;
playheadTime: number | null;
beatTimes: readonly number[];
excludeElementKey?: string | null;
}): TimelineSnapTarget[] {
const byTime = new Map<number, TimelineSnapTarget>();
const add = (time: number, type: TimelineSnapType) => {
if (!Number.isFinite(time) || time < 0) return;
const rounded = Math.round(time * 1000) / 1000;
const existing = byTime.get(rounded);
if (!existing || TYPE_PRIORITY[type] < TYPE_PRIORITY[existing.type]) {
byTime.set(rounded, { time: rounded, type });
}
};

for (const beat of input.beatTimes) add(beat, "beat");
for (const el of input.elements) {
if (input.excludeElementKey != null && (el.key ?? el.id) === input.excludeElementKey) continue;
add(el.start, "clip-edge");
add(el.start + el.duration, "clip-edge");
}
if (input.playheadTime != null) add(input.playheadTime, "playhead");

return Array.from(byTime.values()).sort((a, b) => a.time - b.time);
}

export function snapTimelineTime(
time: number,
targets: readonly TimelineSnapTarget[],
thresholdSecs: number,
): { time: number; target: TimelineSnapTarget | null } {
let best: TimelineSnapTarget | null = null;
let bestDist = thresholdSecs;
for (const target of targets) {
const d = Math.abs(target.time - time);
if (
d < bestDist ||
(d === bestDist && best && TYPE_PRIORITY[target.type] < TYPE_PRIORITY[best.type])
) {
bestDist = d;
best = target;
}
}
return best ? { time: best.time, target: best } : { time, target: null };
}

/**
* Snap a moved clip so whichever edge (start or end) is nearest a target lands
* on it, keeping duration fixed. Mirrors the historical beat-snap semantics:
* clamp to [0, timelineDuration - duration]; if clamping pulls the clip off the
* target, drop the highlight.
*/
export function snapMoveToTargets(
start: number,
duration: number,
targets: readonly TimelineSnapTarget[],
pixelsPerSecond: number,
timelineDuration: number,
): { start: number; snapTime: number | null; snapType: TimelineSnapType | null } {
if (targets.length === 0) return { start, snapTime: null, snapType: null };
const thresholdSecs = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
const startSnap = snapTimelineTime(start, targets, thresholdSecs);
const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs);
const startMoved = startSnap.target !== null;
const endMoved = endSnap.target !== null;

let candidate = start;
let target: TimelineSnapTarget | null = null;
if (
startMoved &&
(!endMoved || Math.abs(startSnap.time - start) <= Math.abs(endSnap.time - (start + duration)))
) {
candidate = startSnap.time;
target = startSnap.target;
} else if (endMoved) {
candidate = endSnap.time - duration;
target = endSnap.target;
}

const maxStart = Math.max(0, timelineDuration - duration);
// Round the candidate to ms FIRST, then compare the clamp against that rounded
// value — not the raw candidate. A frame-quantized duration (e.g. 1/30s, 10/3s)
// leaves sub-ms residue after rounding that exceeds a 1e-6 tolerance, so comparing
// the clamp to the raw candidate dropped the snap-line indicator on every snap
// even though no clamping happened. Comparing against the rounded candidate makes
// the residue exactly 0 unless the timeline-bounds clamp actually moved the clip.
const roundedCandidate = Math.round(candidate * 1000) / 1000;
const clamped = Math.max(0, Math.min(maxStart, roundedCandidate));
if (target && Math.abs(clamped - roundedCandidate) > 1e-6) target = null;
return { start: clamped, snapTime: target?.time ?? null, snapType: target?.type ?? null };
}
Loading