summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/placement
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-31 15:53:23 +0200
committerMaze Winther <mazewinther@gmail.com>2026-03-31 15:53:23 +0200
commitd23be079cce6108482fc3e85401cccf949087174 (patch)
tree7d5acd324f9274b3b823bc67154349b8693e3412 /apps/web/src/lib/timeline/placement
parentdcf54c1250431f5a4a2848f7c07781ef5eebacaf (diff)
parent70a6d58b8432e455ab1443d45bc23dd70b363bea (diff)
downloadopencut-d23be079cce6108482fc3e85401cccf949087174.tar.gz
Merge branch 'feature/track-placement'
Diffstat (limited to 'apps/web/src/lib/timeline/placement')
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts462
-rw-r--r--apps/web/src/lib/timeline/placement/apply.ts45
-rw-r--r--apps/web/src/lib/timeline/placement/compatibility.ts51
-rw-r--r--apps/web/src/lib/timeline/placement/index.ts18
-rw-r--r--apps/web/src/lib/timeline/placement/insert-index.ts99
-rw-r--r--apps/web/src/lib/timeline/placement/main-track.ts96
-rw-r--r--apps/web/src/lib/timeline/placement/overlap.ts40
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts255
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts61
-rw-r--r--apps/web/src/lib/timeline/placement/types.ts39
10 files changed, 1166 insertions, 0 deletions
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;
+ };