From caaa333f0202a6d94519279a0fa41192e02b4f49 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:06:55 +0200 Subject: [PATCH] feat(pipeline): render-derived shot thumbnails, and bulk shot delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent features landing together: Generated shot thumbnails — a shot can now carry a thumbnail promoted from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom) alongside the existing brief-supplied thumbnailUrl, which always takes display precedence (lib/shot-thumbnail.ts) so a promoted frame can only ever fill a gap, never overwrite the brief. Shows with pipelineConfig.autoShotThumbnail promote automatically on every finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/ clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for everyone else. Worker-side seek logic for picking the promoted frame is covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs. Bulk shot delete — the shot-status page's selection bar gets a "Bulk Actions" dropdown (alongside the existing Change Due Date) with Delete Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT by design, so a shot with renders or deliveries on record is protected and gets skipped and reported by name rather than aborting the whole selection or being silently destroyed along with its history. Co-Authored-By: Claude Sonnet 5 --- RenderPipeline2.md | 24 ++- .../ThumbnailSeekTests.cs | 54 +++++++ RenderWorker/VFXReviewWorker/Dtos.cs | 6 + RenderWorker/VFXReviewWorker/PreviewStage.cs | 62 +++++++- RenderWorker/scripts/vfxr_build_preview.jsx | 8 +- actions/projects.ts | 6 +- actions/shots.ts | 143 +++++++++++++++++- .../projects/[id]/ProjectTabsClient.tsx | 1 + app/(dashboard)/projects/[id]/kanban/page.tsx | 2 +- app/(dashboard)/projects/[id]/page.tsx | 3 +- .../projects/[id]/shots/[shotId]/page.tsx | 5 +- .../schedule/SchedulePageClient.tsx | 7 +- app/(dashboard)/schedule/page.tsx | 4 +- .../shot-status/ShotStatusClient.tsx | 131 ++++++++++++++-- app/(dashboard)/shot-status/page.tsx | 1 + app/api/client/[token]/project/route.ts | 9 +- .../ext/projects/[projectCode]/shots/route.ts | 7 +- app/api/ext/shots/lookup/route.ts | 7 +- app/api/playlist/route.ts | 4 +- app/api/shoot-log/shots/[shotId]/route.ts | 8 +- app/api/storyboard/pdf/route.ts | 8 +- app/api/storyboard/shots/route.ts | 11 +- components/projects/ProjectSettingsTab.tsx | 26 ++++ components/schedule/ScheduleTaskBlock.tsx | 3 +- components/shots/ShotCard.tsx | 5 +- components/shots/ShotExportsTab.tsx | 94 +++++++++--- components/shots/ShotSettingsTab.tsx | 104 ++++++++++++- components/shots/ShotSlateTab.tsx | 5 +- components/tasks/KanbanBoard.tsx | 6 +- components/tasks/KanbanCard.tsx | 12 +- lib/render-pipeline/exports.ts | 3 + lib/render-pipeline/preview.ts | 29 +++- lib/render-pipeline/project-templates.test.ts | 30 +++- lib/render-pipeline/project-templates.ts | 20 +++ lib/shot-thumbnail.test.ts | 40 +++++ lib/shot-thumbnail.ts | 41 +++++ .../migration.sql | 6 + prisma/schema.prisma | 9 ++ types/index.ts | 2 + 39 files changed, 876 insertions(+), 70 deletions(-) create mode 100644 RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs create mode 100644 lib/shot-thumbnail.test.ts create mode 100644 lib/shot-thumbnail.ts create mode 100644 prisma/migrations/20260827000000_shot_generated_thumbnail/migration.sql 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;