2026-01-16 18:07:28 +01:00
|
|
|
import type {
|
|
|
|
|
TrackType,
|
|
|
|
|
TimelineTrack,
|
|
|
|
|
ElementType,
|
|
|
|
|
VideoTrack,
|
|
|
|
|
AudioTrack,
|
|
|
|
|
StickerTrack,
|
|
|
|
|
TextTrack,
|
|
|
|
|
} from "@/types/timeline";
|
2026-01-05 01:35:10 +01:00
|
|
|
import {
|
|
|
|
|
TRACK_COLORS,
|
|
|
|
|
TRACK_HEIGHTS,
|
|
|
|
|
TRACK_GAP,
|
|
|
|
|
} from "@/constants/timeline-constants";
|
2025-11-26 08:47:03 +01:00
|
|
|
import { generateUUID } from "@/lib/utils";
|
|
|
|
|
|
2026-01-16 18:07:28 +01:00
|
|
|
export function canTracktHaveAudio(
|
|
|
|
|
track: TimelineTrack,
|
|
|
|
|
): track is VideoTrack | AudioTrack {
|
|
|
|
|
return track.type === "audio" || track.type === "video";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function canTrackBeHidden(
|
|
|
|
|
track: TimelineTrack,
|
|
|
|
|
): track is VideoTrack | TextTrack | StickerTrack {
|
|
|
|
|
return track.type !== "audio";
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 01:35:10 +01:00
|
|
|
export function getTrackColor({ type }: { type: TrackType }) {
|
2025-11-26 08:47:03 +01:00
|
|
|
return TRACK_COLORS[type];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 11:05:17 +01:00
|
|
|
export function getTrackClasses({ type }: { type: TrackType }) {
|
|
|
|
|
const colors = TRACK_COLORS[type];
|
2026-01-20 22:14:49 +01:00
|
|
|
return `${colors.background}`.trim();
|
2026-01-15 11:05:17 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-26 08:47:03 +01:00
|
|
|
export function getTrackHeight({ type }: { type: TrackType }): number {
|
|
|
|
|
return TRACK_HEIGHTS[type];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getCumulativeHeightBefore({
|
|
|
|
|
tracks,
|
|
|
|
|
trackIndex,
|
|
|
|
|
}: {
|
|
|
|
|
tracks: Array<{ type: TrackType }>;
|
|
|
|
|
trackIndex: number;
|
|
|
|
|
}): number {
|
|
|
|
|
return tracks
|
|
|
|
|
.slice(0, trackIndex)
|
|
|
|
|
.reduce(
|
2026-01-05 01:35:10 +01:00
|
|
|
(sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
|
2025-11-26 08:47:03 +01:00
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getTotalTracksHeight({
|
|
|
|
|
tracks,
|
|
|
|
|
}: {
|
|
|
|
|
tracks: Array<{ type: TrackType }>;
|
|
|
|
|
}): number {
|
|
|
|
|
const tracksHeight = tracks.reduce(
|
|
|
|
|
(sum, track) => sum + getTrackHeight({ type: track.type }),
|
|
|
|
|
0,
|
|
|
|
|
);
|
2026-01-05 01:35:10 +01:00
|
|
|
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
|
2025-11-26 08:47:03 +01:00
|
|
|
return tracksHeight + gapsHeight;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:44:25 +01:00
|
|
|
export function buildEmptyTrack({
|
|
|
|
|
id,
|
|
|
|
|
type,
|
|
|
|
|
name,
|
|
|
|
|
}: {
|
|
|
|
|
id: string;
|
|
|
|
|
type: TrackType;
|
|
|
|
|
name?: string;
|
|
|
|
|
}): TimelineTrack {
|
|
|
|
|
const trackName =
|
|
|
|
|
name ??
|
|
|
|
|
(type === "video"
|
|
|
|
|
? "Video track"
|
|
|
|
|
: type === "text"
|
|
|
|
|
? "Text track"
|
|
|
|
|
: type === "audio"
|
|
|
|
|
? "Audio track"
|
|
|
|
|
: type === "sticker"
|
|
|
|
|
? "Sticker track"
|
|
|
|
|
: "Track");
|
|
|
|
|
|
|
|
|
|
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 "sticker":
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
name: trackName,
|
|
|
|
|
type: "sticker",
|
|
|
|
|
elements: [],
|
|
|
|
|
hidden: false,
|
|
|
|
|
};
|
|
|
|
|
case "audio":
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
name: trackName,
|
|
|
|
|
type: "audio",
|
|
|
|
|
elements: [],
|
|
|
|
|
muted: false,
|
|
|
|
|
};
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`Unsupported track type: ${type}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 22:14:49 +01:00
|
|
|
export function getDefaultInsertIndexForTrack({
|
|
|
|
|
tracks,
|
|
|
|
|
trackType,
|
|
|
|
|
}: {
|
|
|
|
|
tracks: TimelineTrack[];
|
|
|
|
|
trackType: TrackType;
|
|
|
|
|
}): number {
|
|
|
|
|
if (trackType === "audio") {
|
|
|
|
|
return tracks.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 18:07:28 +01:00
|
|
|
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
|
|
|
|
|
return track.type === "video" && track.isMain === true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 08:47:03 +01:00
|
|
|
export function getMainTrack({
|
|
|
|
|
tracks,
|
|
|
|
|
}: {
|
|
|
|
|
tracks: TimelineTrack[];
|
|
|
|
|
}): TimelineTrack | null {
|
2026-01-16 18:07:28 +01:00
|
|
|
return tracks.find((track) => isMainTrack(track)) ?? null;
|
2025-11-26 08:47:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ensureMainTrack({
|
|
|
|
|
tracks,
|
|
|
|
|
}: {
|
|
|
|
|
tracks: TimelineTrack[];
|
|
|
|
|
}): TimelineTrack[] {
|
2026-01-16 18:07:28 +01:00
|
|
|
const hasMainTrack = tracks.some((track) => isMainTrack(track));
|
2025-11-26 08:47:03 +01:00
|
|
|
|
|
|
|
|
if (!hasMainTrack) {
|
|
|
|
|
const mainTrack: TimelineTrack = {
|
|
|
|
|
id: generateUUID(),
|
|
|
|
|
name: "Main Track",
|
2026-01-15 11:05:17 +01:00
|
|
|
type: "video",
|
2025-11-26 08:47:03 +01:00
|
|
|
elements: [],
|
|
|
|
|
muted: false,
|
|
|
|
|
isMain: true,
|
2026-01-18 05:44:25 +01:00
|
|
|
hidden: false,
|
2025-11-26 08:47:03 +01:00
|
|
|
};
|
|
|
|
|
return [mainTrack, ...tracks];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tracks;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function canElementGoOnTrack({
|
|
|
|
|
elementType,
|
|
|
|
|
trackType,
|
|
|
|
|
}: {
|
2026-01-15 11:05:17 +01:00
|
|
|
elementType: ElementType;
|
2025-11-26 08:47:03 +01:00
|
|
|
trackType: TrackType;
|
|
|
|
|
}): boolean {
|
2026-01-05 01:35:10 +01:00
|
|
|
if (elementType === "text") return trackType === "text";
|
|
|
|
|
if (elementType === "audio") return trackType === "audio";
|
2026-01-15 11:05:17 +01:00
|
|
|
if (elementType === "sticker") return trackType === "sticker";
|
2026-01-05 01:35:10 +01:00
|
|
|
if (elementType === "video" || elementType === "image") {
|
2026-01-15 11:05:17 +01:00
|
|
|
return trackType === "video";
|
2025-11-26 08:47:03 +01:00
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function validateElementTrackCompatibility({
|
|
|
|
|
element,
|
|
|
|
|
track,
|
|
|
|
|
}: {
|
2026-01-15 11:05:17 +01:00
|
|
|
element: { type: ElementType };
|
2025-11-26 08:47:03 +01:00
|
|
|
track: { type: TrackType };
|
|
|
|
|
}): { isValid: boolean; errorMessage?: string } {
|
|
|
|
|
const isValid = canElementGoOnTrack({
|
|
|
|
|
elementType: element.type,
|
|
|
|
|
trackType: track.type,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!isValid) {
|
2026-01-05 01:35:10 +01:00
|
|
|
return {
|
|
|
|
|
isValid: false,
|
|
|
|
|
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
|
|
|
|
|
};
|
2025-11-26 08:47:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { isValid: true };
|
|
|
|
|
}
|