multiple plates
Deploy / deploy (push) Successful in 2m32s

This commit is contained in:
twotalesanimation
2026-05-21 19:16:03 +02:00
parent 32073fbe4c
commit 3e3a8f7a26
7 changed files with 377 additions and 250 deletions
+87
View File
@@ -392,3 +392,90 @@ export async function getShotsByProject(projectId: string) {
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
});
}
// ─── Footage Plates ────────────────────────────────────────────────────────────
const addFootagePlateSchema = z.object({
shotId: z.string().min(1),
fileUrl: z.string().url(),
fileKey: z.string().default(""),
fileName: z.string().default(""),
fileSize: z.number().int().positive().optional(),
label: z.string().max(100).default(""),
});
export async function addFootagePlate(raw: unknown) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const data = addFootagePlateSchema.parse(raw);
// Determine next sortOrder
const last = await db.footagePlate.findFirst({
where: { shotId: data.shotId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const plate = await db.footagePlate.create({
data: {
shotId: data.shotId,
fileUrl: data.fileUrl,
fileKey: data.fileKey,
fileName: data.fileName,
fileSize: data.fileSize != null ? BigInt(data.fileSize) : null,
label: data.label,
sortOrder: (last?.sortOrder ?? -1) + 1,
},
});
const shot = await db.shot.findUnique({ where: { id: data.shotId }, select: { projectId: true } });
if (shot) {
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${data.shotId}`);
}
return { success: true, plate: { ...plate, fileSize: plate.fileSize?.toString() ?? null } };
}
export async function removeFootagePlate(plateId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const role = session.user.role as string;
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
const plate = await db.footagePlate.findUnique({
where: { id: plateId },
include: { shot: { select: { projectId: true } } },
});
if (!plate) throw new Error("Plate not found");
await db.footagePlate.delete({ where: { id: plateId } });
revalidatePath(`/projects/${plate.shot.projectId}`);
revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`);
return { success: true };
}
export async function renameFootagePlate(plateId: string, label: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const role = session.user.role as string;
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
const trimmed = label.trim().slice(0, 100);
const plate = await db.footagePlate.update({
where: { id: plateId },
data: { label: trimmed },
include: { shot: { select: { projectId: true } } },
});
revalidatePath(`/projects/${plate.shot.projectId}`);
revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`);
return { success: true };
}
+5
View File
@@ -17,6 +17,7 @@ export async function GET(
where: { id: shotId },
include: {
artist: { select: { id: true, name: true, email: true, image: true } },
footagePlates: { orderBy: { sortOrder: "asc" } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
@@ -68,6 +69,10 @@ export async function GET(
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
const shotSerialized = {
...shot,
footagePlates: shot.footagePlates.map((p) => ({
...p,
fileSize: p.fileSize != null ? p.fileSize.toString() : null,
})),
versions: shot.versions.map((v) => ({
...v,
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
+221 -70
View File
@@ -1,17 +1,29 @@
"use client";
"use client";
import { useRef, useState } from "react";
import { Video, Upload, X } from "lucide-react";
import { Video, Upload, X, Plus, Pencil, Trash2, Film } from "lucide-react";
import { Button } from "@/components/ui/button";
import { updateShot } from "@/actions/shots";
import { Input } from "@/components/ui/input";
import { addFootagePlate, removeFootagePlate, renameFootagePlate } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
interface Plate {
id: string;
label: string;
fileUrl: string;
fileKey: string;
fileName: string;
fileSize: bigint | string | null;
sortOrder: number;
createdAt: Date;
}
interface FootageViewerProps {
shot: {
id: string;
shotCode: string;
originalFootageUrl: string | null;
originalFootageKey: string | null;
footagePlates: Plate[];
};
canManage: boolean;
onSaved?: () => void;
@@ -57,36 +69,62 @@ function uploadViaXhr(
});
}
function formatSize(size: bigint | string | null): string {
if (size == null) return "";
const n = typeof size === "string" ? parseInt(size, 10) : Number(size);
if (isNaN(n)) return "";
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps) {
const { toast } = useToast();
const videoRef = useRef<HTMLVideoElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [footageFile, setFootageFile] = useState<File | null>(null);
const [plates, setPlates] = useState<Plate[]>(shot.footagePlates ?? []);
const [selectedId, setSelectedId] = useState<string | null>(
(shot.footagePlates ?? [])[0]?.id ?? null
);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingLabel, setPendingLabel] = useState("");
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [currentUrl, setCurrentUrl] = useState<string | null>(shot.originalFootageUrl ?? null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const selectedPlate = plates.find((p) => p.id === selectedId) ?? null;
// ── upload new plate ──────────────────────────────────────────────────────
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFootageFile(file);
setPendingFile(file);
setPendingLabel("");
e.target.value = "";
};
const handleUpload = async () => {
if (!footageFile) return;
if (!pendingFile) return;
setUploading(true);
setProgress(0);
try {
const { url, key } = await uploadViaXhr(footageFile, setProgress);
await updateShot({
const { url, key } = await uploadViaXhr(pendingFile, setProgress);
const result = await addFootagePlate({
shotId: shot.id,
originalFootageUrl: url,
originalFootageKey: key || undefined,
fileUrl: url,
fileKey: key,
fileName: pendingFile.name,
fileSize: pendingFile.size,
label: pendingLabel.trim() || `Plate ${plates.length + 1}`,
});
setCurrentUrl(url);
setFootageFile(null);
toast({ title: "Footage uploaded" });
const newPlate = result.plate as Plate;
const updated = [...plates, newPlate];
setPlates(updated);
setSelectedId(newPlate.id);
setPendingFile(null);
setPendingLabel("");
toast({ title: "Footage plate added" });
onSaved?.();
} catch (e) {
toast({
@@ -100,62 +138,76 @@ export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps)
}
};
const handleRemove = async () => {
const handleCancelPending = () => {
setPendingFile(null);
setPendingLabel("");
};
// ── remove plate ──────────────────────────────────────────────────────────
const handleRemove = async (plateId: string) => {
setDeletingId(plateId);
try {
await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null });
setCurrentUrl(null);
setFootageFile(null);
toast({ title: "Footage removed" });
await removeFootagePlate(plateId);
const updated = plates.filter((p) => p.id !== plateId);
setPlates(updated);
if (selectedId === plateId) setSelectedId(updated[0]?.id ?? null);
toast({ title: "Plate removed" });
onSaved?.();
} catch (e) {
toast({
title: "Failed to remove footage",
title: "Failed to remove plate",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
} finally {
setDeletingId(null);
}
};
// ── rename plate ──────────────────────────────────────────────────────────
const startEdit = (plate: Plate) => {
setEditingId(plate.id);
setEditLabel(plate.label);
};
const commitEdit = async (plateId: string) => {
try {
await renameFootagePlate(plateId, editLabel);
setPlates((prev) =>
prev.map((p) => (p.id === plateId ? { ...p, label: editLabel.trim() } : p))
);
onSaved?.();
} catch (e) {
toast({
title: "Failed to rename plate",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
} finally {
setEditingId(null);
}
};
return (
<div className="space-y-4 max-w-4xl">
<div className="flex items-center justify-between">
{/* Header */}
<h3 className="text-sm font-semibold text-zinc-300 flex items-center gap-2">
<Video className="h-4 w-4 text-amber-500" />
Original Footage
Footage Plates
<span className="text-xs font-mono text-zinc-500">{shot.shotCode}</span>
</h3>
{canManage && currentUrl && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Replace
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-zinc-500 hover:text-red-400"
onClick={handleRemove}
disabled={uploading}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
</div>
{plates.length > 0 && (
<span className="ml-1 text-xs font-mono bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded">
{plates.length}
</span>
)}
</div>
</h3>
{/* Video player */}
{currentUrl && (
{selectedPlate ? (
<div className="relative w-full rounded-xl overflow-hidden bg-black border border-zinc-800 aspect-video">
<video
ref={videoRef}
src={currentUrl}
key={selectedPlate.fileUrl}
src={selectedPlate.fileUrl}
controls
className="w-full h-full"
preload="metadata"
@@ -163,37 +215,123 @@ export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps)
Your browser does not support the video tag.
</video>
</div>
)}
{/* Empty state */}
{!currentUrl && !footageFile && (
) : (
!pendingFile && (
<div
className={`flex flex-col items-center justify-center py-20 gap-4 rounded-xl border-2 border-dashed border-zinc-800 text-zinc-500 ${
canManage ? "hover:border-amber-500/40 cursor-pointer transition-colors" : ""
}`}
className={cn(
"flex flex-col items-center justify-center py-20 gap-4 rounded-xl border-2 border-dashed border-zinc-800 text-zinc-500",
canManage && "hover:border-amber-500/40 cursor-pointer transition-colors"
)}
onClick={() => canManage && fileInputRef.current?.click()}
>
<Video className="h-12 w-12 opacity-30" />
<p className="text-sm">No original footage uploaded yet.</p>
{canManage && <p className="text-xs text-zinc-600">Click to select a video file</p>}
<Film className="h-12 w-12 opacity-30" />
<p className="text-sm">No footage plates yet.</p>
{canManage && <p className="text-xs text-zinc-600">Click to add a video file</p>}
</div>
)
)}
{/* Plate list */}
{plates.length > 0 && (
<div className="space-y-1">
{plates.map((plate, i) => (
<div
key={plate.id}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors group",
plate.id === selectedId
? "border-amber-500/40 bg-amber-500/5"
: "border-zinc-800 bg-zinc-900/50 hover:border-zinc-700"
)}
onClick={() => {
if (editingId !== plate.id) setSelectedId(plate.id);
}}
>
{/* Index badge */}
<span className="shrink-0 w-5 h-5 rounded text-[10px] font-mono font-bold flex items-center justify-center bg-zinc-800 text-zinc-400">
{i + 1}
</span>
{/* Label (edit or display) */}
{editingId === plate.id ? (
<Input
autoFocus
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit(plate.id);
if (e.key === "Escape") setEditingId(null);
}}
onBlur={() => commitEdit(plate.id)}
className="h-6 text-xs py-0 px-2 flex-1"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="flex-1 text-sm text-zinc-200 truncate">
{plate.label || `Plate ${i + 1}`}
</span>
)}
{/* File info */}
{plate.fileName && (
<span className="text-xs text-zinc-500 truncate max-w-[140px] hidden sm:block font-mono">
{plate.fileName}
</span>
)}
{plate.fileSize && (
<span className="text-xs text-zinc-600 shrink-0">{formatSize(plate.fileSize)}</span>
)}
{/* Actions */}
{canManage && editingId !== plate.id && (
<div
className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => e.stopPropagation()}
>
<button
title="Rename plate"
onClick={() => startEdit(plate)}
className="p-1 rounded text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
title="Remove plate"
disabled={deletingId === plate.id}
onClick={() => handleRemove(plate.id)}
className="p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
))}
</div>
)}
{/* Pending file — ready to upload */}
{footageFile && !uploading && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
{/* Pending file row */}
{pendingFile && !uploading && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-700">
<Video className="h-5 w-5 text-zinc-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">{footageFile.name}</p>
<p className="text-xs text-zinc-500">{(footageFile.size / 1024 / 1024).toFixed(1)} MB</p>
<p className="text-sm text-zinc-300 truncate">{pendingFile.name}</p>
<p className="text-xs text-zinc-500">{(pendingFile.size / 1024 / 1024).toFixed(1)} MB</p>
</div>
<Input
placeholder="Label (optional)"
value={pendingLabel}
onChange={(e) => setPendingLabel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleUpload()}
className="w-36 h-7 text-xs py-0"
/>
<Button size="sm" onClick={handleUpload}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Upload
</Button>
<button
type="button"
onClick={() => setFootageFile(null)}
onClick={handleCancelPending}
className="text-zinc-500 hover:text-zinc-300"
>
<X className="h-4 w-4" />
@@ -205,7 +343,7 @@ export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps)
{uploading && (
<div className="space-y-2 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>Uploading footage</span>
<span>Uploading footageâ¦</span>
<span>{Math.round(progress * 100)}%</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
@@ -217,6 +355,19 @@ export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps)
</div>
)}
{/* Add plate button */}
{canManage && !pendingFile && !uploading && (
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => fileInputRef.current?.click()}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Plate
</Button>
)}
<input
ref={fileInputRef}
type="file"
+1 -176
View File
@@ -19,7 +19,7 @@ import {
} from "@/components/ui/select";
import { updateShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { Upload, X, Film, ImageIcon, Video } from "lucide-react";
import { Upload, X, Film, ImageIcon } from "lucide-react";
const settingsSchema = z.object({
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
@@ -54,53 +54,11 @@ interface ShotSettingsTabProps {
dueDate: Date | string | null;
artistId: string | null;
thumbnailUrl: string | null;
originalFootageUrl: string | null;
originalFootageKey: string | null;
};
artists: Artist[];
onSaved?: () => void;
}
function uploadViaXhr(
file: File,
onProgress: (fraction: number) => void
): Promise<{ url: string; key: string }> {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload/local");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const json = JSON.parse(xhr.responseText);
if (json.url) resolve({ url: json.url, key: json.key ?? "" });
else reject(new Error(json.error ?? "Upload failed"));
} catch {
reject(new Error("Invalid server response"));
}
} else {
try {
const json = JSON.parse(xhr.responseText);
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 aborted")));
xhr.send(formData);
});
}
export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps) {
const { toast } = useToast();
const [isSaving, setIsSaving] = useState(false);
@@ -109,13 +67,6 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
const [clearThumbnail, setClearThumbnail] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Footage upload state
const [footageFile, setFootageFile] = useState<File | null>(null);
const [footageUploading, setFootageUploading] = useState(false);
const [footageProgress, setFootageProgress] = useState(0);
const [currentFootageUrl, setCurrentFootageUrl] = useState<string | null>(shot.originalFootageUrl ?? null);
const footageInputRef = useRef<HTMLInputElement>(null);
const formatDate = (d: Date | string | null) => {
if (!d) return "";
return new Date(d).toISOString().split("T")[0];
@@ -151,47 +102,6 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
reader.readAsDataURL(file);
};
const handleFootageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFootageFile(file);
};
const handleFootageUpload = async () => {
if (!footageFile) return;
setFootageUploading(true);
setFootageProgress(0);
try {
const { url, key } = await uploadViaXhr(footageFile, setFootageProgress);
await updateShot({
shotId: shot.id,
originalFootageUrl: url,
originalFootageKey: key || undefined,
});
setCurrentFootageUrl(url);
setFootageFile(null);
toast({ title: "Footage uploaded" });
onSaved?.();
} catch (e) {
toast({ title: "Upload failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setFootageUploading(false);
setFootageProgress(0);
}
};
const handleFootageRemove = async () => {
try {
await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null });
setCurrentFootageUrl(null);
setFootageFile(null);
toast({ title: "Footage removed" });
onSaved?.();
} catch (e) {
toast({ title: "Failed to remove footage", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
}
};
const onSubmit = async (values: SettingsFormValues) => {
setIsSaving(true);
try {
@@ -392,91 +302,6 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
{isSaving ? "Saving…" : "Save Changes"}
</Button>
</form>
{/* Original Footage — separate section with its own XHR upload */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<Video className="h-4 w-4 text-amber-500" />
Original Footage
</div>
<Separator />
<p className="text-xs text-zinc-500">Upload the original (pre-VFX) footage for reference. Supported formats: MP4, MOV, AVI, MKV, WebM.</p>
{currentFootageUrl ? (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<Video className="h-5 w-5 text-amber-400 shrink-0" />
<span className="text-sm text-zinc-300 truncate flex-1">Footage uploaded</span>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => footageInputRef.current?.click()}
>
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-zinc-500 hover:text-red-400"
onClick={handleFootageRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div
onClick={() => !footageUploading && footageInputRef.current?.click()}
className="w-full rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex flex-col items-center justify-center gap-2 py-8 text-sm text-muted-foreground cursor-pointer transition-colors"
>
<Video className="h-6 w-6" />
<span>Click to select original footage</span>
</div>
)}
{footageFile && !footageUploading && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<Video className="h-5 w-5 text-zinc-400 shrink-0" />
<span className="text-sm text-zinc-300 truncate flex-1">{footageFile.name}</span>
<span className="text-xs text-zinc-500 shrink-0">{(footageFile.size / 1024 / 1024).toFixed(1)} MB</span>
<Button type="button" size="sm" onClick={handleFootageUpload}>
Upload
</Button>
<button
type="button"
onClick={() => setFootageFile(null)}
className="text-zinc-500 hover:text-zinc-300"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{footageUploading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>Uploadingâ¦</span>
<span>{Math.round(footageProgress * 100)}%</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-amber-500 transition-all duration-200"
style={{ width: `${footageProgress * 100}%` }}
/>
</div>
</div>
)}
<input
ref={footageInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/*"
className="hidden"
onChange={handleFootageChange}
/>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "footage_plates" (
"id" TEXT NOT NULL,
"shotId" TEXT NOT NULL,
"label" TEXT NOT NULL DEFAULT '',
"fileUrl" TEXT NOT NULL,
"fileKey" TEXT NOT NULL DEFAULT '',
"fileName" TEXT NOT NULL DEFAULT '',
"fileSize" BIGINT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "footage_plates_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "footage_plates" ADD CONSTRAINT "footage_plates_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Migrate existing single-footage data into footage_plates
INSERT INTO "footage_plates" ("id", "shotId", "label", "fileUrl", "fileKey", "fileName", "fileSize", "sortOrder", "createdAt")
SELECT
gen_random_uuid()::text,
id,
'Plate A',
"originalFootageUrl",
COALESCE("originalFootageKey", ''),
'',
NULL,
0,
NOW()
FROM "shots"
WHERE "originalFootageUrl" IS NOT NULL AND "originalFootageUrl" != '';
+17
View File
@@ -279,11 +279,28 @@ model Shot {
shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
versions Version[]
tasks Task[]
footagePlates FootagePlate[]
@@unique([projectId, shotCode])
@@map("shots")
}
model FootagePlate {
id String @id @default(cuid())
shotId String
label String @default("")
fileUrl String
fileKey String @default("")
fileName String @default("")
fileSize BigInt?
sortOrder Int @default(0)
createdAt DateTime @default(now())
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
@@map("footage_plates")
}
model ShotGroup {
id String @id @default(cuid())
name String
+10
View File
@@ -166,6 +166,16 @@ export interface ShotWithDetails {
originalFootageKey: string | null;
shotGroupId: string | null;
shotGroup: { id: string; name: string } | null;
footagePlates: {
id: string;
label: string;
fileUrl: string;
fileKey: string;
fileName: string;
fileSize: bigint | string | null;
sortOrder: number;
createdAt: Date;
}[];
createdAt: Date;
updatedAt: Date;
artist: {