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 }; +} 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; +} 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 }; +} diff --git a/packages/studio/src/player/components/timelineStackingSync.test.ts b/packages/studio/src/player/components/timelineStackingSync.test.ts new file mode 100644 index 0000000000..925787e772 --- /dev/null +++ b/packages/studio/src/player/components/timelineStackingSync.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync"; + +function el( + key: string, + track: number, + start: number, + duration: number, + zIndex: number, + isAudio = false, + domIndex?: number, +): StackingElement { + return { key, track, start, duration, zIndex, isAudio, domIndex }; +} + +function patchMap(elements: StackingElement[], edited: string[]): Record { + const out: Record = {}; + for (const p of computeStackingPatches(elements, edited)) out[p.key] = p.zIndex; + return out; +} + +describe("laneIsAbove", () => { + it("lower track renders above (top of timeline wins)", () => { + expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true); + expect(laneIsAbove({ track: 2 }, { track: 1 })).toBe(false); + expect(laneIsAbove({ track: 1 }, { track: 1 })).toBe(false); + }); +}); + +describe("computeStackingPatches", () => { + it("no overlapping clips → no patch", () => { + // a (0..5 on track 0) and b (10..15 on track 1) never overlap in time. + const elements = [el("a", 0, 0, 5, 10), el("b", 1, 10, 5, 5)]; + expect(patchMap(elements, ["a"])).toEqual({}); + }); + + it("edited clip moved to a HIGHER lane (top) but z too low → raised above the below-neighbour", () => { + // a on top lane (0) overlaps b on lane 1; a.z=1 is below b.z=5 → wrong. + const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)]; + // Only a is edited → only a gets a patch, lifting it above b (5) → 6. + expect(patchMap(elements, ["a"])).toEqual({ a: 6 }); + }); + + it("edited clip moved to a LOWER lane (bottom) but z too high → lowered below the above-neighbour", () => { + // a on lane 2 (bottom) overlaps b on lane 0 (top); a.z=9 above b.z=5 → wrong. + const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 5)]; + expect(patchMap(elements, ["a"])).toEqual({ a: 4 }); + }); + + it("edited clip already correctly ordered → no patch (authored z preserved)", () => { + // a on top lane already has higher z than the lower-lane b it overlaps. + const elements = [el("a", 0, 0, 10, 8), el("b", 1, 0, 10, 3)]; + expect(patchMap(elements, ["a"])).toEqual({}); + }); + + it("untouched clips never get a patch even when they overlap the edit", () => { + // b is out of order relative to a, but a is the only edited clip. + const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5), el("c", 2, 0, 10, 9)]; + const patches = computeStackingPatches(elements, ["a"]); + expect(patches.map((p) => p.key)).toEqual(["a"]); + }); + + it("sits strictly between neighbours when there is integer room", () => { + // edited a on middle lane 1 between below-lane-2 (z=2) and above-lane-0 (z=10). + const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 2), el("above", 0, 0, 10, 10)]; + // Between 2 and 10 → floor((2+10)/2)=6. + expect(patchMap(elements, ["a"])).toEqual({ a: 6 }); + }); + + it("adjacent neighbours (no integer gap) → edited lands above lower, upper is bumped", () => { + // below z=4, above z=5 (adjacent). There is no integer strictly between 4 and + // 5, so the old single-patch a=5 left `a` TIED with `above` — and with no DOM + // order to break the tie, `above` no longer paints strictly above `a` (the + // under-patch bug). Tie-aware cascade: a→5 (above below's 4) AND above→6 so it + // stays strictly on top. Minimal: only the two overlapping neighbours move. + const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 4), el("above", 0, 0, 10, 5)]; + expect(patchMap(elements, ["a"])).toEqual({ a: 5, above: 6 }); + }); + + it("audio clips are excluded — an audio edit yields no patch", () => { + const elements = [el("music", 3, 0, 10, 0, true), el("v", 0, 0, 10, 5)]; + expect(patchMap(elements, ["music"])).toEqual({}); + }); + + it("audio clips are excluded as neighbours — a visual edit ignores overlapping audio", () => { + // The only overlapping clip is audio → treated as no visual overlap → no patch. + const elements = [el("v", 0, 0, 10, 3), el("music", 3, 0, 10, 99, true)]; + expect(patchMap(elements, ["v"])).toEqual({}); + }); + + it("only-below neighbours → maxBelow + 1", () => { + const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 3), el("c", 2, 0, 10, 7)]; + // a on top overlaps b(3) and c(7), both below → 7+1=8. + expect(patchMap(elements, ["a"])).toEqual({ a: 8 }); + }); + + it("only-above neighbours → minAbove - 1 (clamped ≥ 0)", () => { + const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 1), el("c", 1, 0, 10, 4)]; + // a on bottom overlaps b(1) and c(4), both above → min(1)-1=0. + expect(patchMap(elements, ["a"])).toEqual({ a: 0 }); + }); + + it("partial time overlap still counts", () => { + // a: 0..6, b: 5..15 overlap in [5,6). + const elements = [el("a", 0, 0, 6, 1), el("b", 1, 5, 10, 5)]; + expect(patchMap(elements, ["a"])).toEqual({ a: 6 }); + }); + + it("touching-but-not-overlapping intervals do NOT count", () => { + // a ends exactly where b starts (t=5) → half-open, no overlap. + const elements = [el("a", 0, 0, 5, 1), el("b", 1, 5, 5, 5)]; + expect(patchMap(elements, ["a"])).toEqual({}); + }); + + it("multi-clip edit: two dragged clips resolve consistently against the region", () => { + // Drag a (lane 0) and b (lane 1) onto a region already holding c (lane 2, z=5). + // Both overlap c. Lower-lane b resolves first (above c → 6), then a (above b → 7). + const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)]; + expect(patchMap(elements, ["a", "b"])).toEqual({ a: 7, b: 6 }); + }); + + it("multi-clip edit skips a member that is already correctly ordered", () => { + const elements = [el("a", 0, 0, 10, 20), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)]; + // a(20) already above everything → no patch. b (lane 1) sits between + // below-neighbour c(5) and above-neighbour a(20) → floor((5+20)/2)=12. + expect(patchMap(elements, ["a", "b"])).toEqual({ b: 12 }); + }); + + it("empty edited set → no patches", () => { + const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)]; + expect(computeStackingPatches(elements, [])).toEqual([]); + }); + + it("item 13: an unresolved neighbour (non-finite z) is EXCLUDED, not treated as z=0", () => { + // `ghost` is an overlapping upper-lane clip whose live node could not be + // resolved, so its z came back NaN. If it were fabricated to 0 it would be a + // real above-neighbour at the z-floor and drag the edited clip's cascade down. + // Excluded, the only real overlap is below-neighbour b(3) → a rises to 4. + const elements = [ + el("a", 1, 0, 10, 0), + el("b", 2, 0, 10, 3), + el("ghost", 0, 0, 10, Number.NaN), + ]; + expect(patchMap(elements, ["a"])).toEqual({ a: 4 }); + }); + + it("item 13: an edited clip whose own z is unresolved (non-finite) yields no patch", () => { + // The edited clip itself couldn't be resolved → it is dropped from the working + // set and produces nothing (no fabricated-0 self-patch). + const elements = [el("a", 0, 0, 10, Number.NaN), el("b", 1, 0, 10, 5)]; + expect(patchMap(elements, ["a"])).toEqual({}); + }); +}); + +describe("computeStackingPatches — tie-aware cascade (lane move always realisable)", () => { + it("drag below an overlapping z=0 neighbour → cascade bumps the neighbour, edit→0", () => { + // edited `v` (z=2) dragged to the BOTTOM lane, overlapping `r` (z=0) which is + // now on the upper lane. No z ≥ 0 fits strictly below 0, so the old resolver + // clamped v to 0 (tied with r) and nothing changed on canvas — the reported + // bug. Tie-aware: v→0 AND r bumped to 1 so r paints strictly above v. + const elements = [el("v", 1, 0, 10, 2), el("r", 0, 0, 10, 0)]; + expect(patchMap(elements, ["v"])).toEqual({ v: 0, r: 1 }); + }); + + it("equal-z + domIndex: dragging the LATER-in-DOM clip below is realised via a bump", () => { + // Two equal-z clips; `v` is later in DOM (domIndex 1) so it currently paints + // ON TOP of `r` (domIndex 0). User drags v to the lower lane (track 1). With + // domIndex the sync SEES that v is currently above r and must be lowered: + // v→0 (already 0, stays) then r bumped to 1 so r wins. Without domIndex the + // equal z would look already-correct and under-patch. + const elements = [el("r", 0, 0, 10, 0, false, 0), el("v", 1, 0, 10, 0, false, 1)]; + expect(patchMap(elements, ["v"])).toEqual({ r: 1 }); + }); + + it("equal-z without domIndex is ambiguous → conservatively bumps to guarantee order", () => { + // Same shape but NO domIndex: equal z is ambiguous, so the resolver cannot + // prove v is already below r and patches to make the order explicit (r above). + const elements = [el("r", 0, 0, 10, 0), el("v", 1, 0, 10, 0)]; + const out = patchMap(elements, ["v"]); + // r must end up strictly above v (higher z) regardless of the exact numbers. + const vz = out.v ?? 0; + const rz = out.r ?? 0; + expect(rz).toBeGreaterThan(vz); + }); + + it("#2198 (Abhai repro): a lift cascades transitively so an UNTOUCHED pair never inverts", () => { + // m (z1, lane0, dom0), n (z0, lane1, dom1), e (z2, lane2, dom2, edited). + // e overlaps n [5,10); n overlaps m [12,15); e does NOT overlap m. + // Dragging e to the bottom lane forces n up to paint above e. A naive lift sets + // n→1, which TIES m (z1) and — n being later in the DOM — paints n above m, + // inverting the untouched (m,n) pair (which the next normalize would reshuffle). + // The transitive cascade lifts m too (→2) so m stays strictly above n. + const elements = [ + el("m", 0, 12, 8, 1, false, 0), + el("n", 1, 5, 10, 0, false, 1), + el("e", 2, 0, 10, 2, false, 2), + ]; + expect(patchMap(elements, ["e"])).toEqual({ e: 0, n: 1, m: 2 }); + }); + + it("cascade patches as FEW clips as possible (only the blockers move)", () => { + // v dragged to bottom under r(z0) and s(z0) both on higher lanes; a distant + // non-overlapping clip x is never touched. + const elements = [ + el("v", 2, 0, 10, 5), + el("r", 0, 0, 10, 0), + el("s", 1, 0, 10, 0), + el("x", 3, 50, 10, 0), // no time overlap → untouched + ]; + const out = patchMap(elements, ["v"]); + expect("x" in out).toBe(false); + // v below both r and s. + expect(out.v).toBeLessThan(out.r ?? 0); + expect(out.v).toBeLessThan(out.s ?? 0); + }); +}); + +describe("computeStackingPatches — DOM tie-break gates the cascade (item 12)", () => { + it("tie ACCEPTABLE: edited may sit AT minAbove when the above-neighbour is later in DOM → single patch, no neighbour bump", () => { + // below b (z3, lane2, dom0), edited e (lane1, dom1), above a (z4, lane0, dom2). + // e must paint above b and below a. There is no integer strictly between 3 and + // 4, but a is LATER in the DOM, so e=4 ties a and a still paints on top by DOM + // order — a valid SINGLE patch. The old gap<2 rule cascaded and needlessly + // bumped a's authored z (the over-patch). Only e changes here. + const elements = [ + el("b", 2, 0, 10, 3, false, 0), + el("e", 1, 0, 10, 0, false, 1), + el("a", 0, 0, 10, 4, false, 2), + ]; + expect(patchMap(elements, ["e"])).toEqual({ e: 4 }); + }); + + it("tie INVERTING: edited tying minAbove would paint ABOVE it (earlier in DOM) → cascade bumps the neighbour", () => { + // Same z's, but a is EARLIER in the DOM than e (dom0 vs dom2). Now e=4 would + // tie a AND paint on top (e later in DOM), violating the lane order, so the + // tie-break can't save it: e→4 and a is bumped to 5. + const elements = [ + el("a", 0, 0, 10, 4, false, 0), + el("b", 2, 0, 10, 3, false, 1), + el("e", 1, 0, 10, 0, false, 2), + ]; + expect(patchMap(elements, ["e"])).toEqual({ e: 4, a: 5 }); + }); +}); diff --git a/packages/studio/src/player/components/timelineStackingSync.ts b/packages/studio/src/player/components/timelineStackingSync.ts new file mode 100644 index 0000000000..4362879bef --- /dev/null +++ b/packages/studio/src/player/components/timelineStackingSync.ts @@ -0,0 +1,331 @@ +/** + * timelineStackingSync — lane ↔ stacking unification (pure). + * + * The approved design: **lane order implies stacking**. A clip on a higher lane + * (rendered ABOVE another in the timeline) should render ON TOP of any clip it + * OVERLAPS IN TIME. But authored z-indexes are sacred: z only changes on a user + * edit, and ONLY for the clip(s) the user actually edited. + * + * Lane → screen mapping (see Timeline.tsx trackOrder / TimelineCanvas rows): + * tracks are sorted ASCENDING and rendered top → bottom, so a LOWER `track` + * value renders HIGHER on screen. Standard NLE convention = the top row wins, + * therefore **lower track ⇒ higher z-index**. We express this with a single + * comparator so callers never have to remember the polarity. + * + * This module is DOM-free and store-free. Callers project their world onto + * `StackingElement` (supplying the live z-index they read from the DOM/inline + * style) and apply the returned `StackingPatch[]` however they persist styles. + */ + +/** Minimal element view this module reasons over. */ +export interface StackingElement { + /** Stable identity (TimelineElement.key ?? id). */ + key: string; + /** Absolute start time (seconds). */ + start: number; + /** Duration (seconds). */ + duration: number; + /** + * Display lane (the normalized timeline `track`). Lower = higher on screen = + * should stack on top. This is the post-edit lane for edited clips. + */ + track: number; + /** + * Current z-index (parsed from inline style / computed; "auto" ⇒ 0), or a + * NON-FINITE value (NaN) when the caller could NOT resolve the clip's live node + * (e.g. an unmounted / nested sub-comp element, or one outside the active file). + * A non-finite-z clip is EXCLUDED from the computation — it is neither a stacking + * neighbour nor resolvable as an edit — so an unresolved node never fabricates a + * z=0 neighbour that poisons the boundary math (item 13). The reader signals a + * miss with NaN rather than null so the value stays assignable to the existing + * `(el) => number` reader contract the drag hook / commit deps declare. + */ + zIndex: number; + /** Audio clips have no visual stacking and are excluded from the computation. */ + isAudio: boolean; + /** + * Discovery / DOM document position (optional). Two clips with EQUAL z paint by + * DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above + * B" uses (zIndex, domIndex); without it equal-z is ambiguous and the sync can + * under-patch (the reported bug: a clip dragged to the bottom lane over an + * equal-z neighbour changed nothing on canvas). Callers pass the index of the + * element in the discovery order array. + */ + domIndex?: number; +} + +/** A minimal z-index change for one clip. */ +export interface StackingPatch { + key: string; + zIndex: number; +} + +const EPS = 1e-6; + +/** + * Two clips overlap in time when their half-open [start, end) intervals intersect. + * + * NOTE the `- EPS`: this DELIBERATELY diverges from `timeRangesOverlap`'s exact + * strict-`<` (timelineCollision.ts). A boolean collision decision is idempotent, so + * exact `<` is fine there; here the result drives a VISIBLE stacking re-lane, so the + * epsilon guards against float fuzz (e.g. 5.0000001 vs 5) spuriously overlapping two + * abutting clips and shuffling lanes. The two are intended to differ, not align. + */ +function overlapsInTime(a: StackingElement, b: StackingElement): boolean { + return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS; +} + +/** + * Is `a` visually ABOVE `b` (should stack on top)? Lower track renders higher on + * screen, so a lower track number means "above". Exposed for tests / callers. + */ +export function laneIsAbove( + a: Pick, + b: Pick, +): boolean { + return a.track < b.track; +} + +/** + * Working record for the cascade resolver: a live-mutable, RESOLVED (non-null) z + * the resolver can bump, plus the immutable identity/lane/time/dom fields. Clips + * whose z could not be resolved (null) are dropped before this stage. + */ +interface MutZ extends StackingElement { + zIndex: number; +} + +/** + * Does `a` currently paint ON TOP of `b`? Higher z wins; equal z breaks by DOM + * order (later in DOM paints on top). When either domIndex is absent, equal z is + * treated as "not strictly above" (ambiguous) — callers should supply domIndex to + * disambiguate (see StackingElement.domIndex). Operates on resolved (`MutZ`) clips. + */ +function paintsAbove(a: MutZ, b: MutZ): boolean { + if (a.zIndex !== b.zIndex) return a.zIndex > b.zIndex; + if (a.domIndex != null && b.domIndex != null) return a.domIndex > b.domIndex; + return false; +} + +/** Reduce a neighbour set's z-indices to a single bound, or null when empty. */ +function boundaryZ(neighbours: MutZ[], reduce: (zs: number[]) => number): number | null { + return neighbours.length > 0 ? reduce(neighbours.map((o) => o.zIndex)) : null; +} + +/** + * Resolve `edited` so that, among the clips it OVERLAPS IN TIME, its paint order + * matches its lane order (lower lane ⇒ paints on top). Records every z change + * (edited clip AND any neighbours that must be bumped) into `patchZ`. + * + * Fast path (unchanged behaviour): when a single non-negative z for the edited + * clip alone realises the order — strictly between the neighbours if there is + * integer room, else just above the lower neighbour, else just below the upper — + * emit only that. This keeps every existing single-patch test passing. + * + * Cascade path: when ties/clamping make the single-clip patch impossible or + * ineffective (must sit below an overlapping z=0 neighbour, or between adjacent / + * equal-z neighbours where DOM order alone can't express it), bump the minimum set + * of overlapping neighbours that must stay ABOVE by +1 (cascading only as far as + * needed) so the edited clip's intended lane order is realised with all z ≥ 0. + * "Authored z sacred" stays the default — neighbours are touched only when the + * user's explicit lane move is otherwise inexpressible (same precedent as the + * canvas context-menu tie-aware fix). + * + * Returns true when any z changed (recorded in `patchZ`), false for a no-op. + */ +function resolveEditedZ( + edited: MutZ, + overlapping: MutZ[], + overlappersOf: (clip: MutZ) => MutZ[], + patchZ: (clip: MutZ, z: number) => void, +): boolean { + const visualOverlap = overlapping.filter((o) => !o.isAudio); + if (visualOverlap.length === 0) return false; + + // Neighbours that must end up BELOW edited (lower lane) vs ABOVE (higher lane). + const below = visualOverlap.filter((o) => laneIsAbove(edited, o)); + const above = visualOverlap.filter((o) => laneIsAbove(o, edited)); + + // Already correct against every overlapping neighbour → no-op (authored z kept). + const correct = + below.every((o) => paintsAbove(edited, o)) && above.every((o) => paintsAbove(o, edited)); + if (correct) return false; + + const maxBelow = boundaryZ(below, (zs) => Math.max(...zs)); + + // ── Fast path: try to realise the order by moving only `edited`. ────────────── + const single = trySingleZ(edited, below, above); + if (single != null) { + if (single !== edited.zIndex) patchZ(edited, single); + // Even at an unchanged z the DOM-order ties may already be satisfied; if not, + // `trySingleZ` returned null and we fall through to the cascade. + return single !== edited.zIndex; + } + + // ── Cascade path: can't fit `edited` between the neighbours with one z ≥ 0. ─── + // Sit edited at maxBelow+1 (or 0 when it only has above-neighbours) and lift the + // above-neighbours that are now not strictly above, minimally, one step past it. + const target = maxBelow != null ? maxBelow + 1 : 0; + const clamped = Math.max(0, target); + if (clamped !== edited.zIndex) patchZ(edited, clamped); + liftAbove(edited, overlappersOf, patchZ); + return true; +} + +/** + * Pick a single non-negative z for `edited` that lands it correctly against its + * neighbours (paints above every below-neighbour, below every above-neighbour), or + * null when no such z exists and the caller must cascade. + * + * The candidate is verified with the SAME `paintsAbove` predicate the resolver uses + * (z + DOM tie-break), so an authored z that already paints correctly by DOM order + * is honoured instead of over-patched: with below=z3 and an above-neighbour at z4 + * that is LATER in the DOM, edited=4 ties the neighbour but the neighbour still + * paints on top by DOM order — a valid single patch, no neighbour bump (item 12). + * When the tie would INVERT (the above-neighbour is earlier in DOM) the candidate + * fails verification and the caller cascades. + */ +// edited has neighbours on BOTH sides: prefer the integer midpoint of a real gap; +// with no strict gap a DOM tie-break may still let it sit AT minAbove (item 12), +// else the caller cascades. +function zBetweenNeighbours( + maxBelow: number, + minAbove: number, + correctAt: (z: number) => boolean, +): number | null { + if (minAbove - maxBelow >= 2) { + const mid = Math.floor((maxBelow + minAbove) / 2); + return mid > maxBelow && mid < minAbove ? mid : null; + } + return correctAt(minAbove) ? minAbove : null; +} + +// edited has only above-neighbours: sit one step below minAbove, or at the z=0 +// floor tie minAbove when a DOM tie-break keeps that neighbour on top. +function zBelowOnly(minAbove: number, correctAt: (z: number) => boolean): number | null { + const candidate = minAbove - 1; + if (candidate >= 0) return candidate; + return correctAt(minAbove) ? minAbove : null; +} + +function trySingleZ(edited: MutZ, below: MutZ[], above: MutZ[]): number | null { + const maxBelow = boundaryZ(below, (zs) => Math.max(...zs)); + const minAbove = boundaryZ(above, (zs) => Math.min(...zs)); + + const correctAt = (z: number): boolean => { + const probe: MutZ = { ...edited, zIndex: z }; + return below.every((b) => paintsAbove(probe, b)) && above.every((a) => paintsAbove(a, probe)); + }; + + if (maxBelow != null && minAbove != null) + return zBetweenNeighbours(maxBelow, minAbove, correctAt); + if (maxBelow != null) return maxBelow + 1; // only below-neighbours → grow upward + if (minAbove != null) return zBelowOnly(minAbove, correctAt); + return null; +} + +/** + * Enforce the module invariant — for every OVERLAPPING pair, the clip on the upper + * lane paints on top — starting from `edited` and cascading TRANSITIVELY. + * + * Seeded with `edited`: each of its upper-lane overlappers must paint strictly + * above it (the deliberate lane move). Raising a clip can then tie or cross ANOTHER + * clip it overlaps that sits on an even higher lane — that clip must be lifted too, + * and so on. Without the cascade a lifted neighbour could tie an untouched clip on + * a higher lane and, being later in the DOM, paint above it — an untouched pair + * visibly inverting (#2198). The condition is LANE order (not "was originally + * above"), so a clip that was already violating lane order — e.g. a bottom-lane + * clip painting on top — is fixed, never preserved. Only clips whose z actually + * changes are patched; z climbs by +1 each step so the walk terminates. + */ +function liftAbove( + edited: MutZ, + overlappersOf: (clip: MutZ) => MutZ[], + patchZ: (clip: MutZ, z: number) => void, +): void { + const queue: MutZ[] = [edited]; + const raiseAbove = (clip: MutZ, floor: MutZ): void => { + if (paintsAbove(clip, floor)) return; // already strictly on top + patchZ(clip, floor.zIndex + 1); // patchZ mutates clip.zIndex in place + queue.push(clip); + }; + while (queue.length > 0) { + const floor = queue.shift()!; + for (const other of overlappersOf(floor)) { + if (laneIsAbove(other, floor) && !paintsAbove(other, floor)) raiseAbove(other, floor); + } + } +} + +/** + * Compute z-index patches so each edited clip's stacking matches its lane order. + * + * @param elements The FULL post-edit element set (edited clips already carry + * their new lane/time). Untouched clips keep their current z. + * @param editedKeys Keys of the clip(s) the user just edited. + * @returns Minimal z patches. When a single-clip patch realises the order it is + * the only patch (authored z of neighbours untouched); when ties or a + * z=0 floor make that impossible, the minimum set of overlapping + * neighbours is bumped too so the lane move is always realisable with + * all z ≥ 0. Non-overlapping / already-correct edits yield nothing. + * + * Multi-clip edits: each edited clip is resolved against the CURRENT (already- + * patched) z of all OTHER clips, lower lane first, so a group dragged onto a busy + * region stacks consistently. + */ +export function computeStackingPatches( + elements: StackingElement[], + editedKeys: Iterable, +): StackingPatch[] { + const editedSet = new Set(editedKeys); + if (editedSet.size === 0) return []; + + // Drop clips whose live z couldn't be resolved (non-finite / NaN): a fabricated + // z=0 would enter the boundary math as a phantom neighbour at the z-floor. An + // unresolved clip is neither a neighbour nor resolvable as an edit, so it is + // excluded outright (item 13). + const resolved = elements.filter((e) => Number.isFinite(e.zIndex)); + + // Mutable z snapshot so edits + cascaded bumps see each other's applied z. + const byKey = new Map(resolved.map((e) => [e.key, { ...e }])); + const edited = resolved + .filter((e) => editedSet.has(e.key) && !e.isAudio) + .map((e) => byKey.get(e.key)!) + // Resolve lower-lane (renders below) clips first so their new z is visible + // to higher-lane siblings resolved after them. + .sort((a, b) => b.track - a.track); + + const changed = new Map(); + const patchZ = (clip: MutZ, z: number): void => { + clip.zIndex = z; + changed.set(clip.key, z); + }; + + // The full live set, so the transitive cascade can reach clips that overlap a + // LIFTED neighbour without overlapping the edited clip itself (#2198). + const all = [...byKey.values()]; + const overlappersOf = (clip: MutZ): MutZ[] => + all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o)); + + for (const clip of edited) { + resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ); + } + + // Emit in a stable order (edited clips first in their resolve order, then any + // cascaded neighbours) — deterministic for tests and undo grouping. + const emitted = new Set(); + const patches: StackingPatch[] = []; + for (const clip of edited) { + if (changed.has(clip.key) && !emitted.has(clip.key)) { + patches.push({ key: clip.key, zIndex: changed.get(clip.key)! }); + emitted.add(clip.key); + } + } + for (const [key, zIndex] of changed) { + if (!emitted.has(key)) { + patches.push({ key, zIndex }); + emitted.add(key); + } + } + return patches; +} diff --git a/packages/studio/src/player/components/timelineZones.test.ts b/packages/studio/src/player/components/timelineZones.test.ts new file mode 100644 index 0000000000..4e446b0568 --- /dev/null +++ b/packages/studio/src/player/components/timelineZones.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { classifyZone, normalizeToZones } from "./timelineZones"; +import { computeStackingPatches, type StackingElement } from "./timelineStackingSync"; + +function el(id: string, tag: string, track: number, duration = 2): TimelineElement { + return { id, tag, start: 0, duration, track }; +} + +function zClip( + id: string, + start: number, + duration: number, + track: number, + zIndex: number, + tag = "video", +): TimelineElement { + return { id, tag, start, duration, track, zIndex }; +} + +function trackOf(els: TimelineElement[], id: string): number { + return els.find((e) => e.id === id)!.track; +} + +/** Assert normalizeToZones is idempotent: re-zoning keeps every clip's lane. */ +function expectZoningIdempotent(input: TimelineElement[]): void { + const once = normalizeToZones(input); + const twice = normalizeToZones(once); + for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track); +} + +/** The exact qa-clean live repro (array order = DOM order); fresh objects per call. */ +function qaCleanRepro(): TimelineElement[] { + return [ + zClip("blue-logo", 6.37, 3, 0, 3, "img"), + zClip("ralu", 6.37, 3, 0, 0, "img"), + zClip("black-logo", 11.92, 3, 1, 1, "img"), + zClip("video", 0.84, 20, 3, 2, "video"), + ]; +} + +describe("classifyZone", () => { + it("audio → audio; video / image / everything else → visual", () => { + expect(classifyZone(el("m", "audio", 3))).toBe("audio"); + expect(classifyZone(el("v", "video", 1))).toBe("visual"); + expect(classifyZone(el("i", "img", 0))).toBe("visual"); + }); + + it("zone identity invariant: normalizeToZones preserves each clip's zone (mixed input)", () => { + // normalizeToZones only remaps lanes — it must never reclassify a clip's zone. + const input = [ + el("v", "video", 0), + el("a1", "audio", 1), + el("i", "img", 2), + el("a2", "audio", 3), + ]; + const out = normalizeToZones(input); + for (const e of input) { + expect(classifyZone(out.find((o) => o.id === e.id)!)).toBe(classifyZone(e)); + } + // And the partition holds: every visual lane sits above every audio lane. + const laneOf = (id: string) => trackOf(out, id); + const maxVisual = Math.max(laneOf("v"), laneOf("i")); + const minAudio = Math.min(laneOf("a1"), laneOf("a2")); + expect(maxVisual).toBeLessThan(minAudio); + }); +}); + +describe("normalizeToZones", () => { + it("orders visual (top) → audio (bottom); equal-z overlap stacks by DOM order", () => { + // img and vid are both z=0, start=0 → they OVERLAP in time. CSS paints + // equal-z siblings by DOM order (later paints on top), so the later-in-array + // `vid` must own the upper lane. (Was: pinned img=0/vid=1 to authored order, + // which contradicts the canvas — updated for per-clip DOM-order tie-break.) + const out = normalizeToZones([ + el("img", "img", 0), + el("vid", "video", 2), + el("mus", "audio", 5), + ]); + expect(trackOf(out, "vid")).toBe(0); // later in DOM → paints on top → upper lane + expect(trackOf(out, "img")).toBe(1); // earlier in DOM → below + expect(trackOf(out, "mus")).toBe(2); // audio (bottom) + }); + + it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => { + // All three z=0, start=0 → mutually overlapping. DOM order (later on top) + // decides the stack: v3 (last) top, then i1, then v0. (Was: pinned to authored + // order v0=0/i1=1/v3=2, which the canvas contradicts for overlapping equal-z.) + const out = normalizeToZones([el("v0", "video", 0), el("i1", "img", 1), el("v3", "video", 3)]); + expect(trackOf(out, "v3")).toBe(0); + expect(trackOf(out, "i1")).toBe(1); + expect(trackOf(out, "v0")).toBe(2); + }); + + it("drops audio below the visual lanes even when sharing a track index", () => { + const out = normalizeToZones([el("v", "video", 0), el("a", "audio", 0)]); + expect(trackOf(out, "v")).toBe(0); // visual + expect(trackOf(out, "a")).toBe(1); // audio, below + }); + + it("groups multiple audio tracks at the bottom preserving relative order", () => { + const out = normalizeToZones([el("v", "video", 0), el("a1", "audio", 1), el("a2", "audio", 4)]); + expect(trackOf(out, "v")).toBe(0); + expect(trackOf(out, "a1")).toBe(1); + expect(trackOf(out, "a2")).toBe(2); + }); + + it("returns the same array (identity) when already zoned", () => { + // A fixed-point layout: the two overlapping equal-z visual clips are already + // in DOM-order-consistent lanes (later-in-array `v` on the upper lane 0), so + // re-zoning is a no-op and the SAME array reference comes back. (Was: i=0/v=1, + // which the new per-clip DOM-order pack would flip — so it wasn't a fixed + // point under the corrected canvas semantics; swapped to the stable order.) + const input = [el("v", "video", 1), el("i", "img", 0), el("a", "audio", 2)]; + expect(normalizeToZones(input)).toBe(input); + }); + + it("is idempotent (no drift on re-zoning)", () => { + const input = [ + el("img", "img", 1), + el("v", "video", 3), + el("a1", "audio", 2), + el("a2", "audio", 6), + ]; + expectZoningIdempotent(input); + }); + + it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => { + const clip = (id: string, start: number, duration: number): TimelineElement => ({ + id, + tag: "video", + start, + duration, + track: 1, // all authored on the SAME track, some overlapping in time + }); + // a [0,5), b [2,7) overlaps a, c [6,9) fits after a. All equal-z (absent). + // Per-clip pack (DOM-order tie-break): c is last in DOM so it places on the + // top lane 0; b overlaps c and lands on lane 1; a overlaps b (and is earlier + // in DOM than b, so it must paint BELOW b) → lane 2. a can NOT drop onto c's + // lane 0 even though it doesn't overlap c, because that would place a ABOVE b + // in lane order while a paints below b — the canvas-correct constraint the old + // whole-track packer ignored (it shared a/c on lane 0, contradicting paint). + const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]); + expect(trackOf(out, "c")).toBe(0); // last in DOM → top lane + expect(trackOf(out, "b")).toBe(1); // overlaps c → below it + expect(trackOf(out, "a")).toBe(2); // paints below b (earlier DOM, equal z) → lane below b + + // No two time-overlapping clips share a lane (the real NLE invariant). + expect(trackOf(out, "a")).not.toBe(trackOf(out, "b")); + expect(trackOf(out, "b")).not.toBe(trackOf(out, "c")); + + // Idempotent: re-laying the split result changes nothing. + const twice = normalizeToZones(out); + for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track); + }); +}); + +describe("normalizeToZones — reverse z→lane mapping", () => { + it("orders overlapping same-zone clips by z: higher z → higher (upper) lane", () => { + // lo (z=1) and hi (z=9) fully overlap in time on the same authored track. + const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 0, 9)]); + expect(trackOf(out, "hi")).toBe(0); // higher z → upper lane (top) + expect(trackOf(out, "lo")).toBe(1); // lower z → below + }); + + it("orders three overlapping clips strictly by descending z", () => { + const out = normalizeToZones([ + zClip("mid", 0, 10, 0, 5), + zClip("top", 0, 10, 0, 8), + zClip("bot", 0, 10, 0, 2), + ]); + expect(trackOf(out, "top")).toBe(0); + expect(trackOf(out, "mid")).toBe(1); + expect(trackOf(out, "bot")).toBe(2); + }); + + it("does NOT reorder non-overlapping (sequential) clips by z — they share a lane", () => { + // a [0,5) z=1 then c [6,9) z=9 — no time overlap, so z is irrelevant. + const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]); + expect(trackOf(out, "a")).toBe(0); + expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of higher z + }); + + it("leaves the audio zone unaffected by z", () => { + const out = normalizeToZones([ + zClip("v", 0, 10, 0, 1), + zClip("m1", 0, 10, 1, 99, "audio"), + zClip("m2", 0, 10, 1, 0, "audio"), + ]); + // Two overlapping audio clips split onto lanes below the visual clip; their + // relative z does not lift one above a visual clip. + expect(trackOf(out, "v")).toBe(0); + expect(trackOf(out, "m1")).toBeGreaterThan(trackOf(out, "v")); + expect(trackOf(out, "m2")).toBeGreaterThan(trackOf(out, "v")); + }); + + it("treats missing / auto z as 0 (undefined z clip sinks below a positive-z overlap)", () => { + const out = normalizeToZones([ + { id: "noz", tag: "video", start: 0, duration: 10, track: 0 }, // no zIndex + zClip("pos", 0, 10, 0, 3), + ]); + expect(trackOf(out, "pos")).toBe(0); // z=3 → upper + expect(trackOf(out, "noz")).toBe(1); // absent z ⇒ 0 → below + }); + + it("tie-breaks equal-z overlapping clips on the STABLE id, not the mutated lane", () => { + // Equal z + full overlap: order must be deterministic (id asc) and survive + // re-normalization — the historical oscillation bug tie-broke on the track. + const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]); + expect(trackOf(out, "a")).toBe(0); // "a" < "b" + expect(trackOf(out, "b")).toBe(1); + const twice = normalizeToZones(out); + for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track); + }); + + it("FIXED POINT: normalizeToZones(normalizeToZones(x)) === normalizeToZones(x) with z present", () => { + const input = [ + zClip("hi", 0, 10, 0, 9), + zClip("lo", 0, 10, 0, 1), + zClip("mid", 2, 6, 0, 5), + zClip("seq", 12, 4, 0, 7), + zClip("music", 0, 16, 1, 3, "audio"), + ]; + expectZoningIdempotent(input); + }); + + it("reload simulation: re-deriving lanes from the SAME z values yields identical lanes", () => { + // Simulate two independent discovery passes producing fresh element objects + // carrying the same z — lane assignment must be stable across reloads. + const build = (): TimelineElement[] => [ + zClip("hi", 0, 10, 0, 9), + zClip("lo", 0, 10, 0, 1), + zClip("mid", 3, 5, 0, 5), + ]; + const first = normalizeToZones(build()); + const second = normalizeToZones(build()); + for (const e of first) expect(trackOf(second, e.id)).toBe(e.track); + }); +}); + +describe("normalizeToZones — cross-track z→lane (real qa-clean shape)", () => { + // Derived from /tmp/hf-dnd-qa/qa-clean: a full-length video on authored track 0 + // (z=0), two logo SVGs on track 1 (z=26 and z=0), an icon on track 3 (z=5), and + // background music on track 2. In the canvas the z=26 / z=5 icons paint ON TOP of + // the z=0 video; the timeline must agree — the higher-z tracks sit on upper lanes. + const realProject = (): TimelineElement[] => [ + zClip("ralu", 6.14, 3, 3, 5, "img"), + zClip("video", 1, 20, 0, 0, "video"), + zClip("blueLogo", 5.93, 3, 1, 26, "img"), + zClip("blackLogo", 1, 3, 1, 0, "img"), + zClip("music", 8.93, 8, 2, 0, "audio"), + ]; + + it("stacks a higher-z track ABOVE a lower-z track on a different authored track", () => { + const out = normalizeToZones(realProject()); + // Track 1 (max z 26) tops the visual zone, then track 3 (z 5), then track 0 (z 0). + expect(trackOf(out, "blueLogo")).toBe(0); + expect(trackOf(out, "blackLogo")).toBe(0); // sequential to blueLogo → shares lane + expect(trackOf(out, "ralu")).toBe(1); + expect(trackOf(out, "video")).toBe(2); + // Audio stays at the very bottom regardless of its authored track index. + expect(trackOf(out, "music")).toBe(3); + }); + + it("the video (z=0) no longer sits above the z=26 / z=5 icons — canvas & timeline agree", () => { + const out = normalizeToZones(realProject()); + expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "blueLogo")); + expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "ralu")); + }); + + it("is idempotent on the real-project shape (no lane drift on re-discovery)", () => { + const once = normalizeToZones(realProject()); + const twice = normalizeToZones(once); + for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track); + }); + + it("re-derives identical lanes from fresh objects carrying the same z (reload-stable)", () => { + const first = normalizeToZones(realProject()); + const second = normalizeToZones(realProject()); + for (const e of first) expect(trackOf(second, e.id)).toBe(e.track); + }); + + it("all-equal-z overlapping clips stack by DOM order (later on top), lanes contiguous", () => { + // Was: "keeps ascending authored track order when all tracks share z" — that + // pinned t0=0/t1=1/t3=2 to the authored track index. But these three all + // start at 0 and OVERLAP, all z=0, so CSS paints them by DOM order: t3 (last) + // on top. The per-clip pack now reflects that (t3 lane 0, then t1, then t0), + // which is what the canvas actually shows. Lanes stay contiguous 0..2. + const out = normalizeToZones([ + zClip("t0", 0, 2, 0, 0), + zClip("t1", 0, 2, 1, 0), + zClip("t3", 0, 2, 3, 0), + ]); + expect(trackOf(out, "t3")).toBe(0); // last in DOM → paints on top + expect(trackOf(out, "t1")).toBe(1); + expect(trackOf(out, "t0")).toBe(2); + }); +}); + +describe("normalizeToZones — EXACT qa-clean repro (per-clip constrained pack)", () => { + // The live repro from /tmp/hf-dnd-qa/qa-clean/index.html: + // blue-logo authored track 0, z=3, 6.37–9.37 + // ralu image authored track 0, z=0, 6.37–9.37 (shares track 0 with blue-logo) + // black-logo authored track 1, z=1, 11.92–14.92 + // video authored track 3, z=2, 0.84–20.84 + // Canvas truth: video (z=2) covers ralu (z=0). The OLD whole-track packer + // ordered track 0 by its MAX z (3, from blue-logo), so ralu rode above the + // z=2 video — the timeline↔canvas contradiction. Array order below = DOM order. + + it("ACCEPTANCE: lane order top→bottom is blue-logo, video, black-logo, ralu", () => { + const out = normalizeToZones(qaCleanRepro()); + expect(trackOf(out, "blue-logo")).toBe(0); // z=3 → top + expect(trackOf(out, "video")).toBe(1); // z=2, overlaps blue-logo → below it + expect(trackOf(out, "black-logo")).toBe(2); // z=1, overlaps video (11.92–14.92 ∩ 0.84–20.84) + expect(trackOf(out, "ralu")).toBe(3); // z=0, overlaps blue-logo AND video → bottom + }); + + it("REGRESSION: a low-z clip must not ride its authored trackmate's high z above a clip that covers it", () => { + const out = normalizeToZones(qaCleanRepro()); + // ralu (z=0) shares authored track 0 with blue-logo (z=3) but must sink BELOW + // the video (z=2) that overlaps and paints over it — the whole-track bug. + expect(trackOf(out, "ralu")).toBeGreaterThan(trackOf(out, "video")); + // black-logo (z=1) below video (z=2) because they overlap in time. + expect(trackOf(out, "black-logo")).toBeGreaterThan(trackOf(out, "video")); + }); + + it("FIXED POINT: running the NEW pack on its own output changes nothing", () => { + const once = normalizeToZones(qaCleanRepro()); + const twice = normalizeToZones(once); + for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track); + // And a third pass, to be sure convergence is genuine. + const thrice = normalizeToZones(twice); + for (const e of twice) expect(trackOf(thrice, e.id)).toBe(e.track); + }); +}); + +describe("z ↔ lane round-trip convergence (both directions agree)", () => { + // Project a normalized TimelineElement onto the StackingElement view the + // forward (lane→z) mapping reasons over. + const toStacking = (els: TimelineElement[]): StackingElement[] => + els.map((e, domIndex) => ({ + key: e.key ?? e.id, + start: e.start, + duration: e.duration, + track: e.track, + zIndex: Number.isFinite(e.zIndex) ? (e.zIndex as number) : 0, + isAudio: classifyZone(e) === "audio", + domIndex, + })); + + it("lane-move → z patch → re-discovery orders lanes by that same z → identical lanes (no oscillation)", () => { + // Two fully-overlapping visual clips. Authored: a below (z=1), b above (z=5). + const authored: TimelineElement[] = [zClip("a", 0, 10, 0, 1), zClip("b", 0, 10, 0, 5)]; + const normalized = normalizeToZones(authored); + // z→lane placed b (z=5) on the upper lane 0, a on lane 1. + expect(trackOf(normalized, "b")).toBe(0); + expect(trackOf(normalized, "a")).toBe(1); + + // USER lane-move: drag a to the TOP (lane 0) and push b down (lane 1). + const afterMove = normalized.map((e) => + e.id === "a" ? { ...e, track: 0 } : e.id === "b" ? { ...e, track: 1 } : e, + ); + + // FORWARD: a lane-move writes the minimal z patch for the edited clip. + const patches = computeStackingPatches(toStacking(afterMove), ["a"]); + expect(patches).toEqual([{ key: "a", zIndex: 6 }]); // lifted above b (5) + + // Apply the z patch back onto the elements (what handleDomZIndexReorderCommit + // persists; next discovery re-reads it as TimelineElement.zIndex). + const rediscovered = afterMove.map((e) => { + const p = patches.find((pp) => pp.key === (e.key ?? e.id)); + return p ? { ...e, zIndex: p.zIndex } : e; + }); + + // REVERSE: re-normalize from the new z. a (z=6) must now own the upper lane — + // the same lane the user moved it to. Directions converge, they do not fight. + const renormalized = normalizeToZones(rediscovered); + expect(trackOf(renormalized, "a")).toBe(0); + expect(trackOf(renormalized, "b")).toBe(1); + + // FIXED POINT: forward on the converged state produces NO further patch, and + // reverse is idempotent — the round-trip is stable. + expect(computeStackingPatches(toStacking(renormalized), ["a"])).toEqual([]); + const twice = normalizeToZones(renormalized); + for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track); + }); + + it("qa-clean: drag video BELOW ralu → z patch → re-pack keeps it below, no oscillation", () => { + // EXACT repro fixture (array order = DOM order). + const normalized = normalizeToZones(qaCleanRepro()); + // Baseline lanes: blue-logo 0, video 1, black-logo 2, ralu 3. + expect(trackOf(normalized, "video")).toBe(1); + expect(trackOf(normalized, "ralu")).toBe(3); + + // USER lane-move: drag video to the lane BELOW ralu (bottom). ralu is at + // lane 3, so video goes to a lane strictly greater — model it as lane 4. + const afterMove = normalized.map((e) => (e.id === "video" ? { ...e, track: 4 } : e)); + + // FORWARD: video (z=2) must drop below ralu (z=0). No integer z ≥ 0 fits + // strictly below 0, so the tie-aware sync cascades: video→0 and the clips that + // must stay above it (ralu z=0, black-logo z=1, blue-logo z=3) are bumped as + // needed so video paints below ralu with all z ≥ 0. + const patches = computeStackingPatches(toStacking(afterMove), ["video"]); + const patchByKey = new Map(patches.map((p) => [p.key, p.zIndex])); + // Video was moved; it must now be strictly below ralu in paint order. + const zAfter = (id: string): number => + patchByKey.get(id) ?? (afterMove.find((e) => e.id === id)!.zIndex as number); + expect(zAfter("video")).toBeLessThan(zAfter("ralu")); + expect(zAfter("video")).toBeGreaterThanOrEqual(0); + expect(patchByKey.size).toBeGreaterThan(0); + + // Apply patches and re-pack: video's lane must now be BELOW ralu's. + const rediscovered = afterMove.map((e) => { + const z = patchByKey.get(e.id); + return z != null ? { ...e, zIndex: z } : e; + }); + const renormalized = normalizeToZones(rediscovered); + expect(trackOf(renormalized, "video")).toBeGreaterThan(trackOf(renormalized, "ralu")); + + // FIXED POINT: re-running BOTH directions on the converged state is a no-op. + expect(computeStackingPatches(toStacking(renormalized), ["video"])).toEqual([]); + const twice = normalizeToZones(renormalized); + for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track); + }); +}); diff --git a/packages/studio/src/player/components/timelineZones.ts b/packages/studio/src/player/components/timelineZones.ts new file mode 100644 index 0000000000..aed2a0a921 --- /dev/null +++ b/packages/studio/src/player/components/timelineZones.ts @@ -0,0 +1,198 @@ +import type { TimelineElement } from "../store/playerStore"; +import { isAudioTimelineElement } from "../../utils/timelineInspector"; + +/** + * Free-form vertical zones, top → bottom: visual, audio. There is no "main track" + * — layering is CSS z-index (the renderer ignores track index), so the timeline's + * only job is to keep visual clips grouped above audio clips. + */ +export type TrackZone = "visual" | "audio"; + +/** Which zone a clip belongs to: audio elements sink to the bottom, everything + * else (video / image / text / sub-comp) is a visual lane on top. */ +export function classifyZone(el: TimelineElement): TrackZone { + return isAudioTimelineElement(el) ? "audio" : "visual"; +} + +const keyOf = (el: TimelineElement) => el.key ?? el.id; + +/** Stacking order for a clip: missing / "auto" z is treated as 0. */ +const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0); + +const EPS = 1e-6; + +/** Two clips overlap when their half-open [start, end) intervals intersect. */ +function overlaps(a: TimelineElement, b: TimelineElement): boolean { + return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS; +} + +/** A clip paired with its position in the discovery/document (input) order. */ +interface IndexedClip { + el: TimelineElement; + /** Index in the input `elements` array = discovery/DOM order. */ + domIndex: number; +} + +/** One display lane: the clips packed onto it, in placement order. */ +interface Lane { + occupants: IndexedClip[]; + /** The single authored track all occupants share, or null once mixed (never + * happens — we only ever add same-track clips to an existing lane). */ + track: number; +} + +/** + * Lowest lane index a clip may occupy: strictly above every already-placed lane + * holding a clip it overlaps in time (all of which out-stack it by the z-desc + * placement order). + */ +function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number { + let minLane = 0; + for (let i = 0; i < lanes.length; i++) { + if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1; + } + return minLane; +} + +/** + * First lane at index ≥ minLane that holds solely this clip's authored track and + * nothing overlapping (so sequential same-track clips share a lane); -1 when none + * qualifies and a fresh lane must open. + */ +function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number { + for (let i = minLane; i < lanes.length; i++) { + const lane = lanes[i]; + if (lane.track !== item.el.track) continue; + if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue; + return i; + } + return -1; +} + +/** + * Pack a WHOLE zone's clips onto display lanes with a single constrained pass so + * that, for EVERY pair of time-overlapping clips, lane order (upper = lower index) + * equals canvas stacking order. This replaces the old two-stage + * `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored + * tracks by their MAX z and so lifted a low-z clip above a clip that covers it + * whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a + * low-z image rode its z=3 trackmate above the z=2 video that paints over it). No + * whole-track mapping can fix that; the mapping must be per-clip. + * + * Algorithm: + * 1. Order clips by z DESC; z-tie → INPUT-ARRAY-INDEX (DOM order) DESC (CSS + * paints equal-z siblings by DOM order — LATER in DOM paints on top, so it + * must place first / upper); final tie → stable key. NEVER tie-break on the + * mutated lane/track index (historic oscillation bug — the tie-break must be + * a stable function of the input, not of the output being computed). + * 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it + * OVERLAPS IN TIME). By z-desc placement every already-placed overlapping + * clip out-stacks this one (higher z, or equal z but later in DOM), so this + * guarantees lane order == stacking order for every overlapping pair. + * 3. To preserve the "distinct authored tracks stay distinct / sequential + * same-track clips share a lane" feel, reuse an existing lane at index ≥ that + * minimum ONLY when the lane's occupants are all from the SAME authored track + * AND none overlaps this clip in time; otherwise open a fresh lane. + * + * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and + * returns the number of lanes used (≥ 1 when non-empty). + */ +function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map): number { + const ordered = [...clips].sort( + (a, b) => + zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1), + ); + const lanes: Lane[] = []; + for (const item of ordered) { + const minLane = lowestAllowedLane(lanes, item); + let placed = findReusableLane(lanes, minLane, item); + if (placed === -1) { + placed = lanes.length; + lanes.push({ occupants: [], track: item.el.track }); + } + lanes[placed].occupants.push(item); + laneOf.set(keyOf(item.el), base + placed); + } + return lanes.length; +} + +/** + * Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one + * authored track's clips onto sub-lanes so no two overlap in time — sequential + * clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by + * start (then stable key) so the layout is deterministic and idempotent. Returns + * the number of lanes used (≥ 1 when non-empty). + */ +function packAudioTrackLanes( + clips: IndexedClip[], + base: number, + laneOf: Map, +): number { + const ordered = [...clips].sort( + (a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1), + ); + const lanes: IndexedClip[][] = []; + for (const item of ordered) { + let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el))); + if (sub === -1) { + sub = lanes.length; + lanes.push([]); + } + lanes[sub].push(item); + laneOf.set(keyOf(item.el), base + sub); + } + return Math.max(1, lanes.length); +} + +/** + * Assign display lanes for the timeline: visual lanes on top, audio lanes below. + * + * The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical + * order matches the canvas's CSS stacking for EVERY time-overlapping pair — a + * low-z clip sinks below a clip that covers it even if it shares an authored track + * with a higher-z clip. Time-overlapping clips still split onto separate lanes + * (standard NLE), sequential same-track clips still share a lane, and distinct + * authored tracks stay distinct. + * + * The AUDIO zone keeps the original behavior — authored-track order, per-track + * interval packing — because audio has no z / stacking semantics. + * + * Pure — returns a new array; unchanged clips keep their identity. Display-only + * (runs on discovery); it does not rewrite the source. Idempotent (running it on + * its own output is a fixed point). + */ +export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] { + if (elements.length === 0) return elements; + + const laneOf = new Map(); + let nextLane = 0; + + const visual: IndexedClip[] = []; + const audio: IndexedClip[] = []; + elements.forEach((el, domIndex) => { + (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex }); + }); + + nextLane += packZoneLanes(visual, nextLane, laneOf); + + // Audio: preserve legacy behavior — group by authored track (ascending), pack + // each track's overlapping clips onto sub-lanes. + const audioByTrack = new Map(); + for (const item of audio) { + const list = audioByTrack.get(item.el.track); + if (list) list.push(item); + else audioByTrack.set(item.el.track, [item]); + } + for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) { + nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf); + } + + let changed = false; + const remapped = elements.map((el) => { + const lane = laneOf.get(keyOf(el)); + if (lane == null || lane === el.track) return el; + changed = true; + return { ...el, track: lane }; + }); + return changed ? remapped : elements; +}