From 2dee1bf6fc7d7ea047a133e6a478883fcf6d5bc1 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:15:29 +0200 Subject: [PATCH] feat(batch-upload): bulk footage plate upload matched by filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third section to the Batch Upload page that attaches plate movies to shots derived from the filename: DH2_VFX_058_120_REF.mov becomes the plate "REF" on shot DH2_VFX_058_120. Shot matching reuses findMatchingShotCode, so it behaves like the rest of the page (case-insensitive, longest code wins, underscore-boundary only) and the filename remainder becomes the label. Bytes go straight to Hetzner via the existing presign endpoint since plates are multi-GB, with XHR for per-file progress. Files whose shot already has a plate of the same name are skipped so re-running a batch does not stack duplicates. Also resets the thumbnail and plate previews when the project changes — they are resolved against a specific project, but only the version-upload flow was being cleared. Co-Authored-By: Claude Opus 5 --- app/api/batch-upload/plates/route.ts | 124 ++++++ app/api/batch-upload/plates/upload/route.ts | 96 ++++ components/batch-upload/BatchUploadClient.tsx | 410 ++++++++++++++++++ lib/batch-upload-parser.test.ts | 67 +++ lib/batch-upload-parser.ts | 64 +++ 5 files changed, 761 insertions(+) create mode 100644 app/api/batch-upload/plates/route.ts create mode 100644 app/api/batch-upload/plates/upload/route.ts create mode 100644 lib/batch-upload-parser.test.ts diff --git a/app/api/batch-upload/plates/route.ts b/app/api/batch-upload/plates/route.ts new file mode 100644 index 0000000..0669035 --- /dev/null +++ b/app/api/batch-upload/plates/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { PLATE_EXTS, parsePlateFilename } from "@/lib/batch-upload-parser"; + +export type PlatePreviewItemStatus = + | "match" // shot found → a new footage plate will be added + | "duplicate" // the shot already has a plate with this exact filename + | "no-match" // no shot in this project matches the filename + | "unsupported"; // extension is not an accepted video format + +export interface PlatePreviewItem { + fileName: string; + shotCode: string | null; + shotId: string | null; + /** Label the new plate will be created with */ + label: string; + status: PlatePreviewItemStatus; + /** Number of plates the matched shot already has */ + existingPlateCount: number; +} + +/** + * POST /api/batch-upload/plates + * Body: { projectId: string; fileNames: string[] } + * + * Previews which shot each footage plate file will be attached to, derived + * from the filename. e.g. "DH2_VFX_058_120_REF.mov" → shot "DH2_VFX_058_120" + * with the label "REF". Nothing is uploaded. + */ +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { projectId, fileNames } = body as { projectId?: unknown; fileNames?: unknown }; + + if (!projectId || typeof projectId !== "string" || !Array.isArray(fileNames)) { + return NextResponse.json( + { error: "projectId and fileNames[] are required" }, + { status: 400 } + ); + } + + const shots = await db.shot.findMany({ + where: { projectId }, + select: { + id: true, + shotCode: true, + footagePlates: { select: { fileName: true } }, + }, + }); + + const shotCodes = shots.map((s) => s.shotCode); + const shotByCode = new Map(shots.map((s) => [s.shotCode, s])); + + // Several files in one batch can land on the same shot, so keep a running + // count per shot to number the "Plate N" fallback labels correctly. + const allocated = new Map(); + + const items: PlatePreviewItem[] = fileNames.map((fileName: string) => { + const parsed = parsePlateFilename(fileName, shotCodes); + + if (!PLATE_EXTS.has(parsed.ext)) { + return { + fileName, + shotCode: null, + shotId: null, + label: "", + status: "unsupported" as const, + existingPlateCount: 0, + }; + } + + const shot = parsed.shotCode ? shotByCode.get(parsed.shotCode) ?? null : null; + if (!shot) { + return { + fileName, + shotCode: null, + shotId: null, + label: "", + status: "no-match" as const, + existingPlateCount: 0, + }; + } + + const existingPlateCount = shot.footagePlates.length; + + // Re-running a batch shouldn't silently pile up identical plates. + const isDuplicate = shot.footagePlates.some( + (p) => p.fileName.toLowerCase() === fileName.toLowerCase() + ); + if (isDuplicate) { + return { + fileName, + shotCode: shot.shotCode, + shotId: shot.id, + label: parsed.label, + status: "duplicate" as const, + existingPlateCount, + }; + } + + const takenSoFar = allocated.get(shot.id) ?? 0; + allocated.set(shot.id, takenSoFar + 1); + + return { + fileName, + shotCode: shot.shotCode, + shotId: shot.id, + label: parsed.label || `Plate ${existingPlateCount + takenSoFar + 1}`, + status: "match" as const, + existingPlateCount, + }; + }); + + return NextResponse.json({ items }); +} diff --git a/app/api/batch-upload/plates/upload/route.ts b/app/api/batch-upload/plates/upload/route.ts new file mode 100644 index 0000000..7d5e829 --- /dev/null +++ b/app/api/batch-upload/plates/upload/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { revalidatePath } from "next/cache"; + +export const maxDuration = 30; + +/** + * POST /api/batch-upload/plates/upload + * Body: { projectId, shotId, key, fileName, fileSize?, label? } + * + * Commits a footage plate whose bytes were already uploaded straight to + * Hetzner via POST /api/upload/presign, so large .mov plates never pass + * through this server or the reverse proxy. + */ +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { projectId, shotId, key, fileName, fileSize, label } = body as { + projectId?: unknown; + shotId?: unknown; + key?: unknown; + fileName?: unknown; + fileSize?: unknown; + label?: unknown; + }; + + if ( + typeof projectId !== "string" || + typeof shotId !== "string" || + typeof key !== "string" || + typeof fileName !== "string" || + !projectId || + !shotId || + !key || + !fileName + ) { + return NextResponse.json( + { error: "projectId, shotId, key and fileName are required" }, + { status: 400 } + ); + } + + // The key must be one this app handed out via /api/upload/presign. + if (!key.startsWith("videos/")) { + return NextResponse.json({ error: "Invalid storage key" }, { status: 400 }); + } + + const shot = await db.shot.findFirst({ + where: { id: shotId, projectId }, + select: { id: true }, + }); + if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 }); + + const last = await db.footagePlate.findFirst({ + where: { shotId }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + + const size = typeof fileSize === "number" && Number.isFinite(fileSize) && fileSize > 0 + ? BigInt(Math.round(fileSize)) + : null; + + const plate = await db.footagePlate.create({ + data: { + shotId, + fileUrl: `/api/files/${key}`, + fileKey: key, + fileName, + fileSize: size, + label: typeof label === "string" ? label.trim().slice(0, 100) : "", + sortOrder: (last?.sortOrder ?? -1) + 1, + }, + }); + + revalidatePath(`/projects/${projectId}`); + revalidatePath(`/projects/${projectId}/shots/${shotId}`); + + return NextResponse.json({ + success: true, + plateId: plate.id, + label: plate.label, + }); +} diff --git a/components/batch-upload/BatchUploadClient.tsx b/components/batch-upload/BatchUploadClient.tsx index a73304e..d3c1bdd 100644 --- a/components/batch-upload/BatchUploadClient.tsx +++ b/components/batch-upload/BatchUploadClient.tsx @@ -21,11 +21,14 @@ import { ArrowRight, RotateCcw, ImageIcon, + Film, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useToast } from "@/components/ui/use-toast"; +import { PLATE_EXTS, plateContentType } from "@/lib/batch-upload-parser"; import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route"; import type { ThumbnailPreviewItem } from "@/app/api/batch-upload/thumbnails/route"; +import type { PlatePreviewItem } from "@/app/api/batch-upload/plates/route"; interface Project { id: string; @@ -40,6 +43,12 @@ interface UploadState { error?: string; } +/** Footage plates are large, so they also carry per-file upload progress. */ +interface PlateUploadState extends UploadState { + /** 0–1, only meaningful while status is "uploading" */ + progress?: number; +} + interface BatchUploadClientProps { projects: Project[]; } @@ -76,6 +85,25 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { setThumbUploadComplete(false); }; + // ── Footage plate upload state ───────────────────────────────────────────── + const plateInputRef = useRef(null); + const [plateFiles, setPlateFiles] = useState([]); + const [plateIsDragging, setPlateIsDragging] = useState(false); + const [platePreview, setPlatePreview] = useState(null); + const [plateLoadingPreview, setPlateLoadingPreview] = useState(false); + const [plateUploadStates, setPlateUploadStates] = useState>({}); + const [plateIsUploading, setPlateIsUploading] = useState(false); + const [plateUploadComplete, setPlateUploadComplete] = useState(false); + + const acceptPlate = (f: File) => PLATE_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? ""); + + const resetPlates = () => { + setPlateFiles([]); + setPlatePreview(null); + setPlateUploadStates({}); + setPlateUploadComplete(false); + }; + const reset = () => { setFiles([]); setPreview(null); @@ -324,6 +352,136 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { setThumbUploadComplete(true); }; + // ── Footage plate handlers ───────────────────────────────────────────────── + + const handlePlateDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setPlateIsDragging(false); + const dropped = Array.from(e.dataTransfer.files).filter(acceptPlate); + if (dropped.length > 0) { + setPlateFiles(dropped); + setPlatePreview(null); + setPlateUploadStates({}); + setPlateUploadComplete(false); + } else { + toast({ + title: "No supported files", + description: "Only .mov, .mp4, .mxf, .avi, .mkv and .webm files are accepted.", + }); + } + }, [toast]); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePlateInput = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files ?? []).filter(acceptPlate); + if (selected.length > 0) { + setPlateFiles(selected); + setPlatePreview(null); + setPlateUploadStates({}); + setPlateUploadComplete(false); + } + e.target.value = ""; + }; + + const fetchPlatePreview = async () => { + if (!projectId || plateFiles.length === 0) return; + setPlateLoadingPreview(true); + try { + const res = await fetch("/api/batch-upload/plates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, fileNames: plateFiles.map((f) => f.name) }), + }); + if (!res.ok) throw new Error("Preview failed"); + const data = await res.json(); + setPlatePreview(data.items); + const states: Record = {}; + for (const item of data.items as PlatePreviewItem[]) states[item.fileName] = { status: "pending" }; + setPlateUploadStates(states); + } catch { + toast({ title: "Preview failed", variant: "destructive" }); + } finally { + setPlateLoadingPreview(false); + } + }; + + const startPlateUpload = async () => { + if (!platePreview || !projectId) return; + setPlateIsUploading(true); + + const matched = platePreview.filter((i) => i.status === "match"); + + for (const item of matched) { + const file = plateFiles.find((f) => f.name === item.fileName); + if (!file) continue; + + setPlateUploadStates((prev) => ({ + ...prev, + [item.fileName]: { status: "uploading", progress: 0 }, + })); + + try { + // Step 1 — presigned PUT URL. Plates are multi-GB, so the bytes go + // straight to Hetzner and never touch this server or the proxy. + const presignRes = await fetch("/api/upload/presign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileName: file.name, + contentType: plateContentType(file.name), + folder: "videos", + }), + }); + if (!presignRes.ok) { + const d = await presignRes.json().catch(() => ({ error: "Failed to get upload URL" })); + throw new Error((d as { error?: string }).error ?? "Failed to get upload URL"); + } + const { presignedUrl, key } = (await presignRes.json()) as { + presignedUrl: string; + key: string; + }; + + // Step 2 — PUT directly to storage, tracking progress via XHR. + await putWithProgress(presignedUrl, file, (fraction) => { + setPlateUploadStates((prev) => ({ + ...prev, + [item.fileName]: { status: "uploading", progress: fraction }, + })); + }); + + // Step 3 — commit the plate record against the matched shot. + const commitRes = await fetch("/api/batch-upload/plates/upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectId, + shotId: item.shotId, + key, + fileName: file.name, + fileSize: file.size, + label: item.label, + }), + }); + if (!commitRes.ok) { + const d = await commitRes.json().catch(() => ({ error: "Failed to save plate" })); + throw new Error((d as { error?: string }).error ?? "Failed to save plate"); + } + + setPlateUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } })); + } catch (err) { + setPlateUploadStates((prev) => ({ + ...prev, + [item.fileName]: { + status: "error", + error: err instanceof Error ? err.message : "Upload failed", + }, + })); + } + } + + setPlateIsUploading(false); + setPlateUploadComplete(true); + }; + // ── Derived counts ───────────────────────────────────────────────────────── const uploadable = preview?.filter( @@ -359,7 +517,11 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { value={projectId} onValueChange={(v) => { setProjectId(v); + // Every preview is resolved against a specific project, so none + // of them survive a project switch. reset(); + resetThumbs(); + resetPlates(); }} > @@ -735,12 +897,260 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { ); })()} + + {/* ── Footage plate bulk upload ───────────────────────────────── */} +
+

