summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx2
-rw-r--r--apps/web/src/constants/timeline-constants.ts12
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts2
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts165
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts3
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts46
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts206
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/add-track.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/remove-track.ts2
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts2
-rw-r--r--apps/web/src/lib/scenes.ts2
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts183
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts18
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts462
-rw-r--r--apps/web/src/lib/timeline/placement/apply.ts45
-rw-r--r--apps/web/src/lib/timeline/placement/compatibility.ts51
-rw-r--r--apps/web/src/lib/timeline/placement/index.ts18
-rw-r--r--apps/web/src/lib/timeline/placement/insert-index.ts99
-rw-r--r--apps/web/src/lib/timeline/placement/main-track.ts96
-rw-r--r--apps/web/src/lib/timeline/placement/overlap.ts40
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts255
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts61
-rw-r--r--apps/web/src/lib/timeline/placement/types.ts39
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts235
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts2
29 files changed, 1353 insertions, 706 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 5b1ebb96..819413e6 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/timeline/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<ElementType, TrackType> = {
- 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..43938f96 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/timeline/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 9693e8d8..ce6f1341 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/timeline/placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
@@ -444,7 +444,8 @@ export function useTimelineDragDrop({
if (!createdAsset) continue;
const duration =
- createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ createdAsset.duration ??
+ TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index d9c54ec3..e1b43364 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/timeline/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,
- });
- }
- continue;
}
- const insertIndex = resolveInsertIndexForNewTrack({
+ const applied = applyPlacement({
tracks: updatedTracks,
- sourceTrackIndex,
- trackType,
+ placementResult,
+ elements: elementsForPlacement,
});
- const newTrack = buildTrackWithElements({
- trackType,
- elements: elementsToAdd,
- });
- updatedTracks.splice(insertIndex, 0, newTrack);
- for (const element of elementsToAdd) {
+ if (!applied) {
+ continue;
+ }
+
+ 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..4bbb0d48 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/timeline/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..a4f7ca82 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/timeline/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..cdff3565 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 } from "@/lib/timeline/element-utils";
+import type { MediaAsset } from "@/lib/media/types";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
- requiresMediaId,
- wouldElementOverlap,
-} from "@/lib/timeline/element-utils";
-import {
- buildEmptyTrack,
+ applyPlacement,
canElementGoOnTrack,
- getDefaultInsertIndexForTrack,
+ resolveTrackPlacement,
validateElementTrackCompatibility,
- enforceMainTrackStart,
-} from "@/lib/timeline/track-utils";
-import type { MediaAsset } from "@/lib/media/types";
-import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+} from "@/lib/timeline/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;
- }
-
- 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 };
- }
-
- const newTrackId = generateUUID();
- const newTrack = buildEmptyTrack({
- id: newTrackId,
- type: trackType,
+ 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" },
});
- const newTrackWithElement = {
- ...newTrack,
- elements: [...newTrack.elements, element],
- } as TimelineTrack;
+ 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 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;
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+ console.error(validation.errorMessage);
}
+
+ return null;
}
- return getDefaultInsertIndexForTrack({
- tracks,
- trackType,
- });
- }
+ const elementToPlace =
+ placementResult.kind === "existingTrack"
+ ? {
+ ...element,
+ startTime:
+ placementResult.adjustedStartTime ?? element.startTime,
+ }
+ : element;
- private adjustElementForMainTrack({
- tracks,
- targetTrackId,
- element,
- }: {
- tracks: TimelineTrack[];
- targetTrackId: string;
- element: TimelineElement;
- }): TimelineElement {
- const adjustedStartTime = enforceMainTrackStart({
+ return applyPlacement({
tracks,
- targetTrackId,
- requestedStartTime: element.startTime,
+ placementResult,
+ elements: [elementToPlace],
+ newTrackInsertIndexOverride:
+ placement.mode === "auto" && typeof placement.insertIndex === "number"
+ ? placement.insertIndex
+ : undefined,
});
- 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..73d07bff 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/timeline/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..ff81cff2 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/timeline/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..13ee66dd 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/timeline/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..104c0462 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/timeline/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..f35edbe4 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/timeline/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..380a008c 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/timeline/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..1b5b293c 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/timeline/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..161d2714 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/timeline/placement";
function findElementAtPosition({
mouseX,
@@ -82,57 +76,22 @@ function getTrackAtY({
return null;
}
-function isCompatible({
- elementType,
- trackType,
-}: {
- elementType: ElementType;
- trackType: TimelineTrack["type"];
-}): boolean {
- return canElementGoOnTrack({ elementType, trackType });
-}
-
-function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
- return tracks.findIndex((track) => isMainTrack(track));
-}
+const EMPTY_TARGET_ELEMENT = null;
-function findInsertIndex({
- elementType,
- tracks,
- preferredIndex,
- insertAbove,
+function fallbackNewTrackDropTarget({
+ xPosition,
}: {
- elementType: ElementType;
- tracks: TimelineTrack[];
- preferredIndex: number;
- insertAbove: boolean;
-}): { index: number; position: "above" | "below" } {
- const mainTrackIndex = getMainTrackIndex({ tracks });
-
- if (elementType === "audio") {
- if (preferredIndex <= mainTrackIndex) {
- return { index: mainTrackIndex + 1, position: "below" };
- }
- return {
- index: insertAbove ? preferredIndex : preferredIndex + 1,
- position: insertAbove ? "above" : "below",
- };
- }
-
- const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
-
- if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
- return { index: mainTrackIndex, position: "above" };
- }
-
+ xPosition: number;
+}): DropTarget {
return {
- index: overlayInsertIndex,
- position: insertAbove ? "above" : "below",
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
-const EMPTY_TARGET_ELEMENT = null;
-
export function computeDropTarget({
elementType,
mouseX,
@@ -155,22 +114,28 @@ export function computeDropTarget({
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
- const mainTrackIndex = getMainTrackIndex({ tracks });
-
if (tracks.length === 0) {
- if (elementType === "audio") {
- return {
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ elementType,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
trackIndex: 0,
- isNewTrack: true,
- insertPosition: "below",
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
+ hoverDirection: "below",
+ createNewTrackOnly: true,
+ },
+ });
+ const emptyTimelineResult =
+ placementResult?.kind === "newTrack" ? placementResult : null;
+ if (!emptyTimelineResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
}
+
return {
- trackIndex: 0,
+ trackIndex: emptyTimelineResult.insertIndex,
isNewTrack: true,
- insertPosition: null,
+ insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -181,30 +146,27 @@ export function computeDropTarget({
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
- if (elementType === "audio") {
- return {
- trackIndex: tracks.length,
- isNewTrack: true,
- insertPosition: "below",
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- if (isAboveAllTracks) {
- return {
- trackIndex: 0,
- isNewTrack: true,
- insertPosition: "above",
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ elementType,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
+ hoverDirection: isAboveAllTracks ? "above" : "below",
+ createNewTrackOnly: true,
+ },
+ });
+ const outOfBoundsResult =
+ placementResult?.kind === "newTrack" ? placementResult : null;
+ if (!outOfBoundsResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
}
return {
- trackIndex: Math.max(0, mainTrackIndex),
+ trackIndex: outOfBoundsResult.insertIndex,
isNewTrack: true,
- insertPosition: "above",
+ insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -237,34 +199,27 @@ export function computeDropTarget({
}
const trackHeight = TRACK_CONFIG[track.type].height;
- const isInUpperHalf = relativeY < trackHeight / 2;
-
- const isTrackCompatible = isCompatible({
+ const placementResult = resolveTrackPlacement({
+ tracks,
elementType,
- trackType: track.type,
- });
-
- const endTime = xPosition + elementDuration;
- const hasOverlap = wouldElementOverlap({
- elements: track.elements,
- startTime: xPosition,
- endTime,
- excludeElementId,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
+ trackIndex,
+ hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
+ verticalDragDirection,
+ },
});
+ if (!placementResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
+ }
- if (isTrackCompatible && !hasOverlap) {
- const targetTrack = tracks[trackIndex];
- // safe: snap to 0 only happens when element becomes the new earliest,
- // meaning the space before the current earliest is empty
- const adjustedXPosition = enforceMainTrackStart({
- tracks,
- targetTrackId: targetTrack.id,
- requestedStartTime: xPosition,
- excludeElementId,
- });
+ if (placementResult.kind === "existingTrack") {
+ const adjustedXPosition =
+ placementResult.adjustedStartTime ?? xPosition;
return {
- trackIndex,
+ trackIndex: placementResult.trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
@@ -272,22 +227,10 @@ export function computeDropTarget({
};
}
- let insertAbove = isInUpperHalf;
- if (!isTrackCompatible && verticalDragDirection) {
- insertAbove = verticalDragDirection === "up";
- }
-
- const { index, position } = findInsertIndex({
- elementType,
- tracks,
- preferredIndex: trackIndex,
- insertAbove,
- });
-
return {
- trackIndex: index,
+ trackIndex: placementResult.insertIndex,
isNewTrack: true,
- insertPosition: position,
+ insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index 19b821be..ed7bb174 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -86,24 +86,6 @@ export function requiresMediaId({
);
}
-export function wouldElementOverlap({
- elements,
- startTime,
- endTime,
- excludeElementId,
-}: {
- elements: TimelineElement[];
- startTime: number;
- endTime: number;
- excludeElementId?: string;
-}): boolean {
- return elements.some((element) => {
- if (excludeElementId && element.id === excludeElementId) return false;
- const elementEnd = element.startTime + element.duration;
- return startTime < elementEnd && endTime > element.startTime;
- });
-}
-
function buildTextBackground(
raw: Partial<TextBackground> | 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..95424160
--- /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/timeline/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<ElementType, TrackType> = {
+ audio: "audio",
+ text: "text",
+ sticker: "graphic",
+ graphic: "graphic",
+ effect: "effect",
+ video: "video",
+ image: "video",
+};
+
+export function getTrackTypeForElementType({
+ elementType,
+}: {
+ elementType: ElementType;
+}): TrackType {
+ return ELEMENT_TRACK_MAP[elementType];
+}
+
+export function canElementGoOnTrack({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TrackType;
+}): boolean {
+ return getTrackTypeForElementType({ elementType }) === trackType;
+}
+
+export function validateElementTrackCompatibility({
+ element,
+ track,
+}: {
+ element: { type: ElementType };
+ track: { type: TrackType };
+}): { isValid: boolean; errorMessage?: string } {
+ const isValid = canElementGoOnTrack({
+ elementType: element.type,
+ trackType: track.type,
+ });
+
+ if (!isValid) {
+ return {
+ isValid: false,
+ errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
+ };
+ }
+
+ return { isValid: true };
+}
diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts
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..8fd8631b 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/timeline/placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048;