From 637b141c879dced2ccaf036d4c438b11cf01a535 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:46:50 +0200 Subject: [PATCH] Batch Video Uploads --- app/(dashboard)/batch-upload/page.tsx | 23 + app/api/batch-upload/preview/route.ts | 185 ++++++ app/api/batch-upload/upload/route.ts | 183 ++++++ components/batch-upload/BatchUploadClient.tsx | 617 ++++++++++++++++++ components/layout/Sidebar.tsx | 2 + lib/batch-upload-parser.ts | 95 +++ 6 files changed, 1105 insertions(+) create mode 100644 app/(dashboard)/batch-upload/page.tsx create mode 100644 app/api/batch-upload/preview/route.ts create mode 100644 app/api/batch-upload/upload/route.ts create mode 100644 components/batch-upload/BatchUploadClient.tsx create mode 100644 lib/batch-upload-parser.ts diff --git a/app/(dashboard)/batch-upload/page.tsx b/app/(dashboard)/batch-upload/page.tsx new file mode 100644 index 0000000..96b4f2c --- /dev/null +++ b/app/(dashboard)/batch-upload/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { BatchUploadClient } from "@/components/batch-upload/BatchUploadClient"; + +export const metadata = { title: "Batch Upload — VFX Review" }; + +export default async function BatchUploadPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + redirect("/dashboard"); + } + + const projects = await db.project.findMany({ + where: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + select: { id: true, name: true, code: true }, + orderBy: { name: "asc" }, + }); + + return ; +} diff --git a/app/api/batch-upload/preview/route.ts b/app/api/batch-upload/preview/route.ts new file mode 100644 index 0000000..4bb0505 --- /dev/null +++ b/app/api/batch-upload/preview/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { + parseFilename, + stripVersionFromTitle, + findMatchingShotCode, +} from "@/lib/batch-upload-parser"; + +export type PreviewItemStatus = + | "new-version" // task found, same version prefix → just add a version + | "rename-and-upload" // task found but different version → rename task + add version + | "create-task" // no matching task → use/rename fallback "Comp" task or create new + | "update-highres" // .mov file → replace the shot's high-res file + | "no-shot" // shot code not found in this project + | "unsupported"; // file extension is not .mp4 or .mov + +export interface PreviewItem { + fileName: string; + status: PreviewItemStatus; + shotId: string | null; + shotCode: string | null; + /** For .mp4: ID of the directly matched task, if found */ + taskId: string | null; + /** For .mp4: current title of the matched / fallback task */ + currentTaskTitle: string | null; + /** For .mp4: desired task title after upload (filename without extension) */ + newTaskTitle: string; + /** For .mov: filename of the existing high-res file that will be replaced */ + currentHighResFilename: string | null; + /** For create-task: ID of a "Comp" task to rename & use */ + fallbackTaskId: string | null; + fallbackTaskTitle: string | null; +} + +/** + * POST /api/batch-upload/preview + * Body: { projectId: string; fileNames: string[] } + * + * Returns a preview of what action will be taken for each file, without + * actually uploading anything. + */ +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?: string; + fileNames?: unknown; + }; + + if (!projectId || !Array.isArray(fileNames) || fileNames.length === 0) { + return NextResponse.json( + { error: "projectId and fileNames[] are required" }, + { status: 400 } + ); + } + + // Load all shots for this project with their tasks + const shots = await db.shot.findMany({ + where: { projectId }, + select: { + id: true, + shotCode: true, + highResFilename: true, + tasks: { + select: { id: true, title: true, type: true }, + orderBy: { sortOrder: "asc" }, + }, + }, + }); + + const shotCodes = shots.map((s) => s.shotCode); + + const items: PreviewItem[] = fileNames.map((fileName: string) => { + const parsed = parseFilename(fileName); + + if (parsed.type === "other") { + return { + fileName, + status: "unsupported" as const, + shotId: null, + shotCode: null, + taskId: null, + currentTaskTitle: null, + newTaskTitle: "", + currentHighResFilename: null, + fallbackTaskId: null, + fallbackTaskTitle: null, + }; + } + + // Find the shot whose code is a prefix of this file's task name base + const matchedCode = findMatchingShotCode(parsed.taskNameBase, shotCodes); + const matchedShot = matchedCode + ? shots.find((s) => s.shotCode === matchedCode) ?? null + : null; + + if (!matchedShot) { + return { + fileName, + status: "no-shot" as const, + shotId: null, + shotCode: null, + taskId: null, + currentTaskTitle: null, + newTaskTitle: "", + currentHighResFilename: null, + fallbackTaskId: null, + fallbackTaskTitle: null, + }; + } + + // ── .mov → update-highres ───────────────────────────────────────────── + if (parsed.type === "mov") { + return { + fileName, + status: "update-highres" as const, + shotId: matchedShot.id, + shotCode: matchedShot.shotCode, + taskId: null, + currentTaskTitle: null, + newTaskTitle: "", + currentHighResFilename: matchedShot.highResFilename ?? null, + fallbackTaskId: null, + fallbackTaskTitle: null, + }; + } + + // ── .mp4 → find matching task ───────────────────────────────────────── + const desiredTitle = parsed.taskNameWithVersion; // full name without ext + + // Match by stripping version from existing task titles + const matchedTask = matchedShot.tasks.find( + (t) => stripVersionFromTitle(t.title) === parsed.taskNameBase + ); + + if (matchedTask) { + const sameTitle = matchedTask.title === desiredTitle; + return { + fileName, + status: (sameTitle ? "new-version" : "rename-and-upload") as PreviewItemStatus, + shotId: matchedShot.id, + shotCode: matchedShot.shotCode, + taskId: matchedTask.id, + currentTaskTitle: matchedTask.title, + newTaskTitle: desiredTitle, + currentHighResFilename: null, + fallbackTaskId: null, + fallbackTaskTitle: null, + }; + } + + // No direct match – look for a generic "Comp" or "comp" task, or any COMP type + const compTask = matchedShot.tasks.find( + (t) => + t.title.toLowerCase() === "comp" || + t.type === "COMP" + ); + + return { + fileName, + status: "create-task" as const, + shotId: matchedShot.id, + shotCode: matchedShot.shotCode, + taskId: null, + currentTaskTitle: null, + newTaskTitle: desiredTitle, + currentHighResFilename: null, + fallbackTaskId: compTask?.id ?? null, + fallbackTaskTitle: compTask?.title ?? null, + }; + }); + + return NextResponse.json({ items }); +} diff --git a/app/api/batch-upload/upload/route.ts b/app/api/batch-upload/upload/route.ts new file mode 100644 index 0000000..6789ca5 --- /dev/null +++ b/app/api/batch-upload/upload/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { uploadFile, uploadToHetzner, deleteFromHetzner } from "@/lib/storage"; +import { recalcShotStatus } from "@/lib/shot-status"; + +export const maxDuration = 120; + +/** + * POST /api/batch-upload/upload + * + * Handles a single file upload from the batch upload flow. + * + * FormData fields: + * file – the binary file + * action – "new-version" | "rename-and-upload" | "create-task" | "update-highres" + * shotId – shot DB id (required for all actions) + * projectId – project DB id + * taskId – required for "new-version" and "rename-and-upload" + * fallbackTaskId – optional: the "Comp" task id to rename for "create-task" + * newTaskTitle – desired task title (filename without extension) + */ +export async function POST( + req: NextRequest +): Promise { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + const action = formData.get("action") as string | null; + const shotId = formData.get("shotId") as string | null; + const projectId = formData.get("projectId") as string | null; + const taskId = formData.get("taskId") as string | null; + const fallbackTaskId = formData.get("fallbackTaskId") as string | null; + const newTaskTitle = (formData.get("newTaskTitle") as string | null) ?? ""; + + if (!file || !action || !shotId || !projectId) { + return NextResponse.json( + { error: "Missing required fields: file, action, shotId, projectId" }, + { status: 400 } + ); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + + // ── High-res (.mov) ─────────────────────────────────────────────────────── + if (action === "update-highres") { + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { id: true, highResKey: true }, + }); + if (!shot) { + return NextResponse.json({ error: "Shot not found" }, { status: 404 }); + } + + // Remove the previous high-res file if one exists + if (shot.highResKey) { + await deleteFromHetzner(shot.highResKey).catch(() => {}); + } + + const { key } = await uploadToHetzner(buffer, file.name, file.type, "highres"); + + await db.shot.update({ + where: { id: shotId }, + data: { highResKey: key, highResFilename: file.name }, + }); + + return NextResponse.json({ + success: true, + action: "update-highres", + fileName: file.name, + }); + } + + // ── Version upload (.mp4) ───────────────────────────────────────────────── + let resolvedTaskId: string; + + if (action === "new-version") { + if (!taskId) { + return NextResponse.json( + { error: "taskId is required for new-version action" }, + { status: 400 } + ); + } + resolvedTaskId = taskId; + } else if (action === "rename-and-upload") { + if (!taskId) { + return NextResponse.json( + { error: "taskId is required for rename-and-upload action" }, + { status: 400 } + ); + } + // Rename the task to reflect the new version number + await db.task.update({ + where: { id: taskId }, + data: { title: newTaskTitle }, + }); + resolvedTaskId = taskId; + } else if (action === "create-task") { + if (fallbackTaskId) { + // Rename the existing "Comp" (or similar) task and reuse it + await db.task.update({ + where: { id: fallbackTaskId }, + data: { title: newTaskTitle }, + }); + resolvedTaskId = fallbackTaskId; + } else { + // Create a brand-new task under the shot + const lastTask = await db.task.findFirst({ + where: { shotId }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + const task = await db.task.create({ + data: { + title: newTaskTitle, + type: "COMP", + shotId, + projectId, + createdById: session.user.id, + sortOrder: (lastTask?.sortOrder ?? -1) + 1, + }, + }); + resolvedTaskId = task.id; + } + } else { + return NextResponse.json( + { error: `Unknown action: ${action}` }, + { status: 400 } + ); + } + + // Upload the video to the configured storage backend + const result = await uploadFile(buffer, file.name, file.type, "videos"); + + // Mark all existing versions for this task as no longer latest + await db.version.updateMany({ + where: { taskId: resolvedTaskId }, + data: { isLatest: false }, + }); + + // Determine next sequential version number + const lastVersion = await db.version.findFirst({ + where: { taskId: resolvedTaskId }, + orderBy: { versionNumber: "desc" }, + select: { versionNumber: true }, + }); + const versionNumber = (lastVersion?.versionNumber ?? 0) + 1; + + // Create the version record + const version = await db.version.create({ + data: { + versionNumber, + taskId: resolvedTaskId, + artistId: session.user.id, + fileUrl: result.url, + fileName: file.name, + fileSize: BigInt(file.size), + mimeType: file.type, + isLatest: true, + }, + }); + + // Move the task to INTERNAL_REVIEW + await db.task.update({ + where: { id: resolvedTaskId }, + data: { status: "INTERNAL_REVIEW" }, + }); + + // Recalculate the parent shot's status + await recalcShotStatus(shotId).catch(() => {}); + + return NextResponse.json({ + success: true, + action, + versionId: version.id, + versionNumber, + taskId: resolvedTaskId, + }); +} diff --git a/components/batch-upload/BatchUploadClient.tsx b/components/batch-upload/BatchUploadClient.tsx new file mode 100644 index 0000000..b6b19a9 --- /dev/null +++ b/components/batch-upload/BatchUploadClient.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Upload, + FileVideo, + CheckCircle2, + XCircle, + Loader2, + ChevronRight, + ArrowRight, + RotateCcw, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useToast } from "@/components/ui/use-toast"; +import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route"; + +interface Project { + id: string; + name: string; + code: string; +} + +type UploadStatus = "pending" | "uploading" | "success" | "error"; + +interface UploadState { + status: UploadStatus; + error?: string; +} + +interface BatchUploadClientProps { + projects: Project[]; +} + +export function BatchUploadClient({ projects }: BatchUploadClientProps) { + const { toast } = useToast(); + const [projectId, setProjectId] = useState(""); + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [preview, setPreview] = useState(null); + const [uploadStates, setUploadStates] = useState>({}); + const [isUploading, setIsUploading] = useState(false); + const [uploadComplete, setUploadComplete] = useState(false); + const fileInputRef = useRef(null); + + // ── Helpers ──────────────────────────────────────────────────────────────── + + const reset = () => { + setFiles([]); + setPreview(null); + setUploadStates({}); + setUploadComplete(false); + }; + + const acceptFile = (f: File) => + f.name.toLowerCase().endsWith(".mp4") || f.name.toLowerCase().endsWith(".mov"); + + // ── Drag & Drop ──────────────────────────────────────────────────────────── + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragging(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const dropped = Array.from(e.dataTransfer.files).filter(acceptFile); + if (dropped.length > 0) { + setFiles(dropped); + setPreview(null); + setUploadStates({}); + setUploadComplete(false); + } else { + toast({ title: "No supported files", description: "Only .mp4 and .mov files are accepted." }); + } + }, [toast]); + + const handleFileInput = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files ?? []).filter(acceptFile); + if (selected.length > 0) { + setFiles(selected); + setPreview(null); + setUploadStates({}); + setUploadComplete(false); + } + // Reset so the same file can be re-selected + e.target.value = ""; + }; + + // ── Preview ──────────────────────────────────────────────────────────────── + + const fetchPreview = async () => { + if (!projectId || files.length === 0) return; + setIsLoadingPreview(true); + try { + const res = await fetch("/api/batch-upload/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, fileNames: files.map((f) => f.name) }), + }); + if (!res.ok) throw new Error("Preview request failed"); + const data = await res.json(); + setPreview(data.items); + // Initialise upload states + const states: Record = {}; + for (const item of data.items as PreviewItem[]) { + states[item.fileName] = { status: "pending" }; + } + setUploadStates(states); + } catch { + toast({ + title: "Preview failed", + description: "Could not load the upload preview. Please try again.", + variant: "destructive", + }); + } finally { + setIsLoadingPreview(false); + } + }; + + // ── Upload ───────────────────────────────────────────────────────────────── + + const startUpload = async () => { + if (!preview || !projectId) return; + setIsUploading(true); + + const uploadable = preview.filter( + (item) => item.status !== "no-shot" && item.status !== "unsupported" + ); + + for (const item of uploadable) { + const file = files.find((f) => f.name === item.fileName); + if (!file) continue; + + setUploadStates((prev) => ({ + ...prev, + [item.fileName]: { status: "uploading" }, + })); + + try { + const fd = new FormData(); + fd.append("file", file); + fd.append("action", item.status); + fd.append("shotId", item.shotId!); + fd.append("projectId", projectId); + if (item.taskId) fd.append("taskId", item.taskId); + if (item.fallbackTaskId) fd.append("fallbackTaskId", item.fallbackTaskId); + if (item.newTaskTitle) fd.append("newTaskTitle", item.newTaskTitle); + + const res = await fetch("/api/batch-upload/upload", { + method: "POST", + body: fd, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: "Upload failed" })); + throw new Error(data.error ?? "Upload failed"); + } + + setUploadStates((prev) => ({ + ...prev, + [item.fileName]: { status: "success" }, + })); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Upload failed"; + setUploadStates((prev) => ({ + ...prev, + [item.fileName]: { status: "error", error: message }, + })); + } + } + + setIsUploading(false); + setUploadComplete(true); + }; + + // ── Derived counts ───────────────────────────────────────────────────────── + + const uploadable = preview?.filter( + (i) => i.status !== "no-shot" && i.status !== "unsupported" + ) ?? []; + const skipped = preview?.filter( + (i) => i.status === "no-shot" || i.status === "unsupported" + ) ?? []; + const successCount = Object.values(uploadStates).filter((s) => s.status === "success").length; + const errorCount = Object.values(uploadStates).filter((s) => s.status === "error").length; + const doneCount = successCount + errorCount; + const progress = uploadable.length > 0 ? (doneCount / uploadable.length) * 100 : 0; + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
+ {/* Header */} +
+

Batch Upload

+

+ Drop .mp4 files to upload new versions to tasks, and{" "} + .mov files to replace a shot's high-res deliverable. + Files are matched to shots and tasks by filename. +

+
+ + {/* Project selector */} + + + + + + + + {/* Drop zone */} + {projectId && !uploadComplete && ( + + +
fileInputRef.current?.click()} + className={cn( + "border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors select-none", + isDragging + ? "border-amber-400 bg-amber-400/5" + : "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30" + )} + > + +

+ Drop .mp4 /{" "} + .mov files here +

+

or click to browse

+ {files.length > 0 && ( +

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

+ )} + +
+ + {files.length > 0 && !preview && ( +
+ + +
+ )} +
+
+ )} + + {/* Preview table */} + {preview && !uploadComplete && ( + + + + Upload preview —{" "} + + {preview.length} file{preview.length !== 1 ? "s" : ""} + + + + +
+ + + + + + + + + + + {preview.map((item) => ( + + ))} + +
+ File + + Shot + + Action + + Status +
+
+ + {/* Progress bar while uploading */} + {isUploading && ( +
+
+ + Uploading {Math.min(doneCount + 1, uploadable.length)} of{" "} + {uploadable.length}… + + {Math.round(progress)}% +
+ +
+ )} + + {/* Footer actions */} +
+

+ {uploadable.length} file{uploadable.length !== 1 ? "s" : ""} will be + uploaded + {skipped.length > 0 && ( + + {" "} + · {skipped.length} skipped (not matched or unsupported) + + )} +

+
+ + +
+
+
+
+ )} + + {/* Done summary */} + {uploadComplete && ( + + + +
+

Upload complete

+

+ {successCount} succeeded + {errorCount > 0 && ( + · {errorCount} failed + )} +

+
+ +
+
+ )} +
+ ); +} + +// ── Sub-components ───────────────────────────────────────────────────────────── + +function PreviewRow({ + item, + uploadState, +}: { + item: PreviewItem; + uploadState: UploadState | undefined; +}) { + const ext = item.fileName.split(".").pop()?.toLowerCase() ?? ""; + return ( + + {/* File */} + +
+ + + {item.fileName} + + + .{ext} + +
+ + + {/* Shot */} + + {item.shotCode ? ( + {item.shotCode} + ) : ( + Not found + )} + + + {/* Action */} + + + + + {/* Upload status */} + + + + + ); +} + +function ActionCell({ item }: { item: PreviewItem }) { + switch (item.status) { + case "new-version": + return ( +
+ New version +

{item.currentTaskTitle}

+
+ ); + + case "rename-and-upload": + return ( +
+ Rename task + upload +
+ {item.currentTaskTitle} + + {item.newTaskTitle} +
+
+ ); + + case "create-task": + return ( +
+ {item.fallbackTaskId ? ( + <> + Rename Comp task + upload +
+ {item.fallbackTaskTitle} + + {item.newTaskTitle} +
+ + ) : ( + <> + Create new task +

{item.newTaskTitle}

+ + )} +
+ ); + + case "update-highres": + return ( +
+ Replace high-res + {item.currentHighResFilename && ( +

+ Replaces: {item.currentHighResFilename} +

+ )} +
+ ); + + case "no-shot": + return Shot not found; + + case "unsupported": + return Unsupported file type; + + default: + return null; + } +} + +function StatusCell({ + status, + uploadState, +}: { + status: PreviewItemStatus; + uploadState: UploadState | undefined; +}) { + if (status === "no-shot" || status === "unsupported") { + return Skipped; + } + if (!uploadState || uploadState.status === "pending") { + return Pending; + } + if (uploadState.status === "uploading") { + return ( + + + Uploading + + ); + } + if (uploadState.status === "success") { + return ( + + + Done + + ); + } + if (uploadState.status === "error") { + return ( + + + Error + + ); + } + return null; +} + +function ActionBadge({ + children, + color, +}: { + children: React.ReactNode; + color: "green" | "amber" | "blue" | "purple" | "red" | "zinc"; +}) { + const map: Record = { + green: "bg-green-500/10 text-green-400 border-green-500/30", + amber: "bg-amber-500/10 text-amber-400 border-amber-500/30", + blue: "bg-blue-500/10 text-blue-400 border-blue-500/30", + purple: "bg-purple-500/10 text-purple-400 border-purple-500/30", + red: "bg-red-500/10 text-red-400 border-red-500/30", + zinc: "bg-zinc-500/10 text-zinc-400 border-zinc-500/30", + }; + return ( + + {children} + + ); +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 80fd386..6e9a239 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ import { CalendarRange, BarChart2, ListVideo, + CloudUpload, } from 'lucide-react'; import { useState } from 'react'; import { useSession } from 'next-auth/react'; @@ -30,6 +31,7 @@ const navItems = [ { href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true }, { href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true }, { href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true }, + { href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true }, { href: '/clients', label: 'Clients', icon: Users, adminOnly: true }, { href: '/users', label: 'Users', icon: UserCog, adminOnly: true, adminStrictOnly: true }, { href: '/settings', label: 'Settings', icon: Settings }, diff --git a/lib/batch-upload-parser.ts b/lib/batch-upload-parser.ts new file mode 100644 index 0000000..92cad69 --- /dev/null +++ b/lib/batch-upload-parser.ts @@ -0,0 +1,95 @@ +/** + * Utilities for parsing VFX filenames into shot / task metadata. + * + * Expected naming convention (any separator count is fine): + * {SHOW}_{EP}_{SCENE}_{CUT}_{TASK}_{ARTIST}_v{NNN}.{ext} + * e.g. UNG_106_035_010_cmp_TT_v002.mp4 + * + * The shot code is matched against the database – the parser itself + * does NOT hard-code how many underscore segments belong to the shot. + */ + +export interface ParsedFilename { + originalName: string; + /** Filename without the extension */ + nameWithoutExt: string; + /** Lowercased extension without the leading dot (e.g. "mp4", "mov") */ + ext: string; + /** + * Task name base – the full name without the version suffix. + * e.g. "UNG_106_035_010_cmp_TT" + */ + taskNameBase: string; + /** + * Full task title including the version suffix (no extension). + * This is what the task should be named after upload. + * e.g. "UNG_106_035_010_cmp_TT_v002" + */ + taskNameWithVersion: string; + /** + * Version string extracted from the filename, e.g. "v002". + * Empty string if no version pattern was found. + */ + version: string; + type: "mp4" | "mov" | "other"; +} + +/** + * Parse a VFX filename into its constituent parts. + */ +export function parseFilename(filename: string): ParsedFilename { + const lastDot = filename.lastIndexOf("."); + const ext = lastDot >= 0 ? filename.slice(lastDot + 1).toLowerCase() : ""; + const nameWithoutExt = lastDot >= 0 ? filename.slice(0, lastDot) : filename; + + // Version pattern: _v followed by one or more digits at the end (case insensitive) + const versionMatch = nameWithoutExt.match(/_v(\d+)$/i); + const version = versionMatch ? `v${versionMatch[1]}` : ""; + const taskNameBase = versionMatch + ? nameWithoutExt.slice(0, nameWithoutExt.length - versionMatch[0].length) + : nameWithoutExt; + + const type: "mp4" | "mov" | "other" = + ext === "mp4" ? "mp4" : ext === "mov" ? "mov" : "other"; + + return { + originalName: filename, + nameWithoutExt, + ext, + taskNameBase, + taskNameWithVersion: nameWithoutExt, + version, + type, + }; +} + +/** + * Strip a trailing version suffix (_v###) from a task title. + * e.g. "UNG_106_035_010_cmp_TT_v001" → "UNG_106_035_010_cmp_TT" + */ +export function stripVersionFromTitle(title: string): string { + return title.replace(/_v\d+$/i, ""); +} + +/** + * Given a taskNameBase and a list of shot codes, return the shot code that + * is a prefix of the task name base (shot code + "_" separator). + * + * e.g. taskNameBase "UNG_106_035_010_cmp_TT" matches shot "UNG_106_035_010" + */ +export function findMatchingShotCode( + taskNameBase: string, + shotCodes: string[] +): string | null { + // Sort by length descending so longer (more specific) codes are matched first + const sorted = [...shotCodes].sort((a, b) => b.length - a.length); + for (const code of sorted) { + if ( + taskNameBase === code || + taskNameBase.toLowerCase().startsWith(code.toLowerCase() + "_") + ) { + return code; + } + } + return null; +}