summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/placement
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/timeline/placement')
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts661
-rw-r--r--apps/web/src/lib/timeline/placement/apply.ts158
-rw-r--r--apps/web/src/lib/timeline/placement/compatibility.ts51
-rw-r--r--apps/web/src/lib/timeline/placement/index.ts12
-rw-r--r--apps/web/src/lib/timeline/placement/insert-index.ts88
-rw-r--r--apps/web/src/lib/timeline/placement/main-track.ts54
-rw-r--r--apps/web/src/lib/timeline/placement/overlap.ts44
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts277
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts123
-rw-r--r--apps/web/src/lib/timeline/placement/types.ts39
10 files changed, 0 insertions, 1507 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
deleted file mode 100644
index 7cadb9b7..00000000
--- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
+++ /dev/null
@@ -1,661 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type {
- AudioElement,
- AudioTrack,
- GraphicElement,
- GraphicTrack,
- OverlayTrack,
- SceneTracks,
- TextElement,
- TextTrack,
- TimelineTrack,
- VideoElement,
- VideoTrack,
-} from "@/lib/timeline";
-import type { Transform } from "@/lib/rendering";
-import { resolveTrackPlacement } from "@/lib/timeline/placement";
-
-function buildTransform(): Transform {
- return {
- scaleX: 1,
- scaleY: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
- };
-}
-
-type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
-
-function buildElement(params: {
- id: string;
- type: "audio";
- startTime: number;
- duration: number;
-}): AudioElement;
-function buildElement(params: {
- id: string;
- type: "graphic";
- startTime: number;
- duration: number;
-}): GraphicElement;
-function buildElement(params: {
- id: string;
- type: "text";
- startTime: number;
- duration: number;
-}): TextElement;
-function buildElement(params: {
- id: string;
- type: "video";
- startTime: number;
- duration: number;
-}): VideoElement;
-function buildElement({
- id,
- type,
- startTime,
- duration,
-}: {
- id: string;
- type: TestElement["type"];
- startTime: number;
- duration: number;
-}): TestElement {
- switch (type) {
- case "audio":
- return {
- id,
- type: "audio",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- volume: 1,
- sourceType: "upload",
- mediaId: `media-${id}`,
- } satisfies AudioElement;
- case "graphic":
- return {
- id,
- type: "graphic",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- definitionId: `graphic-${id}`,
- params: {},
- transform: buildTransform(),
- opacity: 1,
- } satisfies GraphicElement;
- case "text":
- return {
- id,
- type: "text",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- content: id,
- fontSize: 32,
- fontFamily: "sans-serif",
- color: "#ffffff",
- background: {
- enabled: false,
- color: "#000000",
- },
- textAlign: "left",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- transform: buildTransform(),
- opacity: 1,
- } satisfies TextElement;
- case "video":
- return {
- id,
- type: "video",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- mediaId: `media-${id}`,
- transform: buildTransform(),
- opacity: 1,
- } satisfies VideoElement;
- }
-
- throw new Error(`Unsupported test element type: ${type}`);
-}
-
-type BuildTrackParams =
- | {
- id: string;
- type: "audio";
- elements?: AudioTrack["elements"];
- }
- | {
- id: string;
- type: "graphic";
- elements?: GraphicTrack["elements"];
- }
- | {
- id: string;
- type: "text";
- elements?: TextTrack["elements"];
- }
- | {
- id: string;
- type: "video";
- elements?: VideoTrack["elements"];
- };
-
-function buildTrack(params: {
- id: string;
- type: "audio";
- elements?: AudioTrack["elements"];
-}): AudioTrack;
-function buildTrack(params: {
- id: string;
- type: "graphic";
- elements?: GraphicTrack["elements"];
-}): GraphicTrack;
-function buildTrack(params: {
- id: string;
- type: "text";
- elements?: TextTrack["elements"];
-}): TextTrack;
-function buildTrack(params: {
- id: string;
- type: "video";
- elements?: VideoTrack["elements"];
-}): VideoTrack;
-function buildTrack(params: BuildTrackParams): TimelineTrack {
- const { id, type } = params;
-
- switch (type) {
- case "audio":
- return {
- id,
- type: "audio",
- name: id,
- elements: params.elements ?? [],
- muted: false,
- };
- case "graphic":
- return {
- id,
- type: "graphic",
- name: id,
- elements: params.elements ?? [],
- hidden: false,
- };
- case "text":
- return {
- id,
- type: "text",
- name: id,
- elements: params.elements ?? [],
- hidden: false,
- };
- case "video":
- return {
- id,
- type: "video",
- name: id,
- elements: params.elements ?? [],
- muted: false,
- hidden: false,
- };
- }
-
- throw new Error(`Unsupported test track type: ${type}`);
-}
-
-function buildTimeSpan({
- startTime,
- duration,
- excludeElementId,
-}: {
- startTime: number;
- duration: number;
- excludeElementId?: string;
-}) {
- return { startTime, duration, excludeElementId };
-}
-
-function buildSceneTracks({
- overlay = [],
- main,
- audio = [],
-}: {
- overlay?: Array<OverlayTrack>;
- main?: VideoTrack;
- audio?: Array<AudioTrack>;
-}): SceneTracks {
- return {
- overlay,
- main:
- main ??
- buildTrack({
- id: "video-main",
- type: "video",
- }),
- audio,
- };
-}
-
-describe("resolveTrackPlacement", () => {
- test("explicit returns the requested compatible track", () => {
- const tracks = buildSceneTracks({
- overlay: [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 = buildSceneTracks({
- main: buildTrack({ id: "video-1", type: "video" }),
- });
-
- 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 = buildSceneTracks({
- overlay: [
- 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 = buildSceneTracks({
- overlay: [
- buildTrack({
- id: "graphic-1",
- type: "graphic",
- elements: [
- buildElement({
- id: "a",
- type: "graphic",
- startTime: 0,
- duration: 5,
- }),
- ],
- }),
- ],
- main: buildTrack({
- id: "video-main",
- type: "video",
- }),
- audio: [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: 0,
- insertPosition: null,
- });
- });
-
- test("preferIndex uses the preferred track when it fits", () => {
- const tracks = buildSceneTracks({
- audio: [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: "newTrack",
- trackType: "audio",
- insertIndex: 1,
- insertPosition: "below",
- });
- });
-
- test("preferIndex creates a new overlay track above the main track", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [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 = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [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 = buildSceneTracks({
- overlay: [
- 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 overlay track in the overlay zone when none fit", () => {
- const tracks = buildSceneTracks({
- overlay: [
- 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: 0,
- insertPosition: null,
- });
- });
-
- test("alwaysNew honors highest and default insertion rules", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [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 = buildSceneTracks({
- audio: [
- 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 main-only timelines, single tracks, and track-type derivation", () => {
- expect(
- resolveTrackPlacement({
- tracks: buildSceneTracks({}),
- 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: "above",
- });
-
- expect(
- resolveTrackPlacement({
- tracks: buildSceneTracks({
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- }),
- elementType: "audio",
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "default" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: null,
- });
- });
-
- test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({
- id: "video-main",
- type: "video",
- 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 = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [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
deleted file mode 100644
index 639b1b42..00000000
--- a/apps/web/src/lib/timeline/placement/apply.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import type {
- AudioTrack,
- EffectTrack,
- GraphicTrack,
- OverlayTrack,
- SceneTracks,
- TextTrack,
- TimelineElement,
- TimelineTrack,
- VideoTrack,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { buildEmptyTrack } from "./track-factory";
-import type { PlacementResult } from "./types";
-import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update";
-
-export function applyPlacement({
- tracks,
- placementResult,
- elements,
- newTrackInsertIndexOverride,
-}: {
- tracks: SceneTracks;
- placementResult: PlacementResult;
- elements: TimelineElement[];
- newTrackInsertIndexOverride?: number;
-}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (placementResult.kind === "existingTrack") {
- const targetTrack = orderedTracks[placementResult.trackIndex];
- if (!targetTrack) {
- return null;
- }
-
- const updatedTracks = updateTrackInSceneTracks({
- tracks,
- trackId: targetTrack.id,
- update: (track) => ({
- ...track,
- elements: [...track.elements, ...elements],
- }),
- });
-
- return { updatedTracks, targetTrackId: targetTrack.id };
- }
-
- const newTrackId = generateUUID();
- const insertIndex =
- newTrackInsertIndexOverride ?? placementResult.insertIndex;
- const updatedTracks =
- placementResult.trackType === "audio"
- ? {
- ...tracks,
- audio: insertIntoAudioTracks({
- tracks,
- insertIndex,
- track: buildPlacedAudioTrack({
- id: newTrackId,
- elements,
- }),
- }),
- }
- : {
- ...tracks,
- overlay: insertIntoOverlayTracks({
- tracks,
- insertIndex,
- track: buildPlacedOverlayTrack({
- id: newTrackId,
- type: placementResult.trackType,
- elements,
- }),
- }),
- };
- return { updatedTracks, targetTrackId: newTrackId };
-}
-
-function insertIntoOverlayTracks({
- tracks,
- insertIndex,
- track,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- track: OverlayTrack;
-}): OverlayTrack[] {
- const normalizedInsertIndex = Math.max(
- 0,
- Math.min(insertIndex, tracks.overlay.length),
- );
- const nextTracks = [...tracks.overlay];
- nextTracks.splice(normalizedInsertIndex, 0, track);
- return nextTracks;
-}
-
-function insertIntoAudioTracks({
- tracks,
- insertIndex,
- track,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- track: AudioTrack;
-}): AudioTrack[] {
- const audioInsertIndex = Math.max(
- 0,
- Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
- );
- const nextTracks = [...tracks.audio];
- nextTracks.splice(audioInsertIndex, 0, track);
- return nextTracks;
-}
-
-function buildPlacedAudioTrack({
- id,
- elements,
-}: {
- id: string;
- elements: TimelineElement[];
-}): AudioTrack {
- return {
- ...buildEmptyTrack({ id, type: "audio" }),
- elements: elements as AudioTrack["elements"],
- };
-}
-
-function buildPlacedOverlayTrack({
- id,
- type,
- elements,
-}: {
- id: string;
- type: Exclude<OverlayTrack["type"], "audio">;
- elements: TimelineElement[];
-}): OverlayTrack {
- switch (type) {
- case "video":
- return {
- ...buildEmptyTrack({ id, type: "video" }),
- elements: elements as VideoTrack["elements"],
- };
- case "text":
- return {
- ...buildEmptyTrack({ id, type: "text" }),
- elements: elements as TextTrack["elements"],
- };
- case "graphic":
- return {
- ...buildEmptyTrack({ id, type: "graphic" }),
- elements: elements as GraphicTrack["elements"],
- };
- case "effect":
- return {
- ...buildEmptyTrack({ id, type: "effect" }),
- elements: elements as EffectTrack["elements"],
- };
- }
-}
diff --git a/apps/web/src/lib/timeline/placement/compatibility.ts b/apps/web/src/lib/timeline/placement/compatibility.ts
deleted file mode 100644
index de1baff3..00000000
--- a/apps/web/src/lib/timeline/placement/compatibility.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-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
deleted file mode 100644
index 530cb69f..00000000
--- a/apps/web/src/lib/timeline/placement/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export { applyPlacement } from "./apply";
-export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
-export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
-export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } 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
deleted file mode 100644
index 04fa5a4a..00000000
--- a/apps/web/src/lib/timeline/placement/insert-index.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { SceneTracks, TrackType } from "@/lib/timeline";
-
-export function getDefaultInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
-}): number {
- if (trackType === "audio") {
- return tracks.overlay.length + 1 + tracks.audio.length;
- }
-
- if (trackType === "effect") {
- return 0;
- }
-
- return tracks.overlay.length;
-}
-
-export function getHighestInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
-}): number {
- if (trackType === "audio") {
- return tracks.overlay.length + 1;
- }
-
- return 0;
-}
-
-export function resolvePreferredNewTrackPlacement({
- tracks,
- trackType,
- preferredIndex,
- direction,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
- preferredIndex: number;
- direction: "above" | "below";
-}): { insertIndex: number; insertPosition: "above" | "below" | null } {
- const trackCount = tracks.overlay.length + 1 + tracks.audio.length;
- if (trackCount === 0) {
- return {
- insertIndex: 0,
- insertPosition: trackType === "audio" ? "below" : null,
- };
- }
-
- const safePreferredIndex = Math.min(
- Math.max(preferredIndex, 0),
- trackCount - 1,
- );
- const mainTrackIndex = tracks.overlay.length;
-
- 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
deleted file mode 100644
index 7e206517..00000000
--- a/apps/web/src/lib/timeline/placement/main-track.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline";
-
-export const MAIN_TRACK_NAME = "Main Track";
-
-export function getEarliestMainTrackElement({
- mainTrack,
- excludeElementId,
-}: {
- mainTrack: VideoTrack;
- excludeElementId?: string;
-}): TimelineElement | 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: SceneTracks;
- targetTrackId: string;
- requestedStartTime: number;
- excludeElementId?: string;
-}): number {
- if (tracks.main.id !== targetTrackId) {
- return requestedStartTime;
- }
-
- const earliestElement = getEarliestMainTrackElement({
- mainTrack: tracks.main,
- 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
deleted file mode 100644
index 5e37c052..00000000
--- a/apps/web/src/lib/timeline/placement/overlap.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { TimelineElement } from "@/lib/timeline";
-import type { PlacementTimeSpan } from "./types";
-
-interface TrackWithElements {
- elements: TimelineElement[];
-}
-
-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: TrackWithElements;
- 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
deleted file mode 100644
index 67943912..00000000
--- a/apps/web/src/lib/timeline/placement/resolve.ts
+++ /dev/null
@@ -1,277 +0,0 @@
-import type { SceneTracks, 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: SceneTracks;
- timeSpans: PlacementTimeSpan[];
- strategy: PlacementStrategy;
-};
-
-function buildExistingTrackResult({
- track,
- trackIndex,
- tracks,
- timeSpans,
-}: {
- track: TimelineTrack;
- trackIndex: number;
- tracks: SceneTracks;
- 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: SceneTracks;
- trackType: TrackType;
- position: "highest" | "default";
-}): PlacementResult {
- const insertIndex =
- position === "highest"
- ? getHighestInsertIndexForTrack({
- tracks,
- trackType,
- })
- : getDefaultInsertIndexForTrack({
- tracks,
- trackType,
- });
-
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition: null,
- });
-}
-
-function getInsertDirection({
- hoverDirection,
- verticalDragDirection,
-}: {
- hoverDirection: "above" | "below";
- verticalDragDirection?: "up" | "down" | null;
-}): "above" | "below" {
- if (verticalDragDirection === "up") {
- return "above";
- }
-
- if (verticalDragDirection === "down") {
- return "below";
- }
-
- return hoverDirection;
-}
-
-export function resolveTrackPlacement({
- tracks,
- ...placement
-}: ResolveTrackPlacementParams): PlacementResult | null {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const trackType =
- "trackType" in placement
- ? placement.trackType
- : getTrackTypeForElementType({
- elementType: placement.elementType,
- });
- const { timeSpans, strategy } = placement;
-
- if (strategy.type === "explicit") {
- const trackIndex = orderedTracks.findIndex(
- (track) => track.id === strategy.trackId,
- );
- if (trackIndex < 0) {
- return null;
- }
-
- const track = orderedTracks[trackIndex];
- if (track.type !== trackType) {
- return null;
- }
-
- return buildExistingTrackResult({
- track,
- trackIndex,
- tracks,
- timeSpans,
- });
- }
-
- if (strategy.type === "firstAvailable") {
- const existingTrackIndex = findFirstAvailableTrackIndex({
- tracks: orderedTracks,
- trackType,
- timeSpans,
- });
- if (existingTrackIndex >= 0) {
- return buildExistingTrackResult({
- track: orderedTracks[existingTrackIndex],
- trackIndex: existingTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- return resolveAlwaysNewTrack({
- tracks,
- trackType,
- position: "highest",
- });
- }
-
- if (strategy.type === "preferIndex") {
- const preferredTrack = orderedTracks[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 { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
- tracks,
- trackType,
- preferredIndex: strategy.trackIndex,
- direction: getInsertDirection({
- hoverDirection: strategy.hoverDirection,
- verticalDragDirection: !isPreferredTrackCompatible
- ? strategy.verticalDragDirection
- : null,
- }),
- });
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition,
- });
- }
-
- if (strategy.type === "aboveSource") {
- const aboveTrackIndex = strategy.sourceTrackIndex - 1;
- const aboveTrack = orderedTracks[aboveTrackIndex];
- if (
- aboveTrack &&
- aboveTrack.type === trackType &&
- canPlaceTimeSpansOnTrack({
- track: aboveTrack,
- timeSpans,
- })
- ) {
- return buildExistingTrackResult({
- track: aboveTrack,
- trackIndex: aboveTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
- tracks: orderedTracks,
- trackType,
- timeSpans,
- });
- if (firstAvailableTrackIndex >= 0) {
- return buildExistingTrackResult({
- track: orderedTracks[firstAvailableTrackIndex],
- trackIndex: firstAvailableTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const insertIndex = 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
deleted file mode 100644
index b73c5155..00000000
--- a/apps/web/src/lib/timeline/placement/track-factory.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
-import type {
- AudioTrack,
- EffectTrack,
- GraphicTrack,
- TextTrack,
- TrackType,
- TimelineTrack,
- VideoTrack,
-} from "@/lib/timeline";
-
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "video";
- name?: string;
-}): VideoTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "text";
- name?: string;
-}): TextTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "audio";
- name?: string;
-}): AudioTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "graphic";
- name?: string;
-}): GraphicTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "effect";
- name?: string;
-}): EffectTrack;
-
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: TrackType;
- name?: string;
-}): TimelineTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: TrackType;
- name?: string;
-}): TimelineTrack {
- const trackName = name ?? DEFAULT_TRACK_NAMES[type];
-
- switch (type) {
- case "video":
- return {
- id,
- name: trackName,
- type: "video",
- elements: [],
- hidden: false,
- muted: 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
deleted file mode 100644
index 559e85cc..00000000
--- a/apps/web/src/lib/timeline/placement/types.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-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;
- };