import { isVideoCanvasDirectorAspectRatio, type VideoCanvasDirectorAspectRatio, } from "@/lib/video-canvas/config"; export type VideoCanvasDirectorCapture = { assetId?: string; cameraId: string; cameraName: string; dataUrl: string; id: string; index: number; name: string; storagePathname?: string; }; export type VideoCanvasDirectorProject = Record & { cameras: Array>; version: 1; viewportAspectRatio?: VideoCanvasDirectorAspectRatio; }; const MAX_PROJECT_JSON_BYTES = 3_000_000; const MAX_CAMERAS = 64; const MAX_CAPTURES_PER_CAMERA = 200; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function normalizeText(value: unknown, maxLength = 160) { return String(value || "").trim().slice(0, maxLength); } function normalizeStoragePathname(value: unknown) { const pathname = normalizeText(value, 1024).replace(/^\/+/, ""); if (!pathname || pathname.includes("..") || pathname.includes("\\")) return ""; return pathname; } export function isPortableDirectorCaptureUrl(value: unknown) { const url = normalizeText(value, 4096); if (!url) return false; if (url.startsWith("/") && !url.startsWith("//")) return true; try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } function normalizeCapture( value: unknown, cameraId: string, cameraName: string, ): VideoCanvasDirectorCapture | null { if (!isRecord(value)) return null; const id = normalizeText(value.id, 160); const dataUrl = normalizeText(value.dataUrl, 4096); if (!id || !isPortableDirectorCaptureUrl(dataUrl)) return null; const parsedIndex = Number(value.index); const index = Number.isFinite(parsedIndex) ? Math.max(1, Math.min(9999, Math.round(parsedIndex))) : 1; return { assetId: normalizeText(value.assetId, 160) || undefined, cameraId, cameraName, dataUrl, id, index, name: normalizeText(value.name, 160) || `${cameraName}-shot-${String(index).padStart(2, "0")}`, storagePathname: normalizeStoragePathname(value.storagePathname) || undefined, }; } export function normalizeVideoCanvasDirectorProject( value: unknown, ): VideoCanvasDirectorProject | null { if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.cameras)) { return null; } let cloned: Record; try { const serialized = JSON.stringify(value, (key, item) => { if (key === "dataUrl" && typeof item === "string" && item.startsWith("data:")) { return ""; } return item; }); if (!serialized || serialized.length > MAX_PROJECT_JSON_BYTES) return null; cloned = JSON.parse(serialized) as Record; } catch { return null; } const cameras = (Array.isArray(cloned.cameras) ? cloned.cameras : []) .slice(0, MAX_CAMERAS) .flatMap((candidate) => { if (!isRecord(candidate)) return []; const id = normalizeText(candidate.id, 160); const name = normalizeText(candidate.name, 160); if (!id || !name) return []; const captures = (Array.isArray(candidate.captures) ? candidate.captures : []) .slice(0, MAX_CAPTURES_PER_CAMERA) .flatMap((capture) => { const normalized = normalizeCapture(capture, id, name); return normalized ? [normalized] : []; }); return [{ ...candidate, captures, lastCaptureUrl: captures[captures.length - 1]?.dataUrl || null, }]; }); if (!isVideoCanvasDirectorAspectRatio(cloned.viewportAspectRatio)) { delete cloned.viewportAspectRatio; } if (!Array.isArray(cloned.assets) || !Array.isArray(cloned.objects)) return null; return { ...cloned, cameras, version: 1, } as VideoCanvasDirectorProject; } export function collectVideoCanvasDirectorCaptures( project: VideoCanvasDirectorProject | null | undefined, ) { if (!project) return []; return project.cameras.flatMap((camera) => { const cameraId = normalizeText(camera.id, 160); const cameraName = normalizeText(camera.name, 160); return (Array.isArray(camera.captures) ? camera.captures : []).flatMap((capture) => { const normalized = normalizeCapture(capture, cameraId, cameraName); return normalized ? [normalized] : []; }); }); } export function mergeVideoCanvasDirectorCapture( project: VideoCanvasDirectorProject, capture: VideoCanvasDirectorCapture, ) { return { ...project, cameras: project.cameras.map((camera) => { if (normalizeText(camera.id, 160) !== capture.cameraId) return camera; const existing = Array.isArray(camera.captures) ? camera.captures : []; const captures = existing.some( (item) => isRecord(item) && normalizeText(item.id, 160) === capture.id, ) ? existing.map((item) => isRecord(item) && normalizeText(item.id, 160) === capture.id ? { ...item, ...capture } : item, ) : [...existing, capture]; return { ...camera, captures, lastCaptureUrl: capture.dataUrl, }; }), } as VideoCanvasDirectorProject; }