diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 5b1ebb96..f435c8ea 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -49,10 +49,10 @@ import { getTotalTracksHeight, canTracktHaveAudio, canTrackBeHidden, - isMainTrack, getTimelineZoomMin, getTimelinePaddingPx, } from "@/lib/timeline"; +import { isMainTrack } from "@/lib/track-placement"; import { TimelineToolbar } from "./timeline-toolbar"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 957775fd..99fa2d2d 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -1,4 +1,4 @@ -import type { ElementType, TrackType } from "@/lib/timeline"; +import type { TrackType } from "@/lib/timeline"; export const TRACK_CONFIG: Record< TrackType, @@ -46,16 +46,6 @@ export const ELEMENT_TYPE_CONFIG: Record< effect: { background: "bg-[#5d93ba]" }, } as const; -export const ELEMENT_TRACK_MAP: Record = { - audio: "audio", - text: "text", - sticker: "graphic", - graphic: "graphic", - effect: "effect", - video: "video", - image: "video", -}; - export const TRACK_GAP = 6; export const TRACK_LABELS_WIDTH_PX = 112; diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 3c53efd6..c5d434bb 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -12,7 +12,7 @@ import { getFrameTime, isBookmarkAtTime, } from "@/lib/timeline/bookmarks"; -import { ensureMainTrack } from "@/lib/timeline/track-utils"; +import { ensureMainTrack } from "@/lib/track-placement"; import { CreateSceneCommand, DeleteSceneCommand, diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 7cd9f140..07318c01 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -16,7 +16,7 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getDragData, hasDragData } from "@/lib/drag-data"; -import { isMainTrack } from "@/lib/timeline/track-utils"; +import { isMainTrack } from "@/lib/track-placement"; import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { MediaDragData, diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index d9c54ec3..f48bc2da 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -6,13 +6,12 @@ import type { ClipboardItem, } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; -import { wouldElementOverlap } from "@/lib/timeline/element-utils"; import { - buildEmptyTrack, - getHighestInsertIndexForTrack, + applyPlacement, + resolveTrackPlacement, isMainTrack, enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; +} from "@/lib/track-placement"; import { cloneAnimations } from "@/lib/animation"; export class PasteCommand extends Command { @@ -59,17 +58,22 @@ export class PasteCommand extends Command { const sourceTrackIndex = updatedTracks.findIndex( (track) => track.id === trackId, ); - const resolvedTargetIndex = resolveTargetTrackIndex({ + const placementResult = resolveTrackPlacement({ tracks: updatedTracks, - sourceTrackIndex, trackType, - elements: elementsToAdd, + timeSpans: elementsToAdd.map((element) => ({ + startTime: element.startTime, + duration: element.duration, + })), + strategy: { type: "aboveSource", sourceTrackIndex }, }); + if (!placementResult) { + continue; + } - if (resolvedTargetIndex >= 0) { - const targetTrack = updatedTracks[resolvedTargetIndex]; - let adjustedElements = elementsToAdd; - + let elementsForPlacement = elementsToAdd; + if (placementResult.kind === "existingTrack") { + const targetTrack = updatedTracks[placementResult.trackIndex]; if (isMainTrack(targetTrack)) { const earliestElement = elementsToAdd.reduce((earliest, element) => element.startTime < earliest.startTime ? element : earliest, @@ -82,39 +86,28 @@ export class PasteCommand extends Command { const delta = adjustedEarliestStartTime - earliestElement.startTime; if (delta !== 0) { - adjustedElements = elementsToAdd.map((element) => ({ + elementsForPlacement = elementsToAdd.map((element) => ({ ...element, startTime: Math.max(0, element.startTime + delta), })); } } + } - updatedTracks[resolvedTargetIndex] = { - ...targetTrack, - elements: [...targetTrack.elements, ...adjustedElements], - } as TimelineTrack; - for (const element of adjustedElements) { - this.pastedElements.push({ - trackId: targetTrack.id, - elementId: element.id, - }); - } + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: elementsForPlacement, + }); + if (!applied) { continue; } - const insertIndex = resolveInsertIndexForNewTrack({ - tracks: updatedTracks, - sourceTrackIndex, - trackType, - }); - const newTrack = buildTrackWithElements({ - trackType, - elements: elementsToAdd, - }); - updatedTracks.splice(insertIndex, 0, newTrack); - for (const element of elementsToAdd) { + updatedTracks = applied.updatedTracks; + + for (const element of elementsForPlacement) { this.pastedElements.push({ - trackId: newTrack.id, + trackId: applied.targetTrackId, elementId: element.id, }); } @@ -187,107 +180,3 @@ function buildPastedElements({ return elementsToAdd; } -function resolveTargetTrackIndex({ - tracks, - sourceTrackIndex, - trackType, - elements, -}: { - tracks: TimelineTrack[]; - sourceTrackIndex: number; - trackType: ClipboardItem["trackType"]; - elements: TimelineElement[]; -}): number { - if (sourceTrackIndex >= 0) { - const aboveIndex = sourceTrackIndex - 1; - if (aboveIndex < 0) { - return -1; - } - - const aboveTrack = tracks[aboveIndex]; - if (aboveTrack.type !== trackType) { - return -1; - } - - const canPlaceOnAbove = canPlaceElementsOnTrack({ - track: aboveTrack, - elements, - }); - return canPlaceOnAbove ? aboveIndex : -1; - } - - const highestCompatibleIndex = tracks.findIndex( - (track) => track.type === trackType, - ); - if (highestCompatibleIndex < 0) { - return -1; - } - - const highestCompatibleTrack = tracks[highestCompatibleIndex]; - const canPlaceOnHighest = canPlaceElementsOnTrack({ - track: highestCompatibleTrack, - elements, - }); - - return canPlaceOnHighest ? highestCompatibleIndex : -1; -} - -function resolveInsertIndexForNewTrack({ - tracks, - sourceTrackIndex, - trackType, -}: { - tracks: TimelineTrack[]; - sourceTrackIndex: number; - trackType: ClipboardItem["trackType"]; -}): number { - if (sourceTrackIndex >= 0) { - return sourceTrackIndex; - } - - const highestCompatibleIndex = tracks.findIndex( - (track) => track.type === trackType, - ); - if (highestCompatibleIndex >= 0) { - return highestCompatibleIndex; - } - - return getHighestInsertIndexForTrack({ tracks, trackType }); -} - -function canPlaceElementsOnTrack({ - track, - elements, -}: { - track: TimelineTrack; - elements: TimelineElement[]; -}): boolean { - for (const element of elements) { - const endTime = element.startTime + element.duration; - const hasOverlap = wouldElementOverlap({ - elements: track.elements, - startTime: element.startTime, - endTime, - }); - if (hasOverlap) { - return false; - } - } - - return true; -} - -function buildTrackWithElements({ - trackType, - elements, -}: { - trackType: ClipboardItem["trackType"]; - elements: TimelineElement[]; -}): TimelineTrack { - const newTrackId = generateUUID(); - const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType }); - return { - ...newTrackBase, - elements, - } as TimelineTrack; -} diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 9b27a3c2..b3078851 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,7 +1,8 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; -import { isMainTrack, rippleShiftElements } from "@/lib/timeline"; +import { rippleShiftElements } from "@/lib/timeline"; +import { isMainTrack } from "@/lib/track-placement"; export class DeleteElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 346f8c73..6b9edae0 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; -import { - buildEmptyTrack, - getHighestInsertIndexForTrack, -} from "@/lib/timeline/track-utils"; +import { applyPlacement, resolveTrackPlacement } from "@/lib/track-placement"; import { cloneAnimations } from "@/lib/animation"; interface DuplicateElementsParams { @@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command { ); const newTrackElements: TimelineElement[] = []; - const newTrackId = generateUUID(); - const newTrackBase = buildEmptyTrack({ - id: newTrackId, - type: track.type, - }); - for (const element of track.elements) { if (!elementIdsToDuplicate.has(element.id)) { continue; } const newId = generateUUID(); - this.duplicatedElements.push({ - trackId: newTrackId, - elementId: newId, - }); newTrackElements.push( buildDuplicateElement({ element, @@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command { ); } - const newTrack = { - ...newTrackBase, - elements: newTrackElements, - } as TimelineTrack; - - const insertIndex = getHighestInsertIndexForTrack({ + const placementResult = resolveTrackPlacement({ tracks: updatedTracks, trackType: track.type, + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, }); - updatedTracks.splice(insertIndex, 0, newTrack); + if (!placementResult || placementResult.kind !== "newTrack") { + continue; + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: newTrackElements, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of newTrackElements) { + this.duplicatedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } } editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index e6d6ee99..d224cfdb 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -5,23 +5,18 @@ import type { TimelineTrack, TimelineElement, TrackType, - ElementType, } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; -import { - requiresMediaId, - wouldElementOverlap, -} from "@/lib/timeline/element-utils"; -import { - buildEmptyTrack, - canElementGoOnTrack, - getDefaultInsertIndexForTrack, - validateElementTrackCompatibility, - enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; +import { requiresMediaId } from "@/lib/timeline/element-utils"; import type { MediaAsset } from "@/lib/media/types"; -import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; +import { + applyPlacement, + canElementGoOnTrack, + resolveTrackPlacement, + validateElementTrackCompatibility, +} from "@/lib/track-placement"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -67,7 +62,7 @@ export class InsertElementCommand extends Command { const isFirstElement = totalElementsInTimeline === 0; const newElement = this.buildElement({ element: this.element }); - const updateResult = this.resolveTracksWithElement({ + const updateResult = this.applyPlacementResult({ tracks: this.savedState, element: newElement, }); @@ -190,7 +185,7 @@ export class InsertElementCommand extends Command { return true; } - private resolveTracksWithElement({ + private applyPlacementResult({ tracks, element, }: { @@ -199,160 +194,71 @@ export class InsertElementCommand extends Command { }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { const placement = this.placement; - if (placement.mode === "explicit") { - const targetTrack = tracks.find( - (track) => track.id === placement.trackId, - ); - - if (!targetTrack) { - console.error("Track not found:", placement.trackId); - return null; - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - - if (!validation.isValid) { - console.error(validation.errorMessage); - return null; - } - - const adjustedElement = this.adjustElementForMainTrack({ - tracks, - targetTrackId: targetTrack.id, - element, - }); - - const updatedTracks = tracks.map((track) => - track.id === targetTrack.id - ? { - ...track, - elements: [...track.elements, adjustedElement], - } - : track, - ) as TimelineTrack[]; - - return { updatedTracks, targetTrackId: targetTrack.id }; - } - - const trackType = - placement.trackType ?? this.getTrackTypeForElement({ element }); - if ( + placement.mode === "auto" && placement.trackType && !canElementGoOnTrack({ elementType: element.type, - trackType, + trackType: placement.trackType, }) ) { console.error( - `${element.type} elements cannot be placed on ${trackType} tracks`, + `${element.type} elements cannot be placed on ${placement.trackType} tracks`, ); return null; } - const elementEndTime = element.startTime + element.duration; - const existingTrack = tracks.find((track) => { - if ( - !canElementGoOnTrack({ - elementType: element.type, - trackType: track.type, - }) - ) { - return false; + const placementResult = resolveTrackPlacement({ + tracks, + ...(placement.mode === "auto" && placement.trackType + ? { trackType: placement.trackType } + : { elementType: element.type }), + timeSpans: [ + { + startTime: element.startTime, + duration: element.duration, + }, + ], + strategy: + placement.mode === "explicit" + ? { type: "explicit", trackId: placement.trackId } + : { type: "firstAvailable" }, + }); + if (!placementResult) { + if (placement.mode === "explicit") { + const targetTrack = tracks.find((track) => track.id === placement.trackId); + if (!targetTrack) { + console.error("Track not found:", placement.trackId); + return null; + } + + const validation = validateElementTrackCompatibility({ + element, + track: targetTrack, + }); + console.error(validation.errorMessage); } - return !wouldElementOverlap({ - elements: track.elements, - startTime: element.startTime, - endTime: elementEndTime, - }); - }); - - if (existingTrack) { - const adjustedElement = this.adjustElementForMainTrack({ - tracks, - targetTrackId: existingTrack.id, - element, - }); - - const updatedTracks = tracks.map((track) => - track.id === existingTrack.id - ? { - ...track, - elements: [...track.elements, adjustedElement], - } - : track, - ) as TimelineTrack[]; - - return { updatedTracks, targetTrackId: existingTrack.id }; + return null; } - const newTrackId = generateUUID(); - const newTrack = buildEmptyTrack({ - id: newTrackId, - type: trackType, - }); - const newTrackWithElement = { - ...newTrack, - elements: [...newTrack.elements, element], - } as TimelineTrack; + const elementToPlace = + placementResult.kind === "existingTrack" + ? { + ...element, + startTime: + placementResult.adjustedStartTime ?? element.startTime, + } + : element; - const updatedTracks = [...tracks]; - const insertIndex = - placement.insertIndex ?? - this.getAutoInsertIndex({ tracks: updatedTracks, trackType }); - updatedTracks.splice(insertIndex, 0, newTrackWithElement); - - return { updatedTracks, targetTrackId: newTrackId }; - } - - private getAutoInsertIndex({ - tracks, - trackType, - }: { - tracks: TimelineTrack[]; - trackType: TrackType; - }): number { - if (trackType === "text") { - const firstVideoTrackIndex = tracks.findIndex( - (track) => track.type === "video", - ); - if (firstVideoTrackIndex >= 0) { - return firstVideoTrackIndex; - } - } - - return getDefaultInsertIndexForTrack({ + return applyPlacement({ tracks, - trackType, + placementResult, + elements: [elementToPlace], + newTrackInsertIndexOverride: + placement.mode === "auto" && typeof placement.insertIndex === "number" + ? placement.insertIndex + : undefined, }); } - - private adjustElementForMainTrack({ - tracks, - targetTrackId, - element, - }: { - tracks: TimelineTrack[]; - targetTrackId: string; - element: TimelineElement; - }): TimelineElement { - const adjustedStartTime = enforceMainTrackStart({ - tracks, - targetTrackId, - requestedStartTime: element.startTime, - }); - return { ...element, startTime: adjustedStartTime }; - } - - private getTrackTypeForElement({ - element, - }: { - element: { type: ElementType }; - }): TrackType { - return ELEMENT_TRACK_MAP[element.type]; - } } diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 24605d0b..f9144d38 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -10,7 +10,7 @@ import { isMainTrack, validateElementTrackCompatibility, enforceMainTrackStart, -} from "@/lib/timeline/track-utils"; +} from "@/lib/track-placement"; import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; export class MoveElementCommand extends Command { diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index d640d001..cd7e9bd9 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; -import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; +import { enforceMainTrackStart } from "@/lib/track-placement"; export class UpdateElementStartTimeCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index b2c77418..54ec5697 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; -import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; +import { enforceMainTrackStart } from "@/lib/track-placement"; export class UpdateElementTrimCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index b1f0daa0..43286b95 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -5,7 +5,7 @@ import { EditorCore } from "@/core"; import { buildEmptyTrack, getDefaultInsertIndexForTrack, -} from "@/lib/timeline/track-utils"; +} from "@/lib/track-placement"; export class AddTrackCommand extends Command { private trackId: string; diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index a9859a1a..b5d57a3d 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TimelineTrack } from "@/lib/timeline"; -import { getMainTrack } from "@/lib/timeline"; +import { getMainTrack } from "@/lib/track-placement"; export class RemoveTrackCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index c8420467..a53cf08e 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -1,6 +1,6 @@ import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; -import { isMainTrack } from "@/lib/timeline"; +import { isMainTrack } from "@/lib/track-placement"; import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; import { measureTextElement } from "@/lib/text/measure-element"; diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts index 9a63242c..96bdfd64 100644 --- a/apps/web/src/lib/scenes.ts +++ b/apps/web/src/lib/scenes.ts @@ -1,7 +1,7 @@ import type { TScene } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { calculateTotalDuration } from "@/lib/timeline"; -import { ensureMainTrack } from "@/lib/timeline/track-utils"; +import { ensureMainTrack } from "@/lib/track-placement"; export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { return scenes.find((scene) => scene.isMain) || null; 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, +const EMPTY_TARGET_ELEMENT = null; + +function fallbackNewTrackDropTarget({ + xPosition, }: { - elementType: ElementType; - trackType: TimelineTrack["type"]; -}): boolean { - return canElementGoOnTrack({ elementType, trackType }); -} - -function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number { - return tracks.findIndex((track) => isMainTrack(track)); -} - -function findInsertIndex({ - elementType, - tracks, - preferredIndex, - insertAbove, -}: { - 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, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex, + hoverDirection: relativeY < trackHeight / 2 ? "above" : "below", + verticalDragDirection, + }, }); + if (!placementResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } - const endTime = xPosition + elementDuration; - const hasOverlap = wouldElementOverlap({ - elements: track.elements, - startTime: xPosition, - endTime, - excludeElementId, - }); - - 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; -} diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 1c3e6ad2..e4010223 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/lib/project/types"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; -import { isMainTrack } from "@/lib/timeline"; +import { isMainTrack } from "@/lib/track-placement"; const PREVIEW_MAX_IMAGE_SIZE = 2048; diff --git a/bun.lock b/bun.lock index 453d507a..f43109d0 100644 --- a/bun.lock +++ b/bun.lock @@ -77,7 +77,7 @@ "unified": "^11.0.5", "use-deep-compare-effect": "^1.8.1", "wavesurfer.js": "^7.9.8", - "zod": "^4.3.6", + "zod": "4.3.6", "zustand": "^5.0.2", }, "devDependencies": {