From b910ef126e5eee273cdeb66320fcce5b6d65f1c5 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:06 -0700 Subject: [PATCH 1/3] feat(studio): timeline collision and placement model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: new pure module timelineCollision — zone-aware drop placement (clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow, resolvePlacement, lane/overlap predicates) with its full test suite. Why: the no-overlap core of the NLE clip-drag engine; plain functions, no DOM, no React, no store writes. How: new files only; type-only imports from the existing playerStore. First runtime consumer arrives with the drag-engine PRs. Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow audit clean (all exports test-consumed). --- .../components/timelineCollision.test.ts | 437 ++++++++++++++++++ .../player/components/timelineCollision.ts | 263 +++++++++++ 2 files changed, 700 insertions(+) create mode 100644 packages/studio/src/player/components/timelineCollision.test.ts create mode 100644 packages/studio/src/player/components/timelineCollision.ts diff --git a/packages/studio/src/player/components/timelineCollision.test.ts b/packages/studio/src/player/components/timelineCollision.test.ts new file mode 100644 index 0000000000..c1c9b26ca5 --- /dev/null +++ b/packages/studio/src/player/components/timelineCollision.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { + clampTrackToZone, + isInsertAllowedForZone, + isLaneFree, + resolveInsertRow, + resolvePlacement, + resolveZoneDropPlacement, + timeRangesOverlap, +} from "./timelineCollision"; + +function el(id: string, track: number, start: number, duration: number): TimelineElement { + return { id, tag: "video", start, duration, track }; +} + +describe("timeRangesOverlap", () => { + it("detects overlap and treats touching edges as free (half-open)", () => { + expect(timeRangesOverlap(0, 2, 1, 3)).toBe(true); + expect(timeRangesOverlap(0, 2, 2, 4)).toBe(false); // touching at 2 + expect(timeRangesOverlap(2, 4, 0, 2)).toBe(false); + }); +}); + +describe("isLaneFree", () => { + const els = [el("a", 0, 0, 5), el("b", 1, 2, 3)]; + + it("is free when nothing overlaps on the track", () => { + expect(isLaneFree(els, 2, 0, 5, null)).toBe(true); + expect(isLaneFree(els, 0, 6, 8, null)).toBe(true); // same track, no time overlap + }); + + it("is occupied when a clip overlaps on the same track", () => { + expect(isLaneFree(els, 0, 1, 3, null)).toBe(false); + }); + + it("ignores the excluded (dragged) clip", () => { + expect(isLaneFree(els, 0, 1, 3, "a")).toBe(true); + }); +}); + +describe("resolvePlacement", () => { + const trackOrder = [0, 1, 2, 3]; + + it("keeps the desired lane when it is free", () => { + const els = [el("a", 2, 0, 4)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 1, + start: 0, + duration: 4, + trackOrder, + excludeKey: null, + }), + ).toEqual({ + track: 1, + needsInsert: false, + }); + }); + + it("pushes up to the nearest free lane above when the target is occupied", () => { + // desired = 2 occupied; 1 free above → land on 1 + const els = [el("blocker", 2, 0, 4)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 2, + start: 1, + duration: 2, + trackOrder, + excludeKey: null, + }), + ).toEqual({ track: 1, needsInsert: false }); + }); + + it("prefers up even when a lane below is also free", () => { + // desired 2 occupied; both 1 (up) and 3 (down) free → up wins + const els = [el("blocker", 2, 0, 5)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 2, + start: 0, + duration: 3, + trackOrder, + excludeKey: null, + }), + ).toEqual({ track: 1, needsInsert: false }); + }); + + it("falls to a lane below when every lane above is occupied", () => { + // desired 1 occupied; 0 occupied above; 2 free below → land on 2 + const els = [el("x", 0, 0, 5), el("y", 1, 0, 5)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 1, + start: 1, + duration: 2, + trackOrder, + excludeKey: null, + }), + ).toEqual({ track: 2, needsInsert: false }); + }); + + it("signals needsInsert when no lane is free", () => { + const els = [el("a", 0, 0, 9), el("b", 1, 0, 9), el("c", 2, 0, 9), el("d", 3, 0, 9)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 2, + start: 1, + duration: 2, + trackOrder, + excludeKey: null, + }), + ).toEqual({ track: 2, needsInsert: true }); + }); + + it("signals needsInsert when the desired track is occupied but absent from the zone's lanes (#2195)", () => { + // desiredTrack 5 is occupied yet not in trackOrder (its kind-zone has no lane + // here). Landing on it would overlap, so the empty-zone branch must insert — + // not silently land on the occupied track (the placement hole). + const els = [el("blocker", 5, 0, 5)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 5, + start: 1, + duration: 2, + trackOrder: [], + excludeKey: null, + }), + ).toEqual({ track: 5, needsInsert: true }); + }); + + it("signals needsInsert when the desired track is FREE but absent from the zone's lanes (#2195 free-span hole)", () => { + // desiredTrack 5 is FREE (no overlap) yet not in trackOrder — its kind-zone has + // no lane here. The old code short-circuited on isLaneFree and landed on the + // foreign-zone lane; the zone check must win and signal an insert. + const els = [el("elsewhere", 9, 0, 5)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 5, + start: 1, + duration: 2, + trackOrder: [], + excludeKey: null, + }), + ).toEqual({ track: 5, needsInsert: true }); + }); + + it("placeholder-scenario excludes the dragged clip so it does not collide with itself", () => { + const els = [el("self", 1, 0, 5)]; + expect( + resolvePlacement({ + elements: els, + desiredTrack: 1, + start: 0, + duration: 5, + trackOrder, + excludeKey: "self", + }), + ).toEqual({ track: 1, needsInsert: false }); + }); +}); + +describe("resolveInsertRow", () => { + const n = 3; // three lanes: rows 0,1,2 + + it("targets the lane (null) when over its middle band", () => { + expect(resolveInsertRow(1.5, n, 0.22)).toBe(null); // dead center of lane 1 + }); + + it("inserts at the top boundary of a lane when near its top edge", () => { + expect(resolveInsertRow(1.1, n, 0.22)).toBe(1); // just into lane 1 → boundary above it + }); + + it("inserts at the bottom boundary of a lane when near its bottom edge", () => { + expect(resolveInsertRow(1.9, n, 0.22)).toBe(2); // near bottom of lane 1 → boundary below + }); + + it("inserts above the top lane when the pointer is above everything", () => { + expect(resolveInsertRow(-0.5, n, 0.22)).toBe(0); + }); + + it("inserts below the bottom lane when the pointer is past the last lane", () => { + expect(resolveInsertRow(3.4, n, 0.22)).toBe(3); + }); +}); + +// (The production insert-band suite imports INSERT_BOUNDARY_BAND from timelineLayout, +// which lands with the timeline glue swap — the full suite re-lands there.) +describe("clampTrackToZone", () => { + // trackOrder [0,1,2,3]: rows 0,1 = visual; rows 2,3 = audio (audioRow = 2). + const order = [0, 1, 2, 3]; + + it("is a no-op when there is no audio zone", () => { + expect(clampTrackToZone(3, order, -1, false)).toBe(3); + }); + + it("keeps a visual clip in the visual zone", () => { + expect(clampTrackToZone(1, order, 2, false)).toBe(1); // already visual + expect(clampTrackToZone(3, order, 2, false)).toBe(1); // in audio → last visual lane + }); + + it("keeps an audio clip in the audio zone", () => { + expect(clampTrackToZone(2, order, 2, true)).toBe(2); // already audio + expect(clampTrackToZone(0, order, 2, true)).toBe(2); // in visual → first audio lane + }); +}); + +describe("isInsertAllowedForZone", () => { + // audioRow = 2 + it("allows any insert when there is no audio zone", () => { + expect(isInsertAllowedForZone(0, -1, false)).toBe(true); + expect(isInsertAllowedForZone(3, -1, true)).toBe(true); + }); + + it("allows a visual insert only at/above the audio zone top", () => { + expect(isInsertAllowedForZone(0, 2, false)).toBe(true); + expect(isInsertAllowedForZone(2, 2, false)).toBe(true); // bottom of the visual zone + expect(isInsertAllowedForZone(3, 2, false)).toBe(false); // inside the audio zone + }); + + it("allows an audio insert only at/below the audio zone top (audio clips make audio tracks)", () => { + expect(isInsertAllowedForZone(2, 2, true)).toBe(true); + expect(isInsertAllowedForZone(4, 2, true)).toBe(true); // below the bottom + expect(isInsertAllowedForZone(1, 2, true)).toBe(false); // inside the visual zone + }); +}); + +describe("resolveZoneDropPlacement (the whole drop decision, no same-track overlap)", () => { + // order [0,1,2] visual + [3] audio. audioRow = 3. + const order = [0, 1, 2, 3]; + const audioTracks = new Set([3]); + const base = { + order, + audioTracks, + deliberateInsertRow: null as number | null, + start: 2, + duration: 2, + dragKey: "x", + isAudio: false, + }; + + it("lands on the aimed track when it is free at that time", () => { + expect( + resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 10, 3)], desiredTrack: 1 }), + ).toEqual({ track: 1, insertRow: null }); + }); + + it("relocates UP to the nearest free track when the aimed spot overlaps a clip", () => { + expect( + resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 0, 5)], desiredTrack: 1 }), + ).toEqual({ track: 0, insertRow: null }); + }); + + it("relocates DOWN when the tracks above are also occupied", () => { + expect( + resolveZoneDropPlacement({ + ...base, + elements: [el("a", 0, 0, 5), el("b", 1, 0, 5)], + desiredTrack: 1, + }), + ).toEqual({ track: 2, insertRow: null }); + }); + + it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => { + expect( + resolveZoneDropPlacement({ + ...base, + elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)], + desiredTrack: 1, + }), + ).toEqual({ track: 1, insertRow: 2 }); + }); + + // Every visual lane (0,1,2) occupied across the drop span → no free lane exists. + const allVisualOccupied = () => [el("a", 0, 0, 10), el("b", 1, 0, 10), el("c", 2, 0, 10)]; + + it("occupied aim with NO free lane creates an adjacent track — never snaps back (UX rule 3)", () => { + // The clip must NOT return to its origin; it gets a fresh track adjacent to the + // aim. Default bias = below the aimed row. + const result = resolveZoneDropPlacement({ + ...base, + elements: allVisualOccupied(), + desiredTrack: 1, + }); + expect(result.insertRow).not.toBeNull(); // a track is created, not an origin snap-back + expect(result).toEqual({ track: 1, insertRow: 2 }); // adjacent, below the aimed row + }); + + it("opens the new adjacent track ABOVE the aimed row when the pointer is in its upper half (UX rule 3)", () => { + expect( + resolveZoneDropPlacement({ + ...base, + elements: allVisualOccupied(), + desiredTrack: 1, + preferInsertAbove: true, + }), + ).toEqual({ track: 1, insertRow: 1 }); // boundary ABOVE lane 1 + // Same aim, pointer in the lower half → the track opens BELOW the aimed row. + expect( + resolveZoneDropPlacement({ + ...base, + elements: allVisualOccupied(), + desiredTrack: 1, + preferInsertAbove: false, + }), + ).toEqual({ track: 1, insertRow: 2 }); + }); + + it("shares a track for sequential (non-overlapping) clips", () => { + expect( + resolveZoneDropPlacement({ + ...base, + elements: [el("a", 1, 0, 2)], + desiredTrack: 1, + start: 2, + }), + ).toEqual({ track: 1, insertRow: null }); + }); + + it("clamps a visual clip OUT of the audio zone before placing", () => { + expect(resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 3 })).toEqual({ + track: 2, + insertRow: null, + }); + }); + + it("clamps an audio clip INTO the audio zone before placing", () => { + expect( + resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 0, isAudio: true }), + ).toEqual({ track: 3, insertRow: null }); + }); + + it("upward create-drag off the top lane inserts at the TOP of the visual zone, never below audio (reviewer repro)", () => { + // 3-visual + 1-audio timeline. Dragging a top-lane visual clip up past the + // create threshold emits the sentinel desiredTrack = minTrack-1 = -1. The + // old hoist did order.indexOf(-1) = -1 and fell to below = order.length = 4, + // creating the new track BELOW the audio zone — repro {track:-1, insertRow:4}. + // It must now anchor to the top boundary of the visual zone. + expect( + resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: -1 }), + ).toEqual({ track: -1, insertRow: 0 }); + }); + + it("downward create-drag off the bottom visual lane inserts at the audio boundary, never below it", () => { + // Sentinel desiredTrack = maxTrack+1 = 4 for a downward create-drag. A visual + // clip must land at the bottom of the visual zone (row 3 = just above audio), + // not past the audio lanes. + expect( + resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: 4 }), + ).toEqual({ track: 4, insertRow: 3 }); + }); + + it("audio create-drag with an out-of-range aim inserts inside the audio zone", () => { + // An audio clip aimed above its zone (sentinel below its own min lane) anchors + // to the audio zone's top boundary (row 3), not into the visual zone. + expect( + resolveZoneDropPlacement({ + ...base, + elements: [el("a", 3, 0, 10)], + desiredTrack: -1, + isAudio: true, + }), + ).toEqual({ track: -1, insertRow: 3 }); + }); + + it("honors a deliberate boundary insert in the clip's own zone", () => { + expect( + resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 1 }), + ).toEqual({ track: 1, insertRow: 1 }); + }); + + it("ignores a deliberate insert that lands in the WRONG zone (visual into audio)", () => { + expect( + resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 4 }), + ).toEqual({ track: 1, insertRow: null }); + }); + + it("lets an AUDIO clip create a new audio track via a boundary insert", () => { + expect( + resolveZoneDropPlacement({ + ...base, + elements: [], + desiredTrack: 3, + isAudio: true, + deliberateInsertRow: 4, + }), + ).toEqual({ track: 3, insertRow: 4 }); + }); + + it("Abhai repro: audio dropped on a visual-only timeline over an occupied span → insert, no overlap (#2195)", () => { + // No audio zone exists yet (audioTracks empty). The audio clip is aimed at the + // sole visual lane, which is occupied at the drop time. The zone-drop must + // create the audio zone's first lane (insertRow set) rather than stacking the + // audio clip onto the occupied visual track — the no-overlap invariant. + const result = resolveZoneDropPlacement({ + order: [0], + audioTracks: new Set(), + elements: [el("v", 0, 0, 5)], + desiredTrack: 0, + deliberateInsertRow: null, + start: 1, + duration: 2, + dragKey: "audio", + isAudio: true, + }); + expect(result.insertRow).not.toBeNull(); + expect(result).toEqual({ track: 0, insertRow: 1 }); + }); + + it("free-span repro: audio dropped on a FREE stretch of a visual-only timeline → insert, not a visual lane (#2195)", () => { + // Same visual-only timeline, but the audio clip is aimed at a stretch the sole + // visual lane is FREE at. The zone check must still fire an insert (create the + // audio zone) rather than short-circuiting on isLaneFree and dropping the audio + // clip onto the visual lane — the kind-zone hole where the free-lane fast path + // beat the wrong-zone check. + const result = resolveZoneDropPlacement({ + order: [0], + audioTracks: new Set(), + elements: [el("v", 0, 0, 5)], + desiredTrack: 0, + deliberateInsertRow: null, + start: 10, // past the visual clip's end → the visual lane is FREE here + duration: 2, + dragKey: "audio", + isAudio: true, + }); + expect(result.insertRow).not.toBeNull(); + expect(result).toEqual({ track: 0, insertRow: 1 }); + }); +}); diff --git a/packages/studio/src/player/components/timelineCollision.ts b/packages/studio/src/player/components/timelineCollision.ts new file mode 100644 index 0000000000..6dce919ec9 --- /dev/null +++ b/packages/studio/src/player/components/timelineCollision.ts @@ -0,0 +1,263 @@ +import type { TimelineElement } from "../store/playerStore"; + +/** + * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in + * the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a + * clip from appearing to land in the wrong zone mid-drag (which normalizeToZones + * would then snap back). `audioRow` = index in `trackOrder` of the first audio + * lane, or -1 when there is no audio zone yet (then it's a no-op). + */ +export function clampTrackToZone( + targetTrack: number, + trackOrder: number[], + audioRow: number, + isAudio: boolean, +): number { + if (audioRow < 0) return targetTrack; + const row = trackOrder.indexOf(targetTrack); + if (row < 0) return targetTrack; + if (isAudio) return row >= audioRow ? targetTrack : (trackOrder[audioRow] ?? targetTrack); + return row < audioRow ? targetTrack : (trackOrder[audioRow - 1] ?? targetTrack); +} + +/** + * Whether a new-track insert at boundary `insertRow` is allowed for a clip of the + * given kind. Visual clips may only insert visual lanes (boundary at/above the top + * of the audio zone); audio clips may only insert audio lanes (boundary at/below + * it) — so audio clips CAN create a new audio track, and neither kind inserts into + * the other's zone. `audioRow` = first audio lane row, or -1 (no audio zone) → any. + */ +export function isInsertAllowedForZone( + insertRow: number, + audioRow: number, + isAudio: boolean, +): boolean { + if (audioRow < 0) return true; + return isAudio ? insertRow >= audioRow : insertRow <= audioRow; +} + +/** + * The full drop-placement decision for a dragged clip — one pure, testable unit. + * Enforces: NO time-overlap on a single track; a clip stays in its kind-zone; + * a new track is created only when needed. Order of resolution: + * 1. Deliberate boundary insert (pointer near a lane edge), if it's in the + * clip's own zone → create a new track there. + * 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it + * if free at [start, start+duration), else the nearest FREE lane in the zone + * (prefer up), else auto-create a new track right below the aimed lane. + * `audioTracks` = the set of track indices that currently hold audio (so the fn + * needs no element-kind import). Returns the landing `track` and, when a new track + * should be created, the `insertRow` boundary (else null). + * + * `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent + * track) toward the boundary ABOVE the aimed row instead of below it, so the new + * lane opens on whichever side of the aimed clip the pointer is nearer (the drag + * preview passes the pointer's sub-row half). A clip whose aimed span is occupied + * never snaps back to its origin — it relocates to a free lane, or (none free) + * gets a fresh track next to the aim. Default (below) preserves prior behaviour. + */ +/** + * Insert-row boundary for an out-of-range aim — a `desired` track that isn't a + * real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent + * repro) or a beyond-the-bottom index a downward one does. Anchors the new track + * to a boundary of the clip's OWN kind-zone so a visual insert can never land + * past the audio zone (the old below = order.length fallback dropped it BELOW the + * audio lanes). Above the zone (`desired` < the zone's min lane) → the zone's TOP + * boundary; otherwise → its BOTTOM boundary (for a visual clip, the top of the + * audio zone). `zoneTracks` = this kind's lanes, in `order` sequence. + */ +function outOfRangeZoneInsertRow( + order: number[], + zoneTracks: number[], + audioRow: number, + desired: number, +): number { + // No lane of this kind yet: fall to the split (audioRow) or the very top. + // A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0. + if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow; + // zoneTracks preserves `order` sequence, so its ends map to the zone boundary + // rows: above the zone's min lane → its top boundary, else its bottom. + const zoneTop = order.indexOf(zoneTracks[0]); + const zoneBottom = order.indexOf(zoneTracks[zoneTracks.length - 1]) + 1; + return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom; +} + +export function resolveZoneDropPlacement(input: { + order: number[]; + audioTracks: ReadonlySet; + elements: TimelineElement[]; + desiredTrack: number; + deliberateInsertRow: number | null; + start: number; + duration: number; + dragKey: string; + isAudio: boolean; + preferInsertAbove?: boolean; +}): { track: number; insertRow: number | null } { + const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input; + const { start, duration, dragKey, isAudio, preferInsertAbove } = input; + const audioRow = order.findIndex((t) => audioTracks.has(t)); + + if ( + deliberateInsertRow !== null && + isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio) + ) { + return { track: desiredTrack, insertRow: deliberateInsertRow }; + } + + const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio); + const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio); + const placement = resolvePlacement({ + elements, + desiredTrack: desired, + start, + duration, + trackOrder: zoneTracks, + excludeKey: dragKey, + }); + if (placement.needsInsert) { + const desiredRow = order.indexOf(desired); + if (desiredRow < 0) { + return { + track: desired, + insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired), + }; + } + // Prefer the gap NEAREST the pointer: insert above the aimed row when the + // pointer sits in its upper half AND that boundary is in the clip's own zone + // (else the visual/audio split would be crossed) — otherwise fall to below. + // `desired` is clamped into the zone, so both boundaries stay in-zone. + const insertRow = + preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio) + ? desiredRow + : desiredRow + 1; + return { track: desired, insertRow }; + } + return { track: placement.track, insertRow: null }; +} + +/** + * Fallback half-width (fraction of a track height) of the insert band straddling + * a lane boundary — used only when the caller passes no explicit band. Production + * threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip + * inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and + * NEVER reaches into a clip body. Kept in sync with that constant; do not widen it + * back toward the old 0.32 (which armed an insert across ~64% of every row — the + * misfire that turned a plain horizontal drag into a phantom track insert). + */ +const INSERT_BAND = 3 / 48; + +/** + * Decide whether a vertical drag is inserting a new track at a lane boundary. + * `rowFloat` is the pointer's position in track-height units from the top of the + * first lane (0 = top of lane 0). Returns the boundary row to insert at + * (0 = above the top lane, `trackCount` = below the bottom), or null when the + * pointer is over a lane's middle band (a normal move/target). + */ +export function resolveInsertRow( + rowFloat: number, + trackCount: number, + band: number = INSERT_BAND, +): number | null { + if (trackCount === 0) return 0; + if (rowFloat <= 0) return 0; + if (rowFloat >= trackCount) return trackCount; + const lane = Math.floor(rowFloat); + const frac = rowFloat - lane; + if (frac < band) return lane; + if (frac > 1 - band) return lane + 1; + return null; +} + +/** Half-open overlap test: [aStart, aEnd) intersects [bStart, bEnd). */ +export function timeRangesOverlap( + aStart: number, + aEnd: number, + bStart: number, + bEnd: number, +): boolean { + return aStart < bEnd && bStart < aEnd; +} + +/** + * True when no clip on `track` overlaps [start, end) — excluding the clip + * identified by `excludeKey` (the one being dragged). + */ +export function isLaneFree( + elements: TimelineElement[], + track: number, + start: number, + end: number, + excludeKey: string | null, +): boolean { + return !elements.some( + (el) => + (el.key ?? el.id) !== excludeKey && + el.track === track && + timeRangesOverlap(start, end, el.start, el.start + el.duration), + ); +} + +export interface PlacementInput { + elements: TimelineElement[]; + desiredTrack: number; + start: number; + duration: number; + trackOrder: number[]; + excludeKey: string | null; +} + +export interface PlacementResult { + /** The lane the clip should land on. */ + track: number; + /** + * True when no existing lane was free and the caller should insert a new + * track instead of landing on `track` (which is then the desired lane as a + * last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it. + */ + needsInsert: boolean; +} + +/** + * Resolve where a dragged clip should land, avoiding overlap. If the desired + * lane is free, keep it. Otherwise search the nearest free lane, **preferring + * up** (all lanes above, nearest first), then down. If none is free, signal an + * insert and fall back to the desired lane. + */ +export function resolvePlacement({ + elements, + desiredTrack, + start, + duration, + trackOrder, + excludeKey, +}: PlacementInput): PlacementResult { + const end = start + duration; + const idx = trackOrder.indexOf(desiredTrack); + // desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane + // yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked + // BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone + // lane (an audio clip aimed at an empty stretch of a visual-only timeline) is + // "free" only because that lane belongs to the wrong zone. Landing there would + // put the clip in the wrong kind-zone, so signal an insert to create the zone's + // first lane instead — regardless of whether the aimed span is occupied (#2195). + if (idx === -1) return { track: desiredTrack, needsInsert: true }; + + if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) { + return { track: desiredTrack, needsInsert: false }; + } + + // Prefer up: nearest lane above first, then the rest above. + for (let up = idx - 1; up >= 0; up--) { + if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) { + return { track: trackOrder[up], needsInsert: false }; + } + } + // Then down: nearest lane below first. + for (let down = idx + 1; down < trackOrder.length; down++) { + if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) { + return { track: trackOrder[down], needsInsert: false }; + } + } + return { track: desiredTrack, needsInsert: true }; +} From f1f90c1ac8b4d98ea6615941278a467790ef1ef4 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:07 -0700 Subject: [PATCH 2/3] feat(studio): timeline magnetic snapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: new pure module timelineSnapping — snap-target collection and pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime, snapMoveToTargets) with tests. Why: the magnet math for clip drags/trims, reviewable standalone. How: new files only; type-only playerStore imports; consumers land with the drag engine. Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow audit clean. --- .../components/timelineSnapping.test.ts | 134 ++++++++++++++++++ .../src/player/components/timelineSnapping.ts | 110 ++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 packages/studio/src/player/components/timelineSnapping.test.ts create mode 100644 packages/studio/src/player/components/timelineSnapping.ts diff --git a/packages/studio/src/player/components/timelineSnapping.test.ts b/packages/studio/src/player/components/timelineSnapping.test.ts new file mode 100644 index 0000000000..35980b85e3 --- /dev/null +++ b/packages/studio/src/player/components/timelineSnapping.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/player/components/timelineSnapping.ts b/packages/studio/src/player/components/timelineSnapping.ts new file mode 100644 index 0000000000..30d1175273 --- /dev/null +++ b/packages/studio/src/player/components/timelineSnapping.ts @@ -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 = { + playhead: 0, + "clip-edge": 1, + beat: 2, +}; + +export function collectTimelineSnapTargets(input: { + elements: ReadonlyArray>; + playheadTime: number | null; + beatTimes: readonly number[]; + excludeElementKey?: string | null; +}): TimelineSnapTarget[] { + const byTime = new Map(); + 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 }; +} From 03318aaeeb1b1fd2d5bdc43babe07723a29f337c Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:07 -0700 Subject: [PATCH 3/3] feat(studio): multi-clip drag preview math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: new pure module timelineMultiDragPreview — group-drag passenger offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds, multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests. Why: the group-drag math, standalone and DOM-free. How: new files only; consumed later by TimelineLanes. Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit; fallow audit clean. --- .../timelineMultiDragPreview.test.ts | 127 ++++++++++++++++++ .../components/timelineMultiDragPreview.ts | 106 +++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 packages/studio/src/player/components/timelineMultiDragPreview.test.ts create mode 100644 packages/studio/src/player/components/timelineMultiDragPreview.ts diff --git a/packages/studio/src/player/components/timelineMultiDragPreview.test.ts b/packages/studio/src/player/components/timelineMultiDragPreview.test.ts new file mode 100644 index 0000000000..6d7791991e --- /dev/null +++ b/packages/studio/src/player/components/timelineMultiDragPreview.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { + clampGroupMoveDelta, + isMultiDragActive, + isMultiDragPassenger, + multiDragDeltaSeconds, + multiDragPassengerOffsetPx, + type MultiDragPreviewInput, +} from "./timelineMultiDragPreview"; + +const base = (over: Partial = {}): MultiDragPreviewInput => ({ + dragStarted: true, + draggedKey: "a", + draggedOriginStart: 2, + draggedPreviewStart: 5, + selectedKeys: new Set(["a", "b", "c"]), + ...over, +}); + +describe("isMultiDragActive", () => { + it("is active when a started drag's clip is part of a 2+ selection", () => { + expect(isMultiDragActive(base())).toBe(true); + }); + + it("is inactive before the drag starts", () => { + expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false); + }); + + it("is inactive for a single-clip selection (single-drag behavior)", () => { + expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false); + }); + + it("is inactive when the dragged clip is not itself selected", () => { + expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false); + }); +}); + +describe("multiDragDeltaSeconds (the one formation delta)", () => { + it("is the grabbed clip's preview − origin start when active", () => { + // The preview start is already group-clamped upstream, so this delta is the + // clamped delta every member (ghost + passengers) moves by. + expect(multiDragDeltaSeconds(base())).toBe(3); + }); + + it("supports a leftward (negative) delta", () => { + expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5); + }); + + it("is zero when no multi-drag is active", () => { + expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0); + }); +}); + +describe("isMultiDragPassenger", () => { + it("marks a selected non-dragged clip as a passenger", () => { + expect(isMultiDragPassenger("b", base())).toBe(true); + expect(isMultiDragPassenger("c", base())).toBe(true); + }); + + it("never marks the dragged clip itself (it is the free ghost)", () => { + expect(isMultiDragPassenger("a", base())).toBe(false); + }); + + it("never marks an unselected clip", () => { + expect(isMultiDragPassenger("d", base())).toBe(false); + }); + + it("marks nothing when the drag is a single-drag", () => { + const single = base({ selectedKeys: new Set(["a"]) }); + expect(isMultiDragPassenger("b", single)).toBe(false); + }); +}); + +describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => { + it("converts the one formation delta to pixels for every passenger", () => { + // Both passengers move by the SAME 3s × 100pps = 300px — spacing locked. + expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300); + expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300); + }); + + it("is zero for the dragged clip and for non-passengers", () => { + expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0); + expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0); + }); + + it("is zero for a non-finite pps", () => { + expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0); + }); + + it("follows a leftward delta", () => { + expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100); + }); +}); + +describe("clampGroupMoveDelta (rigid group move)", () => { + it("passes a rightward delta through unchanged (no right wall)", () => { + expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3); + expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000); + }); + + it("passes a leftward delta through when no member would cross 0", () => { + // Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped. + expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3); + }); + + it("clamps a leftward delta so the leftmost member stops exactly at 0", () => { + // Leftmost at 2 → the furthest left the group can move is -2 (that member → 0). + // A pointer asking for -5 is clamped to -2: the grabbed clip stops with the + // formation instead of out-running it. + expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2); + }); + + it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => { + // Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1. + expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1); + }); + + it("already-at-0 member forbids any leftward move", () => { + expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0); + // rightward still allowed + expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2); + }); + + it("returns the raw delta for an empty formation", () => { + expect(clampGroupMoveDelta(-9, [])).toBe(-9); + }); +}); diff --git a/packages/studio/src/player/components/timelineMultiDragPreview.ts b/packages/studio/src/player/components/timelineMultiDragPreview.ts new file mode 100644 index 0000000000..6b85466322 --- /dev/null +++ b/packages/studio/src/player/components/timelineMultiDragPreview.ts @@ -0,0 +1,106 @@ +/** + * Pure geometry for the LIVE multi-selection drag preview. + * + * Visual model (matches main): while a selected clip is dragged, ALL selected + * clips move together LIVE as one rigid formation following the cursor. The + * GRABBED clip is drawn as the free-floating ghost; every OTHER selected member + * ("passenger") slides by the SAME time delta via a cheap compositor + * `translateX` (no re-layout). Passengers do NOT stay still, and they do NOT lag + * behind individually — the whole formation moves by one delta, spacing locked. + * + * That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`. + * The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview + * runs clampGroupMoveDelta before setting it), so this delta is the clamped delta: + * the instant any member would cross 0 the grabbed clip stops and every passenger + * stops with it — the formation never deforms. On DROP the commit shifts every + * selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag). + * + * Track changes apply to the grabbed clip only (mirroring the commit); passengers + * keep their lanes, so only their x moves. + */ + +export interface MultiDragPreviewInput { + /** The drag is live (past the movement threshold). */ + dragStarted: boolean; + /** Key of the clip under the pointer. */ + draggedKey: string; + /** The dragged clip's committed start (pre-drag). */ + draggedOriginStart: number; + /** The dragged clip's live preview start (already group-clamped upstream). */ + draggedPreviewStart: number; + /** The current multi-selection (store.selectedElementIds). */ + selectedKeys: ReadonlySet; +} + +/** + * Whether a live multi-selection drag is in effect: the drag started, and the + * dragged clip is itself part of a 2+ multi-selection. Below this, single-drag + * behavior is unchanged and there are no passengers. + */ +export function isMultiDragActive(input: MultiDragPreviewInput): boolean { + return ( + input.dragStarted && input.selectedKeys.size > 1 && input.selectedKeys.has(input.draggedKey) + ); +} + +/** + * The single time delta the WHOLE formation shifts by — the grabbed clip's + * preview start minus its origin start. Because the preview start is already + * group-clamped, this is the clamped delta every member (ghost + passengers) + * moves by. Zero when the clip hasn't moved (or no multi-drag). + */ +export function multiDragDeltaSeconds(input: MultiDragPreviewInput): number { + if (!isMultiDragActive(input)) return 0; + return input.draggedPreviewStart - input.draggedOriginStart; +} + +/** + * Whether a specific rendered clip is a passenger — a selected clip that is NOT + * the dragged clip and NOT the same clip key. Passengers get the translateX + * treatment; the dragged clip is drawn as the free-floating ghost instead. + */ +export function isMultiDragPassenger(clipKey: string, input: MultiDragPreviewInput): boolean { + return ( + isMultiDragActive(input) && clipKey !== input.draggedKey && input.selectedKeys.has(clipKey) + ); +} + +/** + * The passenger's rendered x offset in PIXELS (delta seconds × pixels/second), + * to apply as `transform: translateX(...px)`. Every passenger uses the SAME + * formation delta, so the group moves rigidly. Returns 0 for non-passengers so + * callers can compute unconditionally and only branch on the elevated styling. + */ +export function multiDragPassengerOffsetPx( + clipKey: string, + pixelsPerSecond: number, + input: MultiDragPreviewInput, +): number { + if (!isMultiDragPassenger(clipKey, input)) return 0; + const pps = Number.isFinite(pixelsPerSecond) ? pixelsPerSecond : 0; + return multiDragDeltaSeconds(input) * pps; +} + +/** + * Clamp a group move so the WHOLE selection moves as ONE rigid formation. + * + * The grabbed clip proposes a raw delta (its desired preview start minus its + * origin start, after its own snapping). Applied naively, a passenger could be + * pushed below 0 (or past any other member bound), and the commit's per-clip + * `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs + * the group while a passenger sticks at the wall. This ports main's model + * (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is + * bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant + * any member hits 0 and the formation never deforms. + * + * `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip + * included). Only the lower bound (start ≥ 0) constrains a move; the timeline has + * no fixed right wall (the composition grows on commit). + */ +export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number { + if (memberStarts.length === 0) return rawDelta; + // Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0. + const minStart = Math.min(...memberStarts); + const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0 + return rawDelta < minDelta ? minDelta : rawDelta; +}