feat(batch-upload): bulk footage plate upload matched by filename
Deploy / deploy (push) Successful in 6m39s
Deploy / deploy (push) Successful in 6m39s
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>
This commit is contained in:
@@ -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<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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user