High res uploads
Deploy / deploy (push) Successful in 3m16s

This commit is contained in:
twotalesanimation
2026-06-25 09:56:41 +02:00
parent d2e686335f
commit b683976cc6
9 changed files with 364 additions and 2 deletions
@@ -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 });
}
@@ -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 });
@@ -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 });
}
+85
View File
@@ -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 });
}
@@ -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<ReviewPlayerRef>(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({
<CheckCircle2 className="h-3.5 w-3.5" />
Approve
</Button>
{version.shot?.hasHighRes && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1 text-sky-400 border-sky-500/30 hover:bg-sky-500/10"
disabled={downloadingHighRes}
onClick={handleDownloadHighRes}
title={version.shot.highResFilename ?? "Download high-res file"}
>
<Download className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{downloadingHighRes ? "Getting link…" : "High Res"}</span>
</Button>
)}
</div>
{/* Right: Prev / Next navigation */}
+97 -2
View File
@@ -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<HTMLInputElement>(null);
// High-res state
const [highResFilename, setHighResFilename] = useState<string | null>(shot.highResFilename ?? null);
const [highResUploading, setHighResUploading] = useState(false);
const [highResRemoving, setHighResRemoving] = useState(false);
const highResInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -319,9 +362,61 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
</div>
<Button type="submit" disabled={isSaving}>
{isSaving ? "Saving…" : "Save Changes"}
{isSaving ? "Saving\u2026" : "Save Changes"}
</Button>
</form>
{/* High Res File */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<FileVideo className="h-4 w-4 text-amber-500" />
High Res File
</div>
<Separator />
<p className="text-xs text-zinc-500">Upload the full-resolution MOV deliverable. Stored in Hetzner Object Storage. Clients can download it from the review player.</p>
{highResFilename ? (
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800/50 px-4 py-3">
<CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0" />
<span className="flex-1 truncate font-mono text-sm text-zinc-200" title={highResFilename}>
{highResFilename}
</span>
<button
type="button"
disabled={highResRemoving}
onClick={handleHighResRemove}
className="shrink-0 text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
{highResRemoving ? "Removing\u2026" : "Remove"}
</button>
<button
type="button"
onClick={() => highResInputRef.current?.click()}
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
>
Replace
</button>
</div>
) : (
<div
onClick={() => !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 ? (
<><div className="h-4 w-4 animate-spin rounded-full border-2 border-amber-500 border-t-transparent" /><span>Uploading\u2026</span></>
) : (
<><Upload className="h-4 w-4" /><span>Upload high-res MOV</span></>
)}
</div>
)}
<input
ref={highResInputRef}
type="file"
accept="video/*,.mov"
className="hidden"
onChange={handleHighResChange}
/>
</div>
{/* Danger Zone */}
<div className="space-y-3 pt-2">
<div className="flex items-center gap-2 text-sm font-semibold text-red-500">
+68
View File
@@ -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<string> {
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<void> {
const client = buildHetznerClient();
await client.send(
new DeleteObjectCommand({ Bucket: getHetznerBucket(), Key: key })
);
}
@@ -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;
+3
View File
@@ -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