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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
437 changes: 437 additions & 0 deletions packages/studio/src/player/components/timelineCollision.test.ts

Large diffs are not rendered by default.

263 changes: 263 additions & 0 deletions packages/studio/src/player/components/timelineCollision.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
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 };
}
127 changes: 127 additions & 0 deletions packages/studio/src/player/components/timelineMultiDragPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading
Loading