mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'feature/track-placement'
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user