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/web/src/lib/timeline | |
| parent | dcf54c1250431f5a4a2848f7c07781ef5eebacaf (diff) | |
| parent | 70a6d58b8432e455ab1443d45bc23dd70b363bea (diff) | |
| download | opencut-d23be079cce6108482fc3e85401cccf949087174.tar.gz | |
Merge branch 'feature/track-placement'
Diffstat (limited to 'apps/web/src/lib/timeline')
| -rw-r--r-- | apps/web/src/lib/timeline/drop-utils.ts | 183 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 18 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts | 462 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/apply.ts | 45 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/compatibility.ts | 51 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/index.ts | 18 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/insert-index.ts | 99 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/main-track.ts | 96 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/overlap.ts | 40 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/resolve.ts | 255 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/track-factory.ts | 61 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/types.ts | 39 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-utils.ts | 235 |
13 files changed, 1229 insertions, 373 deletions
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;
-}
|