Bulk Footage Plate Upload

+

+ Drop plate movies here to add them to a shot's Footage tab. The shot is taken + from the start of the filename and the remainder becomes the plate label —{" "} + DH2_VFX_058_120_REF.mov{" "} + becomes the plate REF on shot{" "} + DH2_VFX_058_120. +

+
+ + {/* Plate drop zone */} + {!plateUploadComplete && ( + + +
{ e.preventDefault(); setPlateIsDragging(true); }} + onDragLeave={() => setPlateIsDragging(false)} + onDrop={handlePlateDrop} + onClick={() => plateInputRef.current?.click()} + className={cn( + "border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors select-none", + plateIsDragging + ? "border-emerald-400 bg-emerald-400/5" + : "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30" + )} + > + +

Drop footage plates here

+

+ MOV, MP4, MXF, AVI, MKV, WebM · or click to browse +

+ {plateFiles.length > 0 && ( +

+ {plateFiles.length} file{plateFiles.length !== 1 ? "s" : ""} selected +

+ )} + +
+ + {plateFiles.length > 0 && !platePreview && ( +
+ + +
+ )} +
+
+ )} + + {/* Plate preview table */} + {platePreview && !plateUploadComplete && (() => { + const matched = platePreview.filter((i) => i.status === "match"); + const skippedPlates = platePreview.filter((i) => i.status !== "match"); + const dupes = platePreview.filter((i) => i.status === "duplicate"); + const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length; + const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length; + const plateDone = plateSuccess + plateErrors; + const plateProgress = matched.length > 0 ? (plateDone / matched.length) * 100 : 0; + return ( + + + + Footage plate preview —{" "} + + {matched.length} matched · {skippedPlates.length} skipped + + + + +
+ + + + + + + + + + + {platePreview.map((item) => { + const state = plateUploadStates[item.fileName]; + return ( + + + + + + + ); + })} + +
FileMatched ShotPlate labelStatus
+
+ + {item.fileName} +
+
+ {item.shotCode ? ( + + {item.shotCode} + {item.existingPlateCount > 0 && ( + + ({item.existingPlateCount} plate{item.existingPlateCount !== 1 ? "s" : ""}) + + )} + + ) : ( + + {item.status === "unsupported" ? "unsupported file" : "no match"} + + )} + + {item.status === "match" ? ( + {item.label} + ) : ( + — + )} + + {item.status === "duplicate" ? ( + + already added + + ) : item.status !== "match" ? ( + skip + ) : !state || state.status === "pending" ? ( + add plate + ) : state.status === "uploading" ? ( + + + {Math.round((state.progress ?? 0) * 100)}% + + ) : state.status === "success" ? ( + done + ) : ( + error + )} +
+
+ + {plateIsUploading && ( +
+
+ Uploading {Math.min(plateDone + 1, matched.length)} of {matched.length}… + {Math.round(plateProgress)}% +
+ +
+ )} + +
+

+ {matched.length} plate{matched.length !== 1 ? "s" : ""} will be added + {skippedPlates.length > 0 && ( + + {" "}· {skippedPlates.length} skipped + {dupes.length > 0 && ` (${dupes.length} already uploaded)`} + + )} +

+
+ + +
+
+
+
+ ); + })()} + + {/* Plate done summary */} + {plateUploadComplete && (() => { + const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length; + const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length; + return ( + + + +
+

Footage plates uploaded

+

+ {plateSuccess} added{plateErrors > 0 && · {plateErrors} failed} +

+
+ +
+
+ ); + })()} )} ); } +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** + * PUT a file to a presigned storage URL, reporting upload progress. + * + * `fetch` gives no upload progress, and footage plates are big enough that a + * silent multi-minute wait per file is unusable — hence XHR. + */ +function putWithProgress( + url: string, + file: File, + onProgress: (fraction: number) => void +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url); + xhr.setRequestHeader("Content-Type", plateContentType(file.name)); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) onProgress(e.loaded / e.total); + }); + + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else if (xhr.status === 403) + reject(new Error("Storage upload failed — check Hetzner CORS / bucket policy")); + else reject(new Error(`Storage upload failed (HTTP ${xhr.status})`)); + }); + + xhr.addEventListener("error", () => reject(new Error("Network error"))); + xhr.addEventListener("abort", () => reject(new Error("Upload aborted"))); + xhr.send(file); + }); +} + // ── Sub-components ───────────────────────────────────────────────────────────── function PreviewRow({ diff --git a/lib/batch-upload-parser.test.ts b/lib/batch-upload-parser.test.ts new file mode 100644 index 0000000..bf6ba64 --- /dev/null +++ b/lib/batch-upload-parser.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { PLATE_EXTS, parsePlateFilename, plateContentType } from "./batch-upload-parser"; + +const SHOTS = ["DH2_VFX_058_120", "DH2_VFX_058_120_B", "DH2_VFX_058_10", "UNG_106_035_010"]; + +describe("parsePlateFilename", () => { + it("derives the shot and label from a suffixed plate name", () => { + const p = parsePlateFilename("DH2_VFX_058_120_REF.mov", SHOTS); + expect(p.shotCode).toBe("DH2_VFX_058_120"); + expect(p.label).toBe("REF"); + expect(p.ext).toBe("mov"); + }); + + it("returns an empty label when the filename is exactly the shot code", () => { + const p = parsePlateFilename("DH2_VFX_058_120.mov", SHOTS); + expect(p.shotCode).toBe("DH2_VFX_058_120"); + expect(p.label).toBe(""); + }); + + it("prefers the longest matching shot code", () => { + const p = parsePlateFilename("DH2_VFX_058_120_B_REF.mov", SHOTS); + expect(p.shotCode).toBe("DH2_VFX_058_120_B"); + expect(p.label).toBe("REF"); + }); + + it("does not match a shot code that is only a partial segment", () => { + // "DH2_VFX_058_10" must not swallow "DH2_VFX_058_1000" + expect(parsePlateFilename("DH2_VFX_058_1000_REF.mov", SHOTS).shotCode).toBeNull(); + }); + + it("matches case-insensitively but reports the canonical shot code", () => { + const p = parsePlateFilename("dh2_vfx_058_120_ref.mov", SHOTS); + expect(p.shotCode).toBe("DH2_VFX_058_120"); + expect(p.label).toBe("ref"); + }); + + it("keeps multi-part labels intact", () => { + expect(parsePlateFilename("UNG_106_035_010_BG_CLEAN_v002.mov", SHOTS).label).toBe( + "BG_CLEAN_v002" + ); + }); + + it("returns no shot code when nothing matches", () => { + const p = parsePlateFilename("random_file.mov", SHOTS); + expect(p.shotCode).toBeNull(); + expect(p.label).toBe(""); + }); + + it("handles filenames with dots in them", () => { + const p = parsePlateFilename("DH2_VFX_058_120_REF.v2.mov", SHOTS); + expect(p.shotCode).toBe("DH2_VFX_058_120"); + expect(p.label).toBe("REF.v2"); + expect(p.ext).toBe("mov"); + }); +}); + +describe("plateContentType", () => { + it("maps every accepted extension to a video/* type", () => { + for (const ext of PLATE_EXTS) { + expect(plateContentType(`shot.${ext}`)).toMatch(/^video\//); + } + }); + + it("is case-insensitive", () => { + expect(plateContentType("SHOT.MOV")).toBe("video/quicktime"); + }); +}); diff --git a/lib/batch-upload-parser.ts b/lib/batch-upload-parser.ts index 92cad69..04162f6 100644 --- a/lib/batch-upload-parser.ts +++ b/lib/batch-upload-parser.ts @@ -93,3 +93,67 @@ export function findMatchingShotCode( } return null; } + +// ── Footage plates ──────────────────────────────────────────────────────────── + +/** Video extensions accepted by the footage plate batch upload. */ +export const PLATE_EXTS = new Set(["mov", "mp4", "m4v", "mxf", "avi", "mkv", "webm"]); + +const PLATE_CONTENT_TYPES: Record = { + mov: "video/quicktime", + mp4: "video/mp4", + m4v: "video/mp4", + mxf: "video/mxf", + avi: "video/x-msvideo", + mkv: "video/x-matroska", + webm: "video/webm", +}; + +/** + * Derive a `video/*` content type from a plate filename's extension. + * + * Browsers routinely report an empty MIME type for .mov / .mxf files + * (especially on Windows), and the presign endpoint rejects anything that + * isn't `video/*`, so never trust `File.type` for these. + */ +export function plateContentType(fileName: string): string { + const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; + return PLATE_CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +export interface ParsedPlateFilename { + /** Lowercased extension without the leading dot */ + ext: string; + /** Filename without the extension */ + nameWithoutExt: string; + /** Shot code this file belongs to, or null when nothing matched */ + shotCode: string | null; + /** + * The filename remainder after the shot code, used as the plate label. + * e.g. "DH2_VFX_058_120_REF.mov" → "REF". + * Empty when the filename is exactly the shot code. + */ + label: string; +} + +/** + * Resolve a footage plate filename to a shot code and a plate label. + * + * e.g. "DH2_VFX_058_120_REF.mov" against shot "DH2_VFX_058_120" + * → { shotCode: "DH2_VFX_058_120", label: "REF" } + */ +export function parsePlateFilename( + fileName: string, + shotCodes: string[] +): ParsedPlateFilename { + const lastDot = fileName.lastIndexOf("."); + const ext = lastDot >= 0 ? fileName.slice(lastDot + 1).toLowerCase() : ""; + const nameWithoutExt = lastDot >= 0 ? fileName.slice(0, lastDot) : fileName; + + const shotCode = findMatchingShotCode(nameWithoutExt, shotCodes); + const label = shotCode + ? nameWithoutExt.slice(shotCode.length).replace(/^[_\-\s]+/, "").trim() + : ""; + + return { ext, nameWithoutExt, shotCode, label }; +}