feat(pipeline): render-derived shot thumbnails, and bulk shot delete
Deploy / deploy (push) Failing after 19m11s
Deploy / deploy (push) Failing after 19m11s
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 <noreply@anthropic.com>
This commit is contained in:
+21
-3
@@ -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:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The preview MP4 opens on the Netflix slate. ffmpeg's <c>thumbnail</c> 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,12 @@ public sealed class PreviewBuildResult
|
||||
public int? FrameCount { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public double? PictureStartSeconds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw.</summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Optional 960-wide poster frame at 25% duration (§9.1). Never fatal.</summary>
|
||||
private async Task<string?> TryMakeThumbnailAsync(string jobId, string mp4Path, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Optional 960-wide poster frame at 25% of <em>picture</em> duration
|
||||
/// (§9.1). Never fatal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>thumbnail</c> 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
|
||||
/// <c>pictureStart + 25%</c> 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.
|
||||
/// </remarks>
|
||||
private async Task<string?> 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<string> { "-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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+5
-1
@@ -108,10 +108,14 @@ export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
|
||||
...(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,
|
||||
}),
|
||||
|
||||
+142
-1
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ interface ProjectTabsClientProps {
|
||||
overlayComp?: string | null;
|
||||
previewComp?: string | null;
|
||||
lutComp?: string | null;
|
||||
autoShotThumbnail?: boolean | null;
|
||||
} | null;
|
||||
};
|
||||
clients: Client[];
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -76,6 +76,7 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
||||
};
|
||||
|
||||
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() {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{shot.thumbnailUrl && (
|
||||
{shotThumbnail(shot) && (
|
||||
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
|
||||
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
src={shotThumbnail(shot)!}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
|
||||
@@ -53,7 +53,12 @@ export interface ScheduleTask {
|
||||
email: string;
|
||||
image: string | null;
|
||||
} | null;
|
||||
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
thumbnailUrl: string | null;
|
||||
generatedThumbnailUrl: string | null;
|
||||
} | null;
|
||||
asset: { id: string; assetCode: string; name: string } | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default async function SchedulePage({
|
||||
...(artist ? { assignedArtistId: artist } : {}),
|
||||
},
|
||||
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 } },
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
assignedArtist: {
|
||||
@@ -49,7 +49,7 @@ export default async function SchedulePage({
|
||||
...(artist ? { assignedArtistId: artist } : {}),
|
||||
},
|
||||
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 } },
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
assignedArtist: {
|
||||
|
||||
@@ -18,11 +18,18 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateShotNotes, bulkUpdateDueDates, toggleKeyShot } from "@/actions/shots";
|
||||
import { updateShotNotes, bulkUpdateDueDates, bulkDeleteShots, toggleKeyShot } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Film,
|
||||
@@ -38,8 +45,11 @@ import {
|
||||
ShieldCheck,
|
||||
Eye,
|
||||
Star,
|
||||
Trash2,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
|
||||
type ShotRow = {
|
||||
id: string;
|
||||
@@ -51,6 +61,7 @@ type ShotRow = {
|
||||
priority: string;
|
||||
dueDate: Date | string | null;
|
||||
thumbnailUrl: string | null;
|
||||
generatedThumbnailUrl: string | null;
|
||||
notes: string | null;
|
||||
description: string | null;
|
||||
isKeyShot: boolean;
|
||||
@@ -209,6 +220,56 @@ function BulkDueDateDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function BulkDeleteDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
count,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
count: number;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await onConfirm();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-400">Delete {count} shot{count !== 1 ? "s" : ""}?</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-zinc-300">
|
||||
This will permanently delete <span className="font-semibold text-white">{count}</span>{" "}
|
||||
shot{count !== 1 ? "s" : ""}, including all tasks, versions, footage, and annotations.
|
||||
This cannot be undone.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
{isPending ? "Deleting…" : `Yes, delete ${count} shot${count !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NotesCell ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesCell({
|
||||
@@ -382,9 +443,9 @@ function ShotTable({
|
||||
{/* Thumbnail */}
|
||||
<td className="py-2 px-3">
|
||||
<div className="w-24 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 flex items-center justify-center shrink-0">
|
||||
{shot.thumbnailUrl ? (
|
||||
{shotThumbnail(shot) ? (
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
src={shotThumbnail(shot)!}
|
||||
alt={shot.shotCode}
|
||||
width={96}
|
||||
height={40}
|
||||
@@ -513,6 +574,7 @@ export function ShotStatusClient({
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const toggleEpisode = (ep: string) =>
|
||||
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({
|
||||
<span className="text-sm font-medium text-blue-300">
|
||||
{selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
|
||||
onClick={() => setDueDateDialogOpen(true)}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
Change Due Date
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
|
||||
>
|
||||
Bulk Actions
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setDueDateDialogOpen(true)}>
|
||||
<CalendarDays className="h-3.5 w-3.5 mr-2" />
|
||||
Change Due Date
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-red-400 focus:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Delete Shots
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@@ -925,6 +1024,14 @@ export function ShotStatusClient({
|
||||
onApply={handleBulkDueDate}
|
||||
count={selectionCount}
|
||||
/>
|
||||
|
||||
{/* Bulk delete dialog */}
|
||||
<BulkDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={handleBulkDelete}
|
||||
count={selectionCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ async function getShotsForProject(projectId: string) {
|
||||
priority: true,
|
||||
dueDate: true,
|
||||
thumbnailUrl: true,
|
||||
generatedThumbnailUrl: true,
|
||||
notes: true,
|
||||
description: true,
|
||||
isKeyShot: true,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ?? ""));
|
||||
|
||||
@@ -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 }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof settingsSchema>;
|
||||
@@ -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
|
||||
<Input id="lutComp" placeholder="_SHOW LUT" {...register("lutComp")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
id="autoShotThumbnail"
|
||||
type="checkbox"
|
||||
className="accent-amber-500 h-3.5 w-3.5 mt-0.5"
|
||||
{...register("autoShotThumbnail")}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm group-hover:text-foreground transition-colors">
|
||||
Use render frames as shot thumbnails
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Every finished export sets its shot's thumbnail from the render.
|
||||
A thumbnail supplied with the brief always takes priority and is
|
||||
never overwritten — turn this on for shows that arrive with no
|
||||
stills at all, where the alternative is an empty grid.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) */}
|
||||
<div className="mb-3 -mx-6 -mt-6 overflow-hidden rounded-t-lg">
|
||||
<div className="relative w-full aspect-[2.39]">
|
||||
{shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? (
|
||||
{shotThumbnail(shot) || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? (
|
||||
<Image
|
||||
src={shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl || ""}
|
||||
src={shotThumbnail(shot) || latestVersion?.thumbnailUrl || latestVersion?.posterUrl || ""}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Layers, ExternalLink } from "lucide-react";
|
||||
import { Layers, ExternalLink, ImageIcon, Loader2 } from "lucide-react";
|
||||
import { promoteExportThumbnail } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
EXPORT_STATUS_STYLES,
|
||||
@@ -16,6 +21,7 @@ interface ExportRow {
|
||||
statusChangedAt: string;
|
||||
createdAt: string;
|
||||
outputDir: string;
|
||||
thumbnailUrl: string | null;
|
||||
job: {
|
||||
attempt: number;
|
||||
progress: number;
|
||||
@@ -29,6 +35,29 @@ interface ExportRow {
|
||||
* pipeline history for this shot, linking into the Export detail page.
|
||||
*/
|
||||
export function ShotExportsTab({ shotId }: { shotId: string }) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [promotingId, setPromotingId] = useState<string | null>(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 (
|
||||
<div className="space-y-2">
|
||||
{exports.map((e) => (
|
||||
<Link
|
||||
<div
|
||||
key={e.id}
|
||||
href={`/pipeline/exports/${e.id}`}
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
<span className="font-mono text-sm text-white w-14">{e.versionString}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||||
<Link href={`/pipeline/exports/${e.id}`} className="flex flex-1 min-w-0 items-center gap-4">
|
||||
{e.thumbnailUrl ? (
|
||||
<span className="relative h-8 w-[76px] shrink-0 overflow-hidden rounded border border-border">
|
||||
<Image src={e.thumbnailUrl} alt="" fill className="object-cover" sizes="76px" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-8 w-[76px] shrink-0 rounded border border-border bg-zinc-900" />
|
||||
)}
|
||||
>
|
||||
{statusLabel(e.status)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-xs text-muted-foreground">
|
||||
{e.job?.machineName ? `${e.job.machineName} · attempt ${e.job.attempt} · ` : ""}
|
||||
{e.outputDir}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
<span className="font-mono text-sm text-white w-14">{e.versionString}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||||
)}
|
||||
>
|
||||
{statusLabel(e.status)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-xs text-muted-foreground">
|
||||
{e.job?.machineName ? `${e.job.machineName} · attempt ${e.job.attempt} · ` : ""}
|
||||
{e.outputDir}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
|
||||
{e.thumbnailUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => promote(e.id, e.versionString)}
|
||||
disabled={promotingId !== null}
|
||||
title="Use this frame as the shot thumbnail (never replaces one supplied with the brief)"
|
||||
className="shrink-0 flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:border-zinc-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{promotingId === e.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Use frame
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLInputElement>(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. */}
|
||||
<div className="pt-4 mt-2 border-t border-border/60 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
|
||||
<Sparkles className="h-3.5 w-3.5 text-amber-500/70" />
|
||||
From renders
|
||||
</div>
|
||||
|
||||
{generatedUrl ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative w-40 aspect-[2.39] rounded-md overflow-hidden border border-border shrink-0">
|
||||
<Image src={generatedUrl} alt={shot.shotCode} fill className="object-cover" />
|
||||
</div>
|
||||
<div className="space-y-2 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{generatedFrom ? <>Taken from <span className="font-mono">{generatedFrom}</span>.</> : "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."}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handlePromote} disabled={isPromoting}>
|
||||
{isPromoting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Refresh from latest"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleClearGenerated} disabled={isPromoting}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handlePromote} disabled={isPromoting}>
|
||||
{isPromoting ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> Fetching…</>
|
||||
) : (
|
||||
"Use latest render frame"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* References */}
|
||||
|
||||
@@ -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) {
|
||||
<div className="w-56 shrink-0 flex flex-col">
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video bg-zinc-900 border-b border-zinc-800 overflow-hidden flex items-center justify-center">
|
||||
{shot.thumbnailUrl ? (
|
||||
{shotThumbnail(shot) ? (
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
src={shotThumbnail(shot)!}
|
||||
alt={shot.shotCode}
|
||||
width={224}
|
||||
height={126}
|
||||
|
||||
@@ -42,7 +42,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;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "./TaskCard";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof pipelineConfigSchema>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <img src="">.
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user