diff options
Diffstat (limited to 'apps/web/src/lib/timeline/placement')
| -rw-r--r-- | apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts | 661 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/apply.ts | 158 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/compatibility.ts | 51 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/index.ts | 12 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/insert-index.ts | 88 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/main-track.ts | 54 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/overlap.ts | 44 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/resolve.ts | 277 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/track-factory.ts | 123 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/types.ts | 39 |
10 files changed, 0 insertions, 1507 deletions
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<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", - }); - }); -}); diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts deleted file mode 100644 index 639b1b42..00000000 --- a/apps/web/src/lib/timeline/placement/apply.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { - AudioTrack, - EffectTrack, - GraphicTrack, - OverlayTrack, - SceneTracks, - TextTrack, - TimelineElement, - TimelineTrack, - VideoTrack, -} from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { buildEmptyTrack } from "./track-factory"; -import type { PlacementResult } from "./types"; -import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update"; - -export function applyPlacement({ - tracks, - placementResult, - elements, - newTrackInsertIndexOverride, -}: { - tracks: SceneTracks; - placementResult: PlacementResult; - elements: TimelineElement[]; - newTrackInsertIndexOverride?: number; -}): { updatedTracks: SceneTracks; targetTrackId: string } | null { - const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - if (placementResult.kind === "existingTrack") { - const targetTrack = orderedTracks[placementResult.trackIndex]; - if (!targetTrack) { - return null; - } - - const updatedTracks = updateTrackInSceneTracks({ - tracks, - trackId: targetTrack.id, - update: (track) => ({ - ...track, - elements: [...track.elements, ...elements], - }), - }); - - return { updatedTracks, targetTrackId: targetTrack.id }; - } - - const newTrackId = generateUUID(); - const insertIndex = - newTrackInsertIndexOverride ?? placementResult.insertIndex; - const updatedTracks = - placementResult.trackType === "audio" - ? { - ...tracks, - audio: insertIntoAudioTracks({ - tracks, - insertIndex, - track: buildPlacedAudioTrack({ - id: newTrackId, - elements, - }), - }), - } - : { - ...tracks, - overlay: insertIntoOverlayTracks({ - tracks, - insertIndex, - track: buildPlacedOverlayTrack({ - id: newTrackId, - type: placementResult.trackType, - elements, - }), - }), - }; - return { updatedTracks, targetTrackId: newTrackId }; -} - -function insertIntoOverlayTracks({ - tracks, - insertIndex, - track, -}: { - tracks: SceneTracks; - insertIndex: number; - track: OverlayTrack; -}): OverlayTrack[] { - const normalizedInsertIndex = Math.max( - 0, - Math.min(insertIndex, tracks.overlay.length), - ); - const nextTracks = [...tracks.overlay]; - nextTracks.splice(normalizedInsertIndex, 0, track); - return nextTracks; -} - -function insertIntoAudioTracks({ - tracks, - insertIndex, - track, -}: { - tracks: SceneTracks; - insertIndex: number; - track: AudioTrack; -}): AudioTrack[] { - const audioInsertIndex = Math.max( - 0, - Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), - ); - const nextTracks = [...tracks.audio]; - nextTracks.splice(audioInsertIndex, 0, track); - return nextTracks; -} - -function buildPlacedAudioTrack({ - id, - elements, -}: { - id: string; - elements: TimelineElement[]; -}): AudioTrack { - return { - ...buildEmptyTrack({ id, type: "audio" }), - elements: elements as AudioTrack["elements"], - }; -} - -function buildPlacedOverlayTrack({ - id, - type, - elements, -}: { - id: string; - type: Exclude<OverlayTrack["type"], "audio">; - elements: TimelineElement[]; -}): OverlayTrack { - switch (type) { - case "video": - return { - ...buildEmptyTrack({ id, type: "video" }), - elements: elements as VideoTrack["elements"], - }; - case "text": - return { - ...buildEmptyTrack({ id, type: "text" }), - elements: elements as TextTrack["elements"], - }; - case "graphic": - return { - ...buildEmptyTrack({ id, type: "graphic" }), - elements: elements as GraphicTrack["elements"], - }; - case "effect": - return { - ...buildEmptyTrack({ id, type: "effect" }), - elements: elements as EffectTrack["elements"], - }; - } -} diff --git a/apps/web/src/lib/timeline/placement/compatibility.ts b/apps/web/src/lib/timeline/placement/compatibility.ts deleted file mode 100644 index de1baff3..00000000 --- a/apps/web/src/lib/timeline/placement/compatibility.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ElementType, TrackType } from "@/lib/timeline"; - -const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = { - audio: "audio", - text: "text", - sticker: "graphic", - graphic: "graphic", - effect: "effect", - video: "video", - image: "video", -}; - -export function getTrackTypeForElementType({ - elementType, -}: { - elementType: ElementType; -}): TrackType { - return ELEMENT_TRACK_MAP[elementType]; -} - -export function canElementGoOnTrack({ - elementType, - trackType, -}: { - elementType: ElementType; - trackType: TrackType; -}): boolean { - return getTrackTypeForElementType({ elementType }) === trackType; -} - -export function validateElementTrackCompatibility({ - element, - track, -}: { - element: { type: ElementType }; - track: { type: TrackType }; -}): { isValid: boolean; errorMessage?: string } { - const isValid = canElementGoOnTrack({ - elementType: element.type, - trackType: track.type, - }); - - if (!isValid) { - return { - isValid: false, - errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`, - }; - } - - return { isValid: true }; -} diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts deleted file mode 100644 index 530cb69f..00000000 --- a/apps/web/src/lib/timeline/placement/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { applyPlacement } from "./apply"; -export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; -export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; -export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track"; -export { resolveTrackPlacement } from "./resolve"; -export { buildEmptyTrack } from "./track-factory"; -export type { - PlacementResult, - PlacementStrategy, - PlacementSubject, - PlacementTimeSpan, -} from "./types"; diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts deleted file mode 100644 index 04fa5a4a..00000000 --- a/apps/web/src/lib/timeline/placement/insert-index.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { SceneTracks, TrackType } from "@/lib/timeline"; - -export function getDefaultInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: SceneTracks; - trackType: TrackType; -}): number { - if (trackType === "audio") { - return tracks.overlay.length + 1 + tracks.audio.length; - } - - if (trackType === "effect") { - return 0; - } - - return tracks.overlay.length; -} - -export function getHighestInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: SceneTracks; - trackType: TrackType; -}): number { - if (trackType === "audio") { - return tracks.overlay.length + 1; - } - - return 0; -} - -export function resolvePreferredNewTrackPlacement({ - tracks, - trackType, - preferredIndex, - direction, -}: { - tracks: SceneTracks; - trackType: TrackType; - preferredIndex: number; - direction: "above" | "below"; -}): { insertIndex: number; insertPosition: "above" | "below" | null } { - const trackCount = tracks.overlay.length + 1 + tracks.audio.length; - if (trackCount === 0) { - return { - insertIndex: 0, - insertPosition: trackType === "audio" ? "below" : null, - }; - } - - const safePreferredIndex = Math.min( - Math.max(preferredIndex, 0), - trackCount - 1, - ); - const mainTrackIndex = tracks.overlay.length; - - if (trackType === "audio") { - if (safePreferredIndex <= mainTrackIndex) { - return { - insertIndex: mainTrackIndex + 1, - insertPosition: "below", - }; - } - - return { - insertIndex: - direction === "above" ? safePreferredIndex : safePreferredIndex + 1, - insertPosition: direction, - }; - } - - const insertIndex = - direction === "above" ? safePreferredIndex : safePreferredIndex + 1; - if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) { - return { - insertIndex: mainTrackIndex, - insertPosition: "above", - }; - } - - return { - insertIndex, - insertPosition: direction, - }; -} diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts deleted file mode 100644 index 7e206517..00000000 --- a/apps/web/src/lib/timeline/placement/main-track.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline"; - -export const MAIN_TRACK_NAME = "Main Track"; - -export function getEarliestMainTrackElement({ - mainTrack, - excludeElementId, -}: { - mainTrack: VideoTrack; - excludeElementId?: string; -}): TimelineElement | null { - const elements = mainTrack.elements.filter((element) => { - return !excludeElementId || element.id !== excludeElementId; - }); - if (elements.length === 0) { - return null; - } - - return elements.reduce((earliestElement, element) => { - return element.startTime < earliestElement.startTime - ? element - : earliestElement; - }); -} - -export function enforceMainTrackStart({ - tracks, - targetTrackId, - requestedStartTime, - excludeElementId, -}: { - tracks: SceneTracks; - targetTrackId: string; - requestedStartTime: number; - excludeElementId?: string; -}): number { - if (tracks.main.id !== targetTrackId) { - return requestedStartTime; - } - - const earliestElement = getEarliestMainTrackElement({ - mainTrack: tracks.main, - excludeElementId, - }); - if (!earliestElement) { - return 0; - } - - if (requestedStartTime <= earliestElement.startTime) { - return 0; - } - - return requestedStartTime; -} diff --git a/apps/web/src/lib/timeline/placement/overlap.ts b/apps/web/src/lib/timeline/placement/overlap.ts deleted file mode 100644 index 5e37c052..00000000 --- a/apps/web/src/lib/timeline/placement/overlap.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { TimelineElement } from "@/lib/timeline"; -import type { PlacementTimeSpan } from "./types"; - -interface TrackWithElements { - elements: TimelineElement[]; -} - -function wouldElementOverlap({ - elements, - startTime, - endTime, - excludeElementId, -}: { - elements: TimelineElement[]; - startTime: number; - endTime: number; - excludeElementId?: string; -}): boolean { - return elements.some((element) => { - if (excludeElementId && element.id === excludeElementId) { - return false; - } - - const elementEnd = element.startTime + element.duration; - return startTime < elementEnd && endTime > element.startTime; - }); -} - -export function canPlaceTimeSpansOnTrack({ - track, - timeSpans, -}: { - track: TrackWithElements; - timeSpans: PlacementTimeSpan[]; -}): boolean { - return timeSpans.every(({ startTime, duration, excludeElementId }) => { - return !wouldElementOverlap({ - elements: track.elements, - startTime, - endTime: startTime + duration, - excludeElementId, - }); - }); -} diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts deleted file mode 100644 index 67943912..00000000 --- a/apps/web/src/lib/timeline/placement/resolve.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline"; -import { - getDefaultInsertIndexForTrack, - getHighestInsertIndexForTrack, - resolvePreferredNewTrackPlacement, -} from "./insert-index"; -import { getTrackTypeForElementType } from "./compatibility"; -import { enforceMainTrackStart } from "./main-track"; -import { canPlaceTimeSpansOnTrack } from "./overlap"; -import type { - PlacementResult, - PlacementStrategy, - PlacementSubject, - PlacementTimeSpan, -} from "./types"; - -type ResolveTrackPlacementParams = PlacementSubject & { - tracks: SceneTracks; - timeSpans: PlacementTimeSpan[]; - strategy: PlacementStrategy; -}; - -function buildExistingTrackResult({ - track, - trackIndex, - tracks, - timeSpans, -}: { - track: TimelineTrack; - trackIndex: number; - tracks: SceneTracks; - timeSpans: PlacementTimeSpan[]; -}): PlacementResult { - const firstSpan = timeSpans[0]; - const requestedStartTime = firstSpan?.startTime ?? 0; - const adjustedStartTime = enforceMainTrackStart({ - tracks, - targetTrackId: track.id, - requestedStartTime, - excludeElementId: firstSpan?.excludeElementId, - }); - return { - kind: "existingTrack", - trackId: track.id, - trackIndex, - trackType: track.type, - ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}), - }; -} - -function buildNewTrackResult({ - trackType, - insertIndex, - insertPosition, -}: { - trackType: TrackType; - insertIndex: number; - insertPosition: "above" | "below" | null; -}): PlacementResult { - return { - kind: "newTrack", - trackType, - insertIndex, - insertPosition, - }; -} - -function findFirstAvailableTrackIndex({ - tracks, - trackType, - timeSpans, -}: { - tracks: TimelineTrack[]; - trackType: TrackType; - timeSpans: PlacementTimeSpan[]; -}): number { - return tracks.findIndex((track) => { - return ( - track.type === trackType && - canPlaceTimeSpansOnTrack({ - track, - timeSpans, - }) - ); - }); -} - -function resolveAlwaysNewTrack({ - tracks, - trackType, - position, -}: { - tracks: SceneTracks; - trackType: TrackType; - position: "highest" | "default"; -}): PlacementResult { - const insertIndex = - position === "highest" - ? getHighestInsertIndexForTrack({ - tracks, - trackType, - }) - : getDefaultInsertIndexForTrack({ - tracks, - trackType, - }); - - return buildNewTrackResult({ - trackType, - insertIndex, - insertPosition: null, - }); -} - -function getInsertDirection({ - hoverDirection, - verticalDragDirection, -}: { - hoverDirection: "above" | "below"; - verticalDragDirection?: "up" | "down" | null; -}): "above" | "below" { - if (verticalDragDirection === "up") { - return "above"; - } - - if (verticalDragDirection === "down") { - return "below"; - } - - return hoverDirection; -} - -export function resolveTrackPlacement({ - tracks, - ...placement -}: ResolveTrackPlacementParams): PlacementResult | null { - const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - const trackType = - "trackType" in placement - ? placement.trackType - : getTrackTypeForElementType({ - elementType: placement.elementType, - }); - const { timeSpans, strategy } = placement; - - if (strategy.type === "explicit") { - const trackIndex = orderedTracks.findIndex( - (track) => track.id === strategy.trackId, - ); - if (trackIndex < 0) { - return null; - } - - const track = orderedTracks[trackIndex]; - if (track.type !== trackType) { - return null; - } - - return buildExistingTrackResult({ - track, - trackIndex, - tracks, - timeSpans, - }); - } - - if (strategy.type === "firstAvailable") { - const existingTrackIndex = findFirstAvailableTrackIndex({ - tracks: orderedTracks, - trackType, - timeSpans, - }); - if (existingTrackIndex >= 0) { - return buildExistingTrackResult({ - track: orderedTracks[existingTrackIndex], - trackIndex: existingTrackIndex, - tracks, - timeSpans, - }); - } - - return resolveAlwaysNewTrack({ - tracks, - trackType, - position: "highest", - }); - } - - if (strategy.type === "preferIndex") { - const preferredTrack = orderedTracks[strategy.trackIndex]; - const isPreferredTrackCompatible = - !!preferredTrack && preferredTrack.type === trackType; - const canUseExistingTrack = - !strategy.createNewTrackOnly && - isPreferredTrackCompatible && - canPlaceTimeSpansOnTrack({ - track: preferredTrack, - timeSpans, - }); - if (canUseExistingTrack) { - return buildExistingTrackResult({ - track: preferredTrack, - trackIndex: strategy.trackIndex, - tracks, - timeSpans, - }); - } - - const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({ - tracks, - trackType, - preferredIndex: strategy.trackIndex, - direction: getInsertDirection({ - hoverDirection: strategy.hoverDirection, - verticalDragDirection: !isPreferredTrackCompatible - ? strategy.verticalDragDirection - : null, - }), - }); - return buildNewTrackResult({ - trackType, - insertIndex, - insertPosition, - }); - } - - if (strategy.type === "aboveSource") { - const aboveTrackIndex = strategy.sourceTrackIndex - 1; - const aboveTrack = orderedTracks[aboveTrackIndex]; - if ( - aboveTrack && - aboveTrack.type === trackType && - canPlaceTimeSpansOnTrack({ - track: aboveTrack, - timeSpans, - }) - ) { - return buildExistingTrackResult({ - track: aboveTrack, - trackIndex: aboveTrackIndex, - tracks, - timeSpans, - }); - } - - const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ - tracks: orderedTracks, - trackType, - timeSpans, - }); - if (firstAvailableTrackIndex >= 0) { - return buildExistingTrackResult({ - track: orderedTracks[firstAvailableTrackIndex], - trackIndex: firstAvailableTrackIndex, - tracks, - timeSpans, - }); - } - - const insertIndex = getHighestInsertIndexForTrack({ - tracks, - trackType, - }); - - return buildNewTrackResult({ - trackType, - insertIndex, - insertPosition: null, - }); - } - - return resolveAlwaysNewTrack({ - tracks, - trackType, - position: strategy.position, - }); -} diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts deleted file mode 100644 index b73c5155..00000000 --- a/apps/web/src/lib/timeline/placement/track-factory.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks"; -import type { - AudioTrack, - EffectTrack, - GraphicTrack, - TextTrack, - TrackType, - TimelineTrack, - VideoTrack, -} from "@/lib/timeline"; - -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: "video"; - name?: string; -}): VideoTrack; -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: "text"; - name?: string; -}): TextTrack; -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: "audio"; - name?: string; -}): AudioTrack; -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: "graphic"; - name?: string; -}): GraphicTrack; -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: "effect"; - name?: string; -}): EffectTrack; - -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: TrackType; - name?: string; -}): TimelineTrack; -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: TrackType; - name?: string; -}): TimelineTrack { - const trackName = name ?? DEFAULT_TRACK_NAMES[type]; - - switch (type) { - case "video": - return { - id, - name: trackName, - type: "video", - elements: [], - hidden: false, - muted: false, - }; - case "text": - return { - id, - name: trackName, - type: "text", - elements: [], - hidden: false, - }; - case "graphic": - return { - id, - name: trackName, - type: "graphic", - elements: [], - hidden: false, - }; - case "audio": - return { - id, - name: trackName, - type: "audio", - elements: [], - muted: false, - }; - case "effect": - return { - id, - name: trackName, - type: "effect", - elements: [], - hidden: false, - }; - default: - throw new Error(`Unsupported track type: ${type}`); - } -} diff --git a/apps/web/src/lib/timeline/placement/types.ts b/apps/web/src/lib/timeline/placement/types.ts deleted file mode 100644 index 559e85cc..00000000 --- a/apps/web/src/lib/timeline/placement/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ElementType, TrackType } from "@/lib/timeline"; - -export interface PlacementTimeSpan { - startTime: number; - duration: number; - excludeElementId?: string; -} - -export type PlacementSubject = - | { elementType: ElementType } - | { trackType: TrackType }; - -export type PlacementStrategy = - | { type: "explicit"; trackId: string } - | { type: "firstAvailable" } - | { - type: "preferIndex"; - trackIndex: number; - hoverDirection: "above" | "below"; - verticalDragDirection?: "up" | "down" | null; - createNewTrackOnly?: boolean; - } - | { type: "aboveSource"; sourceTrackIndex: number } - | { type: "alwaysNew"; position: "highest" | "default" }; - -export type PlacementResult = - | { - kind: "existingTrack"; - trackId: string; - trackIndex: number; - trackType: TrackType; - adjustedStartTime?: number; - } - | { - kind: "newTrack"; - insertIndex: number; - insertPosition: "above" | "below" | null; - trackType: TrackType; - }; |
