caaa333f02
Deploy / deploy (push) Failing after 19m11s
Two independent features landing together: Generated shot thumbnails — a shot can now carry a thumbnail promoted from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom) alongside the existing brief-supplied thumbnailUrl, which always takes display precedence (lib/shot-thumbnail.ts) so a promoted frame can only ever fill a gap, never overwrite the brief. Shows with pipelineConfig.autoShotThumbnail promote automatically on every finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/ clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for everyone else. Worker-side seek logic for picking the promoted frame is covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs. Bulk shot delete — the shot-status page's selection bar gets a "Bulk Actions" dropdown (alongside the existing Change Due Date) with Delete Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT by design, so a shot with renders or deliveries on record is protected and gets skipped and reported by name rather than aborting the whole selection or being silently destroyed along with its history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
208 lines
8.9 KiB
TypeScript
208 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import Image from "next/image";
|
|
import { Film, Save } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { updateShotSlate } from "@/actions/shots";
|
|
import type { ShotWithDetails } from "@/types";
|
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
|
|
|
interface Props {
|
|
shot: ShotWithDetails;
|
|
projectName: string;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
/** Left-side slate row: label + editable/static value */
|
|
function SlateRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex gap-0 border-b border-zinc-800/60 last:border-0">
|
|
<div className="w-36 shrink-0 py-3 px-4 text-right text-[11px] font-semibold text-zinc-500 uppercase tracking-widest self-start pt-3.5">
|
|
{label}
|
|
</div>
|
|
<div className="flex-1 py-2.5 px-3 text-sm text-zinc-100">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Right-side metadata row */
|
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-center justify-between border-b border-zinc-800/50 last:border-0 py-1.5 px-3">
|
|
<span className="text-[11px] text-zinc-500">{label}</span>
|
|
<span className="text-[11px] text-zinc-300 text-right max-w-[55%] truncate">{value || "—"}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ShotSlateTab({ shot, projectName, onSaved }: Props) {
|
|
const { toast } = useToast();
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const [slateType, setSlateType] = useState(shot.slateType ?? "");
|
|
const [slateDescription, setSlateDescription] = useState(shot.slateDescription ?? shot.description ?? "");
|
|
const [slateVfxScope, setSlateVfxScope] = useState(shot.slateVfxScope ?? "");
|
|
const [slateSubmissionNote, setSlateSubmissionNote] = useState(shot.slateSubmissionNote ?? "");
|
|
const [clientCode, setClientCode] = useState(shot.clientCode ?? "");
|
|
|
|
const today = new Date();
|
|
const dateStr = `${today.getFullYear()}/${String(today.getMonth() + 1).padStart(2, "0")}/${String(today.getDate()).padStart(2, "0")}`;
|
|
// Client code, when set, replaces shotCode in what the client actually
|
|
// sees (slate, overlay burn-in, delivered names) — just their own naming
|
|
// with our version appended, no "_cmp_TT_" studio jargon mixed in.
|
|
const versionName = clientCode.trim()
|
|
? `${clientCode.trim()}_${shot.shotVersion ?? "v001"}`
|
|
: `${shot.shotCode}_cmp_TT_${shot.shotVersion ?? "v001"}`;
|
|
const frames =
|
|
shot.frameStart != null && shot.frameEnd != null
|
|
? String(shot.frameEnd - shot.frameStart + 1)
|
|
: "—";
|
|
|
|
const handleSave = () => {
|
|
startTransition(async () => {
|
|
try {
|
|
await updateShotSlate(shot.id, {
|
|
slateType: slateType.trim() || null,
|
|
slateDescription: slateDescription.trim() || null,
|
|
slateVfxScope: slateVfxScope.trim() || null,
|
|
slateSubmissionNote: slateSubmissionNote.trim() || null,
|
|
clientCode: clientCode.trim() || null,
|
|
});
|
|
toast({ title: "Slate saved" });
|
|
onSaved();
|
|
} catch (e) {
|
|
toast({ title: "Failed to save", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Slate document */}
|
|
<div className="rounded-xl overflow-hidden border border-zinc-800 bg-zinc-950 max-w-4xl">
|
|
{/* Top accent bar */}
|
|
<div className="h-1 bg-red-600" />
|
|
|
|
<div className="flex">
|
|
{/* ── Left column: editorial data ─────────────────────────────── */}
|
|
<div className="flex-1 border-r border-zinc-800">
|
|
{/* Header row */}
|
|
<div className="flex border-b border-zinc-800">
|
|
<div className="flex-1 px-4 py-3 border-r border-zinc-800">
|
|
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-0.5">Show</p>
|
|
<p className="text-base font-bold text-white tracking-wide">{projectName.toUpperCase()}</p>
|
|
</div>
|
|
<div className="px-4 py-3 flex items-center gap-2">
|
|
<p className="text-[10px] text-zinc-500 uppercase tracking-widest">Submitting For</p>
|
|
<span className="ml-1 px-2 py-0.5 rounded bg-red-600/20 border border-red-600/40 text-red-400 text-xs font-bold tracking-widest">REVIEW</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Auto-filled rows */}
|
|
<SlateRow label="Version Name">
|
|
<span className="font-mono text-amber-400">{versionName}</span>
|
|
</SlateRow>
|
|
<SlateRow label="Date">
|
|
<span className="text-zinc-300">{dateStr}</span>
|
|
</SlateRow>
|
|
|
|
{/* Editable rows */}
|
|
<SlateRow label="Client Code">
|
|
<Input
|
|
value={clientCode}
|
|
onChange={(e) => setClientCode(e.target.value)}
|
|
placeholder={`Leave blank to use ${shot.shotCode}`}
|
|
className="h-7 bg-zinc-900 border-zinc-700 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 placeholder:font-sans"
|
|
/>
|
|
</SlateRow>
|
|
|
|
<SlateRow label="Shot Type">
|
|
<Input
|
|
value={slateType}
|
|
onChange={(e) => setSlateType(e.target.value)}
|
|
placeholder="e.g. 2D COMP"
|
|
className="h-7 bg-zinc-900 border-zinc-700 text-sm font-medium text-zinc-100 placeholder:text-zinc-600"
|
|
/>
|
|
</SlateRow>
|
|
|
|
<SlateRow label="Shot Description">
|
|
<Textarea
|
|
value={slateDescription}
|
|
onChange={(e) => setSlateDescription(e.target.value)}
|
|
placeholder={shot.description ?? "Shot description for slate…"}
|
|
rows={2}
|
|
className="bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600 resize-none min-h-[56px]"
|
|
/>
|
|
</SlateRow>
|
|
|
|
<SlateRow label="VFX Scope">
|
|
<Textarea
|
|
value={slateVfxScope}
|
|
onChange={(e) => setSlateVfxScope(e.target.value)}
|
|
placeholder="VFX scope of work…"
|
|
rows={3}
|
|
className="bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600 resize-none min-h-[72px]"
|
|
/>
|
|
</SlateRow>
|
|
|
|
<SlateRow label="Submission Note">
|
|
<Input
|
|
value={slateSubmissionNote}
|
|
onChange={(e) => setSlateSubmissionNote(e.target.value)}
|
|
placeholder="e.g. Done"
|
|
className="h-7 bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600"
|
|
/>
|
|
</SlateRow>
|
|
</div>
|
|
|
|
{/* ── Right column: thumbnail + metadata ──────────────────────── */}
|
|
<div className="w-56 shrink-0 flex flex-col">
|
|
{/* Thumbnail */}
|
|
<div className="aspect-video bg-zinc-900 border-b border-zinc-800 overflow-hidden flex items-center justify-center">
|
|
{shotThumbnail(shot) ? (
|
|
<Image
|
|
src={shotThumbnail(shot)!}
|
|
alt={shot.shotCode}
|
|
width={224}
|
|
height={126}
|
|
className="object-cover w-full h-full"
|
|
/>
|
|
) : (
|
|
<Film className="h-8 w-8 text-zinc-700" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Metadata */}
|
|
<div className="flex-1 text-right">
|
|
<div className="px-3 py-2 border-b border-zinc-800">
|
|
<p className="text-[10px] text-zinc-500 mb-0.5">Vendor</p>
|
|
<p className="text-[11px] text-zinc-200 font-medium leading-tight">TWO TALES ANIMATION (PTY) LTD</p>
|
|
</div>
|
|
<MetaRow label="Shot Name" value={shot.shotCode} />
|
|
<MetaRow label="Episode" value={shot.episode ?? "—"} />
|
|
<MetaRow label="Seq Name" value={shot.sequence ?? "—"} />
|
|
<MetaRow label="Scene" value={shot.scene} />
|
|
<MetaRow label="Frames" value={frames} />
|
|
<MetaRow label="Media Color" value="w/SHOW LUT" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save button */}
|
|
<div className="flex justify-end max-w-4xl">
|
|
<Button onClick={handleSave} disabled={isPending} className="gap-2">
|
|
<Save className="h-4 w-4" />
|
|
{isPending ? "Saving…" : "Save Slate"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|