From 519fe2ad332419846b5268b2d432586ce9e219b4 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:31:32 +0200 Subject: [PATCH] MOV upload update --- app/api/batch-upload/presign/route.ts | 57 +++++++++++++++++ components/batch-upload/BatchUploadClient.tsx | 63 ++++++++++++++----- lib/storage.ts | 19 ++++++ 3 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 app/api/batch-upload/presign/route.ts diff --git a/app/api/batch-upload/presign/route.ts b/app/api/batch-upload/presign/route.ts new file mode 100644 index 0000000..b633950 --- /dev/null +++ b/app/api/batch-upload/presign/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { generateHetznerPresignedUploadUrl } from "@/lib/storage"; +import { randomUUID } from "crypto"; + +export const maxDuration = 10; + +/** + * POST /api/batch-upload/presign + * Body: { fileName: string } + * + * Returns a presigned PUT URL for uploading a high-res file directly from + * the browser to Hetzner Object Storage, together with the storage key that + * must be passed to POST /api/batch-upload/upload (action=update-highres) to + * commit the shot record after the direct upload completes. + * + * This two-phase approach avoids routing large MOV files through the app + * server / reverse proxy, removing any client_max_body_size limits. + */ +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 { fileName } = body as { fileName?: unknown }; + + if (!fileName || typeof fileName !== "string") { + return NextResponse.json({ error: "fileName is required" }, { status: 400 }); + } + + const ext = fileName.split(".").pop()?.toLowerCase(); + const contentType = + ext === "mov" ? "video/quicktime" : + ext === "mp4" ? "video/mp4" : + "application/octet-stream"; + + const key = `highres/${randomUUID()}-${fileName}`; + + try { + const presignedUrl = await generateHetznerPresignedUploadUrl(key, contentType); + return NextResponse.json({ presignedUrl, key }); + } catch (err) { + console.error("[batch-upload/presign]", err); + return NextResponse.json( + { error: "Failed to generate upload URL. Check Hetzner storage configuration." }, + { status: 500 } + ); + } +} diff --git a/components/batch-upload/BatchUploadClient.tsx b/components/batch-upload/BatchUploadClient.tsx index b6b19a9..e3fe2a0 100644 --- a/components/batch-upload/BatchUploadClient.tsx +++ b/components/batch-upload/BatchUploadClient.tsx @@ -154,23 +154,56 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { })); 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); + if (item.status === "update-highres") { + // MOV high-res files: use XHR so the browser streams the file from + // disk chunk-by-chunk, matching the behaviour of HighResUploadDialog + // which is known to work reliably for large files. + await new Promise((resolve, reject) => { + const fd = new FormData(); + fd.append("file", file); + fd.append("action", "update-highres"); + fd.append("shotId", item.shotId!); + fd.append("projectId", projectId); - const res = await fetch("/api/batch-upload/upload", { - method: "POST", - body: fd, - }); + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/batch-upload/upload"); - if (!res.ok) { - const data = await res.json().catch(() => ({ error: "Upload failed" })); - throw new Error(data.error ?? "Upload failed"); + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + try { + const json = JSON.parse(xhr.responseText) as { error?: string }; + reject(new Error(json.error ?? `HTTP ${xhr.status}`)); + } catch { + reject(new Error(`HTTP ${xhr.status}`)); + } + } + }); + + xhr.addEventListener("error", () => reject(new Error("Network error"))); + xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); + xhr.send(fd); + }); + } else { + // MP4 version uploads: existing fetch-based flow + 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) => ({ diff --git a/lib/storage.ts b/lib/storage.ts index 5cad181..4556c2a 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -279,6 +279,25 @@ export async function uploadToHetzner( return { key }; } +/** + * Generate a presigned PUT URL for direct browser-to-Hetzner uploads. + * The caller must use the returned `key` to commit the upload via the API. + * Expires in 1 hour by default. + */ +export async function generateHetznerPresignedUploadUrl( + key: string, + contentType: string, + expiresIn: number = 3600 +): Promise { + const { client, bucket } = await buildHetznerClient(); + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType, + }); + return getSignedUrl(client, command, { expiresIn }); +} + /** * In-process cache for Hetzner key existence checks. * Positive hits (key exists) are cached indefinitely for the process lifetime