From 70a6d58b8432e455ab1443d45bc23dd70b363bea Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 31 Mar 2026 15:47:30 +0200 Subject: refactor: centralize track placement into its own module --- apps/web/src/lib/timeline/drop-utils.ts | 183 +++----- apps/web/src/lib/timeline/element-utils.ts | 18 - .../timeline/placement/__tests__/resolve.test.ts | 462 +++++++++++++++++++++ apps/web/src/lib/timeline/placement/apply.ts | 45 ++ .../src/lib/timeline/placement/compatibility.ts | 51 +++ apps/web/src/lib/timeline/placement/index.ts | 18 + .../web/src/lib/timeline/placement/insert-index.ts | 99 +++++ apps/web/src/lib/timeline/placement/main-track.ts | 96 +++++ apps/web/src/lib/timeline/placement/overlap.ts | 40 ++ apps/web/src/lib/timeline/placement/resolve.ts | 255 ++++++++++++ .../src/lib/timeline/placement/track-factory.ts | 61 +++ apps/web/src/lib/timeline/placement/types.ts | 39 ++ apps/web/src/lib/timeline/track-utils.ts | 235 ----------- 13 files changed, 1229 insertions(+), 373 deletions(-) create mode 100644 apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts create mode 100644 apps/web/src/lib/timeline/placement/apply.ts create mode 100644 apps/web/src/lib/timeline/placement/compatibility.ts create mode 100644 apps/web/src/lib/timeline/placement/index.ts create mode 100644 apps/web/src/lib/timeline/placement/insert-index.ts create mode 100644 apps/web/src/lib/timeline/placement/main-track.ts create mode 100644 apps/web/src/lib/timeline/placement/overlap.ts create mode 100644 apps/web/src/lib/timeline/placement/resolve.ts create mode 100644 apps/web/src/lib/timeline/placement/track-factory.ts create mode 100644 apps/web/src/lib/timeline/placement/types.ts (limited to 'apps/web/src/lib/timeline') diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index cba8c65e..8c995029 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -1,16 +1,10 @@ import type { TimelineTrack, - ElementType, TimelineElement, } from "@/lib/timeline"; import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; -import { wouldElementOverlap } from "./element-utils"; import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; -import { - canElementGoOnTrack, - isMainTrack, - enforceMainTrackStart, -} from "./track-utils"; +import { resolveTrackPlacement } from "@/lib/track-placement"; function findElementAtPosition({ mouseX, @@ -82,57 +76,22 @@ function getTrackAtY({ return null; } -function isCompatible({ - elementType, - trackType, -}: { - elementType: ElementType; - trackType: TimelineTrack["type"]; -}): boolean { - return canElementGoOnTrack({ elementType, trackType }); -} - -function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number { - return tracks.findIndex((track) => isMainTrack(track)); -} +const EMPTY_TARGET_ELEMENT = null; -function findInsertIndex({ - elementType, - tracks, - preferredIndex, - insertAbove, +function fallbackNewTrackDropTarget({ + xPosition, }: { - elementType: ElementType; - tracks: TimelineTrack[]; - preferredIndex: number; - insertAbove: boolean; -}): { index: number; position: "above" | "below" } { - const mainTrackIndex = getMainTrackIndex({ tracks }); - - if (elementType === "audio") { - if (preferredIndex <= mainTrackIndex) { - return { index: mainTrackIndex + 1, position: "below" }; - } - return { - index: insertAbove ? preferredIndex : preferredIndex + 1, - position: insertAbove ? "above" : "below", - }; - } - - const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1; - - if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) { - return { index: mainTrackIndex, position: "above" }; - } - + xPosition: number; +}): DropTarget { return { - index: overlayInsertIndex, - position: insertAbove ? "above" : "below", + trackIndex: 0, + isNewTrack: true, + insertPosition: null, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, }; } -const EMPTY_TARGET_ELEMENT = null; - export function computeDropTarget({ elementType, mouseX, @@ -155,22 +114,28 @@ export function computeDropTarget({ ? playheadTime : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); - const mainTrackIndex = getMainTrackIndex({ tracks }); - if (tracks.length === 0) { - if (elementType === "audio") { - return { + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", trackIndex: 0, - isNewTrack: true, - insertPosition: "below", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; + hoverDirection: "below", + createNewTrackOnly: true, + }, + }); + const emptyTimelineResult = + placementResult?.kind === "newTrack" ? placementResult : null; + if (!emptyTimelineResult) { + return fallbackNewTrackDropTarget({ xPosition }); } + return { - trackIndex: 0, + trackIndex: emptyTimelineResult.insertIndex, isNewTrack: true, - insertPosition: null, + insertPosition: emptyTimelineResult.insertPosition, xPosition, targetElement: EMPTY_TARGET_ELEMENT, }; @@ -181,30 +146,27 @@ export function computeDropTarget({ if (!trackAtMouse) { const isAboveAllTracks = mouseY < 0; - if (elementType === "audio") { - return { - trackIndex: tracks.length, - isNewTrack: true, - insertPosition: "below", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; - } - - if (isAboveAllTracks) { - return { - trackIndex: 0, - isNewTrack: true, - insertPosition: "above", - xPosition, - targetElement: EMPTY_TARGET_ELEMENT, - }; + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex: isAboveAllTracks ? 0 : tracks.length - 1, + hoverDirection: isAboveAllTracks ? "above" : "below", + createNewTrackOnly: true, + }, + }); + const outOfBoundsResult = + placementResult?.kind === "newTrack" ? placementResult : null; + if (!outOfBoundsResult) { + return fallbackNewTrackDropTarget({ xPosition }); } return { - trackIndex: Math.max(0, mainTrackIndex), + trackIndex: outOfBoundsResult.insertIndex, isNewTrack: true, - insertPosition: "above", + insertPosition: outOfBoundsResult.insertPosition, xPosition, targetElement: EMPTY_TARGET_ELEMENT, }; @@ -237,34 +199,27 @@ export function computeDropTarget({ } const trackHeight = TRACK_CONFIG[track.type].height; - const isInUpperHalf = relativeY < trackHeight / 2; - - const isTrackCompatible = isCompatible({ + const placementResult = resolveTrackPlacement({ + tracks, elementType, - trackType: track.type, - }); - - const endTime = xPosition + elementDuration; - const hasOverlap = wouldElementOverlap({ - elements: track.elements, - startTime: xPosition, - endTime, - excludeElementId, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex, + hoverDirection: relativeY < trackHeight / 2 ? "above" : "below", + verticalDragDirection, + }, }); + if (!placementResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } - if (isTrackCompatible && !hasOverlap) { - const targetTrack = tracks[trackIndex]; - // safe: snap to 0 only happens when element becomes the new earliest, - // meaning the space before the current earliest is empty - const adjustedXPosition = enforceMainTrackStart({ - tracks, - targetTrackId: targetTrack.id, - requestedStartTime: xPosition, - excludeElementId, - }); + if (placementResult.kind === "existingTrack") { + const adjustedXPosition = + placementResult.adjustedStartTime ?? xPosition; return { - trackIndex, + trackIndex: placementResult.trackIndex, isNewTrack: false, insertPosition: null, xPosition: adjustedXPosition, @@ -272,22 +227,10 @@ export function computeDropTarget({ }; } - let insertAbove = isInUpperHalf; - if (!isTrackCompatible && verticalDragDirection) { - insertAbove = verticalDragDirection === "up"; - } - - const { index, position } = findInsertIndex({ - elementType, - tracks, - preferredIndex: trackIndex, - insertAbove, - }); - return { - trackIndex: index, + trackIndex: placementResult.insertIndex, isNewTrack: true, - insertPosition: position, + insertPosition: placementResult.insertPosition, xPosition, targetElement: EMPTY_TARGET_ELEMENT, }; diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 19b821be..ed7bb174 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -86,24 +86,6 @@ export function requiresMediaId({ ); } -export 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; - }); -} - function buildTextBackground( raw: Partial | undefined, ): TextBackground { diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts new file mode 100644 index 00000000..db9c38f3 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -0,0 +1,462 @@ +import { describe, expect, test } from "bun:test"; +import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline"; +import { resolveTrackPlacement } from "@/lib/track-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", + }); + }); +}); diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts new file mode 100644 index 00000000..4ff5d50d --- /dev/null +++ b/apps/web/src/lib/timeline/placement/apply.ts @@ -0,0 +1,45 @@ +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; +import { buildEmptyTrack } from "./track-factory"; +import type { PlacementResult } from "./types"; + +export function applyPlacement({ + tracks, + placementResult, + elements, + newTrackInsertIndexOverride, +}: { + tracks: TimelineTrack[]; + placementResult: PlacementResult; + elements: TimelineElement[]; + newTrackInsertIndexOverride?: number; +}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { + if (placementResult.kind === "existingTrack") { + const targetTrack = tracks[placementResult.trackIndex]; + if (!targetTrack) { + return null; + } + + const updatedTracks = tracks.map((track, trackIndex) => + trackIndex === placementResult.trackIndex + ? { + ...track, + elements: [...track.elements, ...elements], + } + : track, + ) as TimelineTrack[]; + + return { updatedTracks, targetTrackId: targetTrack.id }; + } + + const newTrackId = generateUUID(); + const newTrack = { + ...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }), + elements, + } as TimelineTrack; + const insertIndex = + newTrackInsertIndexOverride ?? placementResult.insertIndex; + const updatedTracks = [...tracks]; + updatedTracks.splice(insertIndex, 0, newTrack); + return { updatedTracks, targetTrackId: newTrackId }; +} diff --git a/apps/web/src/lib/timeline/placement/compatibility.ts b/apps/web/src/lib/timeline/placement/compatibility.ts new file mode 100644 index 00000000..de1baff3 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/compatibility.ts @@ -0,0 +1,51 @@ +import type { ElementType, TrackType } from "@/lib/timeline"; + +const ELEMENT_TRACK_MAP: Record = { + 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 new file mode 100644 index 00000000..091acc33 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/index.ts @@ -0,0 +1,18 @@ +export { applyPlacement } from "./apply"; +export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; +export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; +export { + enforceMainTrackStart, + ensureMainTrack, + getEarliestMainTrackElement, + getMainTrack, + isMainTrack, +} 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 new file mode 100644 index 00000000..fecb893a --- /dev/null +++ b/apps/web/src/lib/timeline/placement/insert-index.ts @@ -0,0 +1,99 @@ +import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import { isMainTrack } from "./main-track"; + +export function getDefaultInsertIndexForTrack({ + tracks, + trackType, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; +}): number { + if (trackType === "audio") { + return tracks.length; + } + + if (trackType === "effect") { + return 0; + } + + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + if (mainTrackIndex >= 0) { + return mainTrackIndex; + } + + const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio"); + if (firstAudioTrackIndex >= 0) { + return firstAudioTrackIndex; + } + + return tracks.length; +} + +export function getHighestInsertIndexForTrack({ + tracks, + trackType, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; +}): number { + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + if (trackType === "audio") { + return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; + } + + return 0; +} + +export function resolvePreferredNewTrackPlacement({ + tracks, + trackType, + preferredIndex, + direction, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; + preferredIndex: number; + direction: "above" | "below"; +}): { insertIndex: number; insertPosition: "above" | "below" | null } { + if (tracks.length === 0) { + return { + insertIndex: 0, + insertPosition: trackType === "audio" ? "below" : null, + }; + } + + const safePreferredIndex = Math.min( + Math.max(preferredIndex, 0), + tracks.length - 1, + ); + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + + 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 new file mode 100644 index 00000000..1783352c --- /dev/null +++ b/apps/web/src/lib/timeline/placement/main-track.ts @@ -0,0 +1,96 @@ +import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; + +const MAIN_TRACK_NAME = "Main Track"; + +export function isMainTrack(track: TimelineTrack): track is VideoTrack { + return track.type === "video" && track.isMain === true; +} + +export function getMainTrack({ + tracks, +}: { + tracks: TimelineTrack[]; +}): VideoTrack | null { + return tracks.find((track) => isMainTrack(track)) ?? null; +} + +export function ensureMainTrack({ + tracks, +}: { + tracks: TimelineTrack[]; +}): TimelineTrack[] { + if (tracks.some((track) => isMainTrack(track))) { + return tracks; + } + + return [ + { + id: generateUUID(), + name: MAIN_TRACK_NAME, + type: "video", + elements: [], + muted: false, + isMain: true, + hidden: false, + }, + ...tracks, + ]; +} + +export function getEarliestMainTrackElement({ + tracks, + excludeElementId, +}: { + tracks: TimelineTrack[]; + excludeElementId?: string; +}): TimelineElement | null { + const mainTrack = getMainTrack({ tracks }); + if (!mainTrack) { + return 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: TimelineTrack[]; + targetTrackId: string; + requestedStartTime: number; + excludeElementId?: string; +}): number { + const mainTrack = getMainTrack({ tracks }); + if (!mainTrack || mainTrack.id !== targetTrackId) { + return requestedStartTime; + } + + const earliestElement = getEarliestMainTrackElement({ + tracks, + 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 new file mode 100644 index 00000000..e5f95fa9 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/overlap.ts @@ -0,0 +1,40 @@ +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { PlacementTimeSpan } from "./types"; + +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: TimelineTrack; + 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 new file mode 100644 index 00000000..235c789a --- /dev/null +++ b/apps/web/src/lib/timeline/placement/resolve.ts @@ -0,0 +1,255 @@ +import type { 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: TimelineTrack[]; + timeSpans: PlacementTimeSpan[]; + strategy: PlacementStrategy; +}; + +function buildExistingTrackResult({ + track, + trackIndex, + tracks, + timeSpans, +}: { + track: TimelineTrack; + trackIndex: number; + tracks: TimelineTrack[]; + 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: TimelineTrack[]; + trackType: TrackType; + position: "highest" | "default"; +}): PlacementResult { + const insertIndex = + position === "highest" + ? getHighestInsertIndexForTrack({ + tracks, + trackType, + }) + : getDefaultInsertIndexForTrack({ + tracks, + trackType, + }); + + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition: null, + }); +} + +export function resolveTrackPlacement({ + tracks, + ...placement +}: ResolveTrackPlacementParams): PlacementResult | null { + const trackType = + "trackType" in placement + ? placement.trackType + : getTrackTypeForElementType({ + elementType: placement.elementType, + }); + const { timeSpans, strategy } = placement; + + if (strategy.type === "explicit") { + const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId); + if (trackIndex < 0) { + return null; + } + + const track = tracks[trackIndex]; + if (track.type !== trackType) { + return null; + } + + return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans }); + } + + if (strategy.type === "firstAvailable") { + const existingTrackIndex = findFirstAvailableTrackIndex({ + tracks, + trackType, + timeSpans, + }); + if (existingTrackIndex >= 0) { + return buildExistingTrackResult({ + track: tracks[existingTrackIndex], + trackIndex: existingTrackIndex, + tracks, + timeSpans, + }); + } + + return resolveAlwaysNewTrack({ + tracks, + trackType, + position: "default", + }); + } + + if (strategy.type === "preferIndex") { + const preferredTrack = tracks[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 insertDirection = + !isPreferredTrackCompatible && strategy.verticalDragDirection + ? strategy.verticalDragDirection + : strategy.hoverDirection; + const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({ + tracks, + trackType, + preferredIndex: strategy.trackIndex, + direction: insertDirection, + }); + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition, + }); + } + + if (strategy.type === "aboveSource") { + const aboveTrackIndex = strategy.sourceTrackIndex - 1; + const aboveTrack = tracks[aboveTrackIndex]; + if ( + aboveTrack && + aboveTrack.type === trackType && + canPlaceTimeSpansOnTrack({ + track: aboveTrack, + timeSpans, + }) + ) { + return buildExistingTrackResult({ + track: aboveTrack, + trackIndex: aboveTrackIndex, + tracks, + timeSpans, + }); + } + + const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ + tracks, + trackType, + timeSpans, + }); + if (firstAvailableTrackIndex >= 0) { + return buildExistingTrackResult({ + track: tracks[firstAvailableTrackIndex], + trackIndex: firstAvailableTrackIndex, + tracks, + timeSpans, + }); + } + + const insertIndex = + strategy.sourceTrackIndex >= 0 + ? strategy.sourceTrackIndex + : 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 new file mode 100644 index 00000000..e77a8a96 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/track-factory.ts @@ -0,0 +1,61 @@ +import { TRACK_CONFIG } from "@/constants/timeline-constants"; +import type { TrackType, TimelineTrack } from "@/lib/timeline"; + +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: TrackType; + name?: string; +}): TimelineTrack { + const trackName = name ?? TRACK_CONFIG[type].defaultName; + + switch (type) { + case "video": + return { + id, + name: trackName, + type: "video", + elements: [], + hidden: false, + muted: false, + isMain: 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 new file mode 100644 index 00000000..559e85cc --- /dev/null +++ b/apps/web/src/lib/timeline/placement/types.ts @@ -0,0 +1,39 @@ +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; + }; diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index 0034c72e..cc9e67c4 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -1,21 +1,17 @@ import type { TrackType, TimelineTrack, - ElementType, VideoTrack, AudioTrack, GraphicTrack, TextTrack, EffectTrack, - TimelineElement, } from "@/lib/timeline"; import { - ELEMENT_TRACK_MAP, TRACK_CONFIG, ELEMENT_TYPE_CONFIG, TRACK_GAP, } from "@/constants/timeline-constants"; -import { generateUUID } from "@/utils/id"; export function canTracktHaveAudio( track: TimelineTrack, @@ -64,234 +60,3 @@ export function getTotalTracksHeight({ const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP; return tracksHeight + gapsHeight; } - -export function buildEmptyTrack({ - id, - type, - name, -}: { - id: string; - type: TrackType; - name?: string; -}): TimelineTrack { - const trackName = name ?? TRACK_CONFIG[type].defaultName; - - switch (type) { - case "video": - return { - id, - name: trackName, - type: "video", - elements: [], - hidden: false, - muted: false, - isMain: 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}`); - } -} - -export function getDefaultInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: TimelineTrack[]; - trackType: TrackType; -}): number { - if (trackType === "audio") { - return tracks.length; - } - - if (trackType === "effect") { - return 0; - } - - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - if (mainTrackIndex >= 0) { - return mainTrackIndex; - } - - const firstAudioTrackIndex = tracks.findIndex( - (track) => track.type === "audio", - ); - if (firstAudioTrackIndex >= 0) { - return firstAudioTrackIndex; - } - - return tracks.length; -} - -export function getHighestInsertIndexForTrack({ - tracks, - trackType, -}: { - tracks: TimelineTrack[]; - trackType: TrackType; -}): number { - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - - if (trackType === "audio") { - return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; - } - - return 0; -} - -export function isMainTrack(track: TimelineTrack): track is VideoTrack { - return track.type === "video" && track.isMain === true; -} - -export function getMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack | null { - return tracks.find((track) => isMainTrack(track)) ?? null; -} - -export function ensureMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack[] { - const hasMainTrack = tracks.some((track) => isMainTrack(track)); - - if (!hasMainTrack) { - const mainTrack: TimelineTrack = { - id: generateUUID(), - name: "Main Track", - type: "video", - elements: [], - muted: false, - isMain: true, - hidden: false, - }; - return [mainTrack, ...tracks]; - } - - return tracks; -} - -export function canElementGoOnTrack({ - elementType, - trackType, -}: { - elementType: ElementType; - trackType: TrackType; -}): boolean { - return ELEMENT_TRACK_MAP[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 }; -} - -export function getEarliestMainTrackElement({ - tracks, - excludeElementId, -}: { - tracks: TimelineTrack[]; - excludeElementId?: string; -}): TimelineElement | null { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack) { - return null; - } - - const elements = mainTrack.elements.filter( - (element) => !excludeElementId || element.id !== excludeElementId, - ); - - if (elements.length === 0) { - return null; - } - - return elements.reduce((earliest, element) => - element.startTime < earliest.startTime ? element : earliest, - ); -} - -export function enforceMainTrackStart({ - tracks, - targetTrackId, - requestedStartTime, - excludeElementId, -}: { - tracks: TimelineTrack[]; - targetTrackId: string; - requestedStartTime: number; - excludeElementId?: string; -}): number { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack || mainTrack.id !== targetTrackId) { - return requestedStartTime; - } - - const earliestElement = getEarliestMainTrackElement({ - tracks, - excludeElementId, - }); - - if (!earliestElement) { - return 0; - } - - // main track must always start at time 0; if this element would - // become the earliest, pin it to the start - if (requestedStartTime <= earliestElement.startTime) { - return 0; - } - - return requestedStartTime; -} -- cgit v1.3.1