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(); 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 { 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; /** * 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 { 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, })), }; }