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 +++++++++++++++++++++ apps/web/src/timeline/placement/apply.ts | 158 +++++ apps/web/src/timeline/placement/compatibility.ts | 51 ++ apps/web/src/timeline/placement/index.ts | 12 + apps/web/src/timeline/placement/insert-index.ts | 88 +++ apps/web/src/timeline/placement/main-track.ts | 54 ++ apps/web/src/timeline/placement/overlap.ts | 44 ++ apps/web/src/timeline/placement/resolve.ts | 277 +++++++++ apps/web/src/timeline/placement/track-factory.ts | 123 ++++ apps/web/src/timeline/placement/types.ts | 39 ++ 10 files changed, 1507 insertions(+) create mode 100644 apps/web/src/timeline/placement/__tests__/resolve.test.ts create mode 100644 apps/web/src/timeline/placement/apply.ts create mode 100644 apps/web/src/timeline/placement/compatibility.ts create mode 100644 apps/web/src/timeline/placement/index.ts create mode 100644 apps/web/src/timeline/placement/insert-index.ts create mode 100644 apps/web/src/timeline/placement/main-track.ts create mode 100644 apps/web/src/timeline/placement/overlap.ts create mode 100644 apps/web/src/timeline/placement/resolve.ts create mode 100644 apps/web/src/timeline/placement/track-factory.ts create mode 100644 apps/web/src/timeline/placement/types.ts (limited to 'apps/web/src/timeline/placement') 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; + 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", + }); + }); +}); diff --git a/apps/web/src/timeline/placement/apply.ts b/apps/web/src/timeline/placement/apply.ts new file mode 100644 index 00000000..214b0611 --- /dev/null +++ b/apps/web/src/timeline/placement/apply.ts @@ -0,0 +1,158 @@ +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + OverlayTrack, + SceneTracks, + TextTrack, + TimelineElement, + TimelineTrack, + VideoTrack, +} from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { buildEmptyTrack } from "./track-factory"; +import type { PlacementResult } from "./types"; +import { updateTrackInSceneTracks } from "@/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; + 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/timeline/placement/compatibility.ts b/apps/web/src/timeline/placement/compatibility.ts new file mode 100644 index 00000000..b1af547c --- /dev/null +++ b/apps/web/src/timeline/placement/compatibility.ts @@ -0,0 +1,51 @@ +import type { ElementType, TrackType } from "@/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/timeline/placement/index.ts b/apps/web/src/timeline/placement/index.ts new file mode 100644 index 00000000..530cb69f --- /dev/null +++ b/apps/web/src/timeline/placement/index.ts @@ -0,0 +1,12 @@ +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/timeline/placement/insert-index.ts b/apps/web/src/timeline/placement/insert-index.ts new file mode 100644 index 00000000..5e2786dc --- /dev/null +++ b/apps/web/src/timeline/placement/insert-index.ts @@ -0,0 +1,88 @@ +import type { SceneTracks, TrackType } from "@/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/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts new file mode 100644 index 00000000..ab2ab7bd --- /dev/null +++ b/apps/web/src/timeline/placement/main-track.ts @@ -0,0 +1,54 @@ +import type { SceneTracks, TimelineElement, VideoTrack } from "@/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/timeline/placement/overlap.ts b/apps/web/src/timeline/placement/overlap.ts new file mode 100644 index 00000000..daf3e627 --- /dev/null +++ b/apps/web/src/timeline/placement/overlap.ts @@ -0,0 +1,44 @@ +import type { TimelineElement } from "@/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/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts new file mode 100644 index 00000000..26275392 --- /dev/null +++ b/apps/web/src/timeline/placement/resolve.ts @@ -0,0 +1,277 @@ +import type { SceneTracks, TrackType, TimelineTrack } from "@/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/timeline/placement/track-factory.ts b/apps/web/src/timeline/placement/track-factory.ts new file mode 100644 index 00000000..9c69ea8c --- /dev/null +++ b/apps/web/src/timeline/placement/track-factory.ts @@ -0,0 +1,123 @@ +import { DEFAULT_TRACK_NAMES } from "@/timeline/tracks"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + TextTrack, + TrackType, + TimelineTrack, + VideoTrack, +} from "@/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/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts new file mode 100644 index 00000000..6847ab36 --- /dev/null +++ b/apps/web/src/timeline/placement/types.ts @@ -0,0 +1,39 @@ +import type { ElementType, TrackType } from "@/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; + }; -- cgit v1.3.1