diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-31 15:53:23 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-31 15:53:23 +0200 |
| commit | d23be079cce6108482fc3e85401cccf949087174 (patch) | |
| tree | 7d5acd324f9274b3b823bc67154349b8693e3412 /apps | |
| parent | dcf54c1250431f5a4a2848f7c07781ef5eebacaf (diff) | |
| parent | 70a6d58b8432e455ab1443d45bc23dd70b363bea (diff) | |
| download | opencut-d23be079cce6108482fc3e85401cccf949087174.tar.gz | |
Merge branch 'feature/track-placement'
Diffstat (limited to 'apps')
29 files changed, 1353 insertions, 706 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 5b1ebb96..819413e6 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -49,10 +49,10 @@ import { getTotalTracksHeight, canTracktHaveAudio, canTrackBeHidden, - isMainTrack, getTimelineZoomMin, getTimelinePaddingPx, } from "@/lib/timeline"; +import { isMainTrack } from "@/lib/timeline/placement"; import { TimelineToolbar } from "./timeline-toolbar"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 957775fd..99fa2d2d 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -1,4 +1,4 @@ -import type { ElementType, TrackType } from "@/lib/timeline";
+import type { TrackType } from "@/lib/timeline";
export const TRACK_CONFIG: Record<
TrackType,
@@ -46,16 +46,6 @@ export const ELEMENT_TYPE_CONFIG: Record< effect: { background: "bg-[#5d93ba]" },
} as const;
-export const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
- audio: "audio",
- text: "text",
- sticker: "graphic",
- graphic: "graphic",
- effect: "effect",
- video: "video",
- image: "video",
-};
-
export const TRACK_GAP = 6;
export const TRACK_LABELS_WIDTH_PX = 112;
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 3c53efd6..43938f96 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -12,7 +12,7 @@ import { getFrameTime,
isBookmarkAtTime,
} from "@/lib/timeline/bookmarks";
-import { ensureMainTrack } from "@/lib/timeline/track-utils";
+import { ensureMainTrack } from "@/lib/timeline/placement";
import {
CreateSceneCommand,
DeleteSceneCommand,
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 9693e8d8..ce6f1341 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -16,7 +16,7 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getDragData, hasDragData } from "@/lib/drag-data"; -import { isMainTrack } from "@/lib/timeline/track-utils"; +import { isMainTrack } from "@/lib/timeline/placement"; import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { MediaDragData, @@ -444,7 +444,8 @@ export function useTimelineDragDrop({ if (!createdAsset) continue; const duration = - createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + createdAsset.duration ?? + TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; const currentTracks = editor.timeline.getTracks(); const currentTime = editor.playback.getCurrentTime(); const onlyTrack = currentTracks[0]; diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index d9c54ec3..e1b43364 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -6,13 +6,12 @@ import type { ClipboardItem,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
-import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
- buildEmptyTrack,
- getHighestInsertIndexForTrack,
+ applyPlacement,
+ resolveTrackPlacement,
isMainTrack,
enforceMainTrackStart,
-} from "@/lib/timeline/track-utils";
+} from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
@@ -59,17 +58,22 @@ export class PasteCommand extends Command { const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId,
);
- const resolvedTargetIndex = resolveTargetTrackIndex({
+ const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
- sourceTrackIndex,
trackType,
- elements: elementsToAdd,
+ timeSpans: elementsToAdd.map((element) => ({
+ startTime: element.startTime,
+ duration: element.duration,
+ })),
+ strategy: { type: "aboveSource", sourceTrackIndex },
});
+ if (!placementResult) {
+ continue;
+ }
- if (resolvedTargetIndex >= 0) {
- const targetTrack = updatedTracks[resolvedTargetIndex];
- let adjustedElements = elementsToAdd;
-
+ let elementsForPlacement = elementsToAdd;
+ if (placementResult.kind === "existingTrack") {
+ const targetTrack = updatedTracks[placementResult.trackIndex];
if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
@@ -82,39 +86,28 @@ export class PasteCommand extends Command { const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== 0) {
- adjustedElements = elementsToAdd.map((element) => ({
+ elementsForPlacement = elementsToAdd.map((element) => ({
...element,
startTime: Math.max(0, element.startTime + delta),
}));
}
}
-
- updatedTracks[resolvedTargetIndex] = {
- ...targetTrack,
- elements: [...targetTrack.elements, ...adjustedElements],
- } as TimelineTrack;
- for (const element of adjustedElements) {
- this.pastedElements.push({
- trackId: targetTrack.id,
- elementId: element.id,
- });
- }
- continue;
}
- const insertIndex = resolveInsertIndexForNewTrack({
+ const applied = applyPlacement({
tracks: updatedTracks,
- sourceTrackIndex,
- trackType,
+ placementResult,
+ elements: elementsForPlacement,
});
- const newTrack = buildTrackWithElements({
- trackType,
- elements: elementsToAdd,
- });
- updatedTracks.splice(insertIndex, 0, newTrack);
- for (const element of elementsToAdd) {
+ if (!applied) {
+ continue;
+ }
+
+ updatedTracks = applied.updatedTracks;
+
+ for (const element of elementsForPlacement) {
this.pastedElements.push({
- trackId: newTrack.id,
+ trackId: applied.targetTrackId,
elementId: element.id,
});
}
@@ -187,107 +180,3 @@ function buildPastedElements({ return elementsToAdd;
}
-function resolveTargetTrackIndex({
- tracks,
- sourceTrackIndex,
- trackType,
- elements,
-}: {
- tracks: TimelineTrack[];
- sourceTrackIndex: number;
- trackType: ClipboardItem["trackType"];
- elements: TimelineElement[];
-}): number {
- if (sourceTrackIndex >= 0) {
- const aboveIndex = sourceTrackIndex - 1;
- if (aboveIndex < 0) {
- return -1;
- }
-
- const aboveTrack = tracks[aboveIndex];
- if (aboveTrack.type !== trackType) {
- return -1;
- }
-
- const canPlaceOnAbove = canPlaceElementsOnTrack({
- track: aboveTrack,
- elements,
- });
- return canPlaceOnAbove ? aboveIndex : -1;
- }
-
- const highestCompatibleIndex = tracks.findIndex(
- (track) => track.type === trackType,
- );
- if (highestCompatibleIndex < 0) {
- return -1;
- }
-
- const highestCompatibleTrack = tracks[highestCompatibleIndex];
- const canPlaceOnHighest = canPlaceElementsOnTrack({
- track: highestCompatibleTrack,
- elements,
- });
-
- return canPlaceOnHighest ? highestCompatibleIndex : -1;
-}
-
-function resolveInsertIndexForNewTrack({
- tracks,
- sourceTrackIndex,
- trackType,
-}: {
- tracks: TimelineTrack[];
- sourceTrackIndex: number;
- trackType: ClipboardItem["trackType"];
-}): number {
- if (sourceTrackIndex >= 0) {
- return sourceTrackIndex;
- }
-
- const highestCompatibleIndex = tracks.findIndex(
- (track) => track.type === trackType,
- );
- if (highestCompatibleIndex >= 0) {
- return highestCompatibleIndex;
- }
-
- return getHighestInsertIndexForTrack({ tracks, trackType });
-}
-
-function canPlaceElementsOnTrack({
- track,
- elements,
-}: {
- track: TimelineTrack;
- elements: TimelineElement[];
-}): boolean {
- for (const element of elements) {
- const endTime = element.startTime + element.duration;
- const hasOverlap = wouldElementOverlap({
- elements: track.elements,
- startTime: element.startTime,
- endTime,
- });
- if (hasOverlap) {
- return false;
- }
- }
-
- return true;
-}
-
-function buildTrackWithElements({
- trackType,
- elements,
-}: {
- trackType: ClipboardItem["trackType"];
- elements: TimelineElement[];
-}): TimelineTrack {
- const newTrackId = generateUUID();
- const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
- return {
- ...newTrackBase,
- elements,
- } as TimelineTrack;
-}
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 9b27a3c2..4bbb0d48 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,7 +1,8 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
-import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
+import { rippleShiftElements } from "@/lib/timeline";
+import { isMainTrack } from "@/lib/timeline/placement";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 346f8c73..a4f7ca82 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
-import {
- buildEmptyTrack,
- getHighestInsertIndexForTrack,
-} from "@/lib/timeline/track-utils";
+import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
@@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command { );
const newTrackElements: TimelineElement[] = [];
- const newTrackId = generateUUID();
- const newTrackBase = buildEmptyTrack({
- id: newTrackId,
- type: track.type,
- });
-
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
- this.duplicatedElements.push({
- trackId: newTrackId,
- elementId: newId,
- });
newTrackElements.push(
buildDuplicateElement({
element,
@@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command { );
}
- const newTrack = {
- ...newTrackBase,
- elements: newTrackElements,
- } as TimelineTrack;
-
- const insertIndex = getHighestInsertIndexForTrack({
+ const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType: track.type,
+ timeSpans: [],
+ strategy: { type: "alwaysNew", position: "highest" },
});
- updatedTracks.splice(insertIndex, 0, newTrack);
+ if (!placementResult || placementResult.kind !== "newTrack") {
+ continue;
+ }
+
+ const applied = applyPlacement({
+ tracks: updatedTracks,
+ placementResult,
+ elements: newTrackElements,
+ });
+ if (!applied) {
+ continue;
+ }
+
+ updatedTracks = applied.updatedTracks;
+
+ for (const element of newTrackElements) {
+ this.duplicatedElements.push({
+ trackId: applied.targetTrackId,
+ elementId: element.id,
+ });
+ }
}
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index e6d6ee99..cdff3565 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -5,23 +5,18 @@ import type { TimelineTrack,
TimelineElement,
TrackType,
- ElementType,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
+import { requiresMediaId } from "@/lib/timeline/element-utils";
+import type { MediaAsset } from "@/lib/media/types";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
- requiresMediaId,
- wouldElementOverlap,
-} from "@/lib/timeline/element-utils";
-import {
- buildEmptyTrack,
+ applyPlacement,
canElementGoOnTrack,
- getDefaultInsertIndexForTrack,
+ resolveTrackPlacement,
validateElementTrackCompatibility,
- enforceMainTrackStart,
-} from "@/lib/timeline/track-utils";
-import type { MediaAsset } from "@/lib/media/types";
-import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+} from "@/lib/timeline/placement";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -67,7 +62,7 @@ export class InsertElementCommand extends Command { const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
- const updateResult = this.resolveTracksWithElement({
+ const updateResult = this.applyPlacementResult({
tracks: this.savedState,
element: newElement,
});
@@ -190,7 +185,7 @@ export class InsertElementCommand extends Command { return true;
}
- private resolveTracksWithElement({
+ private applyPlacementResult({
tracks,
element,
}: {
@@ -199,160 +194,71 @@ export class InsertElementCommand extends Command { }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
- if (placement.mode === "explicit") {
- const targetTrack = tracks.find(
- (track) => track.id === placement.trackId,
- );
-
- if (!targetTrack) {
- console.error("Track not found:", placement.trackId);
- return null;
- }
-
- const validation = validateElementTrackCompatibility({
- element,
- track: targetTrack,
- });
-
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return null;
- }
-
- const adjustedElement = this.adjustElementForMainTrack({
- tracks,
- targetTrackId: targetTrack.id,
- element,
- });
-
- const updatedTracks = tracks.map((track) =>
- track.id === targetTrack.id
- ? {
- ...track,
- elements: [...track.elements, adjustedElement],
- }
- : track,
- ) as TimelineTrack[];
-
- return { updatedTracks, targetTrackId: targetTrack.id };
- }
-
- const trackType =
- placement.trackType ?? this.getTrackTypeForElement({ element });
-
if (
+ placement.mode === "auto" &&
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
- trackType,
+ trackType: placement.trackType,
})
) {
console.error(
- `${element.type} elements cannot be placed on ${trackType} tracks`,
+ `${element.type} elements cannot be placed on ${placement.trackType} tracks`,
);
return null;
}
- const elementEndTime = element.startTime + element.duration;
- const existingTrack = tracks.find((track) => {
- if (
- !canElementGoOnTrack({
- elementType: element.type,
- trackType: track.type,
- })
- ) {
- return false;
- }
-
- return !wouldElementOverlap({
- elements: track.elements,
- startTime: element.startTime,
- endTime: elementEndTime,
- });
- });
-
- if (existingTrack) {
- const adjustedElement = this.adjustElementForMainTrack({
- tracks,
- targetTrackId: existingTrack.id,
- element,
- });
-
- const updatedTracks = tracks.map((track) =>
- track.id === existingTrack.id
- ? {
- ...track,
- elements: [...track.elements, adjustedElement],
- }
- : track,
- ) as TimelineTrack[];
-
- return { updatedTracks, targetTrackId: existingTrack.id };
- }
-
- const newTrackId = generateUUID();
- const newTrack = buildEmptyTrack({
- id: newTrackId,
- type: trackType,
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ ...(placement.mode === "auto" && placement.trackType
+ ? { trackType: placement.trackType }
+ : { elementType: element.type }),
+ timeSpans: [
+ {
+ startTime: element.startTime,
+ duration: element.duration,
+ },
+ ],
+ strategy:
+ placement.mode === "explicit"
+ ? { type: "explicit", trackId: placement.trackId }
+ : { type: "firstAvailable" },
});
- const newTrackWithElement = {
- ...newTrack,
- elements: [...newTrack.elements, element],
- } as TimelineTrack;
+ if (!placementResult) {
+ if (placement.mode === "explicit") {
+ const targetTrack = tracks.find((track) => track.id === placement.trackId);
+ if (!targetTrack) {
+ console.error("Track not found:", placement.trackId);
+ return null;
+ }
- const updatedTracks = [...tracks];
- const insertIndex =
- placement.insertIndex ??
- this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
- updatedTracks.splice(insertIndex, 0, newTrackWithElement);
-
- return { updatedTracks, targetTrackId: newTrackId };
- }
-
- private getAutoInsertIndex({
- tracks,
- trackType,
- }: {
- tracks: TimelineTrack[];
- trackType: TrackType;
- }): number {
- if (trackType === "text") {
- const firstVideoTrackIndex = tracks.findIndex(
- (track) => track.type === "video",
- );
- if (firstVideoTrackIndex >= 0) {
- return firstVideoTrackIndex;
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+ console.error(validation.errorMessage);
}
+
+ return null;
}
- return getDefaultInsertIndexForTrack({
- tracks,
- trackType,
- });
- }
+ const elementToPlace =
+ placementResult.kind === "existingTrack"
+ ? {
+ ...element,
+ startTime:
+ placementResult.adjustedStartTime ?? element.startTime,
+ }
+ : element;
- private adjustElementForMainTrack({
- tracks,
- targetTrackId,
- element,
- }: {
- tracks: TimelineTrack[];
- targetTrackId: string;
- element: TimelineElement;
- }): TimelineElement {
- const adjustedStartTime = enforceMainTrackStart({
+ return applyPlacement({
tracks,
- targetTrackId,
- requestedStartTime: element.startTime,
+ placementResult,
+ elements: [elementToPlace],
+ newTrackInsertIndexOverride:
+ placement.mode === "auto" && typeof placement.insertIndex === "number"
+ ? placement.insertIndex
+ : undefined,
});
- return { ...element, startTime: adjustedStartTime };
- }
-
- private getTrackTypeForElement({
- element,
- }: {
- element: { type: ElementType };
- }): TrackType {
- return ELEMENT_TRACK_MAP[element.type];
}
}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 24605d0b..73d07bff 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -10,7 +10,7 @@ import { isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
-} from "@/lib/timeline/track-utils";
+} from "@/lib/timeline/placement";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index d640d001..ff81cff2 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
-import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
+import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index b2c77418..13ee66dd 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; -import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; +import { enforceMainTrackStart } from "@/lib/timeline/placement"; export class UpdateElementTrimCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index b1f0daa0..104c0462 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -5,7 +5,7 @@ import { EditorCore } from "@/core"; import {
buildEmptyTrack,
getDefaultInsertIndexForTrack,
-} from "@/lib/timeline/track-utils";
+} from "@/lib/timeline/placement";
export class AddTrackCommand extends Command {
private trackId: string;
diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index a9859a1a..f35edbe4 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline";
-import { getMainTrack } from "@/lib/timeline";
+import { getMainTrack } from "@/lib/timeline/placement";
export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null;
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index c8420467..380a008c 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -1,6 +1,6 @@ import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
-import { isMainTrack } from "@/lib/timeline";
+import { isMainTrack } from "@/lib/timeline/placement";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { measureTextElement } from "@/lib/text/measure-element";
diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts index 9a63242c..1b5b293c 100644 --- a/apps/web/src/lib/scenes.ts +++ b/apps/web/src/lib/scenes.ts @@ -1,7 +1,7 @@ import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline";
-import { ensureMainTrack } from "@/lib/timeline/track-utils";
+import { ensureMainTrack } from "@/lib/timeline/placement";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts index cba8c65e..161d2714 100644 --- a/apps/web/src/lib/timeline/drop-utils.ts +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -1,16 +1,10 @@ import type {
TimelineTrack,
- ElementType,
TimelineElement,
} from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
-import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
-import {
- canElementGoOnTrack,
- isMainTrack,
- enforceMainTrackStart,
-} from "./track-utils";
+import { resolveTrackPlacement } from "@/lib/timeline/placement";
function findElementAtPosition({
mouseX,
@@ -82,57 +76,22 @@ function getTrackAtY({ return null;
}
-function isCompatible({
- elementType,
- trackType,
-}: {
- elementType: ElementType;
- trackType: TimelineTrack["type"];
-}): boolean {
- return canElementGoOnTrack({ elementType, trackType });
-}
-
-function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
- return tracks.findIndex((track) => isMainTrack(track));
-}
+const EMPTY_TARGET_ELEMENT = null;
-function findInsertIndex({
- elementType,
- tracks,
- preferredIndex,
- insertAbove,
+function fallbackNewTrackDropTarget({
+ xPosition,
}: {
- elementType: ElementType;
- tracks: TimelineTrack[];
- preferredIndex: number;
- insertAbove: boolean;
-}): { index: number; position: "above" | "below" } {
- const mainTrackIndex = getMainTrackIndex({ tracks });
-
- if (elementType === "audio") {
- if (preferredIndex <= mainTrackIndex) {
- return { index: mainTrackIndex + 1, position: "below" };
- }
- return {
- index: insertAbove ? preferredIndex : preferredIndex + 1,
- position: insertAbove ? "above" : "below",
- };
- }
-
- const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
-
- if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
- return { index: mainTrackIndex, position: "above" };
- }
-
+ xPosition: number;
+}): DropTarget {
return {
- index: overlayInsertIndex,
- position: insertAbove ? "above" : "below",
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
-const EMPTY_TARGET_ELEMENT = null;
-
export function computeDropTarget({
elementType,
mouseX,
@@ -155,22 +114,28 @@ export function computeDropTarget({ ? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
- const mainTrackIndex = getMainTrackIndex({ tracks });
-
if (tracks.length === 0) {
- if (elementType === "audio") {
- return {
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ elementType,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
trackIndex: 0,
- isNewTrack: true,
- insertPosition: "below",
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
+ hoverDirection: "below",
+ createNewTrackOnly: true,
+ },
+ });
+ const emptyTimelineResult =
+ placementResult?.kind === "newTrack" ? placementResult : null;
+ if (!emptyTimelineResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
}
+
return {
- trackIndex: 0,
+ trackIndex: emptyTimelineResult.insertIndex,
isNewTrack: true,
- insertPosition: null,
+ insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -181,30 +146,27 @@ export function computeDropTarget({ if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
- if (elementType === "audio") {
- return {
- trackIndex: tracks.length,
- isNewTrack: true,
- insertPosition: "below",
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- if (isAboveAllTracks) {
- return {
- trackIndex: 0,
- isNewTrack: true,
- insertPosition: "above",
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ elementType,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
+ hoverDirection: isAboveAllTracks ? "above" : "below",
+ createNewTrackOnly: true,
+ },
+ });
+ const outOfBoundsResult =
+ placementResult?.kind === "newTrack" ? placementResult : null;
+ if (!outOfBoundsResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
}
return {
- trackIndex: Math.max(0, mainTrackIndex),
+ trackIndex: outOfBoundsResult.insertIndex,
isNewTrack: true,
- insertPosition: "above",
+ insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -237,34 +199,27 @@ export function computeDropTarget({ }
const trackHeight = TRACK_CONFIG[track.type].height;
- const isInUpperHalf = relativeY < trackHeight / 2;
-
- const isTrackCompatible = isCompatible({
+ const placementResult = resolveTrackPlacement({
+ tracks,
elementType,
- trackType: track.type,
- });
-
- const endTime = xPosition + elementDuration;
- const hasOverlap = wouldElementOverlap({
- elements: track.elements,
- startTime: xPosition,
- endTime,
- excludeElementId,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
+ trackIndex,
+ hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
+ verticalDragDirection,
+ },
});
+ if (!placementResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
+ }
- if (isTrackCompatible && !hasOverlap) {
- const targetTrack = tracks[trackIndex];
- // safe: snap to 0 only happens when element becomes the new earliest,
- // meaning the space before the current earliest is empty
- const adjustedXPosition = enforceMainTrackStart({
- tracks,
- targetTrackId: targetTrack.id,
- requestedStartTime: xPosition,
- excludeElementId,
- });
+ if (placementResult.kind === "existingTrack") {
+ const adjustedXPosition =
+ placementResult.adjustedStartTime ?? xPosition;
return {
- trackIndex,
+ trackIndex: placementResult.trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
@@ -272,22 +227,10 @@ export function computeDropTarget({ };
}
- let insertAbove = isInUpperHalf;
- if (!isTrackCompatible && verticalDragDirection) {
- insertAbove = verticalDragDirection === "up";
- }
-
- const { index, position } = findInsertIndex({
- elementType,
- tracks,
- preferredIndex: trackIndex,
- insertAbove,
- });
-
return {
- trackIndex: index,
+ trackIndex: placementResult.insertIndex,
isNewTrack: true,
- insertPosition: position,
+ insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 19b821be..ed7bb174 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -86,24 +86,6 @@ export function requiresMediaId({ );
}
-export function wouldElementOverlap({
- elements,
- startTime,
- endTime,
- excludeElementId,
-}: {
- elements: TimelineElement[];
- startTime: number;
- endTime: number;
- excludeElementId?: string;
-}): boolean {
- return elements.some((element) => {
- if (excludeElementId && element.id === excludeElementId) return false;
- const elementEnd = element.startTime + element.duration;
- return startTime < elementEnd && endTime > element.startTime;
- });
-}
-
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts new file mode 100644 index 00000000..95424160 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -0,0 +1,462 @@ +import { describe, expect, test } from "bun:test"; +import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline"; +import { resolveTrackPlacement } from "@/lib/timeline/placement"; + +function buildElement({ + id, + type, + startTime, + duration, +}: { + id: string; + type: ElementType; + startTime: number; + duration: number; +}): TimelineElement { + return { + id, + type, + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + } as TimelineElement; +} + +function buildTrack({ + id, + type, + elements = [], + isMain = false, +}: { + id: string; + type: TrackType; + elements?: TimelineElement[]; + isMain?: boolean; +}): TimelineTrack { + if (type === "video") { + return { + id, + type, + name: id, + elements: elements as TimelineTrack["elements"], + isMain, + muted: false, + hidden: false, + }; + } + + if (type === "audio") { + return { + id, + type, + name: id, + elements: elements as TimelineTrack["elements"], + muted: false, + }; + } + + return { + id, + type, + name: id, + elements: elements as TimelineTrack["elements"], + hidden: false, + }; +} + +function buildTimeSpan({ + startTime, + duration, + excludeElementId, +}: { + startTime: number; + duration: number; + excludeElementId?: string; +}) { + return { startTime, duration, excludeElementId }; +} + +describe("resolveTrackPlacement", () => { + test("explicit returns the requested compatible track", () => { + const tracks = [buildTrack({ id: "text-1", type: "text" })]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })], + strategy: { type: "explicit", trackId: "text-1" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-1", + trackIndex: 0, + trackType: "text", + }); + }); + + test("explicit rejects missing and incompatible tracks", () => { + const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { type: "explicit", trackId: "missing" }, + }), + ).toBeNull(); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { type: "explicit", trackId: "video-1" }, + }), + ).toBeNull(); + }); + + test("firstAvailable picks the first compatible track without overlap", () => { + const tracks = [ + buildTrack({ + id: "text-1", + type: "text", + elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + }), + buildTrack({ id: "text-2", type: "text" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-2", + trackIndex: 1, + trackType: "text", + }); + }); + + test("firstAvailable creates a new track when all compatible tracks are full", () => { + const tracks = [ + buildTrack({ + id: "graphic-1", + type: "graphic", + elements: [ + buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ + id: "video-main", + type: "video", + isMain: true, + }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "graphic", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "graphic", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("preferIndex uses the preferred track when it fits", () => { + const tracks = [buildTrack({ id: "audio-1", type: "audio" })]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "audio-1", + trackIndex: 0, + trackType: "audio", + }); + }); + + test("preferIndex creates a new overlay track above the main track", () => { + const tracks = [ + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "graphic", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })], + strategy: { + type: "preferIndex", + trackIndex: 1, + hoverDirection: "below", + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "graphic", + insertIndex: 0, + insertPosition: "above", + }); + }); + + test("preferIndex keeps audio tracks below the main track", () => { + const tracks = [ + buildTrack({ id: "text-1", type: "text" }), + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "above", + createNewTrackOnly: true, + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: "below", + }); + }); + + test("aboveSource tries the track above source, then any compatible track", () => { + const tracks = [ + buildTrack({ id: "text-top", type: "text" }), + buildTrack({ + id: "text-middle", + type: "text", + elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + }), + buildTrack({ id: "text-source", type: "text" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "aboveSource", sourceTrackIndex: 2 }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-top", + trackIndex: 0, + trackType: "text", + }); + }); + + test("aboveSource creates a new track near the source when none fit", () => { + const tracks = [ + buildTrack({ + id: "text-top", + type: "text", + elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })], + }), + buildTrack({ + id: "text-source", + type: "text", + elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })], + }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "aboveSource", sourceTrackIndex: 1 }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "text", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("alwaysNew honors highest and default insertion rules", () => { + const tracks = [ + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "default" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: null, + }); + }); + + test("batch time spans reject tracks when any span overlaps", () => { + const tracks = [ + buildTrack({ + id: "audio-1", + type: "audio", + elements: [ + buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), + buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), + ], + }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [ + buildTimeSpan({ startTime: 2.5, duration: 1 }), + buildTimeSpan({ startTime: 5.5, duration: 1 }), + ], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("handles empty timelines, single tracks, and track-type derivation", () => { + expect( + resolveTrackPlacement({ + tracks: [], + elementType: "video", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + createNewTrackOnly: true, + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "video", + insertIndex: 0, + insertPosition: null, + }); + + expect( + resolveTrackPlacement({ + tracks: [buildTrack({ id: "audio-1", type: "audio" })], + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "default" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("existingTrack on main video includes adjustedStartTime when start snaps", () => { + const tracks = [ + buildTrack({ + id: "video-main", + type: "video", + isMain: true, + elements: [ + buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }), + ], + }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "video", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })], + strategy: { type: "explicit", trackId: "video-main" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "video-main", + trackIndex: 0, + trackType: "video", + adjustedStartTime: 0, + }); + }); + + test("preferIndex uses vertical drag direction when hovered track is incompatible", () => { + const tracks = [ + buildTrack({ id: "text-1", type: "text" }), + buildTrack({ id: "video-main", type: "video", isMain: true }), + buildTrack({ id: "audio-1", type: "audio" }), + ]; + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "above", + verticalDragDirection: "down", + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: "below", + }); + }); +}); diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts new file mode 100644 index 00000000..4ff5d50d --- /dev/null +++ b/apps/web/src/lib/timeline/placement/apply.ts @@ -0,0 +1,45 @@ +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; +import { buildEmptyTrack } from "./track-factory"; +import type { PlacementResult } from "./types"; + +export function applyPlacement({ + tracks, + placementResult, + elements, + newTrackInsertIndexOverride, +}: { + tracks: TimelineTrack[]; + placementResult: PlacementResult; + elements: TimelineElement[]; + newTrackInsertIndexOverride?: number; +}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { + if (placementResult.kind === "existingTrack") { + const targetTrack = tracks[placementResult.trackIndex]; + if (!targetTrack) { + return null; + } + + const updatedTracks = tracks.map((track, trackIndex) => + trackIndex === placementResult.trackIndex + ? { + ...track, + elements: [...track.elements, ...elements], + } + : track, + ) as TimelineTrack[]; + + return { updatedTracks, targetTrackId: targetTrack.id }; + } + + const newTrackId = generateUUID(); + const newTrack = { + ...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }), + elements, + } as TimelineTrack; + const insertIndex = + newTrackInsertIndexOverride ?? placementResult.insertIndex; + const updatedTracks = [...tracks]; + updatedTracks.splice(insertIndex, 0, newTrack); + return { updatedTracks, targetTrackId: newTrackId }; +} diff --git a/apps/web/src/lib/timeline/placement/compatibility.ts b/apps/web/src/lib/timeline/placement/compatibility.ts new file mode 100644 index 00000000..de1baff3 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/compatibility.ts @@ -0,0 +1,51 @@ +import type { ElementType, TrackType } from "@/lib/timeline"; + +const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = { + audio: "audio", + text: "text", + sticker: "graphic", + graphic: "graphic", + effect: "effect", + video: "video", + image: "video", +}; + +export function getTrackTypeForElementType({ + elementType, +}: { + elementType: ElementType; +}): TrackType { + return ELEMENT_TRACK_MAP[elementType]; +} + +export function canElementGoOnTrack({ + elementType, + trackType, +}: { + elementType: ElementType; + trackType: TrackType; +}): boolean { + return getTrackTypeForElementType({ elementType }) === trackType; +} + +export function validateElementTrackCompatibility({ + element, + track, +}: { + element: { type: ElementType }; + track: { type: TrackType }; +}): { isValid: boolean; errorMessage?: string } { + const isValid = canElementGoOnTrack({ + elementType: element.type, + trackType: track.type, + }); + + if (!isValid) { + return { + isValid: false, + errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`, + }; + } + + return { isValid: true }; +} diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts new file mode 100644 index 00000000..091acc33 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/index.ts @@ -0,0 +1,18 @@ +export { applyPlacement } from "./apply"; +export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; +export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; +export { + enforceMainTrackStart, + ensureMainTrack, + getEarliestMainTrackElement, + getMainTrack, + isMainTrack, +} from "./main-track"; +export { resolveTrackPlacement } from "./resolve"; +export { buildEmptyTrack } from "./track-factory"; +export type { + PlacementResult, + PlacementStrategy, + PlacementSubject, + PlacementTimeSpan, +} from "./types"; diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts new file mode 100644 index 00000000..fecb893a --- /dev/null +++ b/apps/web/src/lib/timeline/placement/insert-index.ts @@ -0,0 +1,99 @@ +import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import { isMainTrack } from "./main-track"; + +export function getDefaultInsertIndexForTrack({ + tracks, + trackType, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; +}): number { + if (trackType === "audio") { + return tracks.length; + } + + if (trackType === "effect") { + return 0; + } + + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + if (mainTrackIndex >= 0) { + return mainTrackIndex; + } + + const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio"); + if (firstAudioTrackIndex >= 0) { + return firstAudioTrackIndex; + } + + return tracks.length; +} + +export function getHighestInsertIndexForTrack({ + tracks, + trackType, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; +}): number { + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + if (trackType === "audio") { + return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; + } + + return 0; +} + +export function resolvePreferredNewTrackPlacement({ + tracks, + trackType, + preferredIndex, + direction, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; + preferredIndex: number; + direction: "above" | "below"; +}): { insertIndex: number; insertPosition: "above" | "below" | null } { + if (tracks.length === 0) { + return { + insertIndex: 0, + insertPosition: trackType === "audio" ? "below" : null, + }; + } + + const safePreferredIndex = Math.min( + Math.max(preferredIndex, 0), + tracks.length - 1, + ); + const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + + if (trackType === "audio") { + if (safePreferredIndex <= mainTrackIndex) { + return { + insertIndex: mainTrackIndex + 1, + insertPosition: "below", + }; + } + + return { + insertIndex: + direction === "above" ? safePreferredIndex : safePreferredIndex + 1, + insertPosition: direction, + }; + } + + const insertIndex = + direction === "above" ? safePreferredIndex : safePreferredIndex + 1; + if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) { + return { + insertIndex: mainTrackIndex, + insertPosition: "above", + }; + } + + return { + insertIndex, + insertPosition: direction, + }; +} diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts new file mode 100644 index 00000000..1783352c --- /dev/null +++ b/apps/web/src/lib/timeline/placement/main-track.ts @@ -0,0 +1,96 @@ +import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; + +const MAIN_TRACK_NAME = "Main Track"; + +export function isMainTrack(track: TimelineTrack): track is VideoTrack { + return track.type === "video" && track.isMain === true; +} + +export function getMainTrack({ + tracks, +}: { + tracks: TimelineTrack[]; +}): VideoTrack | null { + return tracks.find((track) => isMainTrack(track)) ?? null; +} + +export function ensureMainTrack({ + tracks, +}: { + tracks: TimelineTrack[]; +}): TimelineTrack[] { + if (tracks.some((track) => isMainTrack(track))) { + return tracks; + } + + return [ + { + id: generateUUID(), + name: MAIN_TRACK_NAME, + type: "video", + elements: [], + muted: false, + isMain: true, + hidden: false, + }, + ...tracks, + ]; +} + +export function getEarliestMainTrackElement({ + tracks, + excludeElementId, +}: { + tracks: TimelineTrack[]; + excludeElementId?: string; +}): TimelineElement | null { + const mainTrack = getMainTrack({ tracks }); + if (!mainTrack) { + return null; + } + + const elements = mainTrack.elements.filter((element) => { + return !excludeElementId || element.id !== excludeElementId; + }); + if (elements.length === 0) { + return null; + } + + return elements.reduce((earliestElement, element) => { + return element.startTime < earliestElement.startTime + ? element + : earliestElement; + }); +} + +export function enforceMainTrackStart({ + tracks, + targetTrackId, + requestedStartTime, + excludeElementId, +}: { + tracks: TimelineTrack[]; + targetTrackId: string; + requestedStartTime: number; + excludeElementId?: string; +}): number { + const mainTrack = getMainTrack({ tracks }); + if (!mainTrack || mainTrack.id !== targetTrackId) { + return requestedStartTime; + } + + const earliestElement = getEarliestMainTrackElement({ + tracks, + excludeElementId, + }); + if (!earliestElement) { + return 0; + } + + if (requestedStartTime <= earliestElement.startTime) { + return 0; + } + + return requestedStartTime; +} diff --git a/apps/web/src/lib/timeline/placement/overlap.ts b/apps/web/src/lib/timeline/placement/overlap.ts new file mode 100644 index 00000000..e5f95fa9 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/overlap.ts @@ -0,0 +1,40 @@ +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { PlacementTimeSpan } from "./types"; + +function wouldElementOverlap({ + elements, + startTime, + endTime, + excludeElementId, +}: { + elements: TimelineElement[]; + startTime: number; + endTime: number; + excludeElementId?: string; +}): boolean { + return elements.some((element) => { + if (excludeElementId && element.id === excludeElementId) { + return false; + } + + const elementEnd = element.startTime + element.duration; + return startTime < elementEnd && endTime > element.startTime; + }); +} + +export function canPlaceTimeSpansOnTrack({ + track, + timeSpans, +}: { + track: TimelineTrack; + timeSpans: PlacementTimeSpan[]; +}): boolean { + return timeSpans.every(({ startTime, duration, excludeElementId }) => { + return !wouldElementOverlap({ + elements: track.elements, + startTime, + endTime: startTime + duration, + excludeElementId, + }); + }); +} diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts new file mode 100644 index 00000000..235c789a --- /dev/null +++ b/apps/web/src/lib/timeline/placement/resolve.ts @@ -0,0 +1,255 @@ +import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import { + getDefaultInsertIndexForTrack, + getHighestInsertIndexForTrack, + resolvePreferredNewTrackPlacement, +} from "./insert-index"; +import { getTrackTypeForElementType } from "./compatibility"; +import { enforceMainTrackStart } from "./main-track"; +import { canPlaceTimeSpansOnTrack } from "./overlap"; +import type { + PlacementResult, + PlacementStrategy, + PlacementSubject, + PlacementTimeSpan, +} from "./types"; + +type ResolveTrackPlacementParams = PlacementSubject & { + tracks: TimelineTrack[]; + timeSpans: PlacementTimeSpan[]; + strategy: PlacementStrategy; +}; + +function buildExistingTrackResult({ + track, + trackIndex, + tracks, + timeSpans, +}: { + track: TimelineTrack; + trackIndex: number; + tracks: TimelineTrack[]; + timeSpans: PlacementTimeSpan[]; +}): PlacementResult { + const firstSpan = timeSpans[0]; + const requestedStartTime = firstSpan?.startTime ?? 0; + const adjustedStartTime = enforceMainTrackStart({ + tracks, + targetTrackId: track.id, + requestedStartTime, + excludeElementId: firstSpan?.excludeElementId, + }); + return { + kind: "existingTrack", + trackId: track.id, + trackIndex, + trackType: track.type, + ...(adjustedStartTime !== requestedStartTime + ? { adjustedStartTime } + : {}), + }; +} + +function buildNewTrackResult({ + trackType, + insertIndex, + insertPosition, +}: { + trackType: TrackType; + insertIndex: number; + insertPosition: "above" | "below" | null; +}): PlacementResult { + return { + kind: "newTrack", + trackType, + insertIndex, + insertPosition, + }; +} + +function findFirstAvailableTrackIndex({ + tracks, + trackType, + timeSpans, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; + timeSpans: PlacementTimeSpan[]; +}): number { + return tracks.findIndex((track) => { + return ( + track.type === trackType && + canPlaceTimeSpansOnTrack({ + track, + timeSpans, + }) + ); + }); +} + +function resolveAlwaysNewTrack({ + tracks, + trackType, + position, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; + position: "highest" | "default"; +}): PlacementResult { + const insertIndex = + position === "highest" + ? getHighestInsertIndexForTrack({ + tracks, + trackType, + }) + : getDefaultInsertIndexForTrack({ + tracks, + trackType, + }); + + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition: null, + }); +} + +export function resolveTrackPlacement({ + tracks, + ...placement +}: ResolveTrackPlacementParams): PlacementResult | null { + const trackType = + "trackType" in placement + ? placement.trackType + : getTrackTypeForElementType({ + elementType: placement.elementType, + }); + const { timeSpans, strategy } = placement; + + if (strategy.type === "explicit") { + const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId); + if (trackIndex < 0) { + return null; + } + + const track = tracks[trackIndex]; + if (track.type !== trackType) { + return null; + } + + return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans }); + } + + if (strategy.type === "firstAvailable") { + const existingTrackIndex = findFirstAvailableTrackIndex({ + tracks, + trackType, + timeSpans, + }); + if (existingTrackIndex >= 0) { + return buildExistingTrackResult({ + track: tracks[existingTrackIndex], + trackIndex: existingTrackIndex, + tracks, + timeSpans, + }); + } + + return resolveAlwaysNewTrack({ + tracks, + trackType, + position: "default", + }); + } + + if (strategy.type === "preferIndex") { + const preferredTrack = tracks[strategy.trackIndex]; + const isPreferredTrackCompatible = + !!preferredTrack && preferredTrack.type === trackType; + const canUseExistingTrack = + !strategy.createNewTrackOnly && + isPreferredTrackCompatible && + canPlaceTimeSpansOnTrack({ + track: preferredTrack, + timeSpans, + }); + if (canUseExistingTrack) { + return buildExistingTrackResult({ + track: preferredTrack, + trackIndex: strategy.trackIndex, + tracks, + timeSpans, + }); + } + + const insertDirection = + !isPreferredTrackCompatible && strategy.verticalDragDirection + ? strategy.verticalDragDirection + : strategy.hoverDirection; + const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({ + tracks, + trackType, + preferredIndex: strategy.trackIndex, + direction: insertDirection, + }); + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition, + }); + } + + if (strategy.type === "aboveSource") { + const aboveTrackIndex = strategy.sourceTrackIndex - 1; + const aboveTrack = tracks[aboveTrackIndex]; + if ( + aboveTrack && + aboveTrack.type === trackType && + canPlaceTimeSpansOnTrack({ + track: aboveTrack, + timeSpans, + }) + ) { + return buildExistingTrackResult({ + track: aboveTrack, + trackIndex: aboveTrackIndex, + tracks, + timeSpans, + }); + } + + const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ + tracks, + trackType, + timeSpans, + }); + if (firstAvailableTrackIndex >= 0) { + return buildExistingTrackResult({ + track: tracks[firstAvailableTrackIndex], + trackIndex: firstAvailableTrackIndex, + tracks, + timeSpans, + }); + } + + const insertIndex = + strategy.sourceTrackIndex >= 0 + ? strategy.sourceTrackIndex + : getHighestInsertIndexForTrack({ + tracks, + trackType, + }); + + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition: null, + }); + } + + return resolveAlwaysNewTrack({ + tracks, + trackType, + position: strategy.position, + }); +} diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts new file mode 100644 index 00000000..e77a8a96 --- /dev/null +++ b/apps/web/src/lib/timeline/placement/track-factory.ts @@ -0,0 +1,61 @@ +import { TRACK_CONFIG } from "@/constants/timeline-constants"; +import type { TrackType, TimelineTrack } from "@/lib/timeline"; + +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: TrackType; + name?: string; +}): TimelineTrack { + const trackName = name ?? TRACK_CONFIG[type].defaultName; + + switch (type) { + case "video": + return { + id, + name: trackName, + type: "video", + elements: [], + hidden: false, + muted: false, + isMain: false, + }; + case "text": + return { + id, + name: trackName, + type: "text", + elements: [], + hidden: false, + }; + case "graphic": + return { + id, + name: trackName, + type: "graphic", + elements: [], + hidden: false, + }; + case "audio": + return { + id, + name: trackName, + type: "audio", + elements: [], + muted: false, + }; + case "effect": + return { + id, + name: trackName, + type: "effect", + elements: [], + hidden: false, + }; + default: + throw new Error(`Unsupported track type: ${type}`); + } +} diff --git a/apps/web/src/lib/timeline/placement/types.ts b/apps/web/src/lib/timeline/placement/types.ts new file mode 100644 index 00000000..559e85cc --- /dev/null +++ b/apps/web/src/lib/timeline/placement/types.ts @@ -0,0 +1,39 @@ +import type { ElementType, TrackType } from "@/lib/timeline"; + +export interface PlacementTimeSpan { + startTime: number; + duration: number; + excludeElementId?: string; +} + +export type PlacementSubject = + | { elementType: ElementType } + | { trackType: TrackType }; + +export type PlacementStrategy = + | { type: "explicit"; trackId: string } + | { type: "firstAvailable" } + | { + type: "preferIndex"; + trackIndex: number; + hoverDirection: "above" | "below"; + verticalDragDirection?: "up" | "down" | null; + createNewTrackOnly?: boolean; + } + | { type: "aboveSource"; sourceTrackIndex: number } + | { type: "alwaysNew"; position: "highest" | "default" }; + +export type PlacementResult = + | { + kind: "existingTrack"; + trackId: string; + trackIndex: number; + trackType: TrackType; + adjustedStartTime?: number; + } + | { + kind: "newTrack"; + insertIndex: number; + insertPosition: "above" | "below" | null; + trackType: TrackType; + }; diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts index 0034c72e..cc9e67c4 100644 --- a/apps/web/src/lib/timeline/track-utils.ts +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -1,21 +1,17 @@ import type {
TrackType,
TimelineTrack,
- ElementType,
VideoTrack,
AudioTrack,
GraphicTrack,
TextTrack,
EffectTrack,
- TimelineElement,
} from "@/lib/timeline";
import {
- ELEMENT_TRACK_MAP,
TRACK_CONFIG,
ELEMENT_TYPE_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
-import { generateUUID } from "@/utils/id";
export function canTracktHaveAudio(
track: TimelineTrack,
@@ -64,234 +60,3 @@ export function getTotalTracksHeight({ const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
return tracksHeight + gapsHeight;
}
-
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: TrackType;
- name?: string;
-}): TimelineTrack {
- const trackName = name ?? TRACK_CONFIG[type].defaultName;
-
- switch (type) {
- case "video":
- return {
- id,
- name: trackName,
- type: "video",
- elements: [],
- hidden: false,
- muted: false,
- isMain: false,
- };
- case "text":
- return {
- id,
- name: trackName,
- type: "text",
- elements: [],
- hidden: false,
- };
- case "graphic":
- return {
- id,
- name: trackName,
- type: "graphic",
- elements: [],
- hidden: false,
- };
- case "audio":
- return {
- id,
- name: trackName,
- type: "audio",
- elements: [],
- muted: false,
- };
- case "effect":
- return {
- id,
- name: trackName,
- type: "effect",
- elements: [],
- hidden: false,
- };
- default:
- throw new Error(`Unsupported track type: ${type}`);
- }
-}
-
-export function getDefaultInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: TimelineTrack[];
- trackType: TrackType;
-}): number {
- if (trackType === "audio") {
- return tracks.length;
- }
-
- if (trackType === "effect") {
- return 0;
- }
-
- const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
- if (mainTrackIndex >= 0) {
- return mainTrackIndex;
- }
-
- const firstAudioTrackIndex = tracks.findIndex(
- (track) => track.type === "audio",
- );
- if (firstAudioTrackIndex >= 0) {
- return firstAudioTrackIndex;
- }
-
- return tracks.length;
-}
-
-export function getHighestInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: TimelineTrack[];
- trackType: TrackType;
-}): number {
- const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
-
- if (trackType === "audio") {
- return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
- }
-
- return 0;
-}
-
-export function isMainTrack(track: TimelineTrack): track is VideoTrack {
- return track.type === "video" && track.isMain === true;
-}
-
-export function getMainTrack({
- tracks,
-}: {
- tracks: TimelineTrack[];
-}): TimelineTrack | null {
- return tracks.find((track) => isMainTrack(track)) ?? null;
-}
-
-export function ensureMainTrack({
- tracks,
-}: {
- tracks: TimelineTrack[];
-}): TimelineTrack[] {
- const hasMainTrack = tracks.some((track) => isMainTrack(track));
-
- if (!hasMainTrack) {
- const mainTrack: TimelineTrack = {
- id: generateUUID(),
- name: "Main Track",
- type: "video",
- elements: [],
- muted: false,
- isMain: true,
- hidden: false,
- };
- return [mainTrack, ...tracks];
- }
-
- return tracks;
-}
-
-export function canElementGoOnTrack({
- elementType,
- trackType,
-}: {
- elementType: ElementType;
- trackType: TrackType;
-}): boolean {
- return ELEMENT_TRACK_MAP[elementType] === trackType;
-}
-
-export function validateElementTrackCompatibility({
- element,
- track,
-}: {
- element: { type: ElementType };
- track: { type: TrackType };
-}): { isValid: boolean; errorMessage?: string } {
- const isValid = canElementGoOnTrack({
- elementType: element.type,
- trackType: track.type,
- });
-
- if (!isValid) {
- return {
- isValid: false,
- errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
- };
- }
-
- return { isValid: true };
-}
-
-export function getEarliestMainTrackElement({
- tracks,
- excludeElementId,
-}: {
- tracks: TimelineTrack[];
- excludeElementId?: string;
-}): TimelineElement | null {
- const mainTrack = getMainTrack({ tracks });
- if (!mainTrack) {
- return null;
- }
-
- const elements = mainTrack.elements.filter(
- (element) => !excludeElementId || element.id !== excludeElementId,
- );
-
- if (elements.length === 0) {
- return null;
- }
-
- return elements.reduce((earliest, element) =>
- element.startTime < earliest.startTime ? element : earliest,
- );
-}
-
-export function enforceMainTrackStart({
- tracks,
- targetTrackId,
- requestedStartTime,
- excludeElementId,
-}: {
- tracks: TimelineTrack[];
- targetTrackId: string;
- requestedStartTime: number;
- excludeElementId?: string;
-}): number {
- const mainTrack = getMainTrack({ tracks });
- if (!mainTrack || mainTrack.id !== targetTrackId) {
- return requestedStartTime;
- }
-
- const earliestElement = getEarliestMainTrackElement({
- tracks,
- excludeElementId,
- });
-
- if (!earliestElement) {
- return 0;
- }
-
- // main track must always start at time 0; if this element would
- // become the earliest, pin it to the start
- if (requestedStartTime <= earliestElement.startTime) {
- return 0;
- }
-
- return requestedStartTime;
-}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 1c3e6ad2..8fd8631b 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
-import { isMainTrack } from "@/lib/timeline";
+import { isMainTrack } from "@/lib/timeline/placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
