175 lines
5.2 KiB
TypeScript
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;
|
|
}
|