@hyperframes/studio 0.7.53 → 0.7.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/assets/{index-CJpl5RTK.js → index-BRwkMj0w.js} +108 -108
  2. package/dist/assets/{index-DHrXh-VF.js → index-BXaqaVKt.js} +1 -1
  3. package/dist/assets/{index-Bo8sRL2U.js → index-CPetwHFV.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist/index.js +48 -3
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -7
  8. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  9. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  10. package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
  11. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  12. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  13. package/src/hooks/useRazorSplit.history.test.tsx +173 -0
  14. package/src/player/components/timelineCollision.test.ts +437 -0
  15. package/src/player/components/timelineCollision.ts +263 -0
  16. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  17. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  18. package/src/player/components/timelineSnapping.test.ts +134 -0
  19. package/src/player/components/timelineSnapping.ts +110 -0
  20. package/src/player/components/timelineStackingSync.test.ts +244 -0
  21. package/src/player/components/timelineStackingSync.ts +331 -0
  22. package/src/player/components/timelineZones.test.ts +425 -0
  23. package/src/player/components/timelineZones.ts +198 -0
  24. package/src/player/components/timelineZoom.test.ts +67 -0
  25. package/src/player/components/timelineZoom.ts +51 -0
  26. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  27. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  28. package/src/utils/assetClickBehavior.test.ts +104 -0
  29. package/src/utils/assetClickBehavior.ts +75 -0
  30. package/src/utils/canvasNudgeGate.test.ts +31 -0
  31. package/src/utils/canvasNudgeGate.ts +32 -0
  32. package/src/utils/studioUiPreferences.test.ts +38 -0
  33. package/src/utils/studioUiPreferences.ts +27 -0
  34. package/src/utils/timelineInspector.test.ts +121 -0
  35. package/src/utils/timelineInspector.ts +32 -1
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ clampGroupMoveDelta,
4
+ isMultiDragActive,
5
+ isMultiDragPassenger,
6
+ multiDragDeltaSeconds,
7
+ multiDragPassengerOffsetPx,
8
+ type MultiDragPreviewInput,
9
+ } from "./timelineMultiDragPreview";
10
+
11
+ const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput => ({
12
+ dragStarted: true,
13
+ draggedKey: "a",
14
+ draggedOriginStart: 2,
15
+ draggedPreviewStart: 5,
16
+ selectedKeys: new Set(["a", "b", "c"]),
17
+ ...over,
18
+ });
19
+
20
+ describe("isMultiDragActive", () => {
21
+ it("is active when a started drag's clip is part of a 2+ selection", () => {
22
+ expect(isMultiDragActive(base())).toBe(true);
23
+ });
24
+
25
+ it("is inactive before the drag starts", () => {
26
+ expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
27
+ });
28
+
29
+ it("is inactive for a single-clip selection (single-drag behavior)", () => {
30
+ expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
31
+ });
32
+
33
+ it("is inactive when the dragged clip is not itself selected", () => {
34
+ expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("multiDragDeltaSeconds (the one formation delta)", () => {
39
+ it("is the grabbed clip's preview − origin start when active", () => {
40
+ // The preview start is already group-clamped upstream, so this delta is the
41
+ // clamped delta every member (ghost + passengers) moves by.
42
+ expect(multiDragDeltaSeconds(base())).toBe(3);
43
+ });
44
+
45
+ it("supports a leftward (negative) delta", () => {
46
+ expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
47
+ });
48
+
49
+ it("is zero when no multi-drag is active", () => {
50
+ expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
51
+ });
52
+ });
53
+
54
+ describe("isMultiDragPassenger", () => {
55
+ it("marks a selected non-dragged clip as a passenger", () => {
56
+ expect(isMultiDragPassenger("b", base())).toBe(true);
57
+ expect(isMultiDragPassenger("c", base())).toBe(true);
58
+ });
59
+
60
+ it("never marks the dragged clip itself (it is the free ghost)", () => {
61
+ expect(isMultiDragPassenger("a", base())).toBe(false);
62
+ });
63
+
64
+ it("never marks an unselected clip", () => {
65
+ expect(isMultiDragPassenger("d", base())).toBe(false);
66
+ });
67
+
68
+ it("marks nothing when the drag is a single-drag", () => {
69
+ const single = base({ selectedKeys: new Set(["a"]) });
70
+ expect(isMultiDragPassenger("b", single)).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
75
+ it("converts the one formation delta to pixels for every passenger", () => {
76
+ // Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
77
+ expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
78
+ expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
79
+ });
80
+
81
+ it("is zero for the dragged clip and for non-passengers", () => {
82
+ expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
83
+ expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
84
+ });
85
+
86
+ it("is zero for a non-finite pps", () => {
87
+ expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
88
+ });
89
+
90
+ it("follows a leftward delta", () => {
91
+ expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
92
+ });
93
+ });
94
+
95
+ describe("clampGroupMoveDelta (rigid group move)", () => {
96
+ it("passes a rightward delta through unchanged (no right wall)", () => {
97
+ expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
98
+ expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
99
+ });
100
+
101
+ it("passes a leftward delta through when no member would cross 0", () => {
102
+ // Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
103
+ expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
104
+ });
105
+
106
+ it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
107
+ // Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
108
+ // A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
109
+ // formation instead of out-running it.
110
+ expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
111
+ });
112
+
113
+ it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
114
+ // Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
115
+ expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
116
+ });
117
+
118
+ it("already-at-0 member forbids any leftward move", () => {
119
+ expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
120
+ // rightward still allowed
121
+ expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
122
+ });
123
+
124
+ it("returns the raw delta for an empty formation", () => {
125
+ expect(clampGroupMoveDelta(-9, [])).toBe(-9);
126
+ });
127
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pure geometry for the LIVE multi-selection drag preview.
3
+ *
4
+ * Visual model (matches main): while a selected clip is dragged, ALL selected
5
+ * clips move together LIVE as one rigid formation following the cursor. The
6
+ * GRABBED clip is drawn as the free-floating ghost; every OTHER selected member
7
+ * ("passenger") slides by the SAME time delta via a cheap compositor
8
+ * `translateX` (no re-layout). Passengers do NOT stay still, and they do NOT lag
9
+ * behind individually — the whole formation moves by one delta, spacing locked.
10
+ *
11
+ * That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`.
12
+ * The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview
13
+ * runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
14
+ * the instant any member would cross 0 the grabbed clip stops and every passenger
15
+ * stops with it — the formation never deforms. On DROP the commit shifts every
16
+ * selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
17
+ *
18
+ * Track changes apply to the grabbed clip only (mirroring the commit); passengers
19
+ * keep their lanes, so only their x moves.
20
+ */
21
+
22
+ export interface MultiDragPreviewInput {
23
+ /** The drag is live (past the movement threshold). */
24
+ dragStarted: boolean;
25
+ /** Key of the clip under the pointer. */
26
+ draggedKey: string;
27
+ /** The dragged clip's committed start (pre-drag). */
28
+ draggedOriginStart: number;
29
+ /** The dragged clip's live preview start (already group-clamped upstream). */
30
+ draggedPreviewStart: number;
31
+ /** The current multi-selection (store.selectedElementIds). */
32
+ selectedKeys: ReadonlySet<string>;
33
+ }
34
+
35
+ /**
36
+ * Whether a live multi-selection drag is in effect: the drag started, and the
37
+ * dragged clip is itself part of a 2+ multi-selection. Below this, single-drag
38
+ * behavior is unchanged and there are no passengers.
39
+ */
40
+ export function isMultiDragActive(input: MultiDragPreviewInput): boolean {
41
+ return (
42
+ input.dragStarted && input.selectedKeys.size > 1 && input.selectedKeys.has(input.draggedKey)
43
+ );
44
+ }
45
+
46
+ /**
47
+ * The single time delta the WHOLE formation shifts by — the grabbed clip's
48
+ * preview start minus its origin start. Because the preview start is already
49
+ * group-clamped, this is the clamped delta every member (ghost + passengers)
50
+ * moves by. Zero when the clip hasn't moved (or no multi-drag).
51
+ */
52
+ export function multiDragDeltaSeconds(input: MultiDragPreviewInput): number {
53
+ if (!isMultiDragActive(input)) return 0;
54
+ return input.draggedPreviewStart - input.draggedOriginStart;
55
+ }
56
+
57
+ /**
58
+ * Whether a specific rendered clip is a passenger — a selected clip that is NOT
59
+ * the dragged clip and NOT the same clip key. Passengers get the translateX
60
+ * treatment; the dragged clip is drawn as the free-floating ghost instead.
61
+ */
62
+ export function isMultiDragPassenger(clipKey: string, input: MultiDragPreviewInput): boolean {
63
+ return (
64
+ isMultiDragActive(input) && clipKey !== input.draggedKey && input.selectedKeys.has(clipKey)
65
+ );
66
+ }
67
+
68
+ /**
69
+ * The passenger's rendered x offset in PIXELS (delta seconds × pixels/second),
70
+ * to apply as `transform: translateX(...px)`. Every passenger uses the SAME
71
+ * formation delta, so the group moves rigidly. Returns 0 for non-passengers so
72
+ * callers can compute unconditionally and only branch on the elevated styling.
73
+ */
74
+ export function multiDragPassengerOffsetPx(
75
+ clipKey: string,
76
+ pixelsPerSecond: number,
77
+ input: MultiDragPreviewInput,
78
+ ): number {
79
+ if (!isMultiDragPassenger(clipKey, input)) return 0;
80
+ const pps = Number.isFinite(pixelsPerSecond) ? pixelsPerSecond : 0;
81
+ return multiDragDeltaSeconds(input) * pps;
82
+ }
83
+
84
+ /**
85
+ * Clamp a group move so the WHOLE selection moves as ONE rigid formation.
86
+ *
87
+ * The grabbed clip proposes a raw delta (its desired preview start minus its
88
+ * origin start, after its own snapping). Applied naively, a passenger could be
89
+ * pushed below 0 (or past any other member bound), and the commit's per-clip
90
+ * `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
91
+ * the group while a passenger sticks at the wall. This ports main's model
92
+ * (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
93
+ * bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
94
+ * any member hits 0 and the formation never deforms.
95
+ *
96
+ * `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
97
+ * included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
98
+ * no fixed right wall (the composition grows on commit).
99
+ */
100
+ export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number {
101
+ if (memberStarts.length === 0) return rawDelta;
102
+ // Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0.
103
+ const minStart = Math.min(...memberStarts);
104
+ const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
105
+ return rawDelta < minDelta ? minDelta : rawDelta;
106
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ TIMELINE_SNAP_PX,
4
+ collectTimelineSnapTargets,
5
+ snapMoveToTargets,
6
+ snapTimelineTime,
7
+ } from "./timelineSnapping";
8
+
9
+ describe("collectTimelineSnapTargets", () => {
10
+ const elements = [
11
+ { start: 2, duration: 3, key: "a", id: "a" },
12
+ { start: 10, duration: 1.5, key: "b", id: "b" },
13
+ ];
14
+
15
+ it("collects clip starts and ends, playhead, and beats with types", () => {
16
+ const targets = collectTimelineSnapTargets({
17
+ elements,
18
+ playheadTime: 7.25,
19
+ beatTimes: [0.5, 1.0],
20
+ });
21
+ expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
22
+ expect(targets).toContainEqual({ time: 5, type: "clip-edge" });
23
+ expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
24
+ expect(targets).toContainEqual({ time: 11.5, type: "clip-edge" });
25
+ expect(targets).toContainEqual({ time: 7.25, type: "playhead" });
26
+ expect(targets).toContainEqual({ time: 0.5, type: "beat" });
27
+ });
28
+
29
+ it("excludes the dragged element's own edges", () => {
30
+ const targets = collectTimelineSnapTargets({
31
+ elements,
32
+ playheadTime: null,
33
+ beatTimes: [],
34
+ excludeElementKey: "a",
35
+ });
36
+ expect(targets.some((t) => t.time === 2)).toBe(false);
37
+ expect(targets.some((t) => t.time === 5)).toBe(false);
38
+ expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
39
+ });
40
+
41
+ it("omits playhead when null and dedupes identical times preferring playhead > clip-edge > beat", () => {
42
+ const targets = collectTimelineSnapTargets({
43
+ elements: [{ start: 1, duration: 1, key: "x", id: "x" }],
44
+ playheadTime: 2,
45
+ beatTimes: [2],
46
+ });
47
+ const atTwo = targets.filter((t) => t.time === 2);
48
+ expect(atTwo).toEqual([{ time: 2, type: "playhead" }]);
49
+ });
50
+
51
+ it("dedupes a coincident clip-edge and beat preferring clip-edge (no playhead)", () => {
52
+ // A beat and a clip edge land on the same time (2). clip-edge has higher
53
+ // priority than beat, so the deduped target is clip-edge — not beat.
54
+ const targets = collectTimelineSnapTargets({
55
+ elements: [{ start: 2, duration: 3, key: "x", id: "x" }],
56
+ playheadTime: null,
57
+ beatTimes: [2],
58
+ });
59
+ const atTwo = targets.filter((t) => t.time === 2);
60
+ expect(atTwo).toEqual([{ time: 2, type: "clip-edge" }]);
61
+ });
62
+ });
63
+
64
+ describe("snapTimelineTime", () => {
65
+ const targets = [
66
+ { time: 5, type: "clip-edge" as const },
67
+ { time: 5.3, type: "playhead" as const },
68
+ ];
69
+
70
+ it("snaps to the nearest target within threshold", () => {
71
+ expect(snapTimelineTime(5.05, targets, 0.1)).toEqual({
72
+ time: 5,
73
+ target: { time: 5, type: "clip-edge" },
74
+ });
75
+ });
76
+
77
+ it("returns input unchanged when nothing is within threshold", () => {
78
+ expect(snapTimelineTime(6, targets, 0.1)).toEqual({ time: 6, target: null });
79
+ });
80
+ });
81
+
82
+ describe("snapMoveToTargets", () => {
83
+ // pps=100 → threshold = TIMELINE_SNAP_PX/100 = 0.08s
84
+ const targets = [{ time: 5, type: "playhead" as const }];
85
+
86
+ it("snaps the start edge when it is the closer edge", () => {
87
+ const r = snapMoveToTargets(5.05, 2, targets, 100, 60);
88
+ expect(r).toEqual({ start: 5, snapTime: 5, snapType: "playhead" });
89
+ });
90
+
91
+ it("snaps the end edge, shifting start so the end lands on the target", () => {
92
+ const r = snapMoveToTargets(3.03, 2, targets, 100, 60);
93
+ expect(r.start).toBeCloseTo(3, 5);
94
+ expect(r.snapTime).toBe(5);
95
+ expect(r.snapType).toBe("playhead");
96
+ });
97
+
98
+ it("drops the snap when clamping to timeline bounds pulls it off target", () => {
99
+ // duration 2, timeline 6 → maxStart 4; target at 5.05 wants start 5.05 → clamped to 4
100
+ const r = snapMoveToTargets(5.0, 2, [{ time: 5.05, type: "beat" }], 100, 6);
101
+ expect(r.snapTime).toBeNull();
102
+ });
103
+
104
+ it("threshold scales with pixels-per-second", () => {
105
+ // pps=10 → threshold 0.8s: 5.5 snaps; pps=1000 → threshold 0.008s: it does not
106
+ expect(snapMoveToTargets(5.5, 2, targets, 10, 60).snapTime).toBe(5);
107
+ expect(snapMoveToTargets(5.5, 2, targets, 1000, 60).snapTime).toBeNull();
108
+ });
109
+
110
+ it("TIMELINE_SNAP_PX matches the historical beat-snap threshold", () => {
111
+ expect(TIMELINE_SNAP_PX).toBe(8);
112
+ });
113
+
114
+ it("keeps the snap indicator for a frame-quantized duration (ms-rounding residue, no clamp)", () => {
115
+ // duration 10/3 ≈ 3.3333…; end-snap onto a clip-edge at 5 gives a candidate
116
+ // start of 5 - 10/3 = 1.6666…, whose ms-rounding residue (~3.3e-4) exceeds the
117
+ // old 1e-6 tolerance. With a huge timeline (no bounds clamp), the snap must
118
+ // survive — the clip snaps AND the indicator shows.
119
+ const duration = 10 / 3;
120
+ const edge = [{ time: 5, type: "clip-edge" as const }];
121
+ const r = snapMoveToTargets(5 - duration + 0.001, duration, edge, 100, 1000);
122
+ expect(r.snapTime).toBe(5);
123
+ expect(r.snapType).toBe("clip-edge");
124
+ });
125
+
126
+ it("still drops the snap when the bounds clamp genuinely moves a frame-quantized clip off target", () => {
127
+ // Same 10/3 duration, but the timeline is short enough that clamping to maxStart
128
+ // pulls the clip off the target — the indicator must still vanish (the residue
129
+ // widening must not mask a real clamp).
130
+ const duration = 10 / 3;
131
+ const r = snapMoveToTargets(5.0, duration, [{ time: 5.05, type: "beat" }], 100, 6);
132
+ expect(r.snapTime).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,110 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+
3
+ export type TimelineSnapType = "beat" | "playhead" | "clip-edge";
4
+
5
+ export interface TimelineSnapTarget {
6
+ time: number;
7
+ type: TimelineSnapType;
8
+ }
9
+
10
+ /** Pixel radius within which a time snaps to a target (matches historical beat snap). */
11
+ export const TIMELINE_SNAP_PX = 8;
12
+
13
+ const TYPE_PRIORITY: Record<TimelineSnapType, number> = {
14
+ playhead: 0,
15
+ "clip-edge": 1,
16
+ beat: 2,
17
+ };
18
+
19
+ export function collectTimelineSnapTargets(input: {
20
+ elements: ReadonlyArray<Pick<TimelineElement, "start" | "duration" | "key" | "id">>;
21
+ playheadTime: number | null;
22
+ beatTimes: readonly number[];
23
+ excludeElementKey?: string | null;
24
+ }): TimelineSnapTarget[] {
25
+ const byTime = new Map<number, TimelineSnapTarget>();
26
+ const add = (time: number, type: TimelineSnapType) => {
27
+ if (!Number.isFinite(time) || time < 0) return;
28
+ const rounded = Math.round(time * 1000) / 1000;
29
+ const existing = byTime.get(rounded);
30
+ if (!existing || TYPE_PRIORITY[type] < TYPE_PRIORITY[existing.type]) {
31
+ byTime.set(rounded, { time: rounded, type });
32
+ }
33
+ };
34
+
35
+ for (const beat of input.beatTimes) add(beat, "beat");
36
+ for (const el of input.elements) {
37
+ if (input.excludeElementKey != null && (el.key ?? el.id) === input.excludeElementKey) continue;
38
+ add(el.start, "clip-edge");
39
+ add(el.start + el.duration, "clip-edge");
40
+ }
41
+ if (input.playheadTime != null) add(input.playheadTime, "playhead");
42
+
43
+ return Array.from(byTime.values()).sort((a, b) => a.time - b.time);
44
+ }
45
+
46
+ export function snapTimelineTime(
47
+ time: number,
48
+ targets: readonly TimelineSnapTarget[],
49
+ thresholdSecs: number,
50
+ ): { time: number; target: TimelineSnapTarget | null } {
51
+ let best: TimelineSnapTarget | null = null;
52
+ let bestDist = thresholdSecs;
53
+ for (const target of targets) {
54
+ const d = Math.abs(target.time - time);
55
+ if (
56
+ d < bestDist ||
57
+ (d === bestDist && best && TYPE_PRIORITY[target.type] < TYPE_PRIORITY[best.type])
58
+ ) {
59
+ bestDist = d;
60
+ best = target;
61
+ }
62
+ }
63
+ return best ? { time: best.time, target: best } : { time, target: null };
64
+ }
65
+
66
+ /**
67
+ * Snap a moved clip so whichever edge (start or end) is nearest a target lands
68
+ * on it, keeping duration fixed. Mirrors the historical beat-snap semantics:
69
+ * clamp to [0, timelineDuration - duration]; if clamping pulls the clip off the
70
+ * target, drop the highlight.
71
+ */
72
+ export function snapMoveToTargets(
73
+ start: number,
74
+ duration: number,
75
+ targets: readonly TimelineSnapTarget[],
76
+ pixelsPerSecond: number,
77
+ timelineDuration: number,
78
+ ): { start: number; snapTime: number | null; snapType: TimelineSnapType | null } {
79
+ if (targets.length === 0) return { start, snapTime: null, snapType: null };
80
+ const thresholdSecs = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
81
+ const startSnap = snapTimelineTime(start, targets, thresholdSecs);
82
+ const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs);
83
+ const startMoved = startSnap.target !== null;
84
+ const endMoved = endSnap.target !== null;
85
+
86
+ let candidate = start;
87
+ let target: TimelineSnapTarget | null = null;
88
+ if (
89
+ startMoved &&
90
+ (!endMoved || Math.abs(startSnap.time - start) <= Math.abs(endSnap.time - (start + duration)))
91
+ ) {
92
+ candidate = startSnap.time;
93
+ target = startSnap.target;
94
+ } else if (endMoved) {
95
+ candidate = endSnap.time - duration;
96
+ target = endSnap.target;
97
+ }
98
+
99
+ const maxStart = Math.max(0, timelineDuration - duration);
100
+ // Round the candidate to ms FIRST, then compare the clamp against that rounded
101
+ // value — not the raw candidate. A frame-quantized duration (e.g. 1/30s, 10/3s)
102
+ // leaves sub-ms residue after rounding that exceeds a 1e-6 tolerance, so comparing
103
+ // the clamp to the raw candidate dropped the snap-line indicator on every snap
104
+ // even though no clamping happened. Comparing against the rounded candidate makes
105
+ // the residue exactly 0 unless the timeline-bounds clamp actually moved the clip.
106
+ const roundedCandidate = Math.round(candidate * 1000) / 1000;
107
+ const clamped = Math.max(0, Math.min(maxStart, roundedCandidate));
108
+ if (target && Math.abs(clamped - roundedCandidate) > 1e-6) target = null;
109
+ return { start: clamped, snapTime: target?.time ?? null, snapType: target?.type ?? null };
110
+ }