Files
vfxreview/components/shoot-log/TakeEditorPanel.tsx
T
twotalesanimation c09d06b6c7
Deploy / deploy (push) Successful in 3m38s
reference support
2026-07-12 10:27:46 +02:00

716 lines
26 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, BookImage,
} from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { TakeImageGallery } from "./TakeImageGallery";
import { PreviousTakePanel } from "./PreviousTakePanel";
import type { TakeQuality } from "@prisma/client";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface TakeAttachmentData {
id: string;
fileUrl: string;
fileName: string;
fileType: string;
category: string;
caption: string | null;
sortOrder: number;
}
export interface FullTake {
id: string;
setupId: string;
takeNumber: number;
pipelineShotId: string | null;
scene: string | null;
shotLabel: string | null;
unitLabel: string | null;
cameraLetter: string | null;
clipName: string | null;
roll: string | null;
cameraModel: string | null;
resolution: string | null;
codec: string | null;
fps: number | null;
shutter: string | null;
iso: number | null;
whiteBalance: number | null;
colourSpace: string | null;
lensSet: string | null;
lens: string | null;
tStop: string | null;
filters: string | null;
isAnamorphic: boolean;
hasHdri: boolean;
hasChromeBall: boolean;
hasGreyBall: boolean;
hasMacbeth: boolean;
hasCleanPlate: boolean;
hasSurvey: boolean;
hasLidar: boolean;
hasWitnessCamera: boolean;
hasLensGrid: boolean;
hasTexturePhotos: boolean;
hasPhotogrammetry: boolean;
weather: string | null;
sunDirection: string | null;
artificialLights: string | null;
supervisorNotes: string | null;
continuityNotes: string | null;
vfxRequirements: string | null;
quality: TakeQuality;
attachments: TakeAttachmentData[];
setup: {
takes: { id: string; takeNumber: number }[];
shootDay: { id: string; date: string; unit: string; label: string | null; projectId: string };
};
}
// ─── Quality config ───────────────────────────────────────────────────────────
const QUALITIES: { value: TakeQuality; label: string; color: string; active: string }[] = [
{ value: "FALSE_START", label: "False Start", color: "border-zinc-700 text-zinc-500", active: "bg-zinc-700 text-zinc-200 border-zinc-600" },
{ value: "NO_GOOD", label: "No Good", color: "border-red-900/60 text-red-500", active: "bg-red-900/60 text-red-200 border-red-700" },
{ value: "PRINT", label: "Print", color: "border-amber-800/60 text-amber-500", active: "bg-amber-800/60 text-amber-200 border-amber-600" },
{ value: "GOOD", label: "Good", color: "border-blue-800/60 text-blue-400", active: "bg-blue-900/60 text-blue-200 border-blue-600" },
{ value: "HERO", label: "Hero ★", color: "border-green-800/60 text-green-500", active: "bg-green-900/60 text-green-200 border-green-600" },
];
const TRACKING_ITEMS: { key: keyof FullTake; label: string }[] = [
{ key: "hasHdri", label: "HDRI" },
{ key: "hasChromeBall", label: "Chrome Ball" },
{ key: "hasGreyBall", label: "Grey Ball" },
{ key: "hasMacbeth", label: "Macbeth" },
{ key: "hasCleanPlate", label: "Clean Plate" },
{ key: "hasSurvey", label: "Survey" },
{ key: "hasLidar", label: "LiDAR" },
{ key: "hasWitnessCamera", label: "Witness Cam" },
{ key: "hasLensGrid", label: "Lens Grid" },
{ key: "hasTexturePhotos", label: "Texture Photos" },
{ key: "hasPhotogrammetry", label: "Photogrammetry" },
];
// ─── Autosave hook ────────────────────────────────────────────────────────────
type SaveStatus = "clean" | "dirty" | "saving" | "saved" | "error";
function useAutosave(takeId: string) {
const [status, setStatus] = useState<SaveStatus>("clean");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<Record<string, unknown>>({});
const flush = useCallback(async () => {
if (Object.keys(pendingRef.current).length === 0) return;
const patch = { ...pendingRef.current };
pendingRef.current = {};
setStatus("saving");
try {
const res = await fetch(`/api/shoot-log/takes/${takeId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error("save failed");
setStatus("saved");
setTimeout(() => setStatus("clean"), 2000);
} catch {
setStatus("error");
}
}, [takeId]);
const queue = useCallback(
(patch: Record<string, unknown>) => {
pendingRef.current = { ...pendingRef.current, ...patch };
setStatus("dirty");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(flush, 800);
},
[flush]
);
// Flush immediately when takeId changes
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [takeId]);
return { status, queue, flush };
}
// ─── Field components ─────────────────────────────────────────────────────────
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<label className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{label}
</label>
{children}
</div>
);
}
function TF({
value,
onChange,
placeholder,
type = "text",
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
className?: string;
}) {
return (
<Input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn("h-9 bg-zinc-900 border-zinc-700 text-sm", className)}
/>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<div className="flex items-center gap-3 py-1">
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">{title}</span>
<div className="flex-1 h-px bg-zinc-800" />
</div>
);
}
// ─── Linked shot panel ────────────────────────────────────────────────────────
interface LinkedShot {
id: string;
shotCode: string;
scene: string | null;
episode: string | null;
thumbnailUrl: string | null;
references: { id: string; fileUrl: string; fileName: string; label: string | null }[];
}
function LinkedShotPanel({ shotId }: { shotId: string }) {
const { data, isLoading } = useQuery<{ shot: LinkedShot }>({
queryKey: ["linked-shot", shotId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/shots/${shotId}`);
if (!res.ok) throw new Error("Failed");
return res.json();
},
staleTime: 60_000,
enabled: !!shotId,
});
if (isLoading) {
return (
<div className="flex items-center gap-2 text-xs text-zinc-500 mt-2">
<Loader2 className="h-3 w-3 animate-spin" /> Loading shot data
</div>
);
}
const shot = data?.shot;
if (!shot) return null;
const hasContent = shot.thumbnailUrl || shot.references.length > 0;
if (!hasContent) return null;
return (
<div className="mt-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 space-y-3">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<BookImage className="h-3 w-3" />
Shot Reference {shot.shotCode}
</div>
{shot.thumbnailUrl && (
<div className="relative w-full max-w-xs aspect-[2.39] rounded-md overflow-hidden border border-zinc-700">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
sizes="320px"
/>
</div>
)}
{shot.references.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{shot.references.map((ref) => (
<div key={ref.id} className="relative aspect-square rounded-md overflow-hidden border border-zinc-700 bg-zinc-950 group">
<Image
src={ref.fileUrl}
alt={ref.label ?? ref.fileName}
fill
className="object-cover"
sizes="100px"
/>
{ref.label && (
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[9px] text-zinc-300 truncate px-1 py-0.5">
{ref.label}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
// ─── Shot selector ────────────────────────────────────────────────────────────
interface ShotOption {
id: string;
shotCode: string;
scene: string | null;
episode: string | null;
}
function ShotSelector({
projectId,
value,
onChange,
}: {
projectId: string;
value: string | null;
onChange: (id: string | null) => void;
}) {
const [search, setSearch] = useState("");
const { data, isLoading } = useQuery<{ shots: ShotOption[] }>({
queryKey: ["project-shots", projectId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/shots?projectId=${projectId}`);
if (!res.ok) throw new Error("Failed");
return res.json();
},
staleTime: 60_000,
enabled: !!projectId,
});
const shots = data?.shots ?? [];
const filtered = search
? shots.filter(
(s) =>
s.shotCode.toLowerCase().includes(search.toLowerCase()) ||
(s.scene ?? "").toLowerCase().includes(search.toLowerCase()) ||
(s.episode ?? "").toLowerCase().includes(search.toLowerCase())
)
: shots;
const selected = shots.find((s) => s.id === value);
return (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Filter shots…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-xs bg-zinc-900 border-zinc-700"
/>
<div className="relative flex items-center gap-2">
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
disabled={isLoading}
className="flex-1 h-9 rounded-lg border border-zinc-700 bg-zinc-900 text-sm text-white px-3 pr-8 focus:outline-none focus:border-amber-600 disabled:opacity-50"
>
<option value=""> Not linked </option>
{filtered.map((s) => (
<option key={s.id} value={s.id}>
{s.shotCode}
{s.episode ? ` · Ep ${s.episode}` : ""}
{s.scene ? ` (sc. ${s.scene})` : ""}
</option>
))}
</select>
{value && (
<button
onClick={() => onChange(null)}
className="shrink-0 p-1.5 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
title="Unlink shot"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{selected && (
<div className="flex items-center gap-1.5 text-[11px] text-amber-400">
<Link2 className="h-3 w-3 shrink-0" />
<span>
Linked to <strong>{selected.shotCode}</strong>
{selected.episode ? ` · Ep ${selected.episode}` : ""}
</span>
</div>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
take: FullTake;
previousTake: FullTake | null;
onPrev: (() => void) | null;
onNext: (() => void) | null;
onDuplicate: () => void;
onNewTake: () => void;
onDelete: () => void;
onAttachmentsChange: () => void;
}
export function TakeEditorPanel({
take,
previousTake,
onPrev,
onNext,
onDuplicate,
onNewTake,
onDelete,
onAttachmentsChange,
}: Props) {
const { status, queue } = useAutosave(take.id);
// Local field state — initialised from take prop, synced when take.id changes
const [fields, setFields] = useState<FullTake>(take);
const prevIdRef = useRef(take.id);
useEffect(() => {
if (take.id !== prevIdRef.current) {
setFields(take);
prevIdRef.current = take.id;
}
}, [take]);
const [showPrevPanel, setShowPrevPanel] = useState(false);
function set<K extends keyof FullTake>(key: K, value: FullTake[K]) {
setFields((f) => ({ ...f, [key]: value }));
queue({ [key]: value });
}
const str = (v: string | null) => v ?? "";
const num = (v: number | null) => (v !== null && v !== undefined ? String(v) : "");
// Keyboard shortcuts
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === "ArrowLeft" && onPrev) onPrev();
if (e.key === "ArrowRight" && onNext) onNext();
if (e.key === "d" && !e.metaKey && !e.ctrlKey) onDuplicate();
if (e.key === "n" && !e.metaKey && !e.ctrlKey) onNewTake();
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onPrev, onNext, onDuplicate, onNewTake]);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── Nav bar ── */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-800 shrink-0 flex-wrap">
<div className="flex items-center gap-1">
<Button size="icon-sm" variant="ghost" disabled={!onPrev} onClick={onPrev ?? undefined} title="Previous take (←)">
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="icon-sm" variant="ghost" disabled={!onNext} onClick={onNext ?? undefined} title="Next take (→)">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-white">
Setup {take.setup.shootDay.unit} · Take {take.takeNumber}
</h2>
<p className="text-[11px] text-zinc-500">
{new Date(take.setup.shootDay.date).toLocaleDateString("en-GB", {
weekday: "short", day: "numeric", month: "short", year: "numeric",
})}
{take.setup.shootDay.label ? ` · ${take.setup.shootDay.label}` : ""}
</p>
</div>
{/* Save status */}
<div className="shrink-0">
{status === "saving" && <Loader2 className="h-4 w-4 animate-spin text-zinc-500" />}
{status === "saved" && <Check className="h-4 w-4 text-green-500" />}
{status === "dirty" && <span className="h-2 w-2 rounded-full bg-amber-400 inline-block" />}
{status === "error" && <AlertCircle className="h-4 w-4 text-red-500" />}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button size="sm" variant="secondary" onClick={onDuplicate} title="Duplicate take (D)">
<Copy className="h-3.5 w-3.5" />
Dup
</Button>
<Button size="sm" variant="secondary" onClick={onNewTake} title="New take (N)">
<Plus className="h-3.5 w-3.5" />
Take
</Button>
<Button size="icon-sm" variant="destructive" onClick={onDelete} title="Delete take">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* ── Quality bar ── */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-800 shrink-0 flex-wrap">
{QUALITIES.map((q) => (
<button
key={q.value}
onClick={() => set("quality", q.value)}
className={cn(
"px-4 py-2 rounded-lg border text-xs font-semibold transition-all min-h-[36px]",
fields.quality === q.value ? q.active : q.color
)}
>
{q.label}
</button>
))}
</div>
{/* ── Scrollable form ── */}
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-4 space-y-5 max-w-4xl">
{/* General */}
<SectionHeader title="General" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<Field label="Scene">
<TF value={str(fields.scene)} onChange={(v) => set("scene", v || null)} placeholder="e.g. 12" />
</Field>
<Field label="Shot">
<TF value={str(fields.shotLabel)} onChange={(v) => set("shotLabel", v || null)} placeholder="e.g. 12A" />
</Field>
<Field label="Unit">
<TF value={str(fields.unitLabel)} onChange={(v) => set("unitLabel", v || null)} placeholder="e.g. A" />
</Field>
<Field label="Take #">
<TF value={String(fields.takeNumber)} onChange={() => {}} placeholder="—" className="opacity-60 cursor-default" />
</Field>
</div>
<Field label="Pipeline Shot" className="max-w-sm">
<ShotSelector
projectId={take.setup.shootDay.projectId}
value={fields.pipelineShotId}
onChange={(id) => set("pipelineShotId", id)}
/>
{fields.pipelineShotId && (
<LinkedShotPanel shotId={fields.pipelineShotId} />
)}
</Field>
{/* Camera */}
<SectionHeader title="Camera" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<Field label="Camera Letter">
<TF value={str(fields.cameraLetter)} onChange={(v) => set("cameraLetter", v || null)} placeholder="A" />
</Field>
<Field label="Clip Name" className="sm:col-span-2">
<TF value={str(fields.clipName)} onChange={(v) => set("clipName", v || null)} placeholder="A001_C001" />
</Field>
<Field label="Roll">
<TF value={str(fields.roll)} onChange={(v) => set("roll", v || null)} placeholder="A001" />
</Field>
<Field label="Camera Model" className="sm:col-span-2">
<TF value={str(fields.cameraModel)} onChange={(v) => set("cameraModel", v || null)} placeholder="Alexa Mini LF" />
</Field>
<Field label="Resolution">
<TF value={str(fields.resolution)} onChange={(v) => set("resolution", v || null)} placeholder="4.5K" />
</Field>
<Field label="Codec">
<TF value={str(fields.codec)} onChange={(v) => set("codec", v || null)} placeholder="ARRIRAW" />
</Field>
<Field label="FPS">
<TF
type="number"
value={num(fields.fps)}
onChange={(v) => set("fps", v ? Number(v) : null)}
placeholder="24"
/>
</Field>
<Field label="Shutter">
<TF value={str(fields.shutter)} onChange={(v) => set("shutter", v || null)} placeholder="180°" />
</Field>
<Field label="ISO">
<TF
type="number"
value={num(fields.iso)}
onChange={(v) => set("iso", v ? Number(v) : null)}
placeholder="800"
/>
</Field>
<Field label="White Balance">
<TF
type="number"
value={num(fields.whiteBalance)}
onChange={(v) => set("whiteBalance", v ? Number(v) : null)}
placeholder="5600"
/>
</Field>
<Field label="Colour Space" className="sm:col-span-2">
<TF value={str(fields.colourSpace)} onChange={(v) => set("colourSpace", v || null)} placeholder="LogC3 AWG3" />
</Field>
</div>
{/* Lens */}
<SectionHeader title="Lens" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<Field label="Lens Set" className="sm:col-span-2">
<TF value={str(fields.lensSet)} onChange={(v) => set("lensSet", v || null)} placeholder="Cooke S4" />
</Field>
<Field label="Lens">
<TF value={str(fields.lens)} onChange={(v) => set("lens", v || null)} placeholder="50mm" />
</Field>
<Field label="T Stop">
<TF value={str(fields.tStop)} onChange={(v) => set("tStop", v || null)} placeholder="T2.8" />
</Field>
<Field label="Filters" className="sm:col-span-2">
<TF value={str(fields.filters)} onChange={(v) => set("filters", v || null)} placeholder="IRND 0.6" />
</Field>
<Field label="Anamorphic">
<button
onClick={() => set("isAnamorphic", !fields.isAnamorphic)}
className={cn(
"h-9 px-3 rounded-lg border text-xs font-medium transition-colors text-left",
fields.isAnamorphic
? "bg-amber-500/20 border-amber-600 text-amber-300"
: "bg-zinc-900 border-zinc-700 text-zinc-500"
)}
>
{fields.isAnamorphic ? "Yes" : "No"}
</button>
</Field>
</div>
{/* Tracking */}
<SectionHeader title="Tracking" />
<div className="flex flex-wrap gap-2">
{TRACKING_ITEMS.map(({ key, label }) => {
const checked = fields[key] as boolean;
return (
<button
key={key}
onClick={() => set(key as keyof FullTake, !checked as never)}
className={cn(
"px-3 py-2 rounded-lg border text-xs font-medium transition-all min-h-[36px]",
checked
? "bg-amber-500/15 border-amber-600/60 text-amber-300"
: "bg-zinc-900 border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-400"
)}
>
{label}
</button>
);
})}
</div>
{/* Environment */}
<SectionHeader title="Environment" />
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Field label="Weather" className="sm:col-span-1">
<TF value={str(fields.weather)} onChange={(v) => set("weather", v || null)} placeholder="Overcast" />
</Field>
<Field label="Sun Direction">
<TF value={str(fields.sunDirection)} onChange={(v) => set("sunDirection", v || null)} placeholder="NW high" />
</Field>
<Field label="Artificial Lights" className="sm:col-span-3">
<Textarea
value={str(fields.artificialLights)}
onChange={(e) => set("artificialLights", e.target.value || null)}
placeholder="2x HMI 12K softbox, practical tungsten..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
/>
</Field>
</div>
{/* Supervisor */}
<SectionHeader title="Supervisor" />
<div className="grid gap-3">
<Field label="Notes">
<Textarea
value={str(fields.supervisorNotes)}
onChange={(e) => set("supervisorNotes", e.target.value || null)}
placeholder="General notes on this take..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[80px] resize-none"
/>
</Field>
<Field label="Continuity">
<Textarea
value={str(fields.continuityNotes)}
onChange={(e) => set("continuityNotes", e.target.value || null)}
placeholder="Continuity concerns..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
/>
</Field>
<Field label="VFX Requirements">
<Textarea
value={str(fields.vfxRequirements)}
onChange={(e) => set("vfxRequirements", e.target.value || null)}
placeholder="Screen replacement, wire removal, creature interaction..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
/>
</Field>
</div>
{/* Images */}
<SectionHeader title="Reference Images" />
<TakeImageGallery
takeId={take.id}
attachments={fields.attachments}
onChange={(attachments) => {
setFields((f) => ({ ...f, attachments }));
onAttachmentsChange();
}}
/>
{/* Previous Take comparison */}
{previousTake && (
<div>
<button
onClick={() => setShowPrevPanel((p) => !p)}
className="flex items-center gap-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors mb-2"
>
{showPrevPanel ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
Compare with Take {previousTake.takeNumber}
</button>
{showPrevPanel && (
<PreviousTakePanel current={fields} previous={previousTake} />
)}
</div>
)}
{/* Bottom padding */}
<div className="h-8" />
</div>
</div>
</div>
);
}