import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { DEFAULT_BLEND_MODE, DEFAULT_OPACITY, DEFAULT_TRANSFORM, TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import type { CreateEffectElement, CreateTimelineElement, CreateVideoElement, CreateImageElement, CreateStickerElement, CreateUploadAudioElement, CreateLibraryAudioElement, TextBackground, TextElement, TimelineElement, TimelineTrack, AudioElement, VideoElement, ImageElement, VisualElement, UploadAudioElement, } from "@/types/timeline"; import type { MediaType } from "@/types/assets"; import { buildDefaultEffectInstance } from "@/lib/effects"; import { capitalizeFirstLetter } from "@/utils/string"; export function canElementHaveAudio( element: TimelineElement, ): element is AudioElement | VideoElement { return element.type === "audio" || element.type === "video"; } export function isVisualElement( element: TimelineElement, ): element is VisualElement { return ( element.type === "video" || element.type === "image" || element.type === "text" || element.type === "sticker" ); } export function canElementBeHidden( element: TimelineElement, ): element is VisualElement { return isVisualElement(element); } export function hasMediaId( element: TimelineElement, ): element is UploadAudioElement | VideoElement | ImageElement { return "mediaId" in element; } export function requiresMediaId({ element, }: { element: CreateTimelineElement; }): boolean { return ( element.type === "video" || element.type === "image" || (element.type === "audio" && element.sourceType === "upload") ); } export function checkElementOverlaps({ elements, }: { elements: TimelineElement[]; }): boolean { const sortedElements = [...elements].sort( (a, b) => a.startTime - b.startTime, ); for (let i = 0; i < sortedElements.length - 1; i++) { const current = sortedElements[i]; const next = sortedElements[i + 1]; const currentEnd = current.startTime + current.duration; if (currentEnd > next.startTime) return true; } return false; } export function resolveElementOverlaps({ elements, }: { elements: TimelineElement[]; }): TimelineElement[] { const sortedElements = [...elements].sort( (a, b) => a.startTime - b.startTime, ); const resolvedElements: TimelineElement[] = []; for (let i = 0; i < sortedElements.length; i++) { const current = { ...sortedElements[i] }; if (resolvedElements.length > 0) { const previous = resolvedElements[resolvedElements.length - 1]; const previousEnd = previous.startTime + previous.duration; if (current.startTime < previousEnd) { current.startTime = previousEnd; } } resolvedElements.push(current); } return resolvedElements; } export 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; }); } function buildTextBackground( raw: Partial | undefined, ): TextBackground { const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color; const enabled = typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent"; return { enabled, color, cornerRadius: raw?.cornerRadius, paddingX: raw?.paddingX, paddingY: raw?.paddingY, offsetX: raw?.offsetX, offsetY: raw?.offsetY, }; } export function buildTextElement({ raw, startTime, }: { raw: Partial>; startTime: number; }): CreateTimelineElement { const t = raw as Partial; return { type: "text", name: t.name ?? DEFAULT_TEXT_ELEMENT.name, content: t.content ?? DEFAULT_TEXT_ELEMENT.content, duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, startTime, trimStart: 0, trimEnd: 0, fontSize: typeof t.fontSize === "number" ? t.fontSize : DEFAULT_TEXT_ELEMENT.fontSize, fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, color: t.color ?? DEFAULT_TEXT_ELEMENT.color, background: buildTextBackground(t.background), textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing, lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight, transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform, opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity, blendMode: t.blendMode ?? DEFAULT_BLEND_MODE, }; } export function buildEffectElement({ effectType, startTime, duration, }: { effectType: string; startTime: number; duration?: number; }): CreateEffectElement { const instance = buildDefaultEffectInstance({ effectType }); return { type: "effect", name: capitalizeFirstLetter({ string: instance.type }), effectType, params: instance.params, duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, startTime, trimStart: 0, trimEnd: 0, }; } export function buildStickerElement({ stickerId, name, startTime, }: { stickerId: string; name?: string; startTime: number; }): CreateStickerElement { const stickerNameFromId = stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; return { type: "sticker", name: name ?? stickerNameFromId, stickerId, duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, startTime, trimStart: 0, trimEnd: 0, transform: { ...DEFAULT_TRANSFORM }, opacity: DEFAULT_OPACITY, blendMode: DEFAULT_BLEND_MODE, }; } export function buildVideoElement({ mediaId, name, duration, startTime, }: { mediaId: string; name: string; duration: number; startTime: number; }): CreateVideoElement { return { type: "video", mediaId, name, duration, startTime, trimStart: 0, trimEnd: 0, sourceDuration: duration, muted: false, hidden: false, transform: { ...DEFAULT_TRANSFORM }, opacity: DEFAULT_OPACITY, blendMode: DEFAULT_BLEND_MODE, }; } export function buildImageElement({ mediaId, name, duration, startTime, }: { mediaId: string; name: string; duration: number; startTime: number; }): CreateImageElement { return { type: "image", mediaId, name, duration, startTime, trimStart: 0, trimEnd: 0, hidden: false, transform: { ...DEFAULT_TRANSFORM }, opacity: DEFAULT_OPACITY, blendMode: DEFAULT_BLEND_MODE, }; } export function buildUploadAudioElement({ mediaId, name, duration, startTime, buffer, }: { mediaId: string; name: string; duration: number; startTime: number; buffer?: AudioBuffer; }): CreateUploadAudioElement { const element: CreateUploadAudioElement = { type: "audio", sourceType: "upload", mediaId, name, duration, startTime, trimStart: 0, trimEnd: 0, sourceDuration: duration, volume: 1, muted: false, }; if (buffer) { element.buffer = buffer; } return element; } export function buildElementFromMedia({ mediaId, mediaType, name, duration, startTime, buffer, }: { mediaId: string; mediaType: MediaType; name: string; duration: number; startTime: number; buffer?: AudioBuffer; }): CreateTimelineElement { switch (mediaType) { case "audio": return buildUploadAudioElement({ mediaId, name, duration, startTime, buffer, }); case "video": return buildVideoElement({ mediaId, name, duration, startTime }); case "image": return buildImageElement({ mediaId, name, duration, startTime }); } } export function buildLibraryAudioElement({ sourceUrl, name, duration, startTime, buffer, }: { sourceUrl: string; name: string; duration: number; startTime: number; buffer?: AudioBuffer; }): CreateLibraryAudioElement { const element: CreateLibraryAudioElement = { type: "audio", sourceType: "library", sourceUrl, name, duration, startTime, trimStart: 0, trimEnd: 0, sourceDuration: duration, volume: 1, muted: false, }; if (buffer) { element.buffer = buffer; } return element; } export function getElementsAtTime({ tracks, time, }: { tracks: TimelineTrack[]; time: number; }): { trackId: string; elementId: string }[] { const result: { trackId: string; elementId: string }[] = []; for (const track of tracks) { for (const element of track.elements) { const elementStart = element.startTime; const elementEnd = element.startTime + element.duration; if (time > elementStart && time < elementEnd) { result.push({ trackId: track.id, elementId: element.id }); } } } return result; } export function collectFontFamilies({ tracks, }: { tracks: TimelineTrack[]; }): string[] { const families = new Set(); for (const track of tracks) { for (const element of track.elements) { if (element.type === "text" && element.fontFamily) { families.add(element.fontFamily); } } } return [...families]; }