Merge branch 'feature/track-placement'

This commit is contained in:
Maze Winther
2026-03-31 15:53:23 +02:00
29 changed files with 1356 additions and 709 deletions
@@ -0,0 +1,462 @@
import { describe, expect, test } from "bun:test";
import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
function buildElement({
id,
type,
startTime,
duration,
}: {
id: string;
type: ElementType;
startTime: number;
duration: number;
}): TimelineElement {
return {
id,
type,
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
} as TimelineElement;
}
function buildTrack({
id,
type,
elements = [],
isMain = false,
}: {
id: string;
type: TrackType;
elements?: TimelineElement[];
isMain?: boolean;
}): TimelineTrack {
if (type === "video") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
isMain,
muted: false,
hidden: false,
};
}
if (type === "audio") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
muted: false,
};
}
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
hidden: false,
};
}
function buildTimeSpan({
startTime,
duration,
excludeElementId,
}: {
startTime: number;
duration: number;
excludeElementId?: string;
}) {
return { startTime, duration, excludeElementId };
}
describe("resolveTrackPlacement", () => {
test("explicit returns the requested compatible track", () => {
const tracks = [buildTrack({ id: "text-1", type: "text" })];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })],
strategy: { type: "explicit", trackId: "text-1" },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-1",
trackIndex: 0,
trackType: "text",
});
});
test("explicit rejects missing and incompatible tracks", () => {
const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: { type: "explicit", trackId: "missing" },
}),
).toBeNull();
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: { type: "explicit", trackId: "video-1" },
}),
).toBeNull();
});
test("firstAvailable picks the first compatible track without overlap", () => {
const tracks = [
buildTrack({
id: "text-1",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({ id: "text-2", type: "text" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-2",
trackIndex: 1,
trackType: "text",
});
});
test("firstAvailable creates a new track when all compatible tracks are full", () => {
const tracks = [
buildTrack({
id: "graphic-1",
type: "graphic",
elements: [
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
],
}),
buildTrack({
id: "video-main",
type: "video",
isMain: true,
}),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "graphic",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "newTrack",
trackType: "graphic",
insertIndex: 1,
insertPosition: null,
});
});
test("preferIndex uses the preferred track when it fits", () => {
const tracks = [buildTrack({ id: "audio-1", type: "audio" })];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
},
}),
).toEqual({
kind: "existingTrack",
trackId: "audio-1",
trackIndex: 0,
trackType: "audio",
});
});
test("preferIndex creates a new overlay track above the main track", () => {
const tracks = [
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "graphic",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })],
strategy: {
type: "preferIndex",
trackIndex: 1,
hoverDirection: "below",
},
}),
).toEqual({
kind: "newTrack",
trackType: "graphic",
insertIndex: 0,
insertPosition: "above",
});
});
test("preferIndex keeps audio tracks below the main track", () => {
const tracks = [
buildTrack({ id: "text-1", type: "text" }),
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "above",
createNewTrackOnly: true,
},
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: "below",
});
});
test("aboveSource tries the track above source, then any compatible track", () => {
const tracks = [
buildTrack({ id: "text-top", type: "text" }),
buildTrack({
id: "text-middle",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({ id: "text-source", type: "text" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "aboveSource", sourceTrackIndex: 2 },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-top",
trackIndex: 0,
trackType: "text",
});
});
test("aboveSource creates a new track near the source when none fit", () => {
const tracks = [
buildTrack({
id: "text-top",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({
id: "text-source",
type: "text",
elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "aboveSource", sourceTrackIndex: 1 },
}),
).toEqual({
kind: "newTrack",
trackType: "text",
insertIndex: 1,
insertPosition: null,
});
});
test("alwaysNew honors highest and default insertion rules", () => {
const tracks = [
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "default" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: null,
});
});
test("batch time spans reject tracks when any span overlaps", () => {
const tracks = [
buildTrack({
id: "audio-1",
type: "audio",
elements: [
buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [
buildTimeSpan({ startTime: 2.5, duration: 1 }),
buildTimeSpan({ startTime: 5.5, duration: 1 }),
],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
});
test("handles empty timelines, single tracks, and track-type derivation", () => {
expect(
resolveTrackPlacement({
tracks: [],
elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
createNewTrackOnly: true,
},
}),
).toEqual({
kind: "newTrack",
trackType: "video",
insertIndex: 0,
insertPosition: null,
});
expect(
resolveTrackPlacement({
tracks: [buildTrack({ id: "audio-1", type: "audio" })],
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "default" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
});
test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
const tracks = [
buildTrack({
id: "video-main",
type: "video",
isMain: true,
elements: [
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })],
strategy: { type: "explicit", trackId: "video-main" },
}),
).toEqual({
kind: "existingTrack",
trackId: "video-main",
trackIndex: 0,
trackType: "video",
adjustedStartTime: 0,
});
});
test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
const tracks = [
buildTrack({ id: "text-1", type: "text" }),
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "above",
verticalDragDirection: "down",
},
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: "below",
});
});
});