@@ -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 = {
|
const serializedVersion = {
|
||||||
...version,
|
...version,
|
||||||
fileSize: version.fileSize?.toString() ?? null,
|
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 });
|
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 });
|
||||||
|
}
|
||||||
@@ -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,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useReviewStore } from "@/hooks/use-review-player";
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
|
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
|
||||||
@@ -56,6 +57,8 @@ interface Version {
|
|||||||
shot?: {
|
shot?: {
|
||||||
id: string;
|
id: string;
|
||||||
shotCode: string;
|
shotCode: string;
|
||||||
|
hasHighRes?: boolean;
|
||||||
|
highResFilename?: string | null;
|
||||||
project: { id: string; name: string; code: string };
|
project: { id: string; name: string; code: string };
|
||||||
} | null;
|
} | null;
|
||||||
task?: {
|
task?: {
|
||||||
@@ -108,6 +111,7 @@ export default function ClientReviewPage({
|
|||||||
const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null);
|
const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null);
|
||||||
const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null);
|
const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null);
|
||||||
const [copiedTaskName, setCopiedTaskName] = useState(false);
|
const [copiedTaskName, setCopiedTaskName] = useState(false);
|
||||||
|
const [downloadingHighRes, setDownloadingHighRes] = useState(false);
|
||||||
|
|
||||||
const playerRef = useRef<ReviewPlayerRef>(null);
|
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||||
const { toast } = useToast();
|
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 () => {
|
const handleSubmitApproval = async () => {
|
||||||
if (!approvalDialog.status || !version) return;
|
if (!approvalDialog.status || !version) return;
|
||||||
setSubmittingApproval(true);
|
setSubmittingApproval(true);
|
||||||
@@ -364,6 +384,19 @@ export default function ClientReviewPage({
|
|||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Right: Prev / Next navigation */}
|
{/* Right: Prev / Next navigation */}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { updateShot, deleteShot } from "@/actions/shots";
|
import { updateShot, deleteShot } from "@/actions/shots";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
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({
|
const settingsSchema = z.object({
|
||||||
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
|
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;
|
dueDate: Date | string | null;
|
||||||
artistId: string | null;
|
artistId: string | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
|
highResFilename?: string | null;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
@@ -72,6 +73,12 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
|||||||
const [clearThumbnail, setClearThumbnail] = useState(false);
|
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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) => {
|
const formatDate = (d: Date | string | null) => {
|
||||||
if (!d) return "";
|
if (!d) return "";
|
||||||
return new Date(d).toISOString().split("T")[0];
|
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 handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -319,9 +362,61 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isSaving}>
|
<Button type="submit" disabled={isSaving}>
|
||||||
{isSaving ? "Saving…" : "Save Changes"}
|
{isSaving ? "Saving\u2026" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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 */}
|
{/* Danger Zone */}
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-red-500">
|
<div className="flex items-center gap-2 text-sm font-semibold text-red-500">
|
||||||
|
|||||||
@@ -219,3 +219,71 @@ function getPublicUrl(key: string, provider: StorageProvider): string {
|
|||||||
// Fallback
|
// Fallback
|
||||||
return `/${key}`;
|
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;
|
||||||
@@ -306,6 +306,9 @@ model Shot {
|
|||||||
// Sequence / picture lock timecodes
|
// Sequence / picture lock timecodes
|
||||||
seqTimecodeStart String?
|
seqTimecodeStart String?
|
||||||
seqTimecodeEnd String?
|
seqTimecodeEnd String?
|
||||||
|
// High-res deliverable (stored in Hetzner Object Storage)
|
||||||
|
highResKey String?
|
||||||
|
highResFilename String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user