Files
vfxreview/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx
T
twotalesanimation ae58dc0366 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:34:34 +02:00

692 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { TaskList } from "@/components/tasks/TaskList";
import { Separator } from "@/components/ui/separator";
import { getInitials, cn, formatRelativeDate } from "@/lib/utils";
import {
Film,
ArrowLeft,
Clock,
AlertCircle,
CheckCircle2,
Settings,
ListTodo,
Video,
Copy,
ShieldCheck,
Share2,
Eye,
EyeOff,
MessageSquare,
ExternalLink,
XCircle,
FileVideo,
Pencil,
Check,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
import { FootageViewer } from "@/components/shots/FootageViewer";
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
const STATUS_CONFIG: Record<
string,
{ label: string; className: string; Icon: React.ElementType }
> = {
WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock },
IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film },
INTERNAL_REVIEW: { label: "Internal Review", className: "bg-purple-500/10 text-purple-400 border-purple-500/20", Icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", className: "bg-sky-500/10 text-sky-400 border-sky-500/20", Icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", className: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", Icon: Eye },
REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle },
COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 },
};
const APPROVAL_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
const APPROVAL_ICONS: Record<string, React.ElementType> = {
PENDING_REVIEW: Clock,
APPROVED: CheckCircle2,
REJECTED: XCircle,
NEEDS_CHANGES: AlertCircle,
};
const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
LOW: { label: "Low", dot: "bg-zinc-400" },
NORMAL: { label: "Normal", dot: "bg-blue-400" },
HIGH: { label: "High", dot: "bg-amber-400" },
CRITICAL: { label: "Critical", dot: "bg-red-500" },
};
import { useToast } from "@/components/ui/use-toast";
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const { toast } = useToast();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false);
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
const [tasks, setTasks] = useState<any[]>([]);
const [artists, setArtists] = useState<any[]>([]);
const [canManage, setCanManage] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "exports" | "settings">("tasks");
const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false);
const fetchShot = async () => {
try {
const res = await fetch(`/api/shots/${params.shotId}?projectId=${params.id}`);
if (res.status === 404) {
router.push("/projects");
return;
}
const data = await res.json();
setShot(data.shot);
setProjectName(data.projectName ?? "");
setCanApprove(data.canApprove ?? false);
setCanInternallyApprove(data.canInternallyApprove ?? false);
setTasks(data.tasks ?? []);
setArtists(data.artists ?? []);
setCanManage(data.canApprove ?? false);
} catch {
router.push("/projects");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchShot();
}, [params.shotId]);
const handleInternalApprove = async () => {
if (!shot) return;
setIsActioning(true);
try {
await internallyApproveShot(shot.id);
toast({ title: "Shot internally approved", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleShareWithClient = async () => {
if (!shot) return;
setIsActioning(true);
try {
await shareWithClient(shot.id);
toast({ title: "Shared with client", description: "Status: Client Review" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleUnshare = async () => {
if (!shot) return;
setIsActioning(true);
try {
await unshareFromClient(shot.id);
toast({ title: "Removed from client review", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleUnapproveShot = async () => {
if (!shot) return;
if (!confirm("Undo client approval? The shot will return to Internally Approved.")) return;
setIsActioning(true);
try {
await unapproveShot(shot.id);
toast({ title: "Client approval undone", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleSaveVersion = async () => {
if (!shot) return;
const trimmed = versionInput.trim().toLowerCase();
if (!/^v\d{3}$/.test(trimmed)) {
toast({ title: "Invalid format", description: "Version must be v### (e.g. v001)", variant: "destructive" });
return;
}
setSavingVersion(true);
try {
await updateShotVersion(shot.id, trimmed);
toast({ title: `Version set to ${trimmed}` });
setEditingVersion(false);
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setSavingVersion(false);
}
};
const handleDuplicate = async () => {
if (!shot) return;
setIsDuplicating(true);
try {
const { shot: newShot } = await duplicateShot(shot.id);
toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` });
router.push(`/projects/${params.id}/shots/${newShot.id}`);
} catch (e) {
toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsDuplicating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Film className="h-6 w-6 animate-pulse text-muted-foreground" />
</div>
);
}
if (!shot) return null;
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const priorityCfg = PRIORITY_CONFIG[shot.priority] ?? PRIORITY_CONFIG.NORMAL;
const { Icon: StatusIcon } = statusCfg;
return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
Projects
</Link>
<span>/</span>
<Link href={`/projects/${params.id}`} className="hover:text-foreground transition-colors">
{projectName}
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
</div>
{/* Header */}
<div className="flex items-start gap-6">
{/* Thumbnail cinema scope 2.39:1 */}
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
<Link href={`/projects/${params.id}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
{/* Shot version badge — click to edit for managers */}
{canManage ? (
editingVersion ? (
<div className="flex items-center gap-1.5">
<Input
autoFocus
value={versionInput}
onChange={(e) => setVersionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveVersion();
if (e.key === "Escape") setEditingVersion(false);
}}
className="h-7 w-20 font-mono text-xs px-2"
placeholder="v001"
maxLength={4}
/>
<button
onClick={handleSaveVersion}
disabled={savingVersion}
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
title="Save version"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setEditingVersion(false)}
className="text-zinc-500 hover:text-zinc-300"
title="Cancel"
>
<XCircle className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => { setVersionInput(shot.shotVersion ?? "v001"); setEditingVersion(true); }}
className="group flex items-center gap-1 font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300 hover:border-amber-500/50 hover:text-amber-400 transition-colors"
title="Click to edit version"
>
{shot.shotVersion ?? "v001"}
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)
) : (
<span className="font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300">
{shot.shotVersion ?? "v001"}
</span>
)}
{shot.sequence && (
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
)}
</div>
{shot.description && (
<p className="text-muted-foreground ">{shot.description}</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<Badge className={statusCfg.className} variant="outline">
<StatusIcon className="h-3 w-3 mr-1" />
{statusCfg.label}
</Badge>
{/* Approval status badges */}
{shot.shotApprovalStatus === "PENDING" && (
<Badge variant="outline" className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20 text-[11px]">
Pending Internal Approval
</Badge>
)}
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<Badge variant="outline" className="bg-sky-500/10 text-sky-400 border-sky-500/20 text-[11px]">
<ShieldCheck className="h-3 w-3 mr-1" />
Internally Approved
</Badge>
)}
{shot.shotApprovalStatus === "CLIENT_APPROVED" && (
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 text-[11px]">
<CheckCircle2 className="h-3 w-3 mr-1" />
Client Approved
</Badge>
)}
{/* Client sharing badge */}
{["INTERNALLY_APPROVED", "CLIENT_APPROVED"].includes(shot.shotApprovalStatus) && (
<Badge variant="outline" className={cn(
"text-[11px]",
shot.sharedWithClient
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20"
: "bg-zinc-500/10 text-zinc-500 border-zinc-600/20"
)}>
{shot.sharedWithClient ? "Shared With Client" : "Not Shared"}
</Badge>
)}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
/>
{priorityCfg.label} Priority
</div>
<span className="text-sm text-muted-foreground">{shot.fps} fps</span>
{shot.artist && (
<div className="flex items-center gap-1.5">
<Avatar className="h-5 w-5">
<AvatarImage src={shot.artist.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(shot.artist.name ?? shot.artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">
{shot.artist.name ?? shot.artist.email}
</span>
</div>
)}
</div>
</div>
{/* EDL / Pull metadata */}
{(shot.exrOutput || shot.sourceClip) && (
<div className="mt-3 rounded-lg border border-zinc-800 bg-zinc-900/60 px-4 py-3 space-y-2">
{shot.exrOutput && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">EXR Output</span>
<span className="font-mono text-xs text-zinc-200 flex-1 break-all">{shot.exrOutput}</span>
<button
className="text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
title="Copy EXR output name"
onClick={() => navigator.clipboard.writeText(shot.exrOutput ?? "")}
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
)}
{shot.sourceClip && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">Source Clip</span>
<span className="font-mono text-xs text-zinc-400">{shot.sourceClip}</span>
</div>
)}
{(shot.timecodeStart || shot.timecodeEnd) && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">Timecode</span>
<span className="font-mono text-xs text-zinc-400">
{shot.timecodeStart ?? ""}
{shot.timecodeStart && shot.timecodeEnd && " → "}
{shot.timecodeEnd ?? ""}
{shot.clipDuration && <span className="ml-2 text-zinc-600">({shot.clipDuration})</span>}
</span>
</div>
)}
</div>
)}
{canManage && (
<div className="ml-auto shrink-0 flex items-center gap-2">
{/* Internal approval action */}
{canInternallyApprove && shot.shotApprovalStatus === "PENDING" && (
<Button
variant="outline"
size="sm"
onClick={handleInternalApprove}
disabled={isActioning}
className="gap-2 border-sky-500/40 text-sky-400 hover:bg-sky-500/10"
>
<ShieldCheck className="h-3.5 w-3.5" />
Approve Internally
</Button>
)}
{/* Share / Unshare with client */}
{canInternallyApprove && ["INTERNALLY_APPROVED", "CLIENT_APPROVED"].includes(shot.shotApprovalStatus) && !shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleShareWithClient}
disabled={isActioning}
className="gap-2 border-indigo-500/40 text-indigo-400 hover:bg-indigo-500/10"
>
<Share2 className="h-3.5 w-3.5" />
Share With Client
</Button>
)}
{canInternallyApprove && shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleUnshare}
disabled={isActioning}
className="gap-2 border-zinc-500/40 text-zinc-400 hover:bg-zinc-500/10"
>
<EyeOff className="h-3.5 w-3.5" />
Remove From Client Review
</Button>
)}
{/* Undo client approval */}
{canInternallyApprove && shot.shotApprovalStatus === "CLIENT_APPROVED" && (
<Button
variant="outline"
size="sm"
onClick={handleUnapproveShot}
disabled={isActioning}
className="gap-2 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
>
<AlertCircle className="h-3.5 w-3.5" />
Undo Client Approval
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleDuplicate}
disabled={isDuplicating}
className="gap-2"
>
<Copy className="h-3.5 w-3.5" />
{isDuplicating ? "Duplicating…" : "Duplicate Shot"}
</Button> <Button
variant="outline"
size="sm"
onClick={() => setHighResDialogOpen(true)}
className={cn(
"gap-2",
shot.highResFilename
? "border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10"
: "border-zinc-600 text-zinc-400 hover:bg-zinc-700/50"
)}
>
<FileVideo className="h-3.5 w-3.5" />
{shot.highResFilename ? "High Res ✓" : "Upload High Res"}
</Button> </div>
)}
</div>
<Separator />
{/* Tabs */}
<div>
<div className="flex border-b border-border mb-5">
<button
onClick={() => setActiveTab("tasks")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "tasks"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<ListTodo className="h-4 w-4" />
Tasks
</button>
<button
onClick={() => setActiveTab("reviews")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "reviews"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<MessageSquare className="h-4 w-4" />
Reviews
</button>
<button
onClick={() => setActiveTab("footage")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "footage"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Video className="h-4 w-4" />
Footage
</button>
<button
onClick={() => setActiveTab("exports")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "exports"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Film className="h-4 w-4" />
Exports
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "settings"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Settings className="h-4 w-4" />
Settings
</button>
)}
</div>
{activeTab === "tasks" && (
<TaskList
tasks={tasks}
projectId={params.id}
shotId={shot.id}
artists={artists}
canManage={canManage}
onTaskCreated={fetchShot}
/>
)}
{activeTab === "reviews" && (
<div className="space-y-3">
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<MessageSquare className="h-8 w-8 opacity-30" />
<p className="text-sm">No tasks yet reviews will appear here once tasks are created.</p>
</div>
) : (
tasks.map((task: any) => {
const latestVersion = task.versions?.[0];
const latestApproval = latestVersion?.approvals?.[0];
const ApprovalIcon = latestVersion ? (APPROVAL_ICONS[latestVersion.approvalStatus] ?? Clock) : Clock;
const approvalStyle = latestVersion ? (APPROVAL_STYLES[latestVersion.approvalStatus] ?? "") : "";
return (
<div key={task.id} className="rounded-lg border border-border bg-card p-4 space-y-3">
{/* Task header */}
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm font-medium flex-1 min-w-0 truncate">{task.title}</p>
{latestVersion ? (
<>
<Badge
variant="outline"
className={cn("text-xs gap-1", approvalStyle)}
>
<ApprovalIcon className="h-3 w-3" />
{latestVersion.approvalStatus.replace(/_/g, " ")}
</Badge>
<Link
href={`/review/${latestVersion.id}`}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
>
v{String(latestVersion.versionNumber).padStart(3, "0")}
<ExternalLink className="h-3 w-3" />
</Link>
</>
) : (
<span className="text-xs text-muted-foreground">No versions uploaded</span>
)}
</div>
{/* Latest approval */}
{latestVersion && (
latestApproval ? (
<div className="flex items-start gap-3">
<Avatar className="h-7 w-7 shrink-0">
<AvatarImage src={latestApproval.user.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(latestApproval.user.name ?? "?")}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{latestApproval.user.name ?? "Reviewer"}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeDate(latestApproval.createdAt)}
</span>
</div>
{latestApproval.notes ? (
<p className="text-sm text-muted-foreground italic leading-relaxed">
&ldquo;{latestApproval.notes}&rdquo;
</p>
) : (
<p className="text-xs text-muted-foreground/60">No comment left</p>
)}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground pl-1">Awaiting review</p>
)
)}
</div>
);
})
)}
</div>
)}
{activeTab === "footage" && (
<FootageViewer
shot={shot}
canManage={canManage}
onSaved={fetchShot}
/>
)}
{activeTab === "exports" && <ShotExportsTab shotId={shot.id} />}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
</div>
<HighResUploadDialog
shotId={shot.id}
shotCode={shot.shotCode}
currentFilename={shot.highResFilename ?? null}
open={highResDialogOpen}
onClose={() => setHighResDialogOpen(false)}
onSuccess={fetchShot}
/>
</div>
);
}