diff --git a/lib/render-pipeline/deliveries.test.ts b/lib/render-pipeline/deliveries.test.ts new file mode 100644 index 0000000..7c81eb7 --- /dev/null +++ b/lib/render-pipeline/deliveries.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { DELIVERY_CONFIG_DEFAULT, applyTokens, nextPackageName, tidyPath } from "./deliveries"; + +const BASE = "260901_Delivery"; + +describe("nextPackageName", () => { + it("uses the bare base for the first package of the day", () => { + expect(nextPackageName(BASE, [])).toBe(BASE); + }); + + it("suffixes later packages sharing the base", () => { + expect(nextPackageName(BASE, [BASE])).toBe(`${BASE}_02`); + expect(nextPackageName(BASE, [BASE, `${BASE}_02`])).toBe(`${BASE}_03`); + }); + + it("never reuses a number after a gap", () => { + expect(nextPackageName(BASE, [BASE, `${BASE}_02`, `${BASE}_04`])).toBe(`${BASE}_05`); + }); + + it("ignores names that only share a prefix", () => { + expect(nextPackageName(BASE, [`${BASE}_final`, "260902_Delivery"])).toBe(BASE); + }); +}); + +describe("delivery layout", () => { + const tokens = { + deliveryRoot: "//SAN/DELIVERY", + showId: "ACME", + episode: "101", + packageName: BASE, + }; + + it("separates shows so two shows' episode 101 cannot collide", () => { + const acme = applyTokens(DELIVERY_CONFIG_DEFAULT.layout.packageDir, tokens); + const other = applyTokens(DELIVERY_CONFIG_DEFAULT.layout.packageDir, { ...tokens, showId: "BRAVO" }); + expect(acme).toBe("//SAN/DELIVERY/ACME/101/260901_Delivery"); + expect(other).not.toBe(acme); + }); + + it("puts producer reviews under the show too", () => { + expect(applyTokens(DELIVERY_CONFIG_DEFAULT.layout.reviewDir, tokens)).toBe( + "//SAN/DELIVERY/ACME/PRODUCER_REVIEWS/260901_Delivery" + ); + }); +}); + +describe("tidyPath", () => { + it("drops the empty segment a non-episodic show leaves behind", () => { + const raw = applyTokens(DELIVERY_CONFIG_DEFAULT.layout.packageDir, { + deliveryRoot: "//SAN/DELIVERY", + showId: "ACME", + episode: "", + packageName: BASE, + }); + expect(tidyPath(raw)).toBe("//SAN/DELIVERY/ACME/260901_Delivery"); + }); + + it("keeps the UNC root intact", () => { + expect(tidyPath("//SAN/DELIVERY/ACME")).toBe("//SAN/DELIVERY/ACME"); + }); + + it("leaves a well-formed path alone", () => { + expect(tidyPath("V:/DELIVERY/ACME/101/x")).toBe("V:/DELIVERY/ACME/101/x"); + }); +}); diff --git a/lib/render-pipeline/deliveries.ts b/lib/render-pipeline/deliveries.ts index b19a70a..7d7cded 100644 --- a/lib/render-pipeline/deliveries.ts +++ b/lib/render-pipeline/deliveries.ts @@ -46,10 +46,12 @@ export const DELIVERY_CONFIG_DEFAULT: DeliveryConfig = { packageNameTemplate: "{yymmdd}_Delivery", reviewNameTemplate: "{yyyymmdd}_Producer_review", layout: { - packageDir: "{deliveryRoot}/{episode}/{packageName}", + // {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}/PRODUCER_REVIEWS/{packageName}", + reviewDir: "{deliveryRoot}/{showId}/PRODUCER_REVIEWS/{packageName}", }, deliveryRoot: "//SAN/DELIVERY", includeMovs: true, @@ -108,6 +110,36 @@ export function applyTokens(template: string, tokens: Partial): str }); } +/** + * 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"); @@ -280,8 +312,17 @@ export async function createDeliveryPackage(input: CreateDeliveryInput, userId: versionString: "", packageNumber: String(packageNumber), }; - tokens.packageName = input.options?.name ?? applyTokens(config.packageNameTemplate, tokens); - tokens.packageDir = applyTokens(config.layout.packageDir, tokens); + // 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. + const baseName = applyTokens(config.packageNameTemplate, tokens); + const sameBase = await tx.deliveryPackage.findMany({ + where: { projectId: project.id, 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: { diff --git a/lib/render-pipeline/reviews.ts b/lib/render-pipeline/reviews.ts index d3dd5aa..a96ce3b 100644 --- a/lib/render-pipeline/reviews.ts +++ b/lib/render-pipeline/reviews.ts @@ -6,7 +6,9 @@ import { getConfigNumber } from "./config"; import { applyTokens, dateTokens, + nextPackageName, resolveDeliveryConfig, + tidyPath, type DeliveryConfig, type DeliveryManifest, type TokenValues, @@ -165,25 +167,12 @@ export async function resolveReviewCandidates(shotIds: string[]) { // ── 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. + * 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 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")}`; -} +export const nextReviewName = nextPackageName; async function reviewNameFor( tx: Prisma.TransactionClient | typeof db, @@ -243,7 +232,7 @@ export async function previewMovReviewPackage(shotIds: string[]) { 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); + tokens.packageDir = tidyPath(applyTokens(config.layout.reviewDir, tokens)); return { projectId: project.id, @@ -299,7 +288,7 @@ export async function createMovReviewPackage(input: CreateMovReviewInput, userId 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); + tokens.packageDir = tidyPath(applyTokens(config.layout.reviewDir, tokens)); const pkg = await tx.deliveryPackage.create({ data: {