ai-chatbot/lib/video-canvas/director-project.ts

175 lines
5.2 KiB
TypeScript

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<string, unknown> & {
cameras: Array<Record<string, unknown>>;
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<string, unknown> {
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<string, unknown>;
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<string, unknown>;
} 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;
}