{tasks.length === 0 ? (
diff --git a/components/shots/ShotSlateTab.tsx b/components/shots/ShotSlateTab.tsx
new file mode 100644
index 0000000..3f4b314
--- /dev/null
+++ b/components/shots/ShotSlateTab.tsx
@@ -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 (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+/** Right-side metadata row */
+function MetaRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value || "—"}
+
+ );
+}
+
+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 (
+
+ {/* Slate document */}
+
+ {/* Top accent bar */}
+
+
+
+ {/* ── Left column: editorial data ─────────────────────────────── */}
+
+ {/* Header row */}
+
+
+
Show
+
{projectName.toUpperCase()}
+
+
+
Submitting For
+
REVIEW
+
+
+
+ {/* Auto-filled rows */}
+
+ {versionName}
+
+
+ {dateStr}
+
+
+ {/* Editable rows */}
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+ {/* ── Right column: thumbnail + metadata ──────────────────────── */}
+
+ {/* Thumbnail */}
+
+ {shot.thumbnailUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Metadata */}
+
+
+
Vendor
+
TWO TALES ANIMATION (PTY) LTD
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Save button */}
+
+
+
+
+ );
+}
diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts
index 8d694d5..8f6f619 100644
--- a/lib/render-pipeline/exports.ts
+++ b/lib/render-pipeline/exports.ts
@@ -137,21 +137,17 @@ export async function createExport(input: CreateExportInput) {
});
const freshShot = await tx.shot.findUniqueOrThrow({
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
- // unless the caller supplies new ones.
- const previous = await tx.export.findFirst({
- where: { shotId: shot.id },
- orderBy: { versionNumber: "desc" },
- select: { vfxScope: true, submissionNote: true },
- });
- const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (previous?.vfxScope ?? null);
+ // vfxScope: caller override → shot's slate field
+ const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (freshShot.slateVfxScope ?? null);
+ // submissionNote: caller override (per-export) → shot's slate default
const submissionNote =
- input.submissionNote !== undefined ? input.submissionNote : (previous?.submissionNote ?? null);
- const versionNumber =
- Math.max(latest._max.versionNumber ?? 0, parseVersionString(freshShot.shotVersion)) + 1;
- const versionString = formatVersionString(versionNumber);
+ input.submissionNote !== undefined ? input.submissionNote : (freshShot.slateSubmissionNote ?? null);
+ // versionNumber is an internal sequence for DB uniqueness only; output files
+ // use the shot's current (manually-set) shotVersion.
+ const versionNumber = (latest._max.versionNumber ?? 0) + 1;
+ const versionString = freshShot.shotVersion ?? formatVersionString(versionNumber);
const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString);
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 {
export: {
id: created.id,
@@ -256,7 +245,7 @@ export async function createExport(input: CreateExportInput) {
submissionNote,
},
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),
};
});
diff --git a/prisma/migrations/20260807000000_shot_slate_fields/migration.sql b/prisma/migrations/20260807000000_shot_slate_fields/migration.sql
new file mode 100644
index 0000000..d249a06
--- /dev/null
+++ b/prisma/migrations/20260807000000_shot_slate_fields/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c9eecbd..2fe96c2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -418,6 +418,11 @@ model Shot {
shotVersion String @default("v001")
// Key shot flag — highlighted for prioritisation
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())
updatedAt DateTime @updatedAt
diff --git a/types/index.ts b/types/index.ts
index fd891e7..e86b375 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -181,6 +181,11 @@ export interface ShotWithDetails {
highResFilename: string | null;
// Shot-level version tracking
shotVersion: string;
+ // Netflix slate fields
+ slateType: string | null;
+ slateDescription: string | null;
+ slateVfxScope: string | null;
+ slateSubmissionNote: string | null;
shotGroup: { id: string; name: string } | null;
footagePlates: {
id: string;