summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/placement
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-07 04:41:27 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-07 04:41:27 +0200
commitcbd1b8212370c67b34a70dccd21bd82de72fead2 (patch)
treed97cbee2aae22c22b59c59a50e9f8a6af0ae8ccf /apps/web/src/lib/timeline/placement
parent4f7d401a974047d37673ee8e3840dceaaddc295c (diff)
downloadopencut-cbd1b8212370c67b34a70dccd21bd82de72fead2.tar.gz
refactor: structured track layout per scene
Diffstat (limited to 'apps/web/src/lib/timeline/placement')
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts238
-rw-r--r--apps/web/src/lib/timeline/placement/apply.ts149
-rw-r--r--apps/web/src/lib/timeline/placement/index.ts8
-rw-r--r--apps/web/src/lib/timeline/placement/insert-index.ts33
-rw-r--r--apps/web/src/lib/timeline/placement/main-track.ts56
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts43
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts66
7 files changed, 371 insertions, 222 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
index 5ddccd90..4d39f5d8 100644
--- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
+++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
@@ -4,6 +4,8 @@ import type {
AudioTrack,
GraphicElement,
GraphicTrack,
+ OverlayTrack,
+ SceneTracks,
TextElement,
TextTrack,
TimelineTrack,
@@ -134,53 +136,45 @@ type BuildTrackParams =
id: string;
type: "audio";
elements?: AudioTrack["elements"];
- isMain?: boolean;
}
| {
id: string;
type: "graphic";
elements?: GraphicTrack["elements"];
- isMain?: boolean;
}
| {
id: string;
type: "text";
elements?: TextTrack["elements"];
- isMain?: boolean;
}
| {
id: string;
type: "video";
elements?: VideoTrack["elements"];
- isMain?: boolean;
};
function buildTrack(params: {
id: string;
type: "audio";
elements?: AudioTrack["elements"];
- isMain?: boolean;
}): AudioTrack;
function buildTrack(params: {
id: string;
type: "graphic";
elements?: GraphicTrack["elements"];
- isMain?: boolean;
}): GraphicTrack;
function buildTrack(params: {
id: string;
type: "text";
elements?: TextTrack["elements"];
- isMain?: boolean;
}): TextTrack;
function buildTrack(params: {
id: string;
type: "video";
elements?: VideoTrack["elements"];
- isMain?: boolean;
}): VideoTrack;
function buildTrack(params: BuildTrackParams): TimelineTrack {
- const { id, type, isMain = false } = params;
+ const { id, type } = params;
switch (type) {
case "audio":
@@ -213,7 +207,6 @@ function buildTrack(params: BuildTrackParams): TimelineTrack {
type: "video",
name: id,
elements: params.elements ?? [],
- isMain,
muted: false,
hidden: false,
};
@@ -234,9 +227,32 @@ function buildTimeSpan({
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 = [buildTrack({ id: "text-1", type: "text" })];
+ const tracks = buildSceneTracks({
+ overlay: [buildTrack({ id: "text-1", type: "text" })],
+ });
expect(
resolveTrackPlacement({
@@ -254,7 +270,9 @@ describe("resolveTrackPlacement", () => {
});
test("explicit rejects missing and incompatible tracks", () => {
- const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })];
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-1", type: "video" }),
+ });
expect(
resolveTrackPlacement({
@@ -276,16 +294,18 @@ describe("resolveTrackPlacement", () => {
});
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" }),
- ];
+ 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({
@@ -303,21 +323,22 @@ describe("resolveTrackPlacement", () => {
});
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({
+ 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",
- isMain: true,
}),
- buildTrack({ id: "audio-1", type: "audio" }),
- ];
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
expect(
resolveTrackPlacement({
@@ -335,7 +356,9 @@ describe("resolveTrackPlacement", () => {
});
test("preferIndex uses the preferred track when it fits", () => {
- const tracks = [buildTrack({ id: "audio-1", type: "audio" })];
+ const tracks = buildSceneTracks({
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
expect(
resolveTrackPlacement({
@@ -349,18 +372,18 @@ describe("resolveTrackPlacement", () => {
},
}),
).toEqual({
- kind: "existingTrack",
- trackId: "audio-1",
- trackIndex: 0,
+ kind: "newTrack",
trackType: "audio",
+ insertIndex: 1,
+ insertPosition: "below",
});
});
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" }),
- ];
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
expect(
resolveTrackPlacement({
@@ -382,11 +405,11 @@ describe("resolveTrackPlacement", () => {
});
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" }),
- ];
+ 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({
@@ -409,17 +432,19 @@ describe("resolveTrackPlacement", () => {
});
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" }),
- ];
+ 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({
@@ -436,23 +461,25 @@ describe("resolveTrackPlacement", () => {
});
});
- 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 }),
- ],
- }),
- ];
+ 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({
@@ -464,16 +491,16 @@ describe("resolveTrackPlacement", () => {
).toEqual({
kind: "newTrack",
trackType: "text",
- insertIndex: 1,
+ insertIndex: 0,
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" }),
- ];
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
expect(
resolveTrackPlacement({
@@ -505,16 +532,18 @@ describe("resolveTrackPlacement", () => {
});
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 }),
- ],
- }),
- ];
+ 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({
@@ -534,10 +563,10 @@ describe("resolveTrackPlacement", () => {
});
});
- test("handles empty timelines, single tracks, and track-type derivation", () => {
+ test("handles main-only timelines, single tracks, and track-type derivation", () => {
expect(
resolveTrackPlacement({
- tracks: [],
+ tracks: buildSceneTracks({}),
elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
strategy: {
@@ -551,12 +580,14 @@ describe("resolveTrackPlacement", () => {
kind: "newTrack",
trackType: "video",
insertIndex: 0,
- insertPosition: null,
+ insertPosition: "above",
});
expect(
resolveTrackPlacement({
- tracks: [buildTrack({ id: "audio-1", type: "audio" })],
+ tracks: buildSceneTracks({
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ }),
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "default" },
@@ -564,22 +595,21 @@ describe("resolveTrackPlacement", () => {
).toEqual({
kind: "newTrack",
trackType: "audio",
- insertIndex: 1,
+ insertIndex: 2,
insertPosition: null,
});
});
test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
- const tracks = [
- buildTrack({
+ const tracks = buildSceneTracks({
+ main: buildTrack({
id: "video-main",
type: "video",
- isMain: true,
elements: [
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
],
}),
- ];
+ });
expect(
resolveTrackPlacement({
@@ -598,11 +628,11 @@ describe("resolveTrackPlacement", () => {
});
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" }),
- ];
+ 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({
diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts
index 4ff5d50d..639b1b42 100644
--- a/apps/web/src/lib/timeline/placement/apply.ts
+++ b/apps/web/src/lib/timeline/placement/apply.ts
@@ -1,7 +1,18 @@
-import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+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,
@@ -9,37 +20,139 @@ export function applyPlacement({
elements,
newTrackInsertIndexOverride,
}: {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
placementResult: PlacementResult;
elements: TimelineElement[];
newTrackInsertIndexOverride?: number;
-}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
+}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
if (placementResult.kind === "existingTrack") {
- const targetTrack = tracks[placementResult.trackIndex];
+ const targetTrack = orderedTracks[placementResult.trackIndex];
if (!targetTrack) {
return null;
}
- const updatedTracks = tracks.map((track, trackIndex) =>
- trackIndex === placementResult.trackIndex
- ? {
- ...track,
- elements: [...track.elements, ...elements],
- }
- : track,
- ) as TimelineTrack[];
+ const updatedTracks = updateTrackInSceneTracks({
+ tracks,
+ trackId: targetTrack.id,
+ update: (track) => ({
+ ...track,
+ elements: [...track.elements, ...elements],
+ }),
+ });
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);
+ 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/index.ts b/apps/web/src/lib/timeline/placement/index.ts
index 091acc33..530cb69f 100644
--- a/apps/web/src/lib/timeline/placement/index.ts
+++ b/apps/web/src/lib/timeline/placement/index.ts
@@ -1,13 +1,7 @@
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 { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track";
export { resolveTrackPlacement } from "./resolve";
export { buildEmptyTrack } from "./track-factory";
export type {
diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts
index fecb893a..04fa5a4a 100644
--- a/apps/web/src/lib/timeline/placement/insert-index.ts
+++ b/apps/web/src/lib/timeline/placement/insert-index.ts
@@ -1,44 +1,32 @@
-import type { TrackType, TimelineTrack } from "@/lib/timeline";
-import { isMainTrack } from "./main-track";
+import type { SceneTracks, TrackType } from "@/lib/timeline";
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
trackType: TrackType;
}): number {
if (trackType === "audio") {
- return tracks.length;
+ return tracks.overlay.length + 1 + tracks.audio.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;
+ return tracks.overlay.length;
}
export function getHighestInsertIndexForTrack({
tracks,
trackType,
}: {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
trackType: TrackType;
}): number {
- const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
- return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
+ return tracks.overlay.length + 1;
}
return 0;
@@ -50,12 +38,13 @@ export function resolvePreferredNewTrackPlacement({
preferredIndex,
direction,
}: {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
trackType: TrackType;
preferredIndex: number;
direction: "above" | "below";
}): { insertIndex: number; insertPosition: "above" | "below" | null } {
- if (tracks.length === 0) {
+ const trackCount = tracks.overlay.length + 1 + tracks.audio.length;
+ if (trackCount === 0) {
return {
insertIndex: 0,
insertPosition: trackType === "audio" ? "below" : null,
@@ -64,9 +53,9 @@ export function resolvePreferredNewTrackPlacement({
const safePreferredIndex = Math.min(
Math.max(preferredIndex, 0),
- tracks.length - 1,
+ trackCount - 1,
);
- const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+ const mainTrackIndex = tracks.overlay.length;
if (trackType === "audio") {
if (safePreferredIndex <= mainTrackIndex) {
diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts
index 1783352c..7e206517 100644
--- a/apps/web/src/lib/timeline/placement/main-track.ts
+++ b/apps/web/src/lib/timeline/placement/main-track.ts
@@ -1,55 +1,14 @@
-import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
+import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline";
-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 const MAIN_TRACK_NAME = "Main Track";
export function getEarliestMainTrackElement({
- tracks,
+ mainTrack,
excludeElementId,
}: {
- tracks: TimelineTrack[];
+ mainTrack: VideoTrack;
excludeElementId?: string;
}): TimelineElement | null {
- const mainTrack = getMainTrack({ tracks });
- if (!mainTrack) {
- return null;
- }
-
const elements = mainTrack.elements.filter((element) => {
return !excludeElementId || element.id !== excludeElementId;
});
@@ -70,18 +29,17 @@ export function enforceMainTrackStart({
requestedStartTime,
excludeElementId,
}: {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
targetTrackId: string;
requestedStartTime: number;
excludeElementId?: string;
}): number {
- const mainTrack = getMainTrack({ tracks });
- if (!mainTrack || mainTrack.id !== targetTrackId) {
+ if (tracks.main.id !== targetTrackId) {
return requestedStartTime;
}
const earliestElement = getEarliestMainTrackElement({
- tracks,
+ mainTrack: tracks.main,
excludeElementId,
});
if (!earliestElement) {
diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts
index d0417d0a..67943912 100644
--- a/apps/web/src/lib/timeline/placement/resolve.ts
+++ b/apps/web/src/lib/timeline/placement/resolve.ts
@@ -1,4 +1,4 @@
-import type { TrackType, TimelineTrack } from "@/lib/timeline";
+import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline";
import {
getDefaultInsertIndexForTrack,
getHighestInsertIndexForTrack,
@@ -15,7 +15,7 @@ import type {
} from "./types";
type ResolveTrackPlacementParams = PlacementSubject & {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
timeSpans: PlacementTimeSpan[];
strategy: PlacementStrategy;
};
@@ -28,7 +28,7 @@ function buildExistingTrackResult({
}: {
track: TimelineTrack;
trackIndex: number;
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
timeSpans: PlacementTimeSpan[];
}): PlacementResult {
const firstSpan = timeSpans[0];
@@ -90,7 +90,7 @@ function resolveAlwaysNewTrack({
trackType,
position,
}: {
- tracks: TimelineTrack[];
+ tracks: SceneTracks;
trackType: TrackType;
position: "highest" | "default";
}): PlacementResult {
@@ -134,6 +134,7 @@ export function resolveTrackPlacement({
tracks,
...placement
}: ResolveTrackPlacementParams): PlacementResult | null {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const trackType =
"trackType" in placement
? placement.trackType
@@ -143,30 +144,35 @@ export function resolveTrackPlacement({
const { timeSpans, strategy } = placement;
if (strategy.type === "explicit") {
- const trackIndex = tracks.findIndex(
+ const trackIndex = orderedTracks.findIndex(
(track) => track.id === strategy.trackId,
);
if (trackIndex < 0) {
return null;
}
- const track = tracks[trackIndex];
+ const track = orderedTracks[trackIndex];
if (track.type !== trackType) {
return null;
}
- return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans });
+ return buildExistingTrackResult({
+ track,
+ trackIndex,
+ tracks,
+ timeSpans,
+ });
}
if (strategy.type === "firstAvailable") {
const existingTrackIndex = findFirstAvailableTrackIndex({
- tracks,
+ tracks: orderedTracks,
trackType,
timeSpans,
});
if (existingTrackIndex >= 0) {
return buildExistingTrackResult({
- track: tracks[existingTrackIndex],
+ track: orderedTracks[existingTrackIndex],
trackIndex: existingTrackIndex,
tracks,
timeSpans,
@@ -181,7 +187,7 @@ export function resolveTrackPlacement({
}
if (strategy.type === "preferIndex") {
- const preferredTrack = tracks[strategy.trackIndex];
+ const preferredTrack = orderedTracks[strategy.trackIndex];
const isPreferredTrackCompatible =
!!preferredTrack && preferredTrack.type === trackType;
const canUseExistingTrack =
@@ -220,7 +226,7 @@ export function resolveTrackPlacement({
if (strategy.type === "aboveSource") {
const aboveTrackIndex = strategy.sourceTrackIndex - 1;
- const aboveTrack = tracks[aboveTrackIndex];
+ const aboveTrack = orderedTracks[aboveTrackIndex];
if (
aboveTrack &&
aboveTrack.type === trackType &&
@@ -238,26 +244,23 @@ export function resolveTrackPlacement({
}
const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
- tracks,
+ tracks: orderedTracks,
trackType,
timeSpans,
});
if (firstAvailableTrackIndex >= 0) {
return buildExistingTrackResult({
- track: tracks[firstAvailableTrackIndex],
+ track: orderedTracks[firstAvailableTrackIndex],
trackIndex: firstAvailableTrackIndex,
tracks,
timeSpans,
});
}
- const insertIndex =
- strategy.sourceTrackIndex >= 0
- ? strategy.sourceTrackIndex
- : getHighestInsertIndexForTrack({
- tracks,
- trackType,
- });
+ const insertIndex = getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+ });
return buildNewTrackResult({
trackType,
diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts
index ee056261..b73c5155 100644
--- a/apps/web/src/lib/timeline/placement/track-factory.ts
+++ b/apps/web/src/lib/timeline/placement/track-factory.ts
@@ -1,5 +1,13 @@
import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
-import type { TrackType, TimelineTrack } from "@/lib/timeline";
+import type {
+ AudioTrack,
+ EffectTrack,
+ GraphicTrack,
+ TextTrack,
+ TrackType,
+ TimelineTrack,
+ VideoTrack,
+} from "@/lib/timeline";
export function buildEmptyTrack({
id,
@@ -7,6 +15,61 @@ export function buildEmptyTrack({
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 {
@@ -21,7 +84,6 @@ export function buildEmptyTrack({
elements: [],
hidden: false,
muted: false,
- isMain: false,
};
case "text":
return {