Slates
Deploy / deploy (push) Successful in 6m17s

This commit is contained in:
twotalesanimation
2026-08-07 07:34:10 +02:00
parent 2f2e286adb
commit 7caf41e17c
8 changed files with 282 additions and 54 deletions
+14 -32
View File
@@ -23,7 +23,6 @@ var HANDLE_FRAMES = 8;
lastActionText: null, lastActionText: null,
pipelineStatusText: null, pipelineStatusText: null,
urgentCheckbox: null, urgentCheckbox: null,
vfxScopeField: null,
submissionNoteField: null submissionNoteField: null
}; };
@@ -2452,9 +2451,6 @@ var HANDLE_FRAMES = 8;
if (pipelineState.prefilledShotCode === shotCode) { if (pipelineState.prefilledShotCode === shotCode) {
return; return;
} }
if (uiState.vfxScopeField) {
uiState.vfxScopeField.text = (exportInfo && exportInfo.vfxScope) ? exportInfo.vfxScope : "";
}
if (uiState.submissionNoteField) { if (uiState.submissionNoteField) {
uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : ""; uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : "";
} }
@@ -2534,13 +2530,12 @@ var HANDLE_FRAMES = 8;
if (urgent) { if (urgent) {
body.priority = 20; body.priority = 20;
} }
// Sent every time so edits stick; omitting them would make the server // submissionNote is a per-export override; if blank the server falls back to the shot's slate default.
// inherit the previous submission's values instead.
if (uiState.vfxScopeField) {
body.vfxScope = uiState.vfxScopeField.text;
}
if (uiState.submissionNoteField) { if (uiState.submissionNoteField) {
body.submissionNote = uiState.submissionNoteField.text; var note = uiState.submissionNoteField.text;
if (note && note.trim()) {
body.submissionNote = note;
}
} }
return postJSON(BASE_URL + "/api/ext/exports", body); return postJSON(BASE_URL + "/api/ext/exports", body);
} }
@@ -2671,10 +2666,6 @@ var HANDLE_FRAMES = 8;
var shotBuilderGroup; var shotBuilderGroup;
var shotBuilderButton; var shotBuilderButton;
var pipelinePanel; var pipelinePanel;
var slateScopeGroup;
var slateScopeLabel;
var slateNoteGroup;
var slateNoteLabel;
var pipelineRow1; var pipelineRow1;
var pipelineRow2; var pipelineRow2;
var queueExportButton; var queueExportButton;
@@ -2753,24 +2744,6 @@ var HANDLE_FRAMES = 8;
uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked"); uiState.pipelineStatusText = pipelinePanel.add("statictext", undefined, "Export status: not checked");
slateScopeGroup = pipelinePanel.add("group");
slateScopeGroup.orientation = "row";
slateScopeGroup.alignChildren = ["fill", "center"];
slateScopeGroup.spacing = 6;
slateScopeLabel = slateScopeGroup.add("statictext", undefined, "VFX Scope:");
slateScopeLabel.preferredSize = [90, 20];
uiState.vfxScopeField = slateScopeGroup.add("edittext", undefined, "");
uiState.vfxScopeField.preferredSize = [220, 22];
slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"];
slateNoteGroup.spacing = 6;
slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Submission Note:");
slateNoteLabel.preferredSize = [90, 20];
uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: true });
uiState.submissionNoteField.preferredSize = [220, 48];
pipelineRow1 = pipelinePanel.add("group"); pipelineRow1 = pipelinePanel.add("group");
pipelineRow1.orientation = "row"; pipelineRow1.orientation = "row";
pipelineRow1.alignChildren = ["fill", "center"]; pipelineRow1.alignChildren = ["fill", "center"];
@@ -2778,6 +2751,15 @@ var HANDLE_FRAMES = 8;
queueExportButton = pipelineRow1.add("button", undefined, "Queue Export"); queueExportButton = pipelineRow1.add("button", undefined, "Queue Export");
uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent"); uiState.urgentCheckbox = pipelineRow1.add("checkbox", undefined, "Urgent");
var slateNoteGroup = pipelinePanel.add("group");
slateNoteGroup.orientation = "row";
slateNoteGroup.alignChildren = ["fill", "top"];
slateNoteGroup.spacing = 6;
var slateNoteLabel = slateNoteGroup.add("statictext", undefined, "Sub. Note:");
slateNoteLabel.preferredSize = [70, 20];
uiState.submissionNoteField = slateNoteGroup.add("edittext", undefined, "", { multiline: false });
uiState.submissionNoteField.preferredSize = [240, 22];
pipelineRow2 = pipelinePanel.add("group"); pipelineRow2 = pipelinePanel.add("group");
pipelineRow2.orientation = "row"; pipelineRow2.orientation = "row";
pipelineRow2.alignChildren = ["fill", "center"]; pipelineRow2.alignChildren = ["fill", "center"];
+34
View File
@@ -489,6 +489,40 @@ export async function updateShotNotes(shotId: string, notes: string) {
return { success: true }; return { success: true };
} }
// ── Update Shot Slate Fields ──────────────────────────────────────────────────
export async function updateShotSlate(
shotId: string,
data: {
slateType?: string | null;
slateDescription?: string | null;
slateVfxScope?: string | null;
slateSubmissionNote?: string | null;
}
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } });
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: {
slateType: data.slateType !== undefined ? (data.slateType?.trim() || null) : undefined,
slateDescription: data.slateDescription !== undefined ? (data.slateDescription?.trim() || null) : undefined,
slateVfxScope: data.slateVfxScope !== undefined ? (data.slateVfxScope?.trim() || null) : undefined,
slateSubmissionNote: data.slateSubmissionNote !== undefined ? (data.slateSubmissionNote?.trim() || null) : undefined,
},
});
revalidatePath(`/projects/${shot.projectId}`);
return { success: true };
}
// ── Toggle Key Shot ─────────────────────────────────────────────────────────── // ── Toggle Key Shot ───────────────────────────────────────────────────────────
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) { export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
@@ -30,11 +30,13 @@ import {
FileVideo, FileVideo,
Pencil, Pencil,
Check, Check,
LayoutList,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types"; import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { ShotExportsTab } from "@/components/shots/ShotExportsTab"; import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
import { ShotSlateTab } from "@/components/shots/ShotSlateTab";
import { FootageViewer } from "@/components/shots/FootageViewer"; import { FootageViewer } from "@/components/shots/FootageViewer";
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog"; import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots"; import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
@@ -90,7 +92,7 @@ export default function ShotDetailPage() {
const [isDuplicating, setIsDuplicating] = useState(false); const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false); const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false); const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "exports" | "settings">("tasks"); const [activeTab, setActiveTab] = useState<"tasks" | "slate" | "reviews" | "footage" | "exports" | "settings">("tasks");
const [editingVersion, setEditingVersion] = useState(false); const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState(""); const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false); const [savingVersion, setSavingVersion] = useState(false);
@@ -520,6 +522,18 @@ export default function ShotDetailPage() {
<ListTodo className="h-4 w-4" /> <ListTodo className="h-4 w-4" />
Tasks Tasks
</button> </button>
<button
onClick={() => setActiveTab("slate")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "slate"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<LayoutList className="h-4 w-4" />
Slate
</button>
<button <button
onClick={() => setActiveTab("reviews")} onClick={() => setActiveTab("reviews")}
className={cn( className={cn(
@@ -583,6 +597,10 @@ export default function ShotDetailPage() {
/> />
)} )}
{activeTab === "slate" && (
<ShotSlateTab shot={shot} projectName={projectName} onSaved={fetchShot} />
)}
{activeTab === "reviews" && ( {activeTab === "reviews" && (
<div className="space-y-3"> <div className="space-y-3">
{tasks.length === 0 ? ( {tasks.length === 0 ? (
+190
View File
@@ -0,0 +1,190 @@
"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";
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 today = new Date();
const dateStr = `${today.getFullYear()}/${String(today.getMonth() + 1).padStart(2, "0")}/${String(today.getDate()).padStart(2, "0")}`;
const versionName = `${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,
});
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="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">
{shot.thumbnailUrl ? (
<Image
src={shot.thumbnailUrl}
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>
);
}
+10 -21
View File
@@ -137,21 +137,17 @@ export async function createExport(input: CreateExportInput) {
}); });
const freshShot = await tx.shot.findUniqueOrThrow({ const freshShot = await tx.shot.findUniqueOrThrow({
where: { id: shot.id }, where: { id: shot.id },
select: { shotVersion: true, exrOutput: true }, select: { shotVersion: true, exrOutput: true, slateVfxScope: true, slateSubmissionNote: true },
}); });
// Slate fields carry forward from the shot's previous submission // vfxScope: caller override → shot's slate field
// unless the caller supplies new ones. const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (freshShot.slateVfxScope ?? null);
const previous = await tx.export.findFirst({ // submissionNote: caller override (per-export) → shot's slate default
where: { shotId: shot.id },
orderBy: { versionNumber: "desc" },
select: { vfxScope: true, submissionNote: true },
});
const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (previous?.vfxScope ?? null);
const submissionNote = const submissionNote =
input.submissionNote !== undefined ? input.submissionNote : (previous?.submissionNote ?? null); input.submissionNote !== undefined ? input.submissionNote : (freshShot.slateSubmissionNote ?? null);
const versionNumber = // versionNumber is an internal sequence for DB uniqueness only; output files
Math.max(latest._max.versionNumber ?? 0, parseVersionString(freshShot.shotVersion)) + 1; // use the shot's current (manually-set) shotVersion.
const versionString = formatVersionString(versionNumber); const versionNumber = (latest._max.versionNumber ?? 0) + 1;
const versionString = freshShot.shotVersion ?? formatVersionString(versionNumber);
const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString); const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString);
const outputDir = const outputDir =
@@ -236,13 +232,6 @@ export async function createExport(input: CreateExportInput) {
}, },
}); });
// Mirror the legacy panel PATCH: Shot stays the canonical "current version"
const updatedShot = await tx.shot.update({
where: { id: shot.id },
data: { shotVersion: versionString, exrOutput: exrBase },
select: { id: true, shotVersion: true, exrOutput: true },
});
return { return {
export: { export: {
id: created.id, id: created.id,
@@ -256,7 +245,7 @@ export async function createExport(input: CreateExportInput) {
submissionNote, submissionNote,
}, },
renderJob: { id: renderJob.id, attempt: renderJob.attempt, priority: renderJob.priority }, renderJob: { id: renderJob.id, attempt: renderJob.attempt, priority: renderJob.priority },
shot: updatedShot, shot: { id: shot.id, shotVersion: versionString, exrOutput: exrBase },
superseded: toSupersede.map((s) => s.id), superseded: toSupersede.map((s) => s.id),
}; };
}); });
@@ -0,0 +1,5 @@
-- AlterTable: add Netflix slate fields to shots
ALTER TABLE "shots" ADD COLUMN "slateType" TEXT;
ALTER TABLE "shots" ADD COLUMN "slateDescription" TEXT;
ALTER TABLE "shots" ADD COLUMN "slateVfxScope" TEXT;
ALTER TABLE "shots" ADD COLUMN "slateSubmissionNote" TEXT;
+5
View File
@@ -418,6 +418,11 @@ model Shot {
shotVersion String @default("v001") shotVersion String @default("v001")
// Key shot flag — highlighted for prioritisation // Key shot flag — highlighted for prioritisation
isKeyShot Boolean @default(false) isKeyShot Boolean @default(false)
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
slateType String?
slateDescription String? @db.Text
slateVfxScope String? @db.Text
slateSubmissionNote String? @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+5
View File
@@ -181,6 +181,11 @@ export interface ShotWithDetails {
highResFilename: string | null; highResFilename: string | null;
// Shot-level version tracking // Shot-level version tracking
shotVersion: string; shotVersion: string;
// Netflix slate fields
slateType: string | null;
slateDescription: string | null;
slateVfxScope: string | null;
slateSubmissionNote: string | null;
shotGroup: { id: string; name: string } | null; shotGroup: { id: string; name: string } | null;
footagePlates: { footagePlates: {
id: string; id: string;