+14
-32
@@ -23,7 +23,6 @@ var HANDLE_FRAMES = 8;
|
||||
lastActionText: null,
|
||||
pipelineStatusText: null,
|
||||
urgentCheckbox: null,
|
||||
vfxScopeField: null,
|
||||
submissionNoteField: null
|
||||
};
|
||||
|
||||
@@ -2452,9 +2451,6 @@ var HANDLE_FRAMES = 8;
|
||||
if (pipelineState.prefilledShotCode === shotCode) {
|
||||
return;
|
||||
}
|
||||
if (uiState.vfxScopeField) {
|
||||
uiState.vfxScopeField.text = (exportInfo && exportInfo.vfxScope) ? exportInfo.vfxScope : "";
|
||||
}
|
||||
if (uiState.submissionNoteField) {
|
||||
uiState.submissionNoteField.text = (exportInfo && exportInfo.submissionNote) ? exportInfo.submissionNote : "";
|
||||
}
|
||||
@@ -2534,13 +2530,12 @@ var HANDLE_FRAMES = 8;
|
||||
if (urgent) {
|
||||
body.priority = 20;
|
||||
}
|
||||
// Sent every time so edits stick; omitting them would make the server
|
||||
// inherit the previous submission's values instead.
|
||||
if (uiState.vfxScopeField) {
|
||||
body.vfxScope = uiState.vfxScopeField.text;
|
||||
}
|
||||
// submissionNote is a per-export override; if blank the server falls back to the shot's slate default.
|
||||
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);
|
||||
}
|
||||
@@ -2671,10 +2666,6 @@ var HANDLE_FRAMES = 8;
|
||||
var shotBuilderGroup;
|
||||
var shotBuilderButton;
|
||||
var pipelinePanel;
|
||||
var slateScopeGroup;
|
||||
var slateScopeLabel;
|
||||
var slateNoteGroup;
|
||||
var slateNoteLabel;
|
||||
var pipelineRow1;
|
||||
var pipelineRow2;
|
||||
var queueExportButton;
|
||||
@@ -2753,24 +2744,6 @@ var HANDLE_FRAMES = 8;
|
||||
|
||||
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.orientation = "row";
|
||||
pipelineRow1.alignChildren = ["fill", "center"];
|
||||
@@ -2778,6 +2751,15 @@ var HANDLE_FRAMES = 8;
|
||||
queueExportButton = pipelineRow1.add("button", undefined, "Queue Export");
|
||||
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.orientation = "row";
|
||||
pipelineRow2.alignChildren = ["fill", "center"];
|
||||
|
||||
@@ -489,6 +489,40 @@ export async function updateShotNotes(shotId: string, notes: string) {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
|
||||
|
||||
@@ -30,11 +30,13 @@ import {
|
||||
FileVideo,
|
||||
Pencil,
|
||||
Check,
|
||||
LayoutList,
|
||||
} 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 { ShotSlateTab } from "@/components/shots/ShotSlateTab";
|
||||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||||
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
|
||||
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
|
||||
@@ -90,7 +92,7 @@ export default function ShotDetailPage() {
|
||||
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 [activeTab, setActiveTab] = useState<"tasks" | "slate" | "reviews" | "footage" | "exports" | "settings">("tasks");
|
||||
const [editingVersion, setEditingVersion] = useState(false);
|
||||
const [versionInput, setVersionInput] = useState("");
|
||||
const [savingVersion, setSavingVersion] = useState(false);
|
||||
@@ -520,6 +522,18 @@ export default function ShotDetailPage() {
|
||||
<ListTodo className="h-4 w-4" />
|
||||
Tasks
|
||||
</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
|
||||
onClick={() => setActiveTab("reviews")}
|
||||
className={cn(
|
||||
@@ -583,6 +597,10 @@ export default function ShotDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "slate" && (
|
||||
<ShotSlateTab shot={shot} projectName={projectName} onSaved={fetchShot} />
|
||||
)}
|
||||
|
||||
{activeTab === "reviews" && (
|
||||
<div className="space-y-3">
|
||||
{tasks.length === 0 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user