Merge branch 'feature/track-placement'

This commit is contained in:
Maze Winther
2026-03-31 15:53:23 +02:00
29 changed files with 1356 additions and 709 deletions
@@ -6,13 +6,12 @@ import type {
ClipboardItem,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
applyPlacement,
resolveTrackPlacement,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
} from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
@@ -59,17 +58,22 @@ export class PasteCommand extends Command {
const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId,
);
const resolvedTargetIndex = resolveTargetTrackIndex({
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
elements: elementsToAdd,
timeSpans: elementsToAdd.map((element) => ({
startTime: element.startTime,
duration: element.duration,
})),
strategy: { type: "aboveSource", sourceTrackIndex },
});
if (!placementResult) {
continue;
}
if (resolvedTargetIndex >= 0) {
const targetTrack = updatedTracks[resolvedTargetIndex];
let adjustedElements = elementsToAdd;
let elementsForPlacement = elementsToAdd;
if (placementResult.kind === "existingTrack") {
const targetTrack = updatedTracks[placementResult.trackIndex];
if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
@@ -82,39 +86,28 @@ export class PasteCommand extends Command {
const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== 0) {
adjustedElements = elementsToAdd.map((element) => ({
elementsForPlacement = elementsToAdd.map((element) => ({
...element,
startTime: Math.max(0, element.startTime + delta),
}));
}
}
}
updatedTracks[resolvedTargetIndex] = {
...targetTrack,
elements: [...targetTrack.elements, ...adjustedElements],
} as TimelineTrack;
for (const element of adjustedElements) {
this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,
});
}
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;
}
@@ -1,7 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
import { rippleShiftElements } from "@/lib/timeline";
import { isMainTrack } from "@/lib/timeline/placement";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
@@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command {
);
const newTrackElements: TimelineElement[] = [];
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
this.duplicatedElements.push({
trackId: newTrackId,
elementId: newId,
});
newTrackElements.push(
buildDuplicateElement({
element,
@@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command {
);
}
const newTrack = {
...newTrackBase,
elements: newTrackElements,
} as TimelineTrack;
const insertIndex = getHighestInsertIndexForTrack({
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType: track.type,
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
});
updatedTracks.splice(insertIndex, 0, newTrack);
if (!placementResult || placementResult.kind !== "newTrack") {
continue;
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: newTrackElements,
});
if (!applied) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of newTrackElements) {
this.duplicatedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
@@ -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/timeline/placement";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -67,7 +62,7 @@ export class InsertElementCommand extends Command {
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.resolveTracksWithElement({
const updateResult = this.applyPlacementResult({
tracks: this.savedState,
element: newElement,
});
@@ -190,7 +185,7 @@ export class InsertElementCommand extends Command {
return true;
}
private resolveTracksWithElement({
private applyPlacementResult({
tracks,
element,
}: {
@@ -199,160 +194,71 @@ export class InsertElementCommand extends Command {
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
if (placement.mode === "explicit") {
const targetTrack = tracks.find(
(track) => track.id === placement.trackId,
);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return null;
}
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: targetTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === targetTrack.id
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const trackType =
placement.trackType ?? this.getTrackTypeForElement({ element });
if (
placement.mode === "auto" &&
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType,
trackType: placement.trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${trackType} tracks`,
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
);
return null;
}
const elementEndTime = element.startTime + element.duration;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
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];
}
}
@@ -10,7 +10,7 @@ import {
isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
} from "@/lib/timeline/placement";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getDefaultInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
} from "@/lib/timeline/placement";
export class AddTrackCommand extends Command {
private trackId: string;
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline/placement";
export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null;