refactor: centralize track placement into its own module

This commit is contained in:
Maze Winther 2026-03-31 15:47:30 +02:00
parent 5cc6abc3a4
commit 70a6d58b84
30 changed files with 1355 additions and 709 deletions

View File

@ -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";

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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];
}
}

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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";

View File

@ -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;

View File

@ -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,
};

View File

@ -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 {

View File

@ -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",
});
});
});

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -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";

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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,
});
});
}

View File

@ -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,
});
}

View File

@ -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}`);
}
}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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": {