7f066c1372
Deploy / deploy (push) Successful in 7m28s
A formal review is shared as a flat, dated folder of the preview MOVs —
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review, then _02, _03
for further reviews the same day. It reuses the delivery package's
machinery end to end: DeliveryPackage/DeliveryItem, the DELIVERY_BUILD
job, the same claim path and the same hardlink-or-copy builder, with
DeliveryPackage.kind telling the two apart.
Three things separate a review from a delivery:
A review never advances an Export. Reviews happen throughout a shot's
life, long before QC passes it, and the same version is routinely shown
twice — so nothing is marked PACKAGED, and markDelivered() refuses a
review package outright (the button is hidden too).
Selection is by shot, not by export. Shot Status → Bulk Actions → Build
MOV Review Package; the server resolves each shot to its newest export
that actually has a deliveryMovPath, which is not always its newest
export — a shot whose latest version is still rendering belongs in the
review as the version that can be watched. Shots with no MOV at all are
listed in the confirm dialog with the reason rather than silently
shrinking the folder.
The folder is exactly the MOVs. No CSV, no manifest.json written beside
them; the manifest still returns to the server, so the record of what was
shared is kept without cluttering the share. Names follow the same
client-naming rule as a delivery, so a shot reads identically in a review
folder and in the delivery that follows it.
Verification (DeliveryStage.BuildReviewItem) is proportional as it is for
EXRs, adapted to a single file with no validation-time digest: a hard
link is the source and needs nothing, a copy is checked against its
source by length always and by full hash under "always-hash". A missing
MOV fails the item here — in an EXR delivery the MOV is a viewing copy
beside the real deliverable, but in a review it is the deliverable, and a
review folder quietly one shot short is worse than one that says so.
Worker bumped to 1.6.0 for the new manifest field (DeliveryManifest.Kind).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import { Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { db } from "@/lib/db";
|
|
import { PipelineError } from "./errors";
|
|
import { getConfigNumber } from "./config";
|
|
import {
|
|
applyTokens,
|
|
dateTokens,
|
|
resolveDeliveryConfig,
|
|
type DeliveryConfig,
|
|
type DeliveryManifest,
|
|
type TokenValues,
|
|
} from "./deliveries";
|
|
|
|
/**
|
|
* MOV review packages (RenderPipeline2 §11.6).
|
|
*
|
|
* A formal review is shared as a flat, dated folder of the preview MOVs —
|
|
* `20260828_Producer_review`, `_02` for the second one that day. It is built by
|
|
* the same worker, through the same queue and the same claim path, as an EXR
|
|
* delivery; only the shape of what lands on disk differs.
|
|
*
|
|
* The one rule that separates it from a delivery: **a review never advances an
|
|
* Export**. Reviews happen throughout a shot's life, long before QC passes it,
|
|
* and the same version is routinely shown twice. So there is no eligibility
|
|
* gate beyond "this version has a MOV", and nothing is marked PACKAGED or
|
|
* DELIVERED when the folder is built.
|
|
*/
|
|
|
|
// ── Which version of each shot gets reviewed ─────────────────────────────────
|
|
|
|
export interface ReviewCandidate {
|
|
shotId: string;
|
|
shotCode: string;
|
|
clientCode: string | null;
|
|
episode: string;
|
|
exportId: string;
|
|
versionString: string;
|
|
versionNumber: number;
|
|
status: string;
|
|
deliveryMovPath: string;
|
|
/** What the MOV is named in the review folder. */
|
|
deliveredName: string;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
}
|
|
|
|
export interface ReviewSkip {
|
|
shotId: string;
|
|
shotCode: string;
|
|
reason: string;
|
|
}
|
|
|
|
/**
|
|
* `{clientCode}_{versionString}` when the production supplies its own naming,
|
|
* else the studio's `{shotCode}_cmp_TT_{versionString}` — the same rule the EXR
|
|
* delivery names its files by, so a shot reads identically in a review folder
|
|
* and in the delivery that follows it.
|
|
*/
|
|
export function reviewMovName(
|
|
shot: { shotCode: string; clientCode: string | null },
|
|
versionString: string,
|
|
sourcePath: string
|
|
): string {
|
|
const base = shot.clientCode?.trim()
|
|
? `${shot.clientCode.trim()}_${versionString}`
|
|
: `${shot.shotCode}_cmp_TT_${versionString}`;
|
|
const dot = sourcePath.lastIndexOf(".");
|
|
const slash = Math.max(sourcePath.lastIndexOf("/"), sourcePath.lastIndexOf("\\"));
|
|
const ext = dot > slash ? sourcePath.slice(dot) : ".mov";
|
|
return `${base}${ext}`;
|
|
}
|
|
|
|
/**
|
|
* The newest export of each shot that actually has a delivery MOV on the SAN.
|
|
*
|
|
* Newest-with-a-MOV rather than newest outright: a shot whose latest version is
|
|
* still rendering should go into the review as the version that can be watched,
|
|
* not be dropped from it. Shots with no MOV at all come back in `skipped` so
|
|
* the caller can say which ones and why rather than silently shipping fewer.
|
|
*/
|
|
export async function resolveReviewCandidates(shotIds: string[]) {
|
|
if (shotIds.length === 0) {
|
|
throw new PipelineError(422, "No shots selected");
|
|
}
|
|
|
|
const shots = await db.shot.findMany({
|
|
where: { id: { in: shotIds } },
|
|
select: {
|
|
id: true,
|
|
shotCode: true,
|
|
clientCode: true,
|
|
episode: true,
|
|
projectId: true,
|
|
project: { select: { id: true, name: true, code: true, showId: true, deliveryConfig: true } },
|
|
},
|
|
});
|
|
if (shots.length === 0) throw new PipelineError(404, "No such shots");
|
|
|
|
const projectIds = [...new Set(shots.map((s) => s.projectId))];
|
|
if (projectIds.length > 1) {
|
|
// Delivery roots and naming templates are per project; one folder cannot
|
|
// honour two of them.
|
|
throw new PipelineError(422, "Selected shots span more than one project — review one project at a time");
|
|
}
|
|
|
|
const exports = await db.export.findMany({
|
|
where: {
|
|
shotId: { in: shots.map((s) => s.id) },
|
|
deliveryMovPath: { not: null },
|
|
status: { notIn: ["CANCELLED"] },
|
|
},
|
|
orderBy: [{ shotId: "asc" }, { versionNumber: "desc" }],
|
|
select: {
|
|
id: true,
|
|
shotId: true,
|
|
versionString: true,
|
|
versionNumber: true,
|
|
status: true,
|
|
deliveryMovPath: true,
|
|
frameStart: true,
|
|
frameEnd: true,
|
|
},
|
|
});
|
|
|
|
const newestPerShot = new Map<string, (typeof exports)[number]>();
|
|
for (const e of exports) {
|
|
if (!newestPerShot.has(e.shotId)) newestPerShot.set(e.shotId, e);
|
|
}
|
|
|
|
const candidates: ReviewCandidate[] = [];
|
|
const skipped: ReviewSkip[] = [];
|
|
|
|
for (const shot of shots) {
|
|
const chosen = newestPerShot.get(shot.id);
|
|
if (!chosen?.deliveryMovPath) {
|
|
skipped.push({
|
|
shotId: shot.id,
|
|
shotCode: shot.shotCode,
|
|
reason: "no preview MOV rendered yet",
|
|
});
|
|
continue;
|
|
}
|
|
candidates.push({
|
|
shotId: shot.id,
|
|
shotCode: shot.shotCode,
|
|
clientCode: shot.clientCode,
|
|
episode: shot.episode ?? "",
|
|
exportId: chosen.id,
|
|
versionString: chosen.versionString,
|
|
versionNumber: chosen.versionNumber,
|
|
status: chosen.status,
|
|
deliveryMovPath: chosen.deliveryMovPath,
|
|
deliveredName: reviewMovName(shot, chosen.versionString, chosen.deliveryMovPath),
|
|
frameStart: chosen.frameStart,
|
|
frameEnd: chosen.frameEnd,
|
|
});
|
|
}
|
|
|
|
candidates.sort((a, b) => a.shotCode.localeCompare(b.shotCode));
|
|
skipped.sort((a, b) => a.shotCode.localeCompare(b.shotCode));
|
|
|
|
return { project: shots[0].project, candidates, skipped };
|
|
}
|
|
|
|
// ── Folder naming ────────────────────────────────────────────────────────────
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
/**
|
|
* `20260828_Producer_review`, then `_02`, `_03` for further reviews the same
|
|
* day. Numbering counts every package built under that base — cancelled and
|
|
* failed ones included — so a number is never reused for a different folder.
|
|
*/
|
|
export function nextReviewName(base: string, existingNames: string[]): string {
|
|
const pattern = new RegExp(`^${escapeRegExp(base)}(?:_(\\d+))?$`);
|
|
let highest = 0;
|
|
for (const name of existingNames) {
|
|
const match = pattern.exec(name);
|
|
if (!match) continue;
|
|
highest = Math.max(highest, match[1] ? Number.parseInt(match[1], 10) : 1);
|
|
}
|
|
return highest === 0 ? base : `${base}_${String(highest + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
async function reviewNameFor(
|
|
tx: Prisma.TransactionClient | typeof db,
|
|
projectId: string,
|
|
base: string
|
|
): Promise<string> {
|
|
const existing = await tx.deliveryPackage.findMany({
|
|
where: { projectId, kind: "MOV_REVIEW", name: { startsWith: base } },
|
|
select: { name: true },
|
|
});
|
|
return nextReviewName(base, existing.map((p) => p.name));
|
|
}
|
|
|
|
function reviewTokens(
|
|
config: DeliveryConfig,
|
|
project: { code: string; showId: string | null },
|
|
episode: string,
|
|
buildDate: Date,
|
|
packageNumber: number
|
|
): TokenValues {
|
|
return {
|
|
showId: project.showId || project.code,
|
|
episode,
|
|
...dateTokens(buildDate),
|
|
packageName: "",
|
|
packageDir: "",
|
|
deliveryRoot: config.deliveryRoot,
|
|
shotCode: "",
|
|
versionString: "",
|
|
packageNumber: String(packageNumber),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The episode a review folder belongs to: the one every selected shot shares,
|
|
* or "" when the review spans several. It is a label on the record only —
|
|
* unlike an EXR package, the folder path never includes it.
|
|
*/
|
|
function commonEpisode(candidates: ReviewCandidate[]): string {
|
|
const episodes = new Set(candidates.map((c) => c.episode));
|
|
return episodes.size === 1 ? [...episodes][0] : "";
|
|
}
|
|
|
|
// ── Preview: what would this build ───────────────────────────────────────────
|
|
|
|
/**
|
|
* What the dialog shows before anyone commits: the folder that would be
|
|
* created, the version each shot resolves to, and the shots that have no MOV.
|
|
* The name is what it would be *right now* — another review built in between
|
|
* takes the number and this one moves up.
|
|
*/
|
|
export async function previewMovReviewPackage(shotIds: string[]) {
|
|
const { project, candidates, skipped } = await resolveReviewCandidates(shotIds);
|
|
const config = await resolveDeliveryConfig(project.deliveryConfig);
|
|
const buildDate = new Date();
|
|
|
|
const tokens = reviewTokens(config, project, commonEpisode(candidates), buildDate, 0);
|
|
const base = applyTokens(config.reviewNameTemplate, tokens);
|
|
tokens.packageName = await reviewNameFor(db, project.id, base);
|
|
tokens.packageDir = applyTokens(config.layout.reviewDir, tokens);
|
|
|
|
return {
|
|
projectId: project.id,
|
|
projectName: project.name,
|
|
packageName: tokens.packageName,
|
|
packageDir: tokens.packageDir,
|
|
candidates,
|
|
skipped,
|
|
};
|
|
}
|
|
|
|
// ── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
export const createMovReviewSchema = z.object({
|
|
shotIds: z.array(z.string().min(1)).min(1).max(500),
|
|
options: z
|
|
.object({
|
|
/** Override the dated name entirely, e.g. for a re-share under an agreed folder name. */
|
|
name: z.string().max(120).optional(),
|
|
copyMode: z.enum(["auto", "always-copy"]).optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
export type CreateMovReviewInput = z.infer<typeof createMovReviewSchema>;
|
|
|
|
/**
|
|
* Build a MOV review package from selected shots. Shots without a MOV are
|
|
* skipped and reported rather than failing the build — a producer asking for a
|
|
* review of 15 shots wants the 13 that exist, plus a clear list of the 2 that
|
|
* do not.
|
|
*/
|
|
export async function createMovReviewPackage(input: CreateMovReviewInput, userId: string) {
|
|
const { project, candidates, skipped } = await resolveReviewCandidates(input.shotIds);
|
|
if (candidates.length === 0) {
|
|
throw new PipelineError(422, "None of the selected shots has a preview MOV yet — nothing to review");
|
|
}
|
|
|
|
const config = await resolveDeliveryConfig(project.deliveryConfig);
|
|
if (input.options?.copyMode) config.copyMode = input.options.copyMode;
|
|
|
|
const buildDate = new Date();
|
|
const maxAttempts = await getConfigNumber("render.maxAttempts");
|
|
const episode = commonEpisode(candidates);
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const lastNumber = await tx.deliveryPackage.aggregate({
|
|
where: { projectId: project.id },
|
|
_max: { packageNumber: true },
|
|
});
|
|
const packageNumber = (lastNumber._max.packageNumber ?? 0) + 1;
|
|
|
|
const tokens = reviewTokens(config, project, episode, buildDate, packageNumber);
|
|
const base = applyTokens(config.reviewNameTemplate, tokens);
|
|
tokens.packageName = input.options?.name ?? (await reviewNameFor(tx, project.id, base));
|
|
tokens.packageDir = applyTokens(config.layout.reviewDir, tokens);
|
|
|
|
const pkg = await tx.deliveryPackage.create({
|
|
data: {
|
|
projectId: project.id,
|
|
kind: "MOV_REVIEW",
|
|
episode,
|
|
name: tokens.packageName,
|
|
packageNumber,
|
|
status: "QUEUED",
|
|
rootPath: tokens.packageDir,
|
|
configSnapshot: { ...config, tokens } as unknown as Prisma.InputJsonValue,
|
|
createdById: userId,
|
|
},
|
|
});
|
|
|
|
// Same version lock as a delivery: shotCode, clientCode and versionString
|
|
// are copied in, so a later rename or re-render cannot rewrite what this
|
|
// review was.
|
|
await tx.deliveryItem.createMany({
|
|
data: candidates.map((c) => ({
|
|
packageId: pkg.id,
|
|
exportId: c.exportId,
|
|
shotId: c.shotId,
|
|
shotCode: c.shotCode,
|
|
clientCode: c.clientCode,
|
|
versionString: c.versionString,
|
|
})),
|
|
});
|
|
|
|
const manifest = await buildMovReviewJobManifest(tx, pkg.id);
|
|
const job = await tx.renderJob.create({
|
|
data: {
|
|
type: "DELIVERY_BUILD",
|
|
deliveryId: pkg.id,
|
|
attempt: 1,
|
|
maxAttempts,
|
|
// Same reasoning as a delivery build: someone is waiting on the folder
|
|
// and no After Effects is involved, so it jumps the render queue.
|
|
priority: 10,
|
|
manifest: manifest as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return {
|
|
package: {
|
|
id: pkg.id,
|
|
kind: pkg.kind,
|
|
name: pkg.name,
|
|
packageNumber,
|
|
episode,
|
|
status: pkg.status,
|
|
rootPath: pkg.rootPath,
|
|
itemCount: candidates.length,
|
|
},
|
|
skipped,
|
|
renderJob: { id: job.id },
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── The MOV_REVIEW manifest ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The DELIVERY_BUILD manifest for a review: the same envelope the EXR builder
|
|
* gets, with `kind: "MOV_REVIEW"` and one MOV per item. Every item's `shotDir`
|
|
* is the package folder itself — a review is flat, so the MOVs sit beside each
|
|
* other where a producer can scrub through them in order.
|
|
*/
|
|
export async function buildMovReviewJobManifest(
|
|
tx: Prisma.TransactionClient,
|
|
packageId: string
|
|
): Promise<DeliveryManifest> {
|
|
const pkg = await tx.deliveryPackage.findUniqueOrThrow({
|
|
where: { id: packageId },
|
|
include: {
|
|
items: { include: { export: true } },
|
|
project: { select: { code: true, showId: true } },
|
|
},
|
|
});
|
|
|
|
const snapshot = pkg.configSnapshot as unknown as DeliveryConfig & { tokens: TokenValues };
|
|
const packageDir = pkg.rootPath ?? snapshot.tokens.packageDir;
|
|
|
|
return {
|
|
stage: "DELIVERY",
|
|
kind: "MOV_REVIEW",
|
|
packageId: pkg.id,
|
|
packageName: pkg.name,
|
|
packageNumber: pkg.packageNumber,
|
|
episode: pkg.episode,
|
|
packageDir,
|
|
// A review folder is exactly the MOVs — no CSV, no manifest.json dropped
|
|
// in beside them. The manifest still comes back to the server, so the
|
|
// record of what was shared is kept without cluttering what is shared.
|
|
reportFile: "",
|
|
includeMovs: true,
|
|
copyMode: snapshot.copyMode ?? "auto",
|
|
verifyMode: snapshot.verifyMode ?? "auto",
|
|
checksumAlgo: snapshot.checksumAlgo,
|
|
reportFormats: [],
|
|
items: pkg.items.map((item) => ({
|
|
itemId: item.id,
|
|
exportId: item.exportId,
|
|
shotCode: item.shotCode,
|
|
versionString: item.versionString,
|
|
// No EXR sequence is involved, so the fields that describe one are empty
|
|
// rather than misleadingly populated.
|
|
sourceDir: "",
|
|
outputPattern: "",
|
|
frameStart: item.export.frameStart,
|
|
frameEnd: item.export.frameEnd,
|
|
fileCount: null,
|
|
checksum: null,
|
|
deliveryMovPath: item.export.deliveryMovPath,
|
|
shotDir: packageDir,
|
|
deliveredBaseName: item.clientCode
|
|
? `${item.clientCode}_${item.versionString}`
|
|
: `${item.shotCode}_cmp_TT_${item.versionString}`,
|
|
validatedAt: null,
|
|
})),
|
|
};
|
|
}
|