diff --git a/RenderPipeline2.md b/RenderPipeline2.md index 70e87de..8a2fd11 100644 --- a/RenderPipeline2.md +++ b/RenderPipeline2.md @@ -207,7 +207,7 @@ stateDiagram-v2 | T9 | VALIDATION_FAILED | QUEUED | Retry (full re-render) | User | new attempt; old validation rows kept for history | | T10 | GENERATING_PREVIEW | PREVIEW_FAILED | ffmpeg/upload failure | Worker | error stored | | T11 | PREVIEW_FAILED | GENERATING_PREVIEW | Retry preview | User | validation is NOT re-run | -| T12 | GENERATING_PREVIEW | READY_FOR_QC | `finalize` report | Worker | `Version` row created (isLatest, **never client-shared**), thumbnail set, metadata JSON key stored; **no task/shot status change** — QC typically runs on already client-approved shots and must not disturb review state (Section 10) | +| T12 | GENERATING_PREVIEW | READY_FOR_QC | `finalize` report | Worker | `Version` row created (isLatest, **never client-shared**), thumbnail set, metadata JSON key stored; `Shot.generatedThumbnailUrl` set when the project opts in (§9.1a) — never `Shot.thumbnailUrl`; **no task/shot status change** — QC typically runs on already client-approved shots and must not disturb review state (Section 10) | | T13 | READY_FOR_QC | QC_FAILED | `POST .../qc {FAIL}` | Artist (AE) or user (web) | `QCReview` row; task → CHANGES (drives existing Shot REVISIONS derivation) | | T14 | READY_FOR_QC | READY_FOR_DELIVERY | `POST .../qc {PASS}` | Artist / user | `QCReview` row | | T15 | READY_FOR_DELIVERY | PACKAGED | Delivery build completes | Worker | `DeliveryItem` links Export to package | @@ -1100,18 +1100,36 @@ Runs on the worker after validation passes (Export = GENERATING_PREVIEW). ### 9.1 Stages 1. **Build + render preview comp (AE, headless).** The worker launches `AfterFX.com -noui -r vfxr_build_preview.jsx`, passing a job-context JSON file (manifest + slate/overlay fields fetched from E3). The script is the connector's existing preview-comp code, packaged with the worker: it imports the *validated* EXR sequence, builds the preview comp from the studio template (head slate populated from API data, overlays/burn-ins throughout), applies the colour chain (linear → log → show LUT from `colorPipeline` config), queues it with the project's MOV output-module template, and renders the **delivery-grade preview MOV** to the SAN beside the EXRs (`Export.deliveryMovPath`). This is the "MOV LT Preview" that ships inside delivery packages (Section 11). -2. **Web transcode (ffmpeg).** The delivery MOV is transcoded to a web-playable H.264 (1920-wide, CRF 18) and a thumbnail JPEG (frame at 25% duration, 960-wide), uploaded via E20 presign. +2. **Web transcode (ffmpeg).** The delivery MOV is transcoded to a web-playable H.264 (1920-wide, CRF 18) and a thumbnail JPEG (960-wide), uploaded via E20 presign. The thumbnail is taken at **25% of *picture* duration**, seeking past the head slate: the build script reports the SHOT layer's in-point as `pictureStartSeconds`, and the worker seeks to `pictureStart + 25%`. Builds that report no picture start fall back to scanning the whole file. (Until 2026-08-27 this used ffmpeg's `thumbnail` filter, which only considers the first 100 frames — on a slated preview, the slate card. Every thumbnail produced before then is a picture of the slate.) 3. **Metadata JSON.** Worker-composed (manifest + validation summary + render stats + checksums + tool/template versions), uploaded via E20. | Artifact | Produced by | Destination | |---|---|---| | Delivery MOV (slate + burn-ins, graded via show LUT, delivery codec from AE output template) | AE headless render | SAN → `Export.deliveryMovPath`; copied/linked into delivery packages | | Web preview MOV (H.264) | ffmpeg transcode of the delivery MOV | object storage `videos/` → `Version.fileUrl` | -| Thumbnail | ffmpeg | `image/` → `Version.thumbnailUrl` + `Export.thumbnailKey` | +| Thumbnail | ffmpeg | `image/` → `Version.thumbnailUrl` + `Export.thumbnailKey`; also `Shot.generatedThumbnailUrl` when the project opts in (§9.3) | | Metadata JSON | worker | `renders/` → `Export.metadataKey` | Because the web preview is a transcode of the AE-generated MOV, review media and delivery media are guaranteed to show identical slates, burn-ins, and colour — one source of truth. +### 9.1a Render frames as shot thumbnails + +Shots carry two stills, in two columns, and the distinction is the whole design: + +| Column | Written by | Meaning | +|---|---|---| +| `Shot.thumbnailUrl` | humans only — single upload, batch upload, ext API | The reference frame that came with the brief. Editorial data. | +| `Shot.generatedThumbnailUrl` | the pipeline, and the manual promote action | A frame lifted from a finished export. | + +Display resolution is `thumbnailUrl ?? generatedThumbnailUrl` (`lib/shot-thumbnail.ts`), applied at every shot surface: shot grid, kanban, schedule, shot status, storyboard (screen and PDF), shoot log, slate tab, client portal, and the ext APIs the Nuke/AE panel reads. **The reference always wins**, so the pipeline can only fill a gap and can never overwrite the brief — clearing the generated one always falls back. + +Promotion happens two ways: + +* **Automatically**, for projects with `pipelineConfig.autoShotThumbnail` set (Project Settings → *Use render frames as shot thumbnails*). Off by default and deliberately per-show: productions that ship a still with every shot already have a reference frame and the renders should not compete with it. It exists for shows that arrive with no stills at all, where the alternative is an empty grid. `finalizePreview` writes the column and notes it on the export timeline. +* **By hand**, via `promoteExportThumbnail` / `clearGeneratedThumbnail` (Shot Settings → Thumbnail → *From renders*), which work regardless of the project flag — one shot on an otherwise hands-off show. With no explicit export, the newest export of that shot that has a thumbnail is used. + +Note this is the one place a render-derived artifact reaches the client portal. It is a still, not a `Version`, so §10.0 is intact — no technical render re-enters the review flow and no approval state moves — but a client viewing a shot with no supplied still now sees a frame from the render rather than a placeholder. + ### 9.2 Colour configuration `Project.deliveryConfig.colorPipeline` (per 18.1-Q4/Q12) — **required and unique per project**; enabling the pipeline on a project without it is a validation error, and values must never be carried over from another show: diff --git a/RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs b/RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs new file mode 100644 index 0000000..fda9fa9 --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs @@ -0,0 +1,54 @@ +using VFXReviewWorker; +using Xunit; + +namespace VFXReviewWorker.Tests; + +/// +/// The preview MP4 opens on the Netflix slate. ffmpeg's thumbnail filter +/// only considers the first 100 frames, so every thumbnail the pipeline has ever +/// produced was a picture of the slate card rather than of the shot — invisible +/// while thumbnails were only decoration in the QC queue, and unacceptable now +/// they can become a shot's thumbnail. +/// +/// The fix is to seek past the slate, so the seek arithmetic is worth pinning. +/// +public class ThumbnailSeekTests +{ + private static PreviewBuildResult Build(double? pictureStart, int? frameCount) => + new() { PictureStartSeconds = pictureStart, FrameCount = frameCount }; + + [Fact] + public void SeeksAQuarterIntoPictureNotIntoTheFile() + { + // 5s slate, then 96 frames (4s) of picture at 24fps → 5 + 1 = 6s. + var seek = PreviewStage.ThumbnailSeekSeconds(Build(5.0, 96), 24); + Assert.NotNull(seek); + Assert.Equal(6.0, seek!.Value, 3); + } + + [Fact] + public void HandlesATemplateWithNoSlateAtAll() + { + var seek = PreviewStage.ThumbnailSeekSeconds(Build(0.0, 240), 24); + Assert.Equal(2.5, seek!.Value, 3); + } + + [Fact] + public void FallsBackWhenTheBuildDidNotReportWherePictureStarts() + { + // Older worker, or a template with no SHOT layer: null means "keep the + // old whole-file behaviour" rather than guessing an offset. + Assert.Null(PreviewStage.ThumbnailSeekSeconds(Build(null, 96), 24)); + Assert.Null(PreviewStage.ThumbnailSeekSeconds(null, 24)); + } + + [Theory] + [InlineData(-1.0, 96, 24)] // negative slate length + [InlineData(5.0, 0, 24)] // no picture frames + [InlineData(5.0, -5, 24)] // nonsense frame count + [InlineData(5.0, 96, 0)] // no fps to divide by + public void RefusesNumbersThatDoNotDescribeARealClip(double start, int frames, double fps) + { + Assert.Null(PreviewStage.ThumbnailSeekSeconds(Build(start, frames), fps)); + } +} diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs index 95b6236..ec24c93 100644 --- a/RenderWorker/VFXReviewWorker/Dtos.cs +++ b/RenderWorker/VFXReviewWorker/Dtos.cs @@ -193,6 +193,12 @@ public sealed class PreviewBuildResult public int? FrameCount { get; set; } public int? Width { get; set; } public int? Height { get; set; } + /// + /// Seconds of slate ahead of picture in the preview comp (the SHOT layer's + /// in-point). Null on builds predating this field, or when the template has + /// no SHOT layer — the thumbnail then falls back to scanning from frame 0. + /// + public double? PictureStartSeconds { get; set; } } /// The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw. diff --git a/RenderWorker/VFXReviewWorker/PreviewStage.cs b/RenderWorker/VFXReviewWorker/PreviewStage.cs index 9eec8f3..4031dd8 100644 --- a/RenderWorker/VFXReviewWorker/PreviewStage.cs +++ b/RenderWorker/VFXReviewWorker/PreviewStage.cs @@ -179,7 +179,7 @@ public sealed class PreviewStage null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds); } - var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, shutdown); + var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, build, manifest.Fps, shutdown); sw.Stop(); _log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb})", @@ -231,8 +231,24 @@ public sealed class PreviewStage return process.ExitCode; } - /// Optional 960-wide poster frame at 25% duration (§9.1). Never fatal. - private async Task TryMakeThumbnailAsync(string jobId, string mp4Path, CancellationToken ct) + /// + /// Optional 960-wide poster frame at 25% of picture duration + /// (§9.1). Never fatal. + /// + /// + /// The preview MP4 opens on the Netflix slate, so the frame has to be taken + /// relative to picture rather than to the file. This previously used + /// ffmpeg's thumbnail filter, which considers only the first 100 + /// frames — on a slated preview that is the slate card, which is what every + /// thumbnail in the system was until now. Seeking to + /// pictureStart + 25% matches what §9.1 always documented and is what + /// makes these frames usable as shot thumbnails. + /// + /// A build that reports no picture start (older worker, or a template with + /// no SHOT layer) keeps the old whole-file behaviour rather than guessing. + /// + private async Task TryMakeThumbnailAsync( + string jobId, string mp4Path, PreviewBuildResult? build, double fps, CancellationToken ct) { if (string.IsNullOrWhiteSpace(_options.FfmpegPath) || !File.Exists(_options.FfmpegPath)) { @@ -249,7 +265,32 @@ public sealed class PreviewStage UseShellExecute = false, CreateNoWindow = true, }; - foreach (var a in new[] { "-y", "-i", mp4Path, "-vf", "thumbnail,scale=960:-1", "-frames:v", "1", outPath }) + + var args = new List { "-y" }; + var seek = ThumbnailSeekSeconds(build, fps); + if (seek is { } t) + { + // Before -i: ffmpeg seeks by keyframe, which is fast and accurate + // enough for a poster frame. + args.Add("-ss"); + args.Add(t.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)); + args.Add("-i"); + args.Add(mp4Path); + args.Add("-vf"); + args.Add("scale=960:-1"); + } + else + { + args.Add("-i"); + args.Add(mp4Path); + args.Add("-vf"); + args.Add("thumbnail,scale=960:-1"); + } + args.Add("-frames:v"); + args.Add("1"); + args.Add(outPath); + + foreach (var a in args) { psi.ArgumentList.Add(a); } @@ -270,6 +311,19 @@ public sealed class PreviewStage } } + /// + /// Timestamp for the poster frame: a quarter of the way into picture, + /// skipping the slate. Null when the build did not report where picture + /// starts, or when the numbers do not describe a real clip. + /// + internal static double? ThumbnailSeekSeconds(PreviewBuildResult? build, double fps) + { + if (build?.PictureStartSeconds is not { } start || start < 0) return null; + if (build.FrameCount is not { } frames || frames <= 0) return null; + if (fps <= 0) return null; + return start + (frames / fps) * 0.25; + } + private static PreviewStageResult Fail(string message, bool retryable, Stopwatch sw, PreviewBuildResult? build = null) { sw.Stop(); diff --git a/RenderWorker/scripts/vfxr_build_preview.jsx b/RenderWorker/scripts/vfxr_build_preview.jsx index 81c8b13..441775c 100644 --- a/RenderWorker/scripts/vfxr_build_preview.jsx +++ b/RenderWorker/scripts/vfxr_build_preview.jsx @@ -30,7 +30,11 @@ queued: [], frameCount: null, width: null, - height: null + height: null, + // Seconds of slate ahead of picture in the preview comp — the SHOT + // layer's in-point. The worker needs it to grab a thumbnail from + // picture rather than from the slate card. + pictureStartSeconds: null }; var resultPath = null; @@ -512,6 +516,8 @@ var shotLayer = previewComp.layer("SHOT"); var targetOutPoint = shotLayer.inPoint + shotComp.duration; + // Everything before this is slate; the thumbnail has to start after it. + result.pictureStartSeconds = shotLayer.inPoint; if (targetOutPoint > previewComp.duration) { previewComp.duration = targetOutPoint; } diff --git a/actions/projects.ts b/actions/projects.ts index 743ca7f..12b9184 100644 --- a/actions/projects.ts +++ b/actions/projects.ts @@ -108,10 +108,14 @@ export async function updateProject(data: z.infer) { ...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || null }), // Blank fields are dropped rather than stored as empty strings, so a comp // name left blank keeps following the convention instead of pinning "". + // Booleans are kept as-is: `false` is a meaningful stored value, and the + // blank-string test would otherwise discard every non-string setting. ...(rest.pipelineConfig !== undefined && { pipelineConfig: rest.pipelineConfig ? (Object.fromEntries( - Object.entries(rest.pipelineConfig).filter(([, v]) => typeof v === "string" && v.trim()) + Object.entries(rest.pipelineConfig).filter(([, v]) => + typeof v === "boolean" ? true : typeof v === "string" && !!v.trim() + ) ) as Prisma.InputJsonValue) : Prisma.DbNull, }), diff --git a/actions/shots.ts b/actions/shots.ts index 4ae1696..f675338 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -4,7 +4,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { ShotStatus, ShotPriority, ShotApprovalStatus } from "@prisma/client"; +import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client"; import { recalcShotStatus } from "@/lib/shot-status"; const createShotSchema = z.object({ @@ -615,6 +615,59 @@ export async function bulkUpdateDueDates( return { success: true, updated: shots.length }; } +// ── Bulk Delete Shots ───────────────────────────────────────────────────────── + +export async function bulkDeleteShots(shotIds: string[]) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + + if (!shotIds.length) return { success: true, deleted: 0, failed: [] as { shotId: string; shotCode: string; reason: string }[] }; + + const shots = await db.shot.findMany({ + where: { id: { in: shotIds } }, + select: { id: true, shotCode: true, projectId: true }, + }); + + const projectIds = [...new Set(shots.map((s) => s.projectId))]; + const failed: { shotId: string; shotCode: string; reason: string }[] = []; + let deleted = 0; + + // One at a time, not deleteMany: a shot with renders or deliveries is + // protected at the database level (exports_shotId_fkey is ON DELETE + // RESTRICT, deliberately — delivery history must never be destroyable by + // deleting the shot underneath it). A single bulk statement would abort + // entirely on the first shot it hits; deleting individually means every + // shot that *can* go still does, and the ones that can't are reported by + // name instead of failing the whole selection. + for (const shot of shots) { + try { + await db.shot.delete({ where: { id: shot.id } }); + deleted += 1; + } catch (e) { + failed.push({ + shotId: shot.id, + shotCode: shot.shotCode, + reason: + e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2003" + ? "has renders or deliveries — delete those first" + : e instanceof Error + ? e.message + : "unknown error", + }); + } + } + + for (const pid of projectIds) { + revalidatePath(`/projects/${pid}`); + } + revalidatePath(`/shot-status`); + + return { success: true, deleted, failed }; +} + export async function renameFootagePlate(plateId: string, label: string) { const session = await auth(); if (!session?.user) throw new Error("Unauthorized"); @@ -1213,3 +1266,91 @@ export async function updateShotVersion(shotId: string, version: string) { revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); return { success: true, shotVersion: version }; } + +// ── Generated (render-derived) shot thumbnails ─────────────────────────────── +// +// A shot can carry two stills: `thumbnailUrl`, the reference frame that came +// with the brief, and `generatedThumbnailUrl`, a frame promoted from a render +// export. The reference always wins on display (lib/shot-thumbnail.ts), so +// these actions can only ever fill a gap — a promotion never overwrites the +// brief, and clearing one always falls back to it. +// +// Shows with `pipelineConfig.autoShotThumbnail` promote on every finished +// export automatically (lib/render-pipeline/preview.ts). These actions are the +// manual equivalent, and work regardless of that flag: a supervisor can pull a +// frame onto one shot on a project that is otherwise hands-off. + +/** + * Promote a render export's thumbnail onto its shot. + * + * With no `exportId`, uses the newest export of that shot which actually has a + * thumbnail — "use the latest render frame". + */ +export async function promoteExportThumbnail(shotId: string, exportId?: string) { + 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: { id: true, projectId: true }, + }); + if (!shot) throw new Error("Shot not found"); + + const exportRow = await db.export.findFirst({ + where: { + shotId, + thumbnailKey: { not: null }, + ...(exportId ? { id: exportId } : {}), + }, + orderBy: { versionNumber: "desc" }, + select: { id: true, thumbnailKey: true, versionString: true }, + }); + if (!exportRow?.thumbnailKey) { + throw new Error( + exportId + ? "That export has no thumbnail — its preview may have been skipped, or the worker had no ffmpeg configured" + : "No render of this shot has produced a thumbnail yet" + ); + } + + const generatedThumbnailUrl = `/api/files/${exportRow.thumbnailKey}`; + await db.shot.update({ + where: { id: shotId }, + data: { generatedThumbnailUrl, generatedThumbnailFrom: exportRow.versionString }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + return { + success: true, + generatedThumbnailUrl, + generatedThumbnailFrom: exportRow.versionString, + }; +} + +/** Drop the render-derived still. The reference frame, if any, comes back. */ +export async function clearGeneratedThumbnail(shotId: string) { + 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: { id: true, projectId: true }, + }); + if (!shot) throw new Error("Shot not found"); + + await db.shot.update({ + where: { id: shotId }, + data: { generatedThumbnailUrl: null, generatedThumbnailFrom: null }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + return { success: true }; +} diff --git a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx index 62fd94c..89ec6c7 100644 --- a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx +++ b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx @@ -64,6 +64,7 @@ interface ProjectTabsClientProps { overlayComp?: string | null; previewComp?: string | null; lutComp?: string | null; + autoShotThumbnail?: boolean | null; } | null; }; clients: Client[]; diff --git a/app/(dashboard)/projects/[id]/kanban/page.tsx b/app/(dashboard)/projects/[id]/kanban/page.tsx index 8959bc0..e8cad07 100644 --- a/app/(dashboard)/projects/[id]/kanban/page.tsx +++ b/app/(dashboard)/projects/[id]/kanban/page.tsx @@ -41,7 +41,7 @@ export default async function KanbanPage({ where: { projectId: id }, orderBy: [{ status: "asc" }, { sortOrder: "asc" }], include: { - shot: { select: { id: true, shotCode: true, thumbnailUrl: true } }, + shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } }, asset: { select: { id: true, assetCode: true, name: true } }, assignedArtist: { select: { id: true, name: true, email: true, image: true }, diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index a6bc1fc..b110ca6 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -60,7 +60,7 @@ async function getProject(id: string) { tasks: { orderBy: [{ status: "asc" }, { sortOrder: "asc" }], include: { - shot: { select: { id: true, shotCode: true, thumbnailUrl: true } }, + shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } }, asset: { select: { id: true, assetCode: true, name: true } }, assignedArtist: { select: { id: true, name: true, email: true, image: true } }, _count: { select: { versions: true } }, @@ -207,6 +207,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st overlayComp?: string | null; previewComp?: string | null; lutComp?: string | null; + autoShotThumbnail?: boolean | null; } | null, }} clients={clients} diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 404534a..1f54b54 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -76,6 +76,7 @@ const PRIORITY_CONFIG: Record = { }; import { useToast } from "@/components/ui/use-toast"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; export default function ShotDetailPage() { const params = useParams<{ id: string; shotId: string }>(); @@ -251,11 +252,11 @@ export default function ShotDetailPage() { - {shot.thumbnailUrl && ( + {shotThumbnail(shot) && (
{shot.shotCode} void; + onConfirm: () => Promise; + count: number; +}) { + const [isPending, startTransition] = useTransition(); + + const handleConfirm = () => { + startTransition(async () => { + await onConfirm(); + }); + }; + + return ( + { if (!o && !isPending) onClose(); }}> + + + Delete {count} shot{count !== 1 ? "s" : ""}? + + +

+ This will permanently delete {count}{" "} + shot{count !== 1 ? "s" : ""}, including all tasks, versions, footage, and annotations. + This cannot be undone. +

+

+ A shot with renders or deliveries already on record is protected and will be skipped + rather than deleted — you'll see which ones and why. +

+ + + + + +
+
+ ); +} + // ── NotesCell ───────────────────────────────────────────────────────────────── function NotesCell({ @@ -382,9 +443,9 @@ function ShotTable({ {/* Thumbnail */}
- {shot.thumbnailUrl ? ( + {shotThumbnail(shot) ? ( {shot.shotCode} setExpandedEpisodes((prev) => { @@ -586,6 +648,26 @@ export function ShotStatusClient({ } }; + const handleBulkDelete = async () => { + try { + const result = await bulkDeleteShots([...selectedIds]); + if (result.failed.length > 0) { + toast({ + title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}, ${result.failed.length} skipped`, + description: result.failed.map((f) => `${f.shotCode}: ${f.reason}`).join("; "), + variant: "destructive", + }); + } else { + toast({ title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}` }); + } + setDeleteDialogOpen(false); + clearSelection(); + router.refresh(); + } catch (e) { + toast({ title: "Failed to delete shots", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } + }; + // Group by episode for episodic projects const isEpisodic = selectedProject?.projectType === "EPISODIC"; const episodeGroups: [string, ShotRow[]][] = isEpisodic @@ -722,15 +804,32 @@ export function ShotStatusClient({ {selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected - + + + + + + setDueDateDialogOpen(true)}> + + Change Due Date + + + setDeleteDialogOpen(true)} + className="text-red-400 focus:text-red-300" + > + + Delete Shots + + +
); } diff --git a/app/(dashboard)/shot-status/page.tsx b/app/(dashboard)/shot-status/page.tsx index bea6144..5670eaf 100644 --- a/app/(dashboard)/shot-status/page.tsx +++ b/app/(dashboard)/shot-status/page.tsx @@ -37,6 +37,7 @@ async function getShotsForProject(projectId: string) { priority: true, dueDate: true, thumbnailUrl: true, + generatedThumbnailUrl: true, notes: true, description: true, isKeyShot: true, diff --git a/app/api/client/[token]/project/route.ts b/app/api/client/[token]/project/route.ts index 1326044..518478d 100644 --- a/app/api/client/[token]/project/route.ts +++ b/app/api/client/[token]/project/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { validateReviewToken } from "@/lib/review-auth"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; /** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */ export async function GET( @@ -49,6 +50,7 @@ export async function GET( status: true, shotApprovalStatus: true, thumbnailUrl: true, + generatedThumbnailUrl: true, tasks: { where: { versions: { some: { isClientVisible: true } }, @@ -113,7 +115,12 @@ export async function GET( return NextResponse.json({ project, - shots, + // Shots the client sees resolve the same way as internally: the reference + // still first, a render-derived one only where the show supplied none. + shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({ + ...shot, + thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }), + })), assetTasks, sessionLabel: session.label, }); diff --git a/app/api/ext/projects/[projectCode]/shots/route.ts b/app/api/ext/projects/[projectCode]/shots/route.ts index bf9d0b1..6fca47b 100644 --- a/app/api/ext/projects/[projectCode]/shots/route.ts +++ b/app/api/ext/projects/[projectCode]/shots/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; // ── Auth ───────────────────────────────────────────────────────────────────── @@ -91,6 +92,7 @@ export async function GET( seqTimecodeStart: true, seqTimecodeEnd: true, thumbnailUrl: true, + generatedThumbnailUrl: true, artist: { select: { id: true, name: true, email: true }, }, @@ -111,6 +113,9 @@ export async function GET( return NextResponse.json({ project, pagination: { page, limit, total, pages: Math.ceil(total / limit) }, - shots, + shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({ + ...shot, + thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }), + })), }); } diff --git a/app/api/ext/shots/lookup/route.ts b/app/api/ext/shots/lookup/route.ts index 7eda8d5..0aa8551 100644 --- a/app/api/ext/shots/lookup/route.ts +++ b/app/api/ext/shots/lookup/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; // ── Auth ───────────────────────────────────────────────────────────────────── @@ -67,6 +68,7 @@ export async function GET(req: NextRequest) { seqTimecodeEnd: true, shotVersion: true, thumbnailUrl: true, + generatedThumbnailUrl: true, createdAt: true, updatedAt: true, project: { @@ -104,5 +106,8 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Shot not found" }, { status: 404 }); } - return NextResponse.json({ shot }); + const { generatedThumbnailUrl, ...rest } = shot; + return NextResponse.json({ + shot: { ...rest, thumbnailUrl: shotThumbnail(shot) }, + }); } diff --git a/app/api/playlist/route.ts b/app/api/playlist/route.ts index 6b66041..9ed52ff 100644 --- a/app/api/playlist/route.ts +++ b/app/api/playlist/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; export async function GET(req: NextRequest) { const session = await auth(); @@ -33,6 +34,7 @@ export async function GET(req: NextRequest) { shotApprovalStatus: true, sharedWithClient: true, thumbnailUrl: true, + generatedThumbnailUrl: true, tasks: { select: { id: true, @@ -75,7 +77,7 @@ export async function GET(req: NextRequest) { status: shot.status, shotApprovalStatus: shot.shotApprovalStatus, sharedWithClient: shot.sharedWithClient, - thumbnailUrl: shot.thumbnailUrl, + thumbnailUrl: shotThumbnail(shot), latestVersion: { id: latest.id, versionNumber: latest.versionNumber, diff --git a/app/api/shoot-log/shots/[shotId]/route.ts b/app/api/shoot-log/shots/[shotId]/route.ts index d29746b..f375b01 100644 --- a/app/api/shoot-log/shots/[shotId]/route.ts +++ b/app/api/shoot-log/shots/[shotId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; function requireRole(role: string) { return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); @@ -27,6 +28,7 @@ export async function GET( scene: true, episode: true, thumbnailUrl: true, + generatedThumbnailUrl: true, references: { orderBy: { sortOrder: "asc" }, select: { @@ -41,5 +43,9 @@ export async function GET( }); if (!shot) return NextResponse.json({ error: "Not found" }, { status: 404 }); - return NextResponse.json({ shot }); + + const { generatedThumbnailUrl, ...rest } = shot; + return NextResponse.json({ + shot: { ...rest, thumbnailUrl: shotThumbnail(shot) }, + }); } diff --git a/app/api/storyboard/pdf/route.ts b/app/api/storyboard/pdf/route.ts index 199393c..a49baa4 100644 --- a/app/api/storyboard/pdf/route.ts +++ b/app/api/storyboard/pdf/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -409,13 +410,18 @@ export async function GET(req: NextRequest) { where: { projectId }, select: { id: true, shotCode: true, scene: true, episode: true, shotNumber: true, - description: true, notes: true, status: true, thumbnailUrl: true, + description: true, notes: true, status: true, + thumbnailUrl: true, generatedThumbnailUrl: true, frameStart: true, frameEnd: true, fps: true, isKeyShot: true, shotVersion: true, shotGroup: { select: { id: true, name: true } }, }, orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }], }); + // Collapse the two columns before anything reads them, so the "only shots + // with thumbnails" filter and the page renderer agree on what counts as one. + shots = shots.map((s) => ({ ...s, thumbnailUrl: shotThumbnail(s) })); + if (onlyThumbs) shots = shots.filter((s) => !!s.thumbnailUrl); if (epFilter.length) shots = shots.filter((s) => epFilter.includes(s.episode ?? "")); if (grpFilter.length) shots = shots.filter((s) => grpFilter.includes(s.shotGroup?.id ?? "")); diff --git a/app/api/storyboard/shots/route.ts b/app/api/storyboard/shots/route.ts index f3927d0..3e351fe 100644 --- a/app/api/storyboard/shots/route.ts +++ b/app/api/storyboard/shots/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; // GET /api/storyboard/shots?projectId=xxx export async function GET(req: NextRequest) { @@ -26,6 +27,7 @@ export async function GET(req: NextRequest) { notes: true, status: true, thumbnailUrl: true, + generatedThumbnailUrl: true, frameStart: true, frameEnd: true, fps: true, @@ -40,5 +42,12 @@ export async function GET(req: NextRequest) { ], }); - return NextResponse.json({ shots }); + // The reference still wins; a render-derived one fills the gap where a show + // shipped no stills at all (lib/shot-thumbnail.ts). + return NextResponse.json({ + shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({ + ...shot, + thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }), + })), + }); } diff --git a/components/projects/ProjectSettingsTab.tsx b/components/projects/ProjectSettingsTab.tsx index c3bcb5d..6e38ee2 100644 --- a/components/projects/ProjectSettingsTab.tsx +++ b/components/projects/ProjectSettingsTab.tsx @@ -41,6 +41,7 @@ const settingsSchema = z.object({ overlayComp: z.string().optional(), previewComp: z.string().optional(), lutComp: z.string().optional(), + autoShotThumbnail: z.boolean().optional(), }); type SettingsFormValues = z.infer; @@ -79,6 +80,7 @@ interface ProjectSettingsTabProps { overlayComp?: string | null; previewComp?: string | null; lutComp?: string | null; + autoShotThumbnail?: boolean | null; } | null; }; clients: Client[]; @@ -127,6 +129,7 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet overlayComp: project.pipelineConfig?.overlayComp ?? "", previewComp: project.pipelineConfig?.previewComp ?? "", lutComp: project.pipelineConfig?.lutComp ?? "", + autoShotThumbnail: project.pipelineConfig?.autoShotThumbnail ?? false, }, }); @@ -159,6 +162,7 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet overlayComp: data.overlayComp?.trim() || null, previewComp: data.previewComp?.trim() || null, lutComp: data.lutComp?.trim() || null, + autoShotThumbnail: data.autoShotThumbnail ?? false, }, }); toast({ title: "Project settings saved" }); @@ -427,6 +431,28 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet
+ +
+ +
diff --git a/components/schedule/ScheduleTaskBlock.tsx b/components/schedule/ScheduleTaskBlock.tsx index 30d5748..3b186eb 100644 --- a/components/schedule/ScheduleTaskBlock.tsx +++ b/components/schedule/ScheduleTaskBlock.tsx @@ -23,6 +23,7 @@ import { ActiveDragData, } from "@/app/(dashboard)/schedule/SchedulePageClient"; import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; const STATUS_STYLES: Record< TaskStatus, @@ -134,7 +135,7 @@ export function ScheduleTaskBlock({ const statusStyle = STATUS_STYLES[task.status]; const contextCode = task.shot?.shotCode ?? task.asset?.assetCode; const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title; - const thumbnail = task.shot?.thumbnailUrl ?? null; + const thumbnail = shotThumbnail(task.shot); const isOverdue = task.dueDate && diff --git a/components/shots/ShotCard.tsx b/components/shots/ShotCard.tsx index 952f10d..3ae5630 100644 --- a/components/shots/ShotCard.tsx +++ b/components/shots/ShotCard.tsx @@ -33,6 +33,7 @@ import { import type { ShotWithDetails } from "@/types"; import { duplicateShot } from "@/actions/shots"; import { useToast } from "@/components/ui/use-toast"; +import { shotThumbnail } from "@/lib/shot-thumbnail"; interface ShotCardProps { shot: ShotWithDetails; @@ -128,9 +129,9 @@ export function ShotCard({ shot, projectId, compact = false, canManage = false } {/* Thumbnail with cinema scope aspect ratio (2.39:1) */}
- {shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? ( + {shotThumbnail(shot) || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? ( {shot.shotCode}(null); + + // Take this export's frame as the shot's thumbnail. Fills in only where the + // shot has no reference frame of its own (§9.1a) — it never overwrites one. + const promote = async (exportId: string, versionString: string) => { + setPromotingId(exportId); + try { + await promoteExportThumbnail(shotId, exportId); + toast({ title: `Shot thumbnail taken from ${versionString}` }); + router.refresh(); + } catch (err) { + toast({ + title: "Could not use that frame", + description: (err as Error).message, + variant: "destructive", + }); + } finally { + setPromotingId(null); + } + }; + const { data, isLoading } = useQuery({ queryKey: ["shot-exports", shotId], queryFn: async () => { @@ -56,29 +85,54 @@ export function ShotExportsTab({ shotId }: { shotId: string }) { return (
{exports.map((e) => ( - - {e.versionString} - + {e.thumbnailUrl ? ( + + + + ) : ( + )} - > - {statusLabel(e.status)} - - - {e.job?.machineName ? `${e.job.machineName} · attempt ${e.job.attempt} · ` : ""} - {e.outputDir} - - - {new Date(e.createdAt).toLocaleDateString()} - - - + {e.versionString} + + {statusLabel(e.status)} + + + {e.job?.machineName ? `${e.job.machineName} · attempt ${e.job.attempt} · ` : ""} + {e.outputDir} + + + {new Date(e.createdAt).toLocaleDateString()} + + + + + {e.thumbnailUrl && ( + + )} +
))}
); diff --git a/components/shots/ShotSettingsTab.tsx b/components/shots/ShotSettingsTab.tsx index 3493d8e..fb1adf6 100644 --- a/components/shots/ShotSettingsTab.tsx +++ b/components/shots/ShotSettingsTab.tsx @@ -18,9 +18,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { updateShot, deleteShot } from "@/actions/shots"; +import { + updateShot, + deleteShot, + promoteExportThumbnail, + clearGeneratedThumbnail, +} from "@/actions/shots"; import { useToast } from "@/components/ui/use-toast"; -import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2 } from "lucide-react"; +import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2, Sparkles } from "lucide-react"; // ─── Shot References ────────────────────────────────────────────────────────── @@ -191,6 +196,8 @@ interface ShotSettingsTabProps { dueDate: Date | string | null; artistId: string | null; thumbnailUrl: string | null; + generatedThumbnailUrl?: string | null; + generatedThumbnailFrom?: string | null; projectId: string; }; artists: Artist[]; @@ -208,6 +215,48 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps const [clearThumbnail, setClearThumbnail] = useState(false); const fileInputRef = useRef(null); + // Render-derived still, managed separately from the reference frame above. + const [generatedUrl, setGeneratedUrl] = useState(shot.generatedThumbnailUrl ?? null); + const [generatedFrom, setGeneratedFrom] = useState(shot.generatedThumbnailFrom ?? null); + const [isPromoting, setIsPromoting] = useState(false); + + const handlePromote = async () => { + setIsPromoting(true); + try { + const res = await promoteExportThumbnail(shot.id); + setGeneratedUrl(res.generatedThumbnailUrl); + setGeneratedFrom(res.generatedThumbnailFrom); + toast({ title: `Thumbnail taken from ${res.generatedThumbnailFrom}` }); + router.refresh(); + } catch (err) { + toast({ + title: "Could not use a render frame", + description: (err as Error).message, + variant: "destructive", + }); + } finally { + setIsPromoting(false); + } + }; + + const handleClearGenerated = async () => { + setIsPromoting(true); + try { + await clearGeneratedThumbnail(shot.id); + setGeneratedUrl(null); + setGeneratedFrom(null); + router.refresh(); + } catch (err) { + toast({ + title: "Could not clear the render frame", + description: (err as Error).message, + variant: "destructive", + }); + } finally { + setIsPromoting(false); + } + }; + const formatDate = (d: Date | string | null) => { if (!d) return ""; return new Date(d).toISOString().split("T")[0]; @@ -452,6 +501,57 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps className="hidden" onChange={handleThumbnailChange} /> + + {/* ── From renders ─────────────────────────────────────────────────── + A second, separate still taken from a finished export. It never + replaces the one above — it only shows where there is none — so a + show that ships reference frames keeps them, and a show that ships + none stops rendering empty grids. */} +
+
+ + From renders +
+ + {generatedUrl ? ( +
+
+ {shot.shotCode} +
+
+

+ {generatedFrom ? <>Taken from {generatedFrom}. : "Taken from a render."}{" "} + {thumbnailPreview && !clearThumbnail + ? "Not shown anywhere while this shot has a thumbnail of its own." + : "Shown wherever this shot appears, until a thumbnail is uploaded above."} +

+
+ + +
+
+
+ ) : ( +
+

+ Take a frame from this shot's most recent finished export. Never + replaces the thumbnail above — it only fills in where there + isn't one. +

+ +
+ )} +
{/* References */} diff --git a/components/shots/ShotSlateTab.tsx b/components/shots/ShotSlateTab.tsx index e15b627..905f991 100644 --- a/components/shots/ShotSlateTab.tsx +++ b/components/shots/ShotSlateTab.tsx @@ -9,6 +9,7 @@ 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; @@ -164,9 +165,9 @@ export function ShotSlateTab({ shot, projectName, onSaved }: Props) {
{/* Thumbnail */}
- {shot.thumbnailUrl ? ( + {shotThumbnail(shot) ? ( {shot.shotCode} = { LOW: "bg-zinc-500", @@ -34,7 +35,11 @@ interface KanbanTask { status: TaskStatus; priority: string; dueDate: Date | null; - shot?: { shotCode: string; thumbnailUrl?: string | null } | null; + shot?: { + shotCode: string; + thumbnailUrl?: string | null; + generatedThumbnailUrl?: string | null; + } | null; asset?: { assetCode: string; name: string } | null; assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null; _count?: { versions: number }; @@ -71,11 +76,12 @@ export function KanbanCard({ task, isDragOverlay = false }: KanbanCardProps) { const contextCode = task.shot?.shotCode ?? task.asset?.assetCode; - // Resolve thumbnail: prefer latest version thumbnail, then shot thumbnail + // Resolve thumbnail: prefer latest version thumbnail, then the shot's own + // (reference frame first, render-derived one only as a fallback). const thumbnail = latestVersion?.thumbnailUrl ?? latestVersion?.posterUrl ?? - task.shot?.thumbnailUrl ?? + shotThumbnail(task.shot) ?? null; return ( diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts index 10f20ff..27c17a3 100644 --- a/lib/render-pipeline/exports.ts +++ b/lib/render-pipeline/exports.ts @@ -506,6 +506,9 @@ export async function listExports(filter: ListExportsFilter) { statusChangedAt: e.statusChangedAt, createdAt: e.createdAt, outputDir: e.outputDir, + // Lets the shot's Exports tab offer this export's frame as the shot + // thumbnail (§9.1a) without a second round trip per row. + thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null, job: e.renderJobs[0] ? { id: e.renderJobs[0].id, diff --git a/lib/render-pipeline/preview.ts b/lib/render-pipeline/preview.ts index a261821..4f79f62 100644 --- a/lib/render-pipeline/preview.ts +++ b/lib/render-pipeline/preview.ts @@ -3,7 +3,7 @@ import { db } from "@/lib/db"; import { PipelineError } from "./errors"; import { applyTransition } from "./transitions"; import { getConfigNumber, getPreviewConfig } from "./config"; -import { resolveProjectTemplates } from "./project-templates"; +import { resolveProjectTemplates, autoShotThumbnailEnabled } from "./project-templates"; /** Slate date format used by the panel's getDateString (YYYY/MM/DD). */ function slateDate(d = new Date()): string { @@ -407,6 +407,30 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi } } + // Promote the render frame onto the shot, for shows that opted in. + // + // Only `generatedThumbnailUrl` is written — `thumbnailUrl` holds the + // reference frame that came with the brief and the pipeline never touches + // it. Shows that supply stills therefore look exactly as they did; shows + // that supply none get their empty grids filled from the renders instead. + let thumbnailPromoted = false; + if (artifacts.thumbnailKey) { + const shot = await tx.shot.findUnique({ + where: { id: exportRow.shotId }, + select: { project: { select: { pipelineConfig: true } } }, + }); + if (shot && autoShotThumbnailEnabled(shot.project)) { + await tx.shot.update({ + where: { id: exportRow.shotId }, + data: { + generatedThumbnailUrl: `/api/files/${artifacts.thumbnailKey}`, + generatedThumbnailFrom: exportRow.versionString, + }, + }); + thumbnailPromoted = true; + } + } + // The task title carries the version it is showing (house convention: // "UNG_102_019_010_cmp_TT_v004"). The preview just uploaded *is* the task's // new latest version, so the title has to follow the file — the same rename @@ -444,7 +468,8 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi ? " — review version media replaced" : ` — review version created (${inheritedApproval ?? "PENDING_REVIEW"})` : " — no preview media reported") + - (taskRenamed ? ` — task renamed to ${taskTitle}` : ""), + (taskRenamed ? ` — task renamed to ${taskTitle}` : "") + + (thumbnailPromoted ? " — shot thumbnail updated from this render" : ""), }, { ...(versionId ? { versionId } : {}), diff --git a/lib/render-pipeline/project-templates.test.ts b/lib/render-pipeline/project-templates.test.ts index 728ee67..b086b66 100644 --- a/lib/render-pipeline/project-templates.test.ts +++ b/lib/render-pipeline/project-templates.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { conventionalNames } from "./project-templates"; +import { conventionalNames, autoShotThumbnailEnabled } from "./project-templates"; /** * The convention has to reproduce UNG's existing comp names exactly, or @@ -32,3 +32,31 @@ describe("conventionalNames", () => { expect(conventionalNames("").overlayComp).toBe("SHOW_VFX_OVERLAY"); }); }); + +/** + * The flag decides whether finished renders start writing shot thumbnails, so + * anything short of an explicit `true` has to leave shots alone — a malformed + * config in particular, which must not be read as consent. + */ +describe("autoShotThumbnailEnabled", () => { + it("is on only when explicitly enabled", () => { + expect(autoShotThumbnailEnabled({ pipelineConfig: { autoShotThumbnail: true } })).toBe(true); + }); + + it("defaults to off", () => { + expect(autoShotThumbnailEnabled({ pipelineConfig: {} })).toBe(false); + expect(autoShotThumbnailEnabled({ pipelineConfig: null })).toBe(false); + expect(autoShotThumbnailEnabled({})).toBe(false); + expect(autoShotThumbnailEnabled({ pipelineConfig: { autoShotThumbnail: false } })).toBe(false); + }); + + it("stays off when the config is malformed", () => { + expect(autoShotThumbnailEnabled({ pipelineConfig: { autoShotThumbnail: "yes" } })).toBe(false); + expect(autoShotThumbnailEnabled({ pipelineConfig: "not an object" })).toBe(false); + }); + + it("does not disturb the other settings it shares a column with", () => { + const cfg = { pipelineConfig: { templateAep: "X:/show.aep", autoShotThumbnail: true } }; + expect(autoShotThumbnailEnabled(cfg)).toBe(true); + }); +}); diff --git a/lib/render-pipeline/project-templates.ts b/lib/render-pipeline/project-templates.ts index d2907de..919399c 100644 --- a/lib/render-pipeline/project-templates.ts +++ b/lib/render-pipeline/project-templates.ts @@ -27,6 +27,16 @@ export const pipelineConfigSchema = z.object({ /** AE output-module templates, when this show does not use the studio defaults. */ movTemplate: z.string().max(200).optional().nullable(), mp4Template: z.string().max(200).optional().nullable(), + /** + * Promote each finished export's thumbnail onto its shot + * (`Shot.generatedThumbnailUrl`). Off by default and deliberately per-show: + * productions that ship a still with every shot already have a reference + * frame, and the renders should not compete with it. Turn it on for shows + * that arrive with no stills at all, where an empty grid is the alternative. + * + * Never touches `Shot.thumbnailUrl` — see lib/shot-thumbnail.ts. + */ + autoShotThumbnail: z.boolean().optional().nullable(), }); export type PipelineConfigInput = z.infer; @@ -55,6 +65,16 @@ export function conventionalNames(showId: string) { }; } +/** + * Whether finished exports promote their thumbnail onto the shot. Defaults to + * false, including when `pipelineConfig` is malformed — a broken config must + * not start rewriting shot thumbnails. + */ +export function autoShotThumbnailEnabled(project: { pipelineConfig?: unknown }): boolean { + const parsed = pipelineConfigSchema.safeParse(project.pipelineConfig ?? {}); + return parsed.success ? parsed.data.autoShotThumbnail === true : false; +} + export async function resolveProjectTemplates(project: { showId: string | null; code: string; diff --git a/lib/shot-thumbnail.test.ts b/lib/shot-thumbnail.test.ts new file mode 100644 index 0000000..1ae045a --- /dev/null +++ b/lib/shot-thumbnail.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { shotThumbnail, isGeneratedThumbnail } from "./shot-thumbnail"; + +/** + * The whole point of the second column is that promotion can never destroy the + * reference frame that came with the brief. These cases are that guarantee. + */ +describe("shotThumbnail", () => { + it("prefers the reference frame over a render-derived one", () => { + expect( + shotThumbnail({ thumbnailUrl: "/api/files/image/ref.jpg", generatedThumbnailUrl: "/api/files/image/gen.jpg" }) + ).toBe("/api/files/image/ref.jpg"); + }); + + it("falls back to the render frame when the show supplied no still", () => { + expect(shotThumbnail({ thumbnailUrl: null, generatedThumbnailUrl: "/api/files/image/gen.jpg" })).toBe( + "/api/files/image/gen.jpg" + ); + }); + + it("returns null when the shot has neither", () => { + expect(shotThumbnail({ thumbnailUrl: null, generatedThumbnailUrl: null })).toBeNull(); + expect(shotThumbnail(null)).toBeNull(); + expect(shotThumbnail({})).toBeNull(); + }); + + it("treats an empty string as no thumbnail", () => { + // updateShot accepts "" from a cleared form field, which must not win over + // a generated frame or render as a broken . + expect(shotThumbnail({ thumbnailUrl: "", generatedThumbnailUrl: "/api/files/image/gen.jpg" })).toBe( + "/api/files/image/gen.jpg" + ); + }); + + it("reports whether what is displayed came from a render", () => { + expect(isGeneratedThumbnail({ thumbnailUrl: null, generatedThumbnailUrl: "/g.jpg" })).toBe(true); + expect(isGeneratedThumbnail({ thumbnailUrl: "/r.jpg", generatedThumbnailUrl: "/g.jpg" })).toBe(false); + expect(isGeneratedThumbnail({ thumbnailUrl: null, generatedThumbnailUrl: null })).toBe(false); + }); +}); diff --git a/lib/shot-thumbnail.ts b/lib/shot-thumbnail.ts new file mode 100644 index 0000000..aadf837 --- /dev/null +++ b/lib/shot-thumbnail.ts @@ -0,0 +1,41 @@ +/** + * Shot thumbnail resolution. + * + * A shot can carry two stills: + * + * • `thumbnailUrl` — the reference frame supplied with the brief + * (client still, batch upload, ext API). Editorial + * data: only ever written by a human. + * • `generatedThumbnailUrl` — a frame promoted from a render export, either + * automatically (projects with + * `pipelineConfig.autoShotThumbnail`) or by hand. + * + * The reference always wins. Shows that arrive with stills look exactly as they + * do today; shows that arrive without any get their empty grids filled from the + * renders instead. Because the two live in different columns, a promotion can + * never destroy the reference — clearing the generated one always falls back to + * the brief. + */ +export interface ShotThumbnailFields { + thumbnailUrl?: string | null; + generatedThumbnailUrl?: string | null; +} + +/** The still to display for a shot, or null when it has neither. */ +export function shotThumbnail(shot: ShotThumbnailFields | null | undefined): string | null { + return shot?.thumbnailUrl || shot?.generatedThumbnailUrl || null; +} + +/** True when the displayed still came from a render rather than the brief. */ +export function isGeneratedThumbnail(shot: ShotThumbnailFields | null | undefined): boolean { + return !shot?.thumbnailUrl && !!shot?.generatedThumbnailUrl; +} + +/** + * Prisma `select` fragment for the two columns. Spread it wherever a query + * already selects `thumbnailUrl` so the fallback has something to fall back to. + */ +export const shotThumbnailSelect = { + thumbnailUrl: true, + generatedThumbnailUrl: true, +} as const; diff --git a/prisma/migrations/20260827000000_shot_generated_thumbnail/migration.sql b/prisma/migrations/20260827000000_shot_generated_thumbnail/migration.sql new file mode 100644 index 0000000..f713b94 --- /dev/null +++ b/prisma/migrations/20260827000000_shot_generated_thumbnail/migration.sql @@ -0,0 +1,6 @@ +-- Thumbnail promoted from a render export. Kept separate from "thumbnailUrl" +-- (the client-supplied reference frame) so promotion can only fill a gap and +-- never overwrite the brief. Display precedence is +-- COALESCE("thumbnailUrl", "generatedThumbnailUrl"). +ALTER TABLE "shots" ADD COLUMN "generatedThumbnailUrl" TEXT; +ALTER TABLE "shots" ADD COLUMN "generatedThumbnailFrom" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e385150..5b53c7f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -411,6 +411,15 @@ model Shot { fps Float @default(24) dueDate DateTime? thumbnailUrl String? + /// Thumbnail promoted from a render export — automatically when the project + /// opts in via `pipelineConfig.autoShotThumbnail`, or by hand. Deliberately a + /// separate column: `thumbnailUrl` is the client-supplied reference frame and + /// is never written by the pipeline, so promotion can only fill a gap, never + /// destroy the brief. Display precedence is `thumbnailUrl ?? generatedThumbnailUrl` + /// (lib/shot-thumbnail.ts). + generatedThumbnailUrl String? + /// Version string of the export the generated thumbnail came from, e.g. "v004". + generatedThumbnailFrom String? originalFootageUrl String? originalFootageKey String? shotGroupId String? diff --git a/types/index.ts b/types/index.ts index d5ec3ce..99c6e7e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -165,6 +165,8 @@ export interface ShotWithDetails { frameEnd: number | null; dueDate: Date | null; thumbnailUrl: string | null; + generatedThumbnailUrl: string | null; + generatedThumbnailFrom: string | null; originalFootageUrl: string | null; originalFootageKey: string | null; shotGroupId: string | null;