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) && (
);
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 ? (
+
+
+
+
+
+
+ {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) ? (
= {
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;