mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
feat(studio): timeline collision and placement model (#2195)
What: new pure module timelineCollision — zone-aware drop placement (clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow, resolvePlacement, lane/overlap predicates) with its full test suite. Why: the no-overlap core of the NLE clip-drag engine; plain functions, no DOM, no React, no store writes. How: new files only; type-only imports from the existing playerStore. First runtime consumer arrives with the drag-engine PRs. Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow audit clean (all exports test-consumed).
This commit is contained in:
@@ -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<number>(),
|
||||
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<number>(),
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user