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 | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/lib/timeline')
| -rw-r--r-- | apps/web/src/lib/timeline/bookmarks.ts | 61 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/drop-utils.ts | 234 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/element-utils.ts | 304 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/index.ts | 23 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/ruler-utils.ts | 228 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/track-utils.ts | 244 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/zoom-utils.ts | 53 |
7 files changed, 1147 insertions, 0 deletions
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts new file mode 100644 index 00000000..0b06c2f6 --- /dev/null +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -0,0 +1,61 @@ +import { roundToFrame } from "@/lib/time"; + +export function findBookmarkIndex({ + bookmarks, + frameTime, +}: { + bookmarks: number[]; + frameTime: number; +}): number { + return bookmarks.findIndex( + (bookmark) => Math.abs(bookmark - frameTime) < 0.001, + ); +} + +export function isBookmarkAtTime({ + bookmarks, + frameTime, +}: { + bookmarks: number[]; + frameTime: number; +}): boolean { + return bookmarks.some((bookmark) => Math.abs(bookmark - frameTime) < 0.001); +} + +export function toggleBookmarkInArray({ + bookmarks, + frameTime, +}: { + bookmarks: number[]; + frameTime: number; +}): number[] { + const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); + + if (bookmarkIndex !== -1) { + return bookmarks.filter((_, i) => i !== bookmarkIndex); + } + + return [...bookmarks, frameTime].sort((a, b) => a - b); +} + +export function removeBookmarkFromArray({ + bookmarks, + frameTime, +}: { + bookmarks: number[]; + frameTime: number; +}): number[] { + return bookmarks.filter( + (bookmark) => Math.abs(bookmark - frameTime) >= 0.001, + ); +} + +export function getFrameTime({ + time, + fps, +}: { + time: number; + fps: number; +}): number { + return roundToFrame({ time, fps }); +} diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts new file mode 100644 index 00000000..8c22194a --- /dev/null +++ b/apps/web/src/lib/timeline/drop-utils.ts @@ -0,0 +1,234 @@ +import type { TimelineTrack, ElementType } from "@/types/timeline"; +import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants"; +import { wouldElementOverlap } from "./element-utils"; +import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline"; +import { isMainTrack } from "./track-utils"; + +function getTrackAtY({ + mouseY, + tracks, + verticalDragDirection, +}: { + mouseY: number; + tracks: TimelineTrack[]; + verticalDragDirection?: "up" | "down" | null; +}): { trackIndex: number; relativeY: number } | null { + let cumulativeHeight = 0; + + for (let i = 0; i < tracks.length; i++) { + const trackHeight = TRACK_HEIGHTS[tracks[i].type]; + const trackTop = cumulativeHeight; + const trackBottom = trackTop + trackHeight; + + if (mouseY >= trackTop && mouseY < trackBottom) { + return { + trackIndex: i, + relativeY: mouseY - trackTop, + }; + } + + if (i < tracks.length - 1 && verticalDragDirection) { + const gapTop = trackBottom; + const gapBottom = gapTop + TRACK_GAP; + if (mouseY >= gapTop && mouseY < gapBottom) { + const isDraggingUp = verticalDragDirection === "up"; + return { + trackIndex: isDraggingUp ? i : i + 1, + relativeY: isDraggingUp ? trackHeight - 1 : 0, + }; + } + } + + cumulativeHeight += trackHeight + TRACK_GAP; + } + + return null; +} + +function isCompatible({ + elementType, + trackType, +}: { + elementType: ElementType; + trackType: TimelineTrack["type"]; +}): boolean { + if (elementType === "text") return trackType === "text"; + if (elementType === "audio") return trackType === "audio"; + if (elementType === "sticker") return trackType === "sticker"; + if (elementType === "video" || elementType === "image") { + return trackType === "video"; + } + return false; +} + +function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number { + return tracks.findIndex((track) => isMainTrack(track)); +} + +function findInsertIndex({ + elementType, + tracks, + preferredIndex, + insertAbove, +}: { + elementType: ElementType; + tracks: TimelineTrack[]; + preferredIndex: number; + insertAbove: boolean; +}): { index: number; position: "above" | "below" } { + const mainTrackIndex = getMainTrackIndex({ tracks }); + + if (elementType === "audio") { + if (preferredIndex <= mainTrackIndex) { + return { index: mainTrackIndex + 1, position: "below" }; + } + return { + index: insertAbove ? preferredIndex : preferredIndex + 1, + position: insertAbove ? "above" : "below", + }; + } + + const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1; + + if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) { + return { index: mainTrackIndex, position: "above" }; + } + + return { + index: overlayInsertIndex, + position: insertAbove ? "above" : "below", + }; +} + +export function computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime, + isExternalDrop, + elementDuration, + pixelsPerSecond, + zoomLevel, + verticalDragDirection, + startTimeOverride, + excludeElementId, +}: ComputeDropTargetParams): DropTarget { + const xPosition = + typeof startTimeOverride === "number" + ? startTimeOverride + : isExternalDrop + ? playheadTime + : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); + + const mainTrackIndex = getMainTrackIndex({ tracks }); + + if (tracks.length === 0) { + if (elementType === "audio") { + return { + trackIndex: 0, + isNewTrack: true, + insertPosition: "below", + xPosition, + }; + } + return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition }; + } + + const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); + + if (!trackAtMouse) { + const isAboveAllTracks = mouseY < 0; + + if (elementType === "audio") { + return { + trackIndex: tracks.length, + isNewTrack: true, + insertPosition: "below", + xPosition, + }; + } + + if (isAboveAllTracks) { + return { + trackIndex: 0, + isNewTrack: true, + insertPosition: "above", + xPosition, + }; + } + + return { + trackIndex: Math.max(0, mainTrackIndex), + isNewTrack: true, + insertPosition: "above", + xPosition, + }; + } + + const { trackIndex, relativeY } = trackAtMouse; + const track = tracks[trackIndex]; + const trackHeight = TRACK_HEIGHTS[track.type]; + const isInUpperHalf = relativeY < trackHeight / 2; + + const isTrackCompatible = isCompatible({ + elementType, + trackType: track.type, + }); + + const endTime = xPosition + elementDuration; + const hasOverlap = wouldElementOverlap({ + elements: track.elements, + startTime: xPosition, + endTime, + excludeElementId, + }); + + if (isTrackCompatible && !hasOverlap) { + return { + trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition, + }; + } + + let insertAbove = isInUpperHalf; + if (!isTrackCompatible && verticalDragDirection) { + insertAbove = verticalDragDirection === "up"; + } + + const { index, position } = findInsertIndex({ + elementType, + tracks, + preferredIndex: trackIndex, + insertAbove, + }); + + return { + trackIndex: index, + isNewTrack: true, + insertPosition: position, + xPosition, + }; +} + +export function getDropLineY({ + dropTarget, + tracks, +}: { + dropTarget: DropTarget; + tracks: TimelineTrack[]; +}): number { + const safeTrackIndex = Math.min( + Math.max(dropTarget.trackIndex, 0), + tracks.length, + ); + let y = 0; + + for (let i = 0; i < safeTrackIndex; i++) { + y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP; + } + + return y; +} 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; +} diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts new file mode 100644 index 00000000..f3ace94f --- /dev/null +++ b/apps/web/src/lib/timeline/index.ts @@ -0,0 +1,23 @@ +import type { TimelineTrack } from "@/types/timeline"; + +export * from "./track-utils"; +export * from "./element-utils"; +export * from "./zoom-utils"; +export * from "./ruler-utils"; + +export function calculateTotalDuration({ + tracks, +}: { + tracks: TimelineTrack[]; +}): number { + if (tracks.length === 0) return 0; + + const trackEndTimes = tracks.map((track) => + track.elements.reduce((maxEnd, element) => { + const elementEnd = element.startTime + element.duration; + return Math.max(maxEnd, elementEnd); + }, 0), + ); + + return Math.max(...trackEndTimes, 0); +} diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts new file mode 100644 index 00000000..d0a67857 --- /dev/null +++ b/apps/web/src/lib/timeline/ruler-utils.ts @@ -0,0 +1,228 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +/** + * Frame intervals for labels - starts at 2 so there's always at least + * one tick between labels even at max zoom. + * Pattern: 2, 3, 5, 10, 15 (matches CapCut) + */ +const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const; + +/** + * Frame intervals for ticks - can go down to 1 for max granularity. + */ +const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const; + +/** + * Second intervals for when we're zoomed out past frame-level detail. + */ +const SECOND_MULTIPLIERS = [1, 2, 3, 5, 10, 15, 30, 60] as const; + +/** + * Minimum pixel spacing between labels to keep them readable + */ +const MIN_LABEL_SPACING_PX = 120; + +/** + * Minimum pixel spacing between ticks. Much denser than labels. + */ +const MIN_TICK_SPACING_PX = 18; + +export interface RulerConfig { + /** Time interval in seconds between each label */ + labelIntervalSeconds: number; + /** Time interval in seconds between each tick */ + tickIntervalSeconds: number; +} + +/** + * Determines the optimal label and tick intervals based on zoom level and FPS. + * + * Labels and ticks scale independently: + * - Labels need wide spacing (~50px) to stay readable + * - Ticks can be denser (~8px) to show finer subdivisions + * + * Example at different zoom levels: + * - Very zoomed in: labels every 2f, ticks every 1f + * - Zoomed in: labels every 10f, ticks every 1f + * - Zoomed out: labels every 15f, ticks every 3f + * - Very zoomed out: labels every 1s, ticks every 5f + */ +export function getRulerConfig({ + zoomLevel, + fps, +}: { + zoomLevel: number; + fps: number; +}): RulerConfig { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const pixelsPerFrame = pixelsPerSecond / fps; + + const labelIntervalSeconds = findOptimalInterval({ + pixelsPerFrame, + pixelsPerSecond, + fps, + minSpacingPx: MIN_LABEL_SPACING_PX, + frameIntervals: LABEL_FRAME_INTERVALS, + }); + + const rawTickIntervalSeconds = findOptimalInterval({ + pixelsPerFrame, + pixelsPerSecond, + fps, + minSpacingPx: MIN_TICK_SPACING_PX, + frameIntervals: TICK_FRAME_INTERVALS, + }); + + // Ensure tick interval divides evenly into label interval so labels always land on ticks + const tickIntervalSeconds = ensureTickDividesLabel({ + tickIntervalSeconds: rawTickIntervalSeconds, + labelIntervalSeconds, + pixelsPerFrame, + fps, + }); + + return { labelIntervalSeconds, tickIntervalSeconds }; +} + +/** + * Adjusts tick interval to ensure it divides evenly into the label interval. + * This guarantees labels always land on tick positions. + */ +function ensureTickDividesLabel({ + tickIntervalSeconds, + labelIntervalSeconds, + pixelsPerFrame, + fps, +}: { + tickIntervalSeconds: number; + labelIntervalSeconds: number; + pixelsPerFrame: number; + fps: number; +}): number { + const labelFrames = Math.round(labelIntervalSeconds * fps); + const tickFrames = Math.round(tickIntervalSeconds * fps); + + // If tick already divides label evenly, we're good + if (labelFrames % tickFrames === 0) { + return tickIntervalSeconds; + } + + // Find the smallest tick interval that divides the label interval and has adequate spacing + for (const candidateFrames of TICK_FRAME_INTERVALS) { + if (labelFrames % candidateFrames === 0) { + const candidateSpacing = pixelsPerFrame * candidateFrames; + // Accept if spacing meets minimum threshold + if (candidateSpacing >= MIN_TICK_SPACING_PX) { + return candidateFrames / fps; + } + } + } + + // Fallback: use the label interval itself (no intermediate ticks) + return labelIntervalSeconds; +} + +function findOptimalInterval({ + pixelsPerFrame, + pixelsPerSecond, + fps, + minSpacingPx, + frameIntervals, +}: { + pixelsPerFrame: number; + pixelsPerSecond: number; + fps: number; + minSpacingPx: number; + frameIntervals: readonly number[]; +}): number { + // Try frame-level intervals first + for (const frameInterval of frameIntervals) { + const pixelSpacing = pixelsPerFrame * frameInterval; + if (pixelSpacing >= minSpacingPx) { + return frameInterval / fps; + } + } + + // Then try second-level intervals + for (const secondMultiplier of SECOND_MULTIPLIERS) { + const pixelSpacing = pixelsPerSecond * secondMultiplier; + if (pixelSpacing >= minSpacingPx) { + return secondMultiplier; + } + } + + return 60; +} + +/** + * Checks if a time should have a label based on the label interval. + */ +export function shouldShowLabel({ + time, + labelIntervalSeconds, +}: { + time: number; + labelIntervalSeconds: number; +}): boolean { + const epsilon = 0.0001; + const remainder = time % labelIntervalSeconds; + return remainder < epsilon || remainder > labelIntervalSeconds - epsilon; +} + +/** + * Formats a ruler tick label. + * + * - On second boundaries: "MM:SS" (e.g., "00:00", "01:30") + * - Between seconds: "Xf" (e.g., "5f", "15f") + */ +export function formatRulerLabel({ + timeInSeconds, + fps, +}: { + timeInSeconds: number; + fps: number; +}): string { + if (isSecondBoundary({ timeInSeconds })) { + return formatTimestamp({ timeInSeconds }); + } + + const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps }); + return `${frameWithinSecond}f`; +} + +/** + * Checks if a time falls exactly on a second boundary. + */ +function isSecondBoundary({ + timeInSeconds, +}: { + timeInSeconds: number; +}): boolean { + const epsilon = 0.0001; + const remainder = timeInSeconds % 1; + return remainder < epsilon || remainder > 1 - epsilon; +} + +/** + * Gets the frame number within the current second. + */ +function getFrameWithinSecond({ + timeInSeconds, + fps, +}: { + timeInSeconds: number; + fps: number; +}): number { + const fractionalPart = timeInSeconds % 1; + return Math.round(fractionalPart * fps); +} + +/** + * Formats a timestamp as MM:SS. + */ +function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string { + const totalSeconds = Math.round(timeInSeconds); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts new file mode 100644 index 00000000..b6261569 --- /dev/null +++ b/apps/web/src/lib/timeline/track-utils.ts @@ -0,0 +1,244 @@ +import type { + TrackType, + TimelineTrack, + ElementType, + VideoTrack, + AudioTrack, + StickerTrack, + TextTrack, +} from "@/types/timeline"; +import { + TRACK_COLORS, + TRACK_HEIGHTS, + TRACK_GAP, +} from "@/constants/timeline-constants"; +import { generateUUID } from "@/utils/id"; + +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"; +} + +export function getTrackColor({ type }: { type: TrackType }) { + return TRACK_COLORS[type]; +} + +export function getTrackClasses({ type }: { type: TrackType }) { + const colors = TRACK_COLORS[type]; + return `${colors.background}`.trim(); +} + +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( + (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP, + 0, + ); +} + +export function getTotalTracksHeight({ + tracks, +}: { + tracks: Array<{ type: TrackType }>; +}): number { + const tracksHeight = tracks.reduce( + (sum, track) => sum + getTrackHeight({ type: track.type }), + 0, + ); + const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP; + return tracksHeight + gapsHeight; +} + +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}`); + } +} + +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; +} + +export function isMainTrack(track: TimelineTrack): track is VideoTrack { + return track.type === "video" && track.isMain === true; +} + +export function getMainTrack({ + tracks, +}: { + tracks: TimelineTrack[]; +}): TimelineTrack | null { + return tracks.find((track) => isMainTrack(track)) ?? null; +} + +export function ensureMainTrack({ + tracks, +}: { + tracks: TimelineTrack[]; +}): TimelineTrack[] { + const hasMainTrack = tracks.some((track) => isMainTrack(track)); + + if (!hasMainTrack) { + const mainTrack: TimelineTrack = { + id: generateUUID(), + name: "Main Track", + type: "video", + elements: [], + muted: false, + isMain: true, + hidden: false, + }; + return [mainTrack, ...tracks]; + } + + return tracks; +} + +export function canElementGoOnTrack({ + elementType, + trackType, +}: { + elementType: ElementType; + trackType: TrackType; +}): boolean { + if (elementType === "text") return trackType === "text"; + if (elementType === "audio") return trackType === "audio"; + if (elementType === "sticker") return trackType === "sticker"; + if (elementType === "video" || elementType === "image") { + return trackType === "video"; + } + return false; +} + +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 }; +} diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts new file mode 100644 index 00000000..49f7fcb6 --- /dev/null +++ b/apps/web/src/lib/timeline/zoom-utils.ts @@ -0,0 +1,53 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +const PADDING_MAX_RATIO = 0.75; +const PADDING_MIN_RATIO = 0.15; +const PADDING_MIN_AT_ZOOM_PERCENT = 0.2; + +export function getTimelineZoomMin({ + duration, + containerWidth, +}: { + duration: number; + containerWidth: number | null | undefined; +}): number { + const safeDuration = Math.max(duration, 1); + const safeContainerWidth = containerWidth ?? 1000; + const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO; + const availableWidth = safeContainerWidth * contentRatioAtMinZoom; + const zoomToFit = + availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND); + + return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit); +} + +export function getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom, +}: { + containerWidth: number; + zoomLevel: number; + minZoom: number; +}): number { + const zoomPercent = getZoomPercent({ zoomLevel, minZoom }); + const paddingTransitionPercent = Math.min( + zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT, + 1, + ); + const paddingRatio = + PADDING_MAX_RATIO - + (PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent; + + return containerWidth * paddingRatio; +} + +export function getZoomPercent({ + zoomLevel, + minZoom, +}: { + zoomLevel: number; + minZoom: number; +}): number { + return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom); +} |
