Files
vfxreview/app/api/batch-upload/upload/route.ts
T
twotalesanimation e756eb9015
Deploy / deploy (push) Successful in 2m53s
OBJ then local
2026-07-16 23:04:15 +02:00

194 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
import { recalcShotStatus } from "@/lib/shot-status";
export const maxDuration = 300; // 5 min — large .mov high-res files
/**
* 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<NextResponse> {
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());
// Browsers (especially on Windows) often send an empty MIME type for .mov
// files. Fall back to a safe content type based on the file extension.
const ext = file.name.split(".").pop()?.toLowerCase();
const contentType =
file.type ||
(ext === "mov" ? "video/quicktime" :
ext === "mp4" ? "video/mp4" :
"application/octet-stream");
// ── 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, contentType, "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 Hetzner object storage
const { key: videoKey } = await uploadToHetzner(buffer, file.name, contentType, "videos");
const result = { url: `/api/files/${videoKey}`, key: videoKey };
// 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,
});
}