refactor: centralize track placement into its own module
This commit is contained in:
parent
5cc6abc3a4
commit
70a6d58b84
|
|
@ -49,10 +49,10 @@ import {
|
||||||
getTotalTracksHeight,
|
getTotalTracksHeight,
|
||||||
canTracktHaveAudio,
|
canTracktHaveAudio,
|
||||||
canTrackBeHidden,
|
canTrackBeHidden,
|
||||||
isMainTrack,
|
|
||||||
getTimelineZoomMin,
|
getTimelineZoomMin,
|
||||||
getTimelinePaddingPx,
|
getTimelinePaddingPx,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
|
import { isMainTrack } from "@/lib/track-placement";
|
||||||
import { TimelineToolbar } from "./timeline-toolbar";
|
import { TimelineToolbar } from "./timeline-toolbar";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ElementType, TrackType } from "@/lib/timeline";
|
import type { TrackType } from "@/lib/timeline";
|
||||||
|
|
||||||
export const TRACK_CONFIG: Record<
|
export const TRACK_CONFIG: Record<
|
||||||
TrackType,
|
TrackType,
|
||||||
|
|
@ -46,16 +46,6 @@ export const ELEMENT_TYPE_CONFIG: Record<
|
||||||
effect: { background: "bg-[#5d93ba]" },
|
effect: { background: "bg-[#5d93ba]" },
|
||||||
} as const;
|
} 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_GAP = 6;
|
||||||
export const TRACK_LABELS_WIDTH_PX = 112;
|
export const TRACK_LABELS_WIDTH_PX = 112;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
isBookmarkAtTime,
|
isBookmarkAtTime,
|
||||||
} from "@/lib/timeline/bookmarks";
|
} from "@/lib/timeline/bookmarks";
|
||||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
import { ensureMainTrack } from "@/lib/track-placement";
|
||||||
import {
|
import {
|
||||||
CreateSceneCommand,
|
CreateSceneCommand,
|
||||||
DeleteSceneCommand,
|
DeleteSceneCommand,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
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 { TrackType, DropTarget, ElementType } from "@/lib/timeline";
|
||||||
import type {
|
import type {
|
||||||
MediaDragData,
|
MediaDragData,
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,12 @@ import type {
|
||||||
ClipboardItem,
|
ClipboardItem,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
|
||||||
import {
|
import {
|
||||||
buildEmptyTrack,
|
applyPlacement,
|
||||||
getHighestInsertIndexForTrack,
|
resolveTrackPlacement,
|
||||||
isMainTrack,
|
isMainTrack,
|
||||||
enforceMainTrackStart,
|
enforceMainTrackStart,
|
||||||
} from "@/lib/timeline/track-utils";
|
} from "@/lib/track-placement";
|
||||||
import { cloneAnimations } from "@/lib/animation";
|
import { cloneAnimations } from "@/lib/animation";
|
||||||
|
|
||||||
export class PasteCommand extends Command {
|
export class PasteCommand extends Command {
|
||||||
|
|
@ -59,17 +58,22 @@ export class PasteCommand extends Command {
|
||||||
const sourceTrackIndex = updatedTracks.findIndex(
|
const sourceTrackIndex = updatedTracks.findIndex(
|
||||||
(track) => track.id === trackId,
|
(track) => track.id === trackId,
|
||||||
);
|
);
|
||||||
const resolvedTargetIndex = resolveTargetTrackIndex({
|
const placementResult = resolveTrackPlacement({
|
||||||
tracks: updatedTracks,
|
tracks: updatedTracks,
|
||||||
sourceTrackIndex,
|
|
||||||
trackType,
|
trackType,
|
||||||
elements: elementsToAdd,
|
timeSpans: elementsToAdd.map((element) => ({
|
||||||
|
startTime: element.startTime,
|
||||||
|
duration: element.duration,
|
||||||
|
})),
|
||||||
|
strategy: { type: "aboveSource", sourceTrackIndex },
|
||||||
});
|
});
|
||||||
|
if (!placementResult) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (resolvedTargetIndex >= 0) {
|
let elementsForPlacement = elementsToAdd;
|
||||||
const targetTrack = updatedTracks[resolvedTargetIndex];
|
if (placementResult.kind === "existingTrack") {
|
||||||
let adjustedElements = elementsToAdd;
|
const targetTrack = updatedTracks[placementResult.trackIndex];
|
||||||
|
|
||||||
if (isMainTrack(targetTrack)) {
|
if (isMainTrack(targetTrack)) {
|
||||||
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
||||||
element.startTime < earliest.startTime ? element : earliest,
|
element.startTime < earliest.startTime ? element : earliest,
|
||||||
|
|
@ -82,39 +86,28 @@ export class PasteCommand extends Command {
|
||||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||||
|
|
||||||
if (delta !== 0) {
|
if (delta !== 0) {
|
||||||
adjustedElements = elementsToAdd.map((element) => ({
|
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||||
...element,
|
...element,
|
||||||
startTime: Math.max(0, element.startTime + delta),
|
startTime: Math.max(0, element.startTime + delta),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updatedTracks[resolvedTargetIndex] = {
|
const applied = applyPlacement({
|
||||||
...targetTrack,
|
tracks: updatedTracks,
|
||||||
elements: [...targetTrack.elements, ...adjustedElements],
|
placementResult,
|
||||||
} as TimelineTrack;
|
elements: elementsForPlacement,
|
||||||
for (const element of adjustedElements) {
|
});
|
||||||
this.pastedElements.push({
|
if (!applied) {
|
||||||
trackId: targetTrack.id,
|
|
||||||
elementId: element.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertIndex = resolveInsertIndexForNewTrack({
|
updatedTracks = applied.updatedTracks;
|
||||||
tracks: updatedTracks,
|
|
||||||
sourceTrackIndex,
|
for (const element of elementsForPlacement) {
|
||||||
trackType,
|
|
||||||
});
|
|
||||||
const newTrack = buildTrackWithElements({
|
|
||||||
trackType,
|
|
||||||
elements: elementsToAdd,
|
|
||||||
});
|
|
||||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
|
||||||
for (const element of elementsToAdd) {
|
|
||||||
this.pastedElements.push({
|
this.pastedElements.push({
|
||||||
trackId: newTrack.id,
|
trackId: applied.targetTrackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -187,107 +180,3 @@ function buildPastedElements({
|
||||||
return elementsToAdd;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command } from "@/lib/commands/base-command";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
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 {
|
export class DeleteElementsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command";
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import { applyPlacement, resolveTrackPlacement } from "@/lib/track-placement";
|
||||||
buildEmptyTrack,
|
|
||||||
getHighestInsertIndexForTrack,
|
|
||||||
} from "@/lib/timeline/track-utils";
|
|
||||||
import { cloneAnimations } from "@/lib/animation";
|
import { cloneAnimations } from "@/lib/animation";
|
||||||
|
|
||||||
interface DuplicateElementsParams {
|
interface DuplicateElementsParams {
|
||||||
|
|
@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command {
|
||||||
);
|
);
|
||||||
const newTrackElements: TimelineElement[] = [];
|
const newTrackElements: TimelineElement[] = [];
|
||||||
|
|
||||||
const newTrackId = generateUUID();
|
|
||||||
const newTrackBase = buildEmptyTrack({
|
|
||||||
id: newTrackId,
|
|
||||||
type: track.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (!elementIdsToDuplicate.has(element.id)) {
|
if (!elementIdsToDuplicate.has(element.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newId = generateUUID();
|
const newId = generateUUID();
|
||||||
this.duplicatedElements.push({
|
|
||||||
trackId: newTrackId,
|
|
||||||
elementId: newId,
|
|
||||||
});
|
|
||||||
newTrackElements.push(
|
newTrackElements.push(
|
||||||
buildDuplicateElement({
|
buildDuplicateElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTrack = {
|
const placementResult = resolveTrackPlacement({
|
||||||
...newTrackBase,
|
|
||||||
elements: newTrackElements,
|
|
||||||
} as TimelineTrack;
|
|
||||||
|
|
||||||
const insertIndex = getHighestInsertIndexForTrack({
|
|
||||||
tracks: updatedTracks,
|
tracks: updatedTracks,
|
||||||
trackType: track.type,
|
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);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,18 @@ import type {
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
TrackType,
|
TrackType,
|
||||||
ElementType,
|
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import {
|
import { requiresMediaId } from "@/lib/timeline/element-utils";
|
||||||
requiresMediaId,
|
|
||||||
wouldElementOverlap,
|
|
||||||
} from "@/lib/timeline/element-utils";
|
|
||||||
import {
|
|
||||||
buildEmptyTrack,
|
|
||||||
canElementGoOnTrack,
|
|
||||||
getDefaultInsertIndexForTrack,
|
|
||||||
validateElementTrackCompatibility,
|
|
||||||
enforceMainTrackStart,
|
|
||||||
} from "@/lib/timeline/track-utils";
|
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
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 { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||||
|
import {
|
||||||
|
applyPlacement,
|
||||||
|
canElementGoOnTrack,
|
||||||
|
resolveTrackPlacement,
|
||||||
|
validateElementTrackCompatibility,
|
||||||
|
} from "@/lib/track-placement";
|
||||||
|
|
||||||
type InsertElementPlacement =
|
type InsertElementPlacement =
|
||||||
| { mode: "explicit"; trackId: string }
|
| { mode: "explicit"; trackId: string }
|
||||||
|
|
@ -67,7 +62,7 @@ export class InsertElementCommand extends Command {
|
||||||
const isFirstElement = totalElementsInTimeline === 0;
|
const isFirstElement = totalElementsInTimeline === 0;
|
||||||
|
|
||||||
const newElement = this.buildElement({ element: this.element });
|
const newElement = this.buildElement({ element: this.element });
|
||||||
const updateResult = this.resolveTracksWithElement({
|
const updateResult = this.applyPlacementResult({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
element: newElement,
|
element: newElement,
|
||||||
});
|
});
|
||||||
|
|
@ -190,7 +185,7 @@ export class InsertElementCommand extends Command {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTracksWithElement({
|
private applyPlacementResult({
|
||||||
tracks,
|
tracks,
|
||||||
element,
|
element,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -199,160 +194,71 @@ export class InsertElementCommand extends Command {
|
||||||
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
||||||
const placement = this.placement;
|
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 (
|
if (
|
||||||
|
placement.mode === "auto" &&
|
||||||
placement.trackType &&
|
placement.trackType &&
|
||||||
!canElementGoOnTrack({
|
!canElementGoOnTrack({
|
||||||
elementType: element.type,
|
elementType: element.type,
|
||||||
trackType,
|
trackType: placement.trackType,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
`${element.type} elements cannot be placed on ${trackType} tracks`,
|
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementEndTime = element.startTime + element.duration;
|
const placementResult = resolveTrackPlacement({
|
||||||
const existingTrack = tracks.find((track) => {
|
tracks,
|
||||||
if (
|
...(placement.mode === "auto" && placement.trackType
|
||||||
!canElementGoOnTrack({
|
? { trackType: placement.trackType }
|
||||||
elementType: element.type,
|
: { elementType: element.type }),
|
||||||
trackType: track.type,
|
timeSpans: [
|
||||||
})
|
{
|
||||||
) {
|
startTime: element.startTime,
|
||||||
return false;
|
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({
|
return null;
|
||||||
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 elementToPlace =
|
||||||
const newTrack = buildEmptyTrack({
|
placementResult.kind === "existingTrack"
|
||||||
id: newTrackId,
|
? {
|
||||||
type: trackType,
|
...element,
|
||||||
});
|
startTime:
|
||||||
const newTrackWithElement = {
|
placementResult.adjustedStartTime ?? element.startTime,
|
||||||
...newTrack,
|
}
|
||||||
elements: [...newTrack.elements, element],
|
: element;
|
||||||
} as TimelineTrack;
|
|
||||||
|
|
||||||
const updatedTracks = [...tracks];
|
return applyPlacement({
|
||||||
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({
|
|
||||||
tracks,
|
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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
isMainTrack,
|
isMainTrack,
|
||||||
validateElementTrackCompatibility,
|
validateElementTrackCompatibility,
|
||||||
enforceMainTrackStart,
|
enforceMainTrackStart,
|
||||||
} from "@/lib/timeline/track-utils";
|
} from "@/lib/track-placement";
|
||||||
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
||||||
|
|
||||||
export class MoveElementCommand extends Command {
|
export class MoveElementCommand extends Command {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command } from "@/lib/commands/base-command";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
import { enforceMainTrackStart } from "@/lib/track-placement";
|
||||||
|
|
||||||
export class UpdateElementStartTimeCommand extends Command {
|
export class UpdateElementStartTimeCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
||||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
import { enforceMainTrackStart } from "@/lib/track-placement";
|
||||||
|
|
||||||
export class UpdateElementTrimCommand extends Command {
|
export class UpdateElementTrimCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
buildEmptyTrack,
|
buildEmptyTrack,
|
||||||
getDefaultInsertIndexForTrack,
|
getDefaultInsertIndexForTrack,
|
||||||
} from "@/lib/timeline/track-utils";
|
} from "@/lib/track-placement";
|
||||||
|
|
||||||
export class AddTrackCommand extends Command {
|
export class AddTrackCommand extends Command {
|
||||||
private trackId: string;
|
private trackId: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Command } from "@/lib/commands/base-command";
|
import { Command } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import { getMainTrack } from "@/lib/timeline";
|
import { getMainTrack } from "@/lib/track-placement";
|
||||||
|
|
||||||
export class RemoveTrackCommand extends Command {
|
export class RemoveTrackCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
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 { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
|
||||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
|
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
|
||||||
import { measureTextElement } from "@/lib/text/measure-element";
|
import { measureTextElement } from "@/lib/text/measure-element";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { TScene } from "@/lib/timeline";
|
import type { TScene } from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { calculateTotalDuration } from "@/lib/timeline";
|
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 {
|
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
||||||
return scenes.find((scene) => scene.isMain) || null;
|
return scenes.find((scene) => scene.isMain) || null;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
import type {
|
import type {
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
ElementType,
|
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
|
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
|
||||||
import { wouldElementOverlap } from "./element-utils";
|
|
||||||
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
||||||
import {
|
import { resolveTrackPlacement } from "@/lib/track-placement";
|
||||||
canElementGoOnTrack,
|
|
||||||
isMainTrack,
|
|
||||||
enforceMainTrackStart,
|
|
||||||
} from "./track-utils";
|
|
||||||
|
|
||||||
function findElementAtPosition({
|
function findElementAtPosition({
|
||||||
mouseX,
|
mouseX,
|
||||||
|
|
@ -82,57 +76,22 @@ function getTrackAtY({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCompatible({
|
const EMPTY_TARGET_ELEMENT = null;
|
||||||
elementType,
|
|
||||||
trackType,
|
function fallbackNewTrackDropTarget({
|
||||||
|
xPosition,
|
||||||
}: {
|
}: {
|
||||||
elementType: ElementType;
|
xPosition: number;
|
||||||
trackType: TimelineTrack["type"];
|
}): DropTarget {
|
||||||
}): 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" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
index: overlayInsertIndex,
|
trackIndex: 0,
|
||||||
position: insertAbove ? "above" : "below",
|
isNewTrack: true,
|
||||||
|
insertPosition: null,
|
||||||
|
xPosition,
|
||||||
|
targetElement: EMPTY_TARGET_ELEMENT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_TARGET_ELEMENT = null;
|
|
||||||
|
|
||||||
export function computeDropTarget({
|
export function computeDropTarget({
|
||||||
elementType,
|
elementType,
|
||||||
mouseX,
|
mouseX,
|
||||||
|
|
@ -155,22 +114,28 @@ export function computeDropTarget({
|
||||||
? playheadTime
|
? playheadTime
|
||||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||||
|
|
||||||
const mainTrackIndex = getMainTrackIndex({ tracks });
|
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
if (elementType === "audio") {
|
const placementResult = resolveTrackPlacement({
|
||||||
return {
|
tracks,
|
||||||
|
elementType,
|
||||||
|
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||||
|
strategy: {
|
||||||
|
type: "preferIndex",
|
||||||
trackIndex: 0,
|
trackIndex: 0,
|
||||||
isNewTrack: true,
|
hoverDirection: "below",
|
||||||
insertPosition: "below",
|
createNewTrackOnly: true,
|
||||||
xPosition,
|
},
|
||||||
targetElement: EMPTY_TARGET_ELEMENT,
|
});
|
||||||
};
|
const emptyTimelineResult =
|
||||||
|
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||||
|
if (!emptyTimelineResult) {
|
||||||
|
return fallbackNewTrackDropTarget({ xPosition });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackIndex: 0,
|
trackIndex: emptyTimelineResult.insertIndex,
|
||||||
isNewTrack: true,
|
isNewTrack: true,
|
||||||
insertPosition: null,
|
insertPosition: emptyTimelineResult.insertPosition,
|
||||||
xPosition,
|
xPosition,
|
||||||
targetElement: EMPTY_TARGET_ELEMENT,
|
targetElement: EMPTY_TARGET_ELEMENT,
|
||||||
};
|
};
|
||||||
|
|
@ -181,30 +146,27 @@ export function computeDropTarget({
|
||||||
if (!trackAtMouse) {
|
if (!trackAtMouse) {
|
||||||
const isAboveAllTracks = mouseY < 0;
|
const isAboveAllTracks = mouseY < 0;
|
||||||
|
|
||||||
if (elementType === "audio") {
|
const placementResult = resolveTrackPlacement({
|
||||||
return {
|
tracks,
|
||||||
trackIndex: tracks.length,
|
elementType,
|
||||||
isNewTrack: true,
|
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||||
insertPosition: "below",
|
strategy: {
|
||||||
xPosition,
|
type: "preferIndex",
|
||||||
targetElement: EMPTY_TARGET_ELEMENT,
|
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
||||||
};
|
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||||
}
|
createNewTrackOnly: true,
|
||||||
|
},
|
||||||
if (isAboveAllTracks) {
|
});
|
||||||
return {
|
const outOfBoundsResult =
|
||||||
trackIndex: 0,
|
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||||
isNewTrack: true,
|
if (!outOfBoundsResult) {
|
||||||
insertPosition: "above",
|
return fallbackNewTrackDropTarget({ xPosition });
|
||||||
xPosition,
|
|
||||||
targetElement: EMPTY_TARGET_ELEMENT,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackIndex: Math.max(0, mainTrackIndex),
|
trackIndex: outOfBoundsResult.insertIndex,
|
||||||
isNewTrack: true,
|
isNewTrack: true,
|
||||||
insertPosition: "above",
|
insertPosition: outOfBoundsResult.insertPosition,
|
||||||
xPosition,
|
xPosition,
|
||||||
targetElement: EMPTY_TARGET_ELEMENT,
|
targetElement: EMPTY_TARGET_ELEMENT,
|
||||||
};
|
};
|
||||||
|
|
@ -237,34 +199,27 @@ export function computeDropTarget({
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackHeight = TRACK_CONFIG[track.type].height;
|
const trackHeight = TRACK_CONFIG[track.type].height;
|
||||||
const isInUpperHalf = relativeY < trackHeight / 2;
|
const placementResult = resolveTrackPlacement({
|
||||||
|
tracks,
|
||||||
const isTrackCompatible = isCompatible({
|
|
||||||
elementType,
|
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;
|
if (placementResult.kind === "existingTrack") {
|
||||||
const hasOverlap = wouldElementOverlap({
|
const adjustedXPosition =
|
||||||
elements: track.elements,
|
placementResult.adjustedStartTime ?? xPosition;
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackIndex,
|
trackIndex: placementResult.trackIndex,
|
||||||
isNewTrack: false,
|
isNewTrack: false,
|
||||||
insertPosition: null,
|
insertPosition: null,
|
||||||
xPosition: adjustedXPosition,
|
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 {
|
return {
|
||||||
trackIndex: index,
|
trackIndex: placementResult.insertIndex,
|
||||||
isNewTrack: true,
|
isNewTrack: true,
|
||||||
insertPosition: position,
|
insertPosition: placementResult.insertPosition,
|
||||||
xPosition,
|
xPosition,
|
||||||
targetElement: EMPTY_TARGET_ELEMENT,
|
targetElement: EMPTY_TARGET_ELEMENT,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(
|
function buildTextBackground(
|
||||||
raw: Partial<TextBackground> | undefined,
|
raw: Partial<TextBackground> | undefined,
|
||||||
): TextBackground {
|
): TextBackground {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import type {
|
import type {
|
||||||
TrackType,
|
TrackType,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
ElementType,
|
|
||||||
VideoTrack,
|
VideoTrack,
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
GraphicTrack,
|
GraphicTrack,
|
||||||
TextTrack,
|
TextTrack,
|
||||||
EffectTrack,
|
EffectTrack,
|
||||||
TimelineElement,
|
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
ELEMENT_TRACK_MAP,
|
|
||||||
TRACK_CONFIG,
|
TRACK_CONFIG,
|
||||||
ELEMENT_TYPE_CONFIG,
|
ELEMENT_TYPE_CONFIG,
|
||||||
TRACK_GAP,
|
TRACK_GAP,
|
||||||
} from "@/constants/timeline-constants";
|
} from "@/constants/timeline-constants";
|
||||||
import { generateUUID } from "@/utils/id";
|
|
||||||
|
|
||||||
export function canTracktHaveAudio(
|
export function canTracktHaveAudio(
|
||||||
track: TimelineTrack,
|
track: TimelineTrack,
|
||||||
|
|
@ -64,234 +60,3 @@ export function getTotalTracksHeight({
|
||||||
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
|
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
|
||||||
return tracksHeight + gapsHeight;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
|
||||||
import type { BaseNode } from "./nodes/base-node";
|
import type { BaseNode } from "./nodes/base-node";
|
||||||
import type { TBackground, TCanvasSize } from "@/lib/project/types";
|
import type { TBackground, TCanvasSize } from "@/lib/project/types";
|
||||||
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
|
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;
|
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
||||||
|
|
||||||
|
|
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -77,7 +77,7 @@
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"use-deep-compare-effect": "^1.8.1",
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"wavesurfer.js": "^7.9.8",
|
"wavesurfer.js": "^7.9.8",
|
||||||
"zod": "^4.3.6",
|
"zod": "4.3.6",
|
||||||
"zustand": "^5.0.2",
|
"zustand": "^5.0.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue