Files
twotalesanimation 2dee1bf6fc
Deploy / deploy (push) Successful in 6m39s
feat(batch-upload): bulk footage plate upload matched by filename
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 <noreply@anthropic.com>
2026-09-12 12:15:29 +02:00

125 lines
3.6 KiB
TypeScript

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<string, number>();
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 });
}