summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/placement/__tests__
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/placement/__tests__
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/placement/__tests__')
-rw-r--r--apps/web/src/timeline/placement/__tests__/resolve.test.ts661
1 files changed, 661 insertions, 0 deletions
diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
new file mode 100644
index 00000000..ebbfe578
--- /dev/null
+++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
@@ -0,0 +1,661 @@
+import { describe, expect, test } from "bun:test";
+import type {
+ AudioElement,
+ AudioTrack,
+ GraphicElement,
+ GraphicTrack,
+ OverlayTrack,
+ SceneTracks,
+ TextElement,
+ TextTrack,
+ TimelineTrack,
+ VideoElement,
+ VideoTrack,
+} from "@/timeline";
+import type { Transform } from "@/rendering";
+import { resolveTrackPlacement } from "@/timeline/placement";
+
+function buildTransform(): Transform {
+ return {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ };
+}
+
+type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
+
+function buildElement(params: {
+ id: string;
+ type: "audio";
+ startTime: number;
+ duration: number;
+}): AudioElement;
+function buildElement(params: {
+ id: string;
+ type: "graphic";
+ startTime: number;
+ duration: number;
+}): GraphicElement;
+function buildElement(params: {
+ id: string;
+ type: "text";
+ startTime: number;
+ duration: number;
+}): TextElement;
+function buildElement(params: {
+ id: string;
+ type: "video";
+ startTime: number;
+ duration: number;
+}): VideoElement;
+function buildElement({
+ id,
+ type,
+ startTime,
+ duration,
+}: {
+ id: string;
+ type: TestElement["type"];
+ startTime: number;
+ duration: number;
+}): TestElement {
+ switch (type) {
+ case "audio":
+ return {
+ id,
+ type: "audio",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ volume: 1,
+ sourceType: "upload",
+ mediaId: `media-${id}`,
+ } satisfies AudioElement;
+ case "graphic":
+ return {
+ id,
+ type: "graphic",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ definitionId: `graphic-${id}`,
+ params: {},
+ transform: buildTransform(),
+ opacity: 1,
+ } satisfies GraphicElement;
+ case "text":
+ return {
+ id,
+ type: "text",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ content: id,
+ fontSize: 32,
+ fontFamily: "sans-serif",
+ color: "#ffffff",
+ background: {
+ enabled: false,
+ color: "#000000",
+ },
+ textAlign: "left",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ transform: buildTransform(),
+ opacity: 1,
+ } satisfies TextElement;
+ case "video":
+ return {
+ id,
+ type: "video",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ mediaId: `media-${id}`,
+ transform: buildTransform(),
+ opacity: 1,
+ } satisfies VideoElement;
+ }
+
+ throw new Error(`Unsupported test element type: ${type}`);
+}
+
+type BuildTrackParams =
+ | {
+ id: string;
+ type: "audio";
+ elements?: AudioTrack["elements"];
+ }
+ | {
+ id: string;
+ type: "graphic";
+ elements?: GraphicTrack["elements"];
+ }
+ | {
+ id: string;
+ type: "text";
+ elements?: TextTrack["elements"];
+ }
+ | {
+ id: string;
+ type: "video";
+ elements?: VideoTrack["elements"];
+ };
+
+function buildTrack(params: {
+ id: string;
+ type: "audio";
+ elements?: AudioTrack["elements"];
+}): AudioTrack;
+function buildTrack(params: {
+ id: string;
+ type: "graphic";
+ elements?: GraphicTrack["elements"];
+}): GraphicTrack;
+function buildTrack(params: {
+ id: string;
+ type: "text";
+ elements?: TextTrack["elements"];
+}): TextTrack;
+function buildTrack(params: {
+ id: string;
+ type: "video";
+ elements?: VideoTrack["elements"];
+}): VideoTrack;
+function buildTrack(params: BuildTrackParams): TimelineTrack {
+ const { id, type } = params;
+
+ switch (type) {
+ case "audio":
+ return {
+ id,
+ type: "audio",
+ name: id,
+ elements: params.elements ?? [],
+ muted: false,
+ };
+ case "graphic":
+ return {
+ id,
+ type: "graphic",
+ name: id,
+ elements: params.elements ?? [],
+ hidden: false,
+ };
+ case "text":
+ return {
+ id,
+ type: "text",
+ name: id,
+ elements: params.elements ?? [],
+ hidden: false,
+ };
+ case "video":
+ return {
+ id,
+ type: "video",
+ name: id,
+ elements: params.elements ?? [],
+ muted: false,
+ hidden: false,
+ };
+ }
+
+ throw new Error(`Unsupported test track type: ${type}`);
+}
+
+function buildTimeSpan({
+ startTime,
+ duration,
+ excludeElementId,
+}: {
+ startTime: number;
+ duration: number;
+ excludeElementId?: string;
+}) {
+ return { startTime, duration, excludeElementId };
+}
+
+function buildSceneTracks({
+ overlay = [],
+ main,
+ audio = [],
+}: {
+ overlay?: Array<OverlayTrack>;
+ main?: VideoTrack;
+ audio?: Array<AudioTrack>;
+}): SceneTracks {
+ return {
+ overlay,
+ main:
+ main ??
+ buildTrack({
+ id: "video-main",
+ type: "video",
+ }),
+ audio,
+ };
+}
+
+describe("resolveTrackPlacement", () => {
+ test("explicit returns the requested compatible track", () => {
+ const tracks = buildSceneTracks({
+ overlay: [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 = buildSceneTracks({
+ main: buildTrack({ id: "video-1", type: "video" }),
+ });
+
+ 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 = buildSceneTracks({
+ overlay: [
+ 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 = buildSceneTracks({
+ overlay: [
+ buildTrack({
+ id: "graphic-1",
+ type: "graphic",
+ elements: [
+ buildElement({
+ id: "a",
+ type: "graphic",
+ startTime: 0,
+ duration: 5,
+ }),
+ ],
+ }),
+ ],
+ main: buildTrack({
+ id: "video-main",
+ type: "video",
+ }),
+ audio: [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: 0,
+ insertPosition: null,
+ });
+ });
+
+ test("preferIndex uses the preferred track when it fits", () => {
+ const tracks = buildSceneTracks({
+ audio: [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: "newTrack",
+ trackType: "audio",
+ insertIndex: 1,
+ insertPosition: "below",
+ });
+ });
+
+ test("preferIndex creates a new overlay track above the main track", () => {
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [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 = buildSceneTracks({
+ overlay: [buildTrack({ id: "text-1", type: "text" })],
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [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 = buildSceneTracks({
+ overlay: [
+ 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 overlay track in the overlay zone when none fit", () => {
+ const tracks = buildSceneTracks({
+ overlay: [
+ 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: 0,
+ insertPosition: null,
+ });
+ });
+
+ test("alwaysNew honors highest and default insertion rules", () => {
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [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 = buildSceneTracks({
+ audio: [
+ 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 main-only timelines, single tracks, and track-type derivation", () => {
+ expect(
+ resolveTrackPlacement({
+ tracks: buildSceneTracks({}),
+ 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: "above",
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks: buildSceneTracks({
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ }),
+ elementType: "audio",
+ timeSpans: [],
+ strategy: { type: "alwaysNew", position: "default" },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 2,
+ insertPosition: null,
+ });
+ });
+
+ test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
+ const tracks = buildSceneTracks({
+ main: buildTrack({
+ id: "video-main",
+ type: "video",
+ 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 = buildSceneTracks({
+ overlay: [buildTrack({ id: "text-1", type: "text" })],
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [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",
+ });
+ });
+});