mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'feature/track-placement'
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user