import { formatTime } from "../lib/time"; import type { ZoomMode } from "../store/playerStore"; /* ── Layout constants ──────────────────────────────────────────────── */ export const GUTTER = 32; export const TRACK_H = 48; export const RULER_H = 24; export const CLIP_Y = 3; export const CLIP_HANDLE_W = 18; const TIMELINE_SCROLL_BUFFER = 20; /* ── Tick generation ──────────────────────────────────────────────── */ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number { const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) { const targetMajorPx = 128; return ( zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600 ); } const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60]; const target = duration / 6; return durationIntervals.find((interval) => interval >= target) ?? 60; } function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number { let interval = majorInterval / 2; if (majorInterval >= 30) interval = majorInterval / 6; else if (majorInterval >= 15) interval = majorInterval / 3; else if (majorInterval >= 5) interval = majorInterval / 5; else if (majorInterval >= 1) interval = majorInterval / 4; if ( Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0 && interval * (pixelsPerSecond ?? 0) < 20 ) { return Math.max(0.25, majorInterval / 2); } return Math.max(0.25, interval); } export function generateTicks( duration: number, pixelsPerSecond?: number, ): { major: number[]; minor: number[] } { if (duration <= 0 || !Number.isFinite(duration) || duration > 7200) return { major: [], minor: [] }; const majorInterval = getMajorTickInterval(duration, pixelsPerSecond); const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond); const major: number[] = []; const minor: number[] = []; const maxTicks = 2000; // Safety cap to prevent runaway tick generation for ( let t = 0; t <= duration + 0.001 && major.length + minor.length < maxTicks; t += minorInterval ) { const rounded = Math.round(t * 100) / 100; const isMajor = Math.abs(rounded % majorInterval) < 0.01 || Math.abs((rounded % majorInterval) - majorInterval) < 0.01; if (isMajor) major.push(rounded); else minor.push(rounded); } return { major, minor }; } export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) { if (!Number.isFinite(time)) return "0:00"; const safeTime = Math.max(0, time); if (majorInterval < 1) { const totalTenths = Math.round(safeTime * 10); const wholeSeconds = Math.floor(totalTenths / 10); const tenth = totalTenths % 10; return `${formatTime(wholeSeconds)}.${tenth}`; } if (duration >= 3600 || safeTime >= 3600) { const totalSeconds = Math.floor(safeTime); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } return formatTime(safeTime); } /* ── Scroll / zoom helpers ────────────────────────────────────────── */ export function shouldAutoScrollTimeline( zoomMode: ZoomMode, scrollWidth: number, clientWidth: number, ): boolean { if (zoomMode === "fit") return false; if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false; return scrollWidth - clientWidth > 1; } export function getTimelineScrollLeftForZoomTransition( previousZoomMode: ZoomMode | null, nextZoomMode: ZoomMode, currentScrollLeft: number, ): number { if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0; return currentScrollLeft; } export function getTimelineScrollLeftForZoomAnchor(input: { pointerX: number; currentScrollLeft: number; gutter: number; currentPixelsPerSecond: number; nextPixelsPerSecond: number; duration: number; }): number { const currentPps = Math.max(0, input.currentPixelsPerSecond); const nextPps = Math.max(0, input.nextPixelsPerSecond); if ( !Number.isFinite(input.pointerX) || !Number.isFinite(input.currentScrollLeft) || !Number.isFinite(input.duration) || input.duration <= 0 || currentPps <= 0 || nextPps <= 0 ) { return Math.max(0, input.currentScrollLeft); } const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter); const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps)); return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX); } /* ── Playhead / canvas ────────────────────────────────────────────── */ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number { if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER; return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond); } export function getTimelineCanvasHeight(trackCount: number): number { return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER; } /* ── UI helpers ───────────────────────────────────────────────────── */ export function shouldShowTimelineShortcutHint( scrollHeight: number, clientHeight: number, ): boolean { if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true; return scrollHeight - clientHeight <= 1; } export function shouldHandleTimelineDeleteKey(input: { key: string; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; target?: EventTarget | null; }): boolean { if (input.key !== "Delete" && input.key !== "Backspace") return false; if (input.metaKey || input.ctrlKey || input.altKey) return false; const target = input.target && typeof input.target === "object" ? (input.target as { tagName?: string; isContentEditable?: boolean; closest?: (selector: string) => Element | null; }) : null; if (target) { const tag = target.tagName?.toLowerCase() ?? ""; if (target.isContentEditable) return false; if (["input", "textarea", "select"].includes(tag)) return false; if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) { return false; } } return true; } /* ── Asset drop ───────────────────────────────────────────────────── */ export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number { if (trackOrder.length === 0) return 0; if (rowIndex == null || rowIndex < 0) return trackOrder[0]; if (rowIndex >= trackOrder.length) { return Math.max(...trackOrder) + 1; } return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0; } export function resolveTimelineAssetDrop( input: { rectLeft: number; rectTop: number; scrollLeft: number; scrollTop: number; pixelsPerSecond: number; duration: number; trackHeight: number; trackOrder: number[]; }, clientX: number, clientY: number, ): { start: number; track: number } { const x = clientX - input.rectLeft + input.scrollLeft - GUTTER; const y = clientY - input.rectTop + input.scrollTop - RULER_H; const start = Math.max( 0, Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100), ); const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1)); return { start, track: getDefaultDroppedTrack(input.trackOrder, rowIndex), }; }