From b683976cc676bd63d6eedbd837f4e2c4cb323e77 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:56:41 +0200 Subject: [PATCH] High res uploads --- .../shots/[shotId]/highres/download/route.ts | 37 +++++++ .../[token]/versions/[versionId]/route.ts | 9 ++ .../shots/[shotId]/highres/download/route.ts | 29 ++++++ app/api/shots/[shotId]/highres/route.ts | 85 ++++++++++++++++ .../[token]/review/[versionId]/page.tsx | 33 +++++++ components/shots/ShotSettingsTab.tsx | 99 ++++++++++++++++++- lib/storage.ts | 68 +++++++++++++ .../migration.sql | 3 + prisma/schema.prisma | 3 + 9 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 app/api/client/[token]/shots/[shotId]/highres/download/route.ts create mode 100644 app/api/shots/[shotId]/highres/download/route.ts create mode 100644 app/api/shots/[shotId]/highres/route.ts create mode 100644 prisma/migrations/20260625000000_add_shot_highres_fields/migration.sql diff --git a/app/api/client/[token]/shots/[shotId]/highres/download/route.ts b/app/api/client/[token]/shots/[shotId]/highres/download/route.ts new file mode 100644 index 0000000..37ae39e --- /dev/null +++ b/app/api/client/[token]/shots/[shotId]/highres/download/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { validateReviewToken } from "@/lib/review-auth"; +import { generateHetznerDownloadUrl } from "@/lib/storage"; + +/** + * GET /api/client/[token]/shots/[shotId]/highres/download + * Validates the client review token then returns a presigned Hetzner download URL. + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ token: string; shotId: string }> } +) { + const { token, shotId } = await params; + + const result = await validateReviewToken(token, req); + if (result.type !== "ok") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { highResKey: true, highResFilename: true, projectId: true }, + }); + + // Ensure the shot belongs to the review session's project + if (!shot || shot.projectId !== result.session.projectId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + if (!shot.highResKey || !shot.highResFilename) { + return NextResponse.json({ error: "No high-res file available" }, { status: 404 }); + } + + const url = await generateHetznerDownloadUrl(shot.highResKey, shot.highResFilename); + return NextResponse.json({ url }); +} diff --git a/app/api/client/[token]/versions/[versionId]/route.ts b/app/api/client/[token]/versions/[versionId]/route.ts index d6a565c..9d258dd 100644 --- a/app/api/client/[token]/versions/[versionId]/route.ts +++ b/app/api/client/[token]/versions/[versionId]/route.ts @@ -81,6 +81,15 @@ export async function GET( const serializedVersion = { ...version, fileSize: version.fileSize?.toString() ?? null, + // Expose only whether a high-res file exists, never the storage key + shot: version.shot + ? { + ...version.shot, + hasHighRes: !!version.shot.highResKey, + highResFilename: version.shot.highResFilename ?? null, + highResKey: undefined, + } + : null, }; return NextResponse.json({ version: serializedVersion, comments }); diff --git a/app/api/shots/[shotId]/highres/download/route.ts b/app/api/shots/[shotId]/highres/download/route.ts new file mode 100644 index 0000000..e93c98a --- /dev/null +++ b/app/api/shots/[shotId]/highres/download/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { generateHetznerDownloadUrl } from "@/lib/storage"; + +/** GET /api/shots/[shotId]/highres/download — returns a short-lived presigned download URL */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { shotId } = await params; + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { highResKey: true, highResFilename: true }, + }); + + if (!shot?.highResKey || !shot?.highResFilename) { + return NextResponse.json({ error: "No high-res file available" }, { status: 404 }); + } + + const url = await generateHetznerDownloadUrl(shot.highResKey, shot.highResFilename); + return NextResponse.json({ url }); +} diff --git a/app/api/shots/[shotId]/highres/route.ts b/app/api/shots/[shotId]/highres/route.ts new file mode 100644 index 0000000..8a0b82b --- /dev/null +++ b/app/api/shots/[shotId]/highres/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage"; + +export const maxDuration = 120; + +/** POST /api/shots/[shotId]/highres — upload a high-res file to Hetzner */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { shotId } = await params; + + 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 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + if (!file.type.startsWith("video/")) { + return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 }); + } + + // Remove previous high-res file if one exists + if (shot.highResKey) { + await deleteFromHetzner(shot.highResKey).catch(() => {}); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const { key } = await uploadToHetzner(buffer, file.name, file.type, "highres"); + + await db.shot.update({ + where: { id: shotId }, + data: { highResKey: key, highResFilename: file.name }, + }); + + return NextResponse.json({ success: true, filename: file.name }); +} + +/** DELETE /api/shots/[shotId]/highres — remove the high-res file */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { shotId } = await params; + + 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: null, highResFilename: null }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/client/[token]/review/[versionId]/page.tsx b/app/client/[token]/review/[versionId]/page.tsx index fc1f535..d1d3d1f 100644 --- a/app/client/[token]/review/[versionId]/page.tsx +++ b/app/client/[token]/review/[versionId]/page.tsx @@ -30,6 +30,7 @@ import { ChevronRight, Copy, Check, + Download, } from "lucide-react"; import { useReviewStore } from "@/hooks/use-review-player"; import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate"; @@ -56,6 +57,8 @@ interface Version { shot?: { id: string; shotCode: string; + hasHighRes?: boolean; + highResFilename?: string | null; project: { id: string; name: string; code: string }; } | null; task?: { @@ -108,6 +111,7 @@ export default function ClientReviewPage({ const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null); const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null); const [copiedTaskName, setCopiedTaskName] = useState(false); + const [downloadingHighRes, setDownloadingHighRes] = useState(false); const playerRef = useRef(null); const { toast } = useToast(); @@ -200,6 +204,22 @@ export default function ClientReviewPage({ } }; + const handleDownloadHighRes = async () => { + if (!version?.shot?.id || !token) return; + setDownloadingHighRes(true); + try { + const res = await fetch(`/api/client/${token}/shots/${version.shot.id}/highres/download`); + if (!res.ok) throw new Error("Could not get download link"); + const { url } = await res.json(); + // Open the presigned URL — the Content-Disposition header forces a download + window.open(url, "_blank", "noopener,noreferrer"); + } catch { + toast({ title: "Download failed", variant: "destructive" }); + } finally { + setDownloadingHighRes(false); + } + }; + const handleSubmitApproval = async () => { if (!approvalDialog.status || !version) return; setSubmittingApproval(true); @@ -364,6 +384,19 @@ export default function ClientReviewPage({ Approve + {version.shot?.hasHighRes && ( + + )} {/* Right: Prev / Next navigation */} diff --git a/components/shots/ShotSettingsTab.tsx b/components/shots/ShotSettingsTab.tsx index 2a26b1f..26e1f40 100644 --- a/components/shots/ShotSettingsTab.tsx +++ b/components/shots/ShotSettingsTab.tsx @@ -20,7 +20,7 @@ import { } from "@/components/ui/select"; import { updateShot, deleteShot } from "@/actions/shots"; import { useToast } from "@/components/ui/use-toast"; -import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react"; +import { Upload, X, Film, ImageIcon, Trash2, FileVideo, CheckCircle2 } from "lucide-react"; const settingsSchema = z.object({ shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"), @@ -55,6 +55,7 @@ interface ShotSettingsTabProps { dueDate: Date | string | null; artistId: string | null; thumbnailUrl: string | null; + highResFilename?: string | null; projectId: string; }; artists: Artist[]; @@ -72,6 +73,12 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps const [clearThumbnail, setClearThumbnail] = useState(false); const fileInputRef = useRef(null); + // High-res state + const [highResFilename, setHighResFilename] = useState(shot.highResFilename ?? null); + const [highResUploading, setHighResUploading] = useState(false); + const [highResRemoving, setHighResRemoving] = useState(false); + const highResInputRef = useRef(null); + const formatDate = (d: Date | string | null) => { if (!d) return ""; return new Date(d).toISOString().split("T")[0]; @@ -97,6 +104,42 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps }, }); + const handleHighResChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setHighResUploading(true); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch(`/api/shots/${shot.id}/highres`, { method: "POST", body: fd }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error ?? "Upload failed"); + } + setHighResFilename(file.name); + toast({ title: "High-res file uploaded" }); + } catch (err) { + toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" }); + } finally { + setHighResUploading(false); + if (highResInputRef.current) highResInputRef.current.value = ""; + } + }; + + const handleHighResRemove = async () => { + setHighResRemoving(true); + try { + const res = await fetch(`/api/shots/${shot.id}/highres`, { method: "DELETE" }); + if (!res.ok) throw new Error("Remove failed"); + setHighResFilename(null); + toast({ title: "High-res file removed" }); + } catch (err) { + toast({ title: "Failed to remove", description: err instanceof Error ? err.message : undefined, variant: "destructive" }); + } finally { + setHighResRemoving(false); + } + }; + const handleThumbnailChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -319,9 +362,61 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps + + {/* High Res File */} +
+
+ + High Res File +
+ +

Upload the full-resolution MOV deliverable. Stored in Hetzner Object Storage. Clients can download it from the review player.

+ + {highResFilename ? ( +
+ + + {highResFilename} + + + +
+ ) : ( +
!highResUploading && highResInputRef.current?.click()} + className="flex cursor-pointer items-center justify-center gap-2 rounded-lg border-2 border-dashed border-zinc-700 px-4 py-6 text-sm text-zinc-500 transition-colors hover:border-amber-500/50 hover:text-zinc-400" + > + {highResUploading ? ( + <>
Uploading\u2026 + ) : ( + <>Upload high-res MOV + )} +
+ )} + +
{/* Danger Zone */}
diff --git a/lib/storage.ts b/lib/storage.ts index 12aaf91..c6d4c59 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -219,3 +219,71 @@ function getPublicUrl(key: string, provider: StorageProvider): string { // Fallback return `/${key}`; } + +// ── Hetzner Object Storage (dedicated high-res bucket) ─────────────────────── + +function buildHetznerClient(): S3Client { + return new S3Client({ + region: "auto", + endpoint: process.env.HETZNER_ENDPOINT!, + credentials: { + accessKeyId: process.env.HETZNER_ACCESS_KEY!, + secretAccessKey: process.env.HETZNER_SECRET_KEY!, + }, + }); +} + +function getHetznerBucket(): string { + return process.env.HETZNER_BUCKET_NAME ?? "vfx-review"; +} + +/** + * Upload a buffer directly to the Hetzner bucket (always uses Hetzner credentials, + * regardless of the global STORAGE_PROVIDER setting). + */ +export async function uploadToHetzner( + buffer: Buffer, + fileName: string, + contentType: string, + folder: string = "highres" +): Promise<{ key: string }> { + const key = `${folder}/${randomUUID()}-${fileName}`; + const client = buildHetznerClient(); + await client.send( + new PutObjectCommand({ + Bucket: getHetznerBucket(), + Key: key, + Body: buffer, + ContentType: contentType, + }) + ); + return { key }; +} + +/** + * Generate a presigned download URL from Hetzner that forces a file download + * with the original filename preserved via Content-Disposition. + */ +export async function generateHetznerDownloadUrl( + key: string, + originalFilename: string, + expiresIn: number = 3600 +): Promise { + const client = buildHetznerClient(); + const command = new GetObjectCommand({ + Bucket: getHetznerBucket(), + Key: key, + ResponseContentDisposition: `attachment; filename="${originalFilename}"`, + }); + return getSignedUrl(client, command, { expiresIn }); +} + +/** + * Delete a file from the Hetzner bucket by key. + */ +export async function deleteFromHetzner(key: string): Promise { + const client = buildHetznerClient(); + await client.send( + new DeleteObjectCommand({ Bucket: getHetznerBucket(), Key: key }) + ); +} diff --git a/prisma/migrations/20260625000000_add_shot_highres_fields/migration.sql b/prisma/migrations/20260625000000_add_shot_highres_fields/migration.sql new file mode 100644 index 0000000..79d3f8b --- /dev/null +++ b/prisma/migrations/20260625000000_add_shot_highres_fields/migration.sql @@ -0,0 +1,3 @@ +-- AddColumn highResKey and highResFilename to shots table +ALTER TABLE "shots" ADD COLUMN "highResKey" TEXT; +ALTER TABLE "shots" ADD COLUMN "highResFilename" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 27ee48b..8406467 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -306,6 +306,9 @@ model Shot { // Sequence / picture lock timecodes seqTimecodeStart String? seqTimecodeEnd String? + // High-res deliverable (stored in Hetzner Object Storage) + highResKey String? + highResFilename String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt