Files
vfxreview/app/api/batch-upload/upload/route.ts
T
twotalesanimation 15fda1ec44
Deploy / deploy (push) Successful in 3m5s
CORS UPloads
2026-07-22 00:03:02 +02:00

208 lines
6.5 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 (!action || !shotId || !projectId) {
return NextResponse.json(
{ error: "Missing required fields: action, shotId, projectId" },
{ status: 400 }
);
}
// ── High-res (.mov) — file was uploaded directly to Hetzner via presigned URL ──
// The file never passes through this server or the Nginx proxy, avoiding
// HTTP 413 errors caused by client_max_body_size limits.
if (action === "update-highres") {
const preUploadedKey = formData.get("key") as string | null;
const preUploadedFileName = formData.get("fileName") as string | null;
if (!preUploadedKey || !preUploadedFileName) {
return NextResponse.json(
{ error: "key and fileName are required for update-highres" },
{ status: 400 }
);
}
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 });
}
if (shot.highResKey) {
await deleteFromHetzner(shot.highResKey).catch(() => {});
}
await db.shot.update({
where: { id: shotId },
data: { highResKey: preUploadedKey, highResFilename: preUploadedFileName },
});
return NextResponse.json({
success: true,
action: "update-highres",
fileName: preUploadedFileName,
});
}
// For all other actions (MP4 version uploads) a file is required.
if (!file) {
return NextResponse.json({ error: "file is required" }, { 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");
// ── 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,
});
}