a073f92da3
Deploy / deploy (push) Successful in 7m31s
Numbering was scoped to the project, so the day's first delivery of
episode 102 was named _02 purely because episode 101 had already gone out
that morning. The count is meant to distinguish repeat deliveries of the
same content, and packageDir already separates episodes — three episodes
delivering today each land in their own folder and each are that folder's
first delivery. Adding `episode` to the lookup makes the suffix mean what
the folder layout already implies: only a second delivery of the *same*
episode becomes _02. The [projectId, episode] index already exists, so
the extra filter costs nothing.
packageNameTemplate also moves from {yymmdd} to {yyyymmdd}. The AE panel
has always built its local delivery folders with a four-digit year, so
this is the server matching the connector rather than a new convention.
The {yymmdd} token stays in TokenValues: a project carrying its own
template in deliveryConfig is unaffected either way.
Existing packages are untouched — buildDeliveryJobManifest reads rootPath
off the record. On the day of the changeover an episode that already
delivered as 260903_Delivery starts again at the bare 20260903_Delivery,
which is a different name and a different folder, so nothing merges.
Producer reviews keep their per-project numbering: a review can span
several episodes and stores episode as "", so scoping it the same way
would put every such review in one bucket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
726 lines
27 KiB
TypeScript
726 lines
27 KiB
TypeScript
import { DeliveryKind, DeliveryStatus, Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { db } from "@/lib/db";
|
|
import { PipelineError } from "./errors";
|
|
import { applyTransition } from "./transitions";
|
|
import { getConfigNumber, getConfigValue } from "./config";
|
|
import { clientShotCode } from "@/lib/shot-code";
|
|
|
|
/**
|
|
* Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a
|
|
* package and records it; a worker does the file work through the same queue
|
|
* and claim path as a render.
|
|
*
|
|
* Version locking: a package references Export rows and denormalises
|
|
* shotCode/versionString at creation, so a later re-render can never mutate
|
|
* delivery history.
|
|
*/
|
|
|
|
// ── Config (§11.2) ───────────────────────────────────────────────────────────
|
|
|
|
export interface DeliveryConfig {
|
|
packageNameTemplate: string;
|
|
/**
|
|
* MOV review packages (§11.6). A formal review is shared as a flat, dated
|
|
* folder of preview MOVs, so it gets its own name template and its own
|
|
* directory under the delivery root — never mixed in with the episode
|
|
* folders that hold shippable EXR packages.
|
|
*/
|
|
reviewNameTemplate: string;
|
|
layout: { packageDir: string; shotDir: string; reportFile: string; reviewDir: string };
|
|
deliveryRoot: string;
|
|
includeMovs: boolean;
|
|
checksumAlgo: string;
|
|
reportFormats: string[];
|
|
copyMode: "auto" | "always-copy";
|
|
/**
|
|
* "auto" verifies copies by hash and hard links by identity — a link is the
|
|
* same data as the source, so re-reading it cannot disagree with what
|
|
* validation already hashed, and at SAN speeds costs ~30 s per shot to prove
|
|
* nothing. "always-hash" reads every delivered file back regardless.
|
|
*/
|
|
verifyMode: "auto" | "always-hash";
|
|
}
|
|
|
|
export const DELIVERY_CONFIG_DEFAULT: DeliveryConfig = {
|
|
packageNameTemplate: "{yyyymmdd}_Delivery",
|
|
reviewNameTemplate: "{yyyymmdd}_Producer_review",
|
|
layout: {
|
|
// {showId} is not optional in practice: without it every show's episode 101
|
|
// lands in one folder on the delivery volume.
|
|
packageDir: "{deliveryRoot}/{showId}/{episode}/{packageName}",
|
|
shotDir: "{packageDir}/{shotCode}",
|
|
reportFile: "{packageDir}/{packageName}_report.csv",
|
|
reviewDir: "{deliveryRoot}/{showId}/PRODUCER_REVIEWS/{packageName}",
|
|
},
|
|
deliveryRoot: "//SAN/DELIVERY",
|
|
includeMovs: true,
|
|
checksumAlgo: "xxh64",
|
|
reportFormats: ["csv", "json"],
|
|
copyMode: "auto",
|
|
verifyMode: "auto",
|
|
};
|
|
|
|
/**
|
|
* Project config → SystemConfig default → built-in. Merged shallowly per top
|
|
* level key so a project can override just `deliveryRoot` without restating the
|
|
* whole layout.
|
|
*/
|
|
export async function resolveDeliveryConfig(projectDeliveryConfig: unknown): Promise<DeliveryConfig> {
|
|
let systemDefault: Partial<DeliveryConfig> = {};
|
|
const raw = await getConfigValue("delivery.defaultConfig");
|
|
if (raw?.trim() && raw.trim() !== "{}") {
|
|
try {
|
|
systemDefault = JSON.parse(raw) as Partial<DeliveryConfig>;
|
|
} catch {
|
|
console.error("[render-pipeline] delivery.defaultConfig is not valid JSON — ignoring");
|
|
}
|
|
}
|
|
|
|
const project = (projectDeliveryConfig ?? {}) as Partial<DeliveryConfig>;
|
|
return {
|
|
...DELIVERY_CONFIG_DEFAULT,
|
|
...systemDefault,
|
|
...project,
|
|
layout: {
|
|
...DELIVERY_CONFIG_DEFAULT.layout,
|
|
...(systemDefault.layout ?? {}),
|
|
...(project.layout ?? {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface TokenValues {
|
|
showId: string;
|
|
episode: string;
|
|
yymmdd: string;
|
|
yyyymmdd: string;
|
|
packageName: string;
|
|
packageDir: string;
|
|
deliveryRoot: string;
|
|
shotCode: string;
|
|
versionString: string;
|
|
packageNumber: string;
|
|
}
|
|
|
|
export function applyTokens(template: string, tokens: Partial<TokenValues>): string {
|
|
return template.replace(/\{(\w+)\}/g, (whole, key: string) => {
|
|
const value = (tokens as Record<string, string | undefined>)[key];
|
|
return value === undefined ? whole : value;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collapse the empty segment an unset token leaves behind — a non-episodic
|
|
* show resolves {episode} to "" and would otherwise build ".../SHOW//NAME".
|
|
* The leading pair of a UNC root (//SAN/...) is preserved.
|
|
*/
|
|
export function tidyPath(path: string): string {
|
|
const uncPrefix = /^\/\//.test(path) ? "//" : "";
|
|
return uncPrefix + path.slice(uncPrefix.length).replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
/**
|
|
* Second and later packages sharing a base name get `_02`, `_03`, ... 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 nextPackageName(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")}`;
|
|
}
|
|
|
|
export function dateTokens(date: Date) {
|
|
const yyyy = String(date.getFullYear());
|
|
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(date.getDate()).padStart(2, "0");
|
|
return { yymmdd: `${yyyy.slice(2)}${mm}${dd}`, yyyymmdd: `${yyyy}${mm}${dd}` };
|
|
}
|
|
|
|
// ── E18: what is ready ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* READY_FOR_DELIVERY exports grouped by episode. Only the newest ready export
|
|
* per shot is offered: an older ready version of the same shot is history, not
|
|
* a second thing to ship.
|
|
*/
|
|
export async function getReadyForDelivery(projectId: string, episode?: string | null) {
|
|
const rows = await db.export.findMany({
|
|
where: {
|
|
projectId,
|
|
status: "READY_FOR_DELIVERY",
|
|
...(episode ? { shot: { episode } } : {}),
|
|
},
|
|
orderBy: [{ shotId: "asc" }, { versionNumber: "desc" }],
|
|
include: {
|
|
shot: { select: { id: true, shotCode: true, episode: true } },
|
|
qcReviews: { orderBy: { createdAt: "desc" }, take: 1, select: { result: true, reviewerName: true, createdAt: true } },
|
|
},
|
|
});
|
|
|
|
const newestPerShot = new Map<string, (typeof rows)[number]>();
|
|
for (const row of rows) {
|
|
if (!newestPerShot.has(row.shotId)) newestPerShot.set(row.shotId, row);
|
|
}
|
|
|
|
const byEpisode = new Map<string, ReturnType<typeof serialiseReady>[]>();
|
|
for (const row of newestPerShot.values()) {
|
|
const key = row.shot.episode ?? "";
|
|
if (!byEpisode.has(key)) byEpisode.set(key, []);
|
|
byEpisode.get(key)!.push(serialiseReady(row));
|
|
}
|
|
|
|
return {
|
|
episodes: [...byEpisode.entries()]
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([ep, exports]) => ({
|
|
episode: ep,
|
|
count: exports.length,
|
|
exports: exports.sort((a, b) => a.shotCode.localeCompare(b.shotCode)),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function serialiseReady(row: {
|
|
id: string;
|
|
versionString: string;
|
|
outputDir: string;
|
|
exrFileCount: number | null;
|
|
exrTotalBytes: bigint | null;
|
|
deliveryMovPath: string | null;
|
|
checksum: string | null;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
shot: { id: string; shotCode: string; episode: string | null };
|
|
qcReviews: { result: string; reviewerName: string | null; createdAt: Date }[];
|
|
}) {
|
|
return {
|
|
exportId: row.id,
|
|
shotId: row.shot.id,
|
|
shotCode: row.shot.shotCode,
|
|
episode: row.shot.episode ?? "",
|
|
versionString: row.versionString,
|
|
outputDir: row.outputDir,
|
|
frameStart: row.frameStart,
|
|
frameEnd: row.frameEnd,
|
|
fileCount: row.exrFileCount,
|
|
totalBytes: row.exrTotalBytes == null ? null : Number(row.exrTotalBytes),
|
|
deliveryMovPath: row.deliveryMovPath,
|
|
hasChecksum: row.checksum != null,
|
|
qc: row.qcReviews[0]
|
|
? { result: row.qcReviews[0].result, reviewer: row.qcReviews[0].reviewerName, at: row.qcReviews[0].createdAt }
|
|
: null,
|
|
};
|
|
}
|
|
|
|
// ── E16: create a package ────────────────────────────────────────────────────
|
|
|
|
export const createDeliverySchema = z.object({
|
|
projectId: z.string().min(1),
|
|
episode: z.string().max(50).optional().nullable(),
|
|
/** Omitted → every ready export in the episode. */
|
|
exportIds: z.array(z.string().min(1)).max(500).optional(),
|
|
options: z
|
|
.object({
|
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
name: z.string().max(120).optional(),
|
|
includeMovs: z.boolean().optional(),
|
|
copyMode: z.enum(["auto", "always-copy"]).optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
export type CreateDeliveryInput = z.infer<typeof createDeliverySchema>;
|
|
|
|
export async function createDeliveryPackage(input: CreateDeliveryInput, userId: string) {
|
|
const project = await db.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: { id: true, code: true, showId: true, deliveryConfig: true },
|
|
});
|
|
if (!project) throw new PipelineError(404, "Project not found");
|
|
|
|
const config = await resolveDeliveryConfig(project.deliveryConfig);
|
|
if (input.options?.includeMovs !== undefined) config.includeMovs = input.options.includeMovs;
|
|
if (input.options?.copyMode) config.copyMode = input.options.copyMode;
|
|
|
|
const buildDate = input.options?.date ? new Date(`${input.options.date}T00:00:00`) : new Date();
|
|
if (Number.isNaN(buildDate.getTime())) throw new PipelineError(422, "options.date is not a valid date");
|
|
|
|
const maxAttempts = await getConfigNumber("render.maxAttempts");
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const exports = await tx.export.findMany({
|
|
where: {
|
|
projectId: project.id,
|
|
status: "READY_FOR_DELIVERY",
|
|
...(input.exportIds?.length ? { id: { in: input.exportIds } } : {}),
|
|
...(input.episode ? { shot: { episode: input.episode } } : {}),
|
|
},
|
|
include: { shot: { select: { id: true, shotCode: true, clientCode: true, episode: true } } },
|
|
orderBy: { shotId: "asc" },
|
|
});
|
|
|
|
if (exports.length === 0) {
|
|
throw new PipelineError(422, "No READY_FOR_DELIVERY exports match — nothing to package");
|
|
}
|
|
if (input.exportIds?.length && exports.length !== input.exportIds.length) {
|
|
const found = new Set(exports.map((e) => e.id));
|
|
throw new PipelineError(
|
|
409,
|
|
"Some exports are no longer ready for delivery (re-rendered or already packaged?)",
|
|
{ missing: input.exportIds.filter((id) => !found.has(id)) }
|
|
);
|
|
}
|
|
// One export per shot: two versions of the same shot in one package is
|
|
// always a mistake, and it is cheaper to refuse than to ship it.
|
|
const shotIds = new Set<string>();
|
|
for (const e of exports) {
|
|
if (shotIds.has(e.shotId)) {
|
|
throw new PipelineError(422, `Two versions of ${e.shot.shotCode} selected — pick one`);
|
|
}
|
|
shotIds.add(e.shotId);
|
|
}
|
|
|
|
const episode = input.episode ?? exports[0].shot.episode ?? "";
|
|
const dates = dateTokens(buildDate);
|
|
const showId = project.showId || project.code;
|
|
|
|
const lastNumber = await tx.deliveryPackage.aggregate({
|
|
where: { projectId: project.id },
|
|
_max: { packageNumber: true },
|
|
});
|
|
const packageNumber = (lastNumber._max.packageNumber ?? 0) + 1;
|
|
|
|
const tokens: TokenValues = {
|
|
showId,
|
|
episode,
|
|
...dates,
|
|
packageName: "",
|
|
packageDir: "",
|
|
deliveryRoot: config.deliveryRoot,
|
|
shotCode: "",
|
|
versionString: "",
|
|
packageNumber: String(packageNumber),
|
|
};
|
|
// A second delivery on the same day is normal — a retake batch, or a client
|
|
// asking for the rest of a sequence — and the date-only template gives it a
|
|
// name identical to the morning's. Suffix it the way MOV reviews already
|
|
// are, so it gets its own folder instead of merging into the first one.
|
|
//
|
|
// Numbering is per episode, not per project: packageDir already separates
|
|
// episodes, so three episodes delivering today are each the day's first
|
|
// delivery and each get the bare name. Only a repeat delivery of the *same*
|
|
// episode collides, and only that one suffixes to _02.
|
|
const baseName = applyTokens(config.packageNameTemplate, tokens);
|
|
const sameBase = await tx.deliveryPackage.findMany({
|
|
where: { projectId: project.id, episode, kind: "EXR_DELIVERY", name: { startsWith: baseName } },
|
|
select: { name: true },
|
|
});
|
|
tokens.packageName = input.options?.name ?? nextPackageName(baseName, sameBase.map((p) => p.name));
|
|
tokens.packageDir = tidyPath(applyTokens(config.layout.packageDir, tokens));
|
|
|
|
const pkg = await tx.deliveryPackage.create({
|
|
data: {
|
|
projectId: project.id,
|
|
episode,
|
|
name: tokens.packageName,
|
|
packageNumber,
|
|
status: "QUEUED",
|
|
rootPath: tokens.packageDir,
|
|
// Snapshot: historic packages always show the rules they were built with.
|
|
configSnapshot: { ...config, tokens } as unknown as Prisma.InputJsonValue,
|
|
createdById: userId,
|
|
},
|
|
});
|
|
|
|
await tx.deliveryItem.createMany({
|
|
data: exports.map((e) => ({
|
|
packageId: pkg.id,
|
|
exportId: e.id,
|
|
shotId: e.shotId,
|
|
shotCode: e.shot.shotCode,
|
|
clientCode: e.shot.clientCode,
|
|
versionString: e.versionString,
|
|
})),
|
|
});
|
|
|
|
const manifest = await buildDeliveryJobManifest(tx, pkg.id);
|
|
const job = await tx.renderJob.create({
|
|
data: {
|
|
type: "DELIVERY_BUILD",
|
|
deliveryId: pkg.id,
|
|
attempt: 1,
|
|
maxAttempts,
|
|
// Deliveries are interactive: someone is waiting on the folder, and the
|
|
// work is file copying rather than hours of CPU, so it jumps renders.
|
|
priority: 10,
|
|
manifest: manifest as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return {
|
|
package: {
|
|
id: pkg.id,
|
|
name: pkg.name,
|
|
packageNumber,
|
|
episode,
|
|
status: pkg.status,
|
|
rootPath: pkg.rootPath,
|
|
itemCount: exports.length,
|
|
},
|
|
renderJob: { id: job.id },
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── The DELIVERY_BUILD manifest ──────────────────────────────────────────────
|
|
|
|
export interface DeliveryManifestItem {
|
|
itemId: string;
|
|
exportId: string;
|
|
shotCode: string;
|
|
versionString: string;
|
|
sourceDir: string;
|
|
outputPattern: string;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
fileCount: number | null;
|
|
checksum: string | null;
|
|
deliveryMovPath: string | null;
|
|
shotDir: string;
|
|
/**
|
|
* `{clientCode}_{versionString}` when the shot has a client code, else the
|
|
* studio's own `{shotCode}_cmp_TT_{versionString}` — what the worker names
|
|
* the delivered EXR sequence and MOV as, replacing the internal render
|
|
* filename it copies from.
|
|
*/
|
|
deliveredBaseName: string;
|
|
/** When validation last ran for this export; null when it never did. */
|
|
validatedAt: string | null;
|
|
}
|
|
|
|
export interface DeliveryManifest {
|
|
stage: "DELIVERY";
|
|
/**
|
|
* EXR_DELIVERY builds the per-shot folders of EXRs + MOVs; MOV_REVIEW builds
|
|
* a flat folder of preview MOVs only. One job type, one claim path, one
|
|
* verification story — the worker branches on this.
|
|
*/
|
|
kind: DeliveryKind;
|
|
packageId: string;
|
|
packageName: string;
|
|
packageNumber: number;
|
|
episode: string;
|
|
packageDir: string;
|
|
reportFile: string;
|
|
includeMovs: boolean;
|
|
copyMode: string;
|
|
verifyMode: string;
|
|
checksumAlgo: string;
|
|
reportFormats: string[];
|
|
items: DeliveryManifestItem[];
|
|
}
|
|
|
|
export async function buildDeliveryJobManifest(
|
|
tx: Prisma.TransactionClient,
|
|
packageId: string
|
|
): Promise<DeliveryManifest> {
|
|
const pkg = await tx.deliveryPackage.findUniqueOrThrow({
|
|
where: { id: packageId },
|
|
include: {
|
|
items: {
|
|
include: {
|
|
export: {
|
|
include: {
|
|
// Newest validation run: a linked package uses it to detect a
|
|
// source file changed after it passed QC.
|
|
validations: { orderBy: { createdAt: "desc" }, take: 1, select: { createdAt: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
project: { select: { code: true, showId: true } },
|
|
},
|
|
});
|
|
|
|
const snapshot = pkg.configSnapshot as unknown as DeliveryConfig & { tokens: TokenValues };
|
|
const baseTokens: TokenValues = {
|
|
...snapshot.tokens,
|
|
packageName: pkg.name,
|
|
packageDir: pkg.rootPath ?? snapshot.tokens.packageDir,
|
|
episode: pkg.episode,
|
|
};
|
|
|
|
return {
|
|
stage: "DELIVERY",
|
|
kind: pkg.kind,
|
|
packageId: pkg.id,
|
|
packageName: pkg.name,
|
|
packageNumber: pkg.packageNumber,
|
|
episode: pkg.episode,
|
|
packageDir: baseTokens.packageDir,
|
|
reportFile: applyTokens(snapshot.layout.reportFile, baseTokens),
|
|
includeMovs: snapshot.includeMovs,
|
|
copyMode: snapshot.copyMode ?? "auto",
|
|
verifyMode: snapshot.verifyMode ?? "auto",
|
|
checksumAlgo: snapshot.checksumAlgo,
|
|
reportFormats: snapshot.reportFormats,
|
|
items: pkg.items.map((item) => ({
|
|
itemId: item.id,
|
|
exportId: item.exportId,
|
|
shotCode: item.shotCode,
|
|
versionString: item.versionString,
|
|
sourceDir: item.export.outputDir,
|
|
outputPattern: item.export.outputPattern,
|
|
frameStart: item.export.frameStart,
|
|
frameEnd: item.export.frameEnd,
|
|
fileCount: item.export.exrFileCount,
|
|
checksum: item.export.checksum,
|
|
deliveryMovPath: snapshot.includeMovs ? item.export.deliveryMovPath : null,
|
|
validatedAt: item.export.validations[0]?.createdAt.toISOString() ?? null,
|
|
// Client code substitutes for shotCode in what the client sees; folder
|
|
// naming follows the same rule as the delivered filenames themselves.
|
|
shotDir: applyTokens(snapshot.layout.shotDir, {
|
|
...baseTokens,
|
|
shotCode: clientShotCode(item),
|
|
versionString: item.versionString,
|
|
}),
|
|
deliveredBaseName: item.clientCode
|
|
? `${item.clientCode}_${item.versionString}`
|
|
: `${item.shotCode}_cmp_TT_${item.versionString}`,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ── Worker reporting ─────────────────────────────────────────────────────────
|
|
|
|
export const deliveryReportSchema = z.object({
|
|
machineId: z.string().min(1),
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
itemId: z.string().min(1),
|
|
status: z.enum(["COPIED", "VERIFIED", "FAILED"]),
|
|
exrPath: z.string().max(1000).optional().nullable(),
|
|
movPath: z.string().max(1000).optional().nullable(),
|
|
fileCount: z.number().int().nonnegative().optional().nullable(),
|
|
totalBytes: z.number().nonnegative().optional().nullable(),
|
|
checksum: z.string().max(200).optional().nullable(),
|
|
linkMode: z.enum(["hardlink", "copy"]).optional().nullable(),
|
|
errorMessage: z.string().max(2000).optional().nullable(),
|
|
})
|
|
)
|
|
.max(500),
|
|
manifest: z.unknown().optional(),
|
|
reportKey: z.string().max(500).optional().nullable(),
|
|
rootPath: z.string().max(1000).optional().nullable(),
|
|
errorMessage: z.string().max(2000).optional().nullable(),
|
|
/** false → the package failed as a whole. */
|
|
ok: z.boolean(),
|
|
});
|
|
|
|
export type DeliveryReport = z.infer<typeof deliveryReportSchema>;
|
|
|
|
/**
|
|
* The worker finished (or gave up on) a DELIVERY_BUILD job. Exports move to
|
|
* PACKAGED only for items that actually verified — a package with a failed item
|
|
* is FAILED, and nothing about it is silently treated as delivered.
|
|
*/
|
|
export async function reportDeliveryBuild(jobId: string, body: DeliveryReport) {
|
|
return db.$transaction(async (tx) => {
|
|
const job = await tx.renderJob.findUnique({ where: { id: jobId } });
|
|
if (!job) throw new PipelineError(404, "Render job not found");
|
|
if (job.machineId !== body.machineId) {
|
|
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
|
|
}
|
|
if (!job.deliveryId) throw new PipelineError(422, "Job has no delivery package");
|
|
|
|
const pkg = await tx.deliveryPackage.findUnique({
|
|
where: { id: job.deliveryId },
|
|
include: { items: true },
|
|
});
|
|
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
|
if (pkg.status === "READY" || pkg.status === "DELIVERED") {
|
|
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
|
|
}
|
|
|
|
await tx.renderJob.update({
|
|
where: { id: job.id },
|
|
data: {
|
|
status: body.ok ? "COMPLETED" : "FAILED",
|
|
progress: 1,
|
|
finishedAt: new Date(),
|
|
errorMessage: body.errorMessage ?? null,
|
|
},
|
|
});
|
|
|
|
for (const reported of body.items) {
|
|
await tx.deliveryItem.updateMany({
|
|
where: { id: reported.itemId, packageId: pkg.id },
|
|
data: {
|
|
status: reported.status,
|
|
exrPath: reported.exrPath ?? null,
|
|
movPath: reported.movPath ?? null,
|
|
fileCount: reported.fileCount ?? null,
|
|
totalBytes: reported.totalBytes == null ? null : BigInt(Math.round(reported.totalBytes)),
|
|
checksum: reported.checksum ?? null,
|
|
linkMode: reported.linkMode ?? null,
|
|
errorMessage: reported.errorMessage ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
const verified = body.items.filter((i) => i.status === "VERIFIED");
|
|
const failed = body.items.filter((i) => i.status === "FAILED");
|
|
const success = body.ok && failed.length === 0 && verified.length === pkg.items.length;
|
|
|
|
// A review is not a delivery: the same version is shown in as many reviews
|
|
// as it takes, so building one must never advance the Export state machine.
|
|
if (success && pkg.kind === "EXR_DELIVERY") {
|
|
for (const item of pkg.items) {
|
|
const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } });
|
|
if (exportRow?.status === "READY_FOR_DELIVERY") {
|
|
await applyTransition(tx, exportRow, "PACKAGED", {
|
|
type: "WORKER",
|
|
id: body.machineId,
|
|
note: `Packaged into #${pkg.packageNumber} ${pkg.name}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const updated = await tx.deliveryPackage.update({
|
|
where: { id: pkg.id },
|
|
data: {
|
|
status: success ? "READY" : "FAILED",
|
|
builtAt: new Date(),
|
|
rootPath: body.rootPath ?? pkg.rootPath,
|
|
manifest: (body.manifest ?? undefined) as Prisma.InputJsonValue | undefined,
|
|
reportKey: body.reportKey ?? pkg.reportKey,
|
|
errorMessage: success
|
|
? null
|
|
: (body.errorMessage ??
|
|
(failed.length ? `${failed.length} item(s) failed: ${failed.map((f) => f.itemId).join(", ")}` : "Build did not complete")),
|
|
},
|
|
});
|
|
|
|
return {
|
|
package: { id: updated.id, status: updated.status },
|
|
verified: verified.length,
|
|
failed: failed.length,
|
|
alreadyApplied: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── E17 / E19 / cancel ───────────────────────────────────────────────────────
|
|
|
|
export async function listDeliveryPackages(filter: {
|
|
projectId?: string;
|
|
episode?: string;
|
|
status?: DeliveryStatus;
|
|
kind?: DeliveryKind;
|
|
}) {
|
|
const packages = await db.deliveryPackage.findMany({
|
|
where: {
|
|
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
|
...(filter.episode ? { episode: filter.episode } : {}),
|
|
...(filter.status ? { status: filter.status } : {}),
|
|
...(filter.kind ? { kind: filter.kind } : {}),
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
include: {
|
|
items: { select: { id: true, shotCode: true, versionString: true, status: true, fileCount: true, linkMode: true, errorMessage: true } },
|
|
project: { select: { id: true, name: true, code: true } },
|
|
},
|
|
});
|
|
|
|
return {
|
|
packages: packages.map((p) => ({
|
|
id: p.id,
|
|
packageNumber: p.packageNumber,
|
|
name: p.name,
|
|
kind: p.kind,
|
|
episode: p.episode,
|
|
status: p.status,
|
|
rootPath: p.rootPath,
|
|
errorMessage: p.errorMessage,
|
|
projectName: p.project.name,
|
|
projectCode: p.project.code,
|
|
createdAt: p.createdAt,
|
|
builtAt: p.builtAt,
|
|
deliveredAt: p.deliveredAt,
|
|
itemCount: p.items.length,
|
|
verifiedCount: p.items.filter((i) => i.status === "VERIFIED").length,
|
|
items: p.items,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** E19 — the human uploaded the folder to the client and is confirming it. */
|
|
export async function markDelivered(packageId: string, userId: string) {
|
|
return db.$transaction(async (tx) => {
|
|
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } });
|
|
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
|
if (pkg.status === "DELIVERED") {
|
|
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
|
|
}
|
|
if (pkg.status !== "READY") {
|
|
throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`);
|
|
}
|
|
if (pkg.kind !== "EXR_DELIVERY") {
|
|
throw new PipelineError(
|
|
409,
|
|
"A MOV review package is shared for review, not delivered — it has no delivered state"
|
|
);
|
|
}
|
|
|
|
for (const item of pkg.items) {
|
|
const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } });
|
|
if (exportRow?.status === "PACKAGED") {
|
|
await applyTransition(tx, exportRow, "DELIVERED", {
|
|
type: "USER",
|
|
id: userId,
|
|
note: `Delivered in #${pkg.packageNumber} ${pkg.name}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const updated = await tx.deliveryPackage.update({
|
|
where: { id: pkg.id },
|
|
data: { status: "DELIVERED", deliveredAt: new Date(), deliveredById: userId },
|
|
});
|
|
return { package: { id: updated.id, status: updated.status }, alreadyApplied: false };
|
|
});
|
|
}
|
|
|
|
/** A mistaken package is cancelled, never deleted (§11.5). */
|
|
export async function cancelDeliveryPackage(packageId: string, userId: string) {
|
|
return db.$transaction(async (tx) => {
|
|
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId } });
|
|
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
|
if (!["DRAFT", "QUEUED", "FAILED"].includes(pkg.status)) {
|
|
throw new PipelineError(409, `Package is ${pkg.status} — only DRAFT, QUEUED or FAILED packages can be cancelled`);
|
|
}
|
|
await tx.renderJob.updateMany({
|
|
where: { deliveryId: pkg.id, status: { in: ["QUEUED", "CLAIMED", "RUNNING"] } },
|
|
data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Delivery package cancelled" },
|
|
});
|
|
const updated = await tx.deliveryPackage.update({
|
|
where: { id: pkg.id },
|
|
data: { status: "CANCELLED", errorMessage: `Cancelled by user ${userId}` },
|
|
});
|
|
return { package: { id: updated.id, status: updated.status } };
|
|
});
|
|
}
|