269a5cbfda
Deploy / deploy (push) Successful in 6m27s
Two faults in how a package's folder is named, both of which put unrelated
files in one directory on the delivery volume.
The default layout had no {showId}, so every show's episode 101 resolved to
{deliveryRoot}/101/{packageName}. The token existed and was populated — it
just never appeared in the path. With hardlinked packages that means two
shows delivering the same episode number on the same day write into the same
folder, and identically named shots overwrite each other. reviewDir had the
same hole, and is worse there: reviewNameFor scopes its lookup by projectId,
so a second show computes the bare base name and lands on the first show's
folder instead of suffixing past it.
createDeliveryPackage also never checked for an existing package of the same
name. packageNameTemplate is date-only and packageNumber is a per-project
counter that is not in the template, so a second delivery on the same day got
an identical name and rootPath and built on top of the first. MOV reviews
already did this correctly, so nextReviewName moves to deliveries.ts as
nextPackageName and both paths share it; the review-side name is kept as an
alias because it reads better at that call site.
Putting {showId} ahead of {episode} exposes an empty segment on non-episodic
shows, so paths go through tidyPath, which collapses it and leaves the UNC
root alone.
Historic packages are unaffected: buildDeliveryJobManifest reads rootPath and
configSnapshot off the record, so only new packages use the new layout. A
project with its own deliveryConfig.layout in the database still overrides
these defaults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
414 lines
14 KiB
TypeScript
414 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,
|
|
nextPackageName,
|
|
resolveDeliveryConfig,
|
|
tidyPath,
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* `20260828_Producer_review`, then `_02`, `_03` for further reviews the same
|
|
* day. EXR deliveries now number the same way, so the rule lives in
|
|
* deliveries.ts; this name is kept because it reads better at the call site.
|
|
*/
|
|
export const nextReviewName = nextPackageName;
|
|
|
|
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 = tidyPath(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 = tidyPath(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,
|
|
})),
|
|
};
|
|
}
|