From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../timeline/placement/__tests__/resolve.test.ts | 661 --------------------- 1 file changed, 661 deletions(-) delete mode 100644 apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts (limited to 'apps/web/src/lib/timeline/placement/__tests__') diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts deleted file mode 100644 index 7cadb9b7..00000000 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { - AudioElement, - AudioTrack, - GraphicElement, - GraphicTrack, - OverlayTrack, - SceneTracks, - TextElement, - TextTrack, - TimelineTrack, - VideoElement, - VideoTrack, -} from "@/lib/timeline"; -import type { Transform } from "@/lib/rendering"; -import { resolveTrackPlacement } from "@/lib/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; - main?: VideoTrack; - audio?: Array; -}): 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", - }); - }); -}); -- cgit v1.3.1