diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/lib/timeline/element-utils.ts | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/lib/timeline/element-utils.ts')
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 304 |
1 files changed, 304 insertions, 0 deletions
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts new file mode 100644 index 00000000..a167e548 --- /dev/null +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -0,0 +1,304 @@ +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { + CreateTimelineElement, + CreateVideoElement, + CreateImageElement, + CreateStickerElement, + CreateUploadAudioElement, + CreateLibraryAudioElement, + TextElement, + TimelineElement, + TimelineTrack, + AudioElement, + VideoElement, + ImageElement, + StickerElement, + UploadAudioElement, +} from "@/types/timeline"; + +export function canElementHaveAudio( + element: TimelineElement, +): element is AudioElement | VideoElement { + return element.type === "audio" || element.type === "video"; +} + +export function canElementBeHidden( + element: TimelineElement, +): element is VideoElement | ImageElement | TextElement | StickerElement { + return element.type !== "audio"; +} + +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((el) => { + if (excludeElementId && el.id === excludeElementId) return false; + const elEnd = el.startTime + el.duration; + return startTime < elEnd && endTime > el.startTime; + }); +} + +export function buildTextElement({ + raw, + startTime, +}: { + raw: Partial<Omit<TextElement, "type" | "id">>; + startTime: number; +}): CreateTimelineElement { + const t = raw as Partial<TextElement>; + + 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, + backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor, + 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, + transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform, + opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity, + }; +} + +export function buildStickerElement({ + iconName, + startTime, +}: { + iconName: string; + startTime: number; +}): CreateStickerElement { + return { + type: "sticker", + name: iconName.split(":")[1] || iconName, + iconName, + duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, + opacity: 1, + }; +} + +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, + muted: false, + hidden: false, + transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, + opacity: 1, + }; +} + +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: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, + opacity: 1, + }; +} + +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, + volume: 1, + muted: false, + }; + if (buffer) { + element.buffer = buffer; + } + return element; +} + +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, + 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; +} |
