export const DEFAULT_VIDEO_ANALYSIS_COST = 2; export const VIDEO_STORY_NODE_HEIGHT = 400; export const VIDEO_STORY_NODE_WIDTH = 800; export const VIDEO_STORY_SOURCE_GAP = 94; export type VideoAnalysisNodeBounds = { height: number; id?: string; width: number; x: number; y: number; }; type VideoAnalysisTimelineRow = { duration: string; endTime: string; startTime: string; }; export function normalizeVideoAnalysisTimeline( rows: T[], totalDurationSeconds?: number | null, ) { if ( rows.length === 0 || !Number.isFinite(totalDurationSeconds) || Number(totalDurationSeconds) <= 0 ) { return rows; } const total = Number(totalDurationSeconds); const parsedTimeline = rows.map((row) => ({ start: Number.parseFloat(row.startTime), end: Number.parseFloat(row.endTime), })); const timelineIsValid = parsedTimeline.every((item, index) => { const previous = parsedTimeline[index - 1]; return ( Number.isFinite(item.start) && Number.isFinite(item.end) && item.start >= 0 && item.end > item.start && item.end <= total + 0.05 && (!previous || item.start >= previous.end - 0.05) ); }); const coversDuration = timelineIsValid && Math.abs((parsedTimeline[0]?.start || 0) - 0) <= 0.05 && Math.abs((parsedTimeline.at(-1)?.end || 0) - total) <= 0.05; const formatTime = (number: number) => String(Number(number.toFixed(2))); return rows.map((row, index) => { const start = coversDuration ? Math.max(0, parsedTimeline[index]!.start) : (total * index) / rows.length; const end = coversDuration ? Math.min(total, parsedTimeline[index]!.end) : (total * (index + 1)) / rows.length; return { ...row, startTime: formatTime(start), endTime: formatTime(end), duration: formatTime(Math.max(0, end - start)), }; }); } function overlapsWithMargin( candidate: VideoAnalysisNodeBounds, existing: VideoAnalysisNodeBounds, margin: number, ) { return !( candidate.x + candidate.width + margin <= existing.x || existing.x + existing.width + margin <= candidate.x || candidate.y + candidate.height + margin <= existing.y || existing.y + existing.height + margin <= candidate.y ); } export function getVideoStoryPlacement( source: VideoAnalysisNodeBounds, existingNodes: VideoAnalysisNodeBounds[], ) { const baseY = Math.round(source.y + source.height + VIDEO_STORY_SOURCE_GAP); const horizontalOffsets = [0, 450, -450, 900, -900]; const occupied = existingNodes.filter((node) => node.id !== source.id); for (let row = 0; row < 12; row += 1) { for (const offset of horizontalOffsets) { const candidate = { height: VIDEO_STORY_NODE_HEIGHT, width: VIDEO_STORY_NODE_WIDTH, x: Math.round(source.x + offset), y: baseY + row * (VIDEO_STORY_NODE_HEIGHT + VIDEO_STORY_SOURCE_GAP), }; if (!occupied.some((node) => overlapsWithMargin(candidate, node, 24))) { return { x: candidate.x, y: candidate.y }; } } } return { x: Math.round(source.x), y: baseY }; }