diff --git a/app/api/client/[token]/project/route.ts b/app/api/client/[token]/project/route.ts index 518478d..cfbd0a9 100644 --- a/app/api/client/[token]/project/route.ts +++ b/app/api/client/[token]/project/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { validateReviewToken } from "@/lib/review-auth"; import { shotThumbnail } from "@/lib/shot-thumbnail"; +import { clientShotCode, clientTaskTitle } from "@/lib/shot-code"; /** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */ export async function GET( @@ -44,6 +45,7 @@ export async function GET( select: { id: true, shotCode: true, + clientCode: true, episode: true, sequence: true, description: true, @@ -117,9 +119,19 @@ export async function GET( project, // Shots the client sees resolve the same way as internally: the reference // still first, a render-derived one only where the show supplied none. - shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({ + // Everything the client reads is in their own naming: the alias replaces + // our shot code, and the same swap runs over task titles, which carry the + // code by convention ("UNG_106_010_020_cmp_TT_v004"). The internal code is + // not sent at all — there is nothing here for it to be useful for, and + // leaking it invites the client to quote it back at us. + shots: shots.map(({ generatedThumbnailUrl, clientCode, ...shot }) => ({ ...shot, + shotCode: clientShotCode({ ...shot, clientCode }), thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }), + tasks: shot.tasks.map((task) => ({ + ...task, + title: clientTaskTitle(task.title, { ...shot, clientCode }), + })), })), assetTasks, sessionLabel: session.label, diff --git a/app/api/client/[token]/versions/[versionId]/route.ts b/app/api/client/[token]/versions/[versionId]/route.ts index 8f62ef5..dfa06ab 100644 --- a/app/api/client/[token]/versions/[versionId]/route.ts +++ b/app/api/client/[token]/versions/[versionId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { validateReviewToken } from "@/lib/review-auth"; +import { clientShotCode, clientTaskTitle } from "@/lib/shot-code"; /** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */ export async function GET( @@ -40,7 +41,7 @@ export async function GET( task: { include: { project: { select: { id: true, name: true, code: true } }, - shot: { select: { id: true, shotCode: true, highResKey: true, highResFilename: true } }, + shot: { select: { id: true, shotCode: true, clientCode: true, highResKey: true, highResFilename: true } }, asset: { select: { assetCode: true, name: true } }, }, }, @@ -81,6 +82,9 @@ export async function GET( // Resolve the shot that has the high-res file (comes from version.shot OR version.task.shot) const highResShot = version.shot ?? (version.task as any)?.shot ?? null; + // The shot the title and breadcrumb are drawn from — either relation. + const namingShot = version.shot ?? ((version.task as any)?.shot ?? null); + const serializedVersion = { ...version, fileSize: version.fileSize?.toString() ?? null, @@ -92,17 +96,24 @@ export async function GET( shot: version.shot ? { ...version.shot, + shotCode: clientShotCode(version.shot), + clientCode: undefined, hasHighRes: !!version.shot.highResKey, highResFilename: version.shot.highResFilename ?? null, highResKey: undefined, } : null, + // Task titles carry the shot code by convention, so the player header gets + // the same swap as the shot list (lib/shot-code.ts). task: version.task ? { ...version.task, + title: namingShot ? clientTaskTitle(version.task.title, namingShot) : version.task.title, shot: (version.task as any)?.shot ? { ...((version.task as any).shot), + shotCode: clientShotCode((version.task as any).shot), + clientCode: undefined, highResKey: undefined, // strip storage key } : null, diff --git a/lib/render-pipeline/deliveries.ts b/lib/render-pipeline/deliveries.ts index 39c00b4..6d9ffaf 100644 --- a/lib/render-pipeline/deliveries.ts +++ b/lib/render-pipeline/deliveries.ts @@ -4,6 +4,7 @@ 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 @@ -429,7 +430,7 @@ export async function buildDeliveryJobManifest( // naming follows the same rule as the delivered filenames themselves. shotDir: applyTokens(snapshot.layout.shotDir, { ...baseTokens, - shotCode: item.clientCode || item.shotCode, + shotCode: clientShotCode(item), versionString: item.versionString, }), deliveredBaseName: item.clientCode diff --git a/lib/render-pipeline/preview.ts b/lib/render-pipeline/preview.ts index cd9f929..288699c 100644 --- a/lib/render-pipeline/preview.ts +++ b/lib/render-pipeline/preview.ts @@ -4,6 +4,7 @@ import { PipelineError } from "./errors"; import { applyTransition } from "./transitions"; import { getConfigNumber, getPreviewConfig } from "./config"; import { resolveProjectTemplates, autoShotThumbnailEnabled } from "./project-templates"; +import { clientShotCode } from "@/lib/shot-code"; /** Slate date format used by the panel's getDateString (YYYY/MM/DD). */ function slateDate(d = new Date()): string { @@ -124,7 +125,7 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise // tab shows as its placeholder. description: shot.slateDescription ?? shot.description, notes: shot.notes, - shotCode: shot.clientCode || shot.shotCode, + shotCode: clientShotCode(shot), // The production's own name, not its code: this is read off a slate. show: shot.project?.name ?? shot.project?.code ?? null, episode: shot.episode, diff --git a/lib/shot-code.test.ts b/lib/shot-code.test.ts new file mode 100644 index 0000000..6906dc6 --- /dev/null +++ b/lib/shot-code.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { clientShotCode, clientTaskTitle } from "./shot-code"; + +describe("clientShotCode", () => { + it("prefers the client's alias", () => { + expect(clientShotCode({ shotCode: "UNG_106_010_020", clientCode: "EP6_VFX_012" })).toBe("EP6_VFX_012"); + }); + + it("falls back to our code when there is no alias", () => { + expect(clientShotCode({ shotCode: "UNG_106_010_020", clientCode: null })).toBe("UNG_106_010_020"); + expect(clientShotCode({ shotCode: "UNG_106_010_020" })).toBe("UNG_106_010_020"); + }); + + it("ignores a whitespace-only alias", () => { + expect(clientShotCode({ shotCode: "UNG_106_010_020", clientCode: " " })).toBe("UNG_106_010_020"); + }); +}); + +describe("clientTaskTitle", () => { + const shot = { shotCode: "UNG_106_010_020", clientCode: "EP6_VFX_012" }; + + it("swaps the internal code out of a house-convention title", () => { + expect(clientTaskTitle("UNG_106_010_020_cmp_TT_v004", shot)).toBe("EP6_VFX_012_cmp_TT_v004"); + }); + + it("leaves the title alone when there is no alias", () => { + const t = "UNG_106_010_020_cmp_TT_v004"; + expect(clientTaskTitle(t, { shotCode: "UNG_106_010_020", clientCode: null })).toBe(t); + }); + + it("leaves a title that does not carry the code untouched", () => { + // Hand-edited titles are not guessed at — better an unhelpful title than a + // mangled one. + expect(clientTaskTitle("Roto pass", shot)).toBe("Roto pass"); + }); + + it("swaps every occurrence", () => { + expect(clientTaskTitle("UNG_106_010_020 / UNG_106_010_020_cmp", shot)).toBe("EP6_VFX_012 / EP6_VFX_012_cmp"); + }); + + it("is a no-op when the alias equals the code", () => { + const same = { shotCode: "UNG_106_010_020", clientCode: "UNG_106_010_020" }; + expect(clientTaskTitle("UNG_106_010_020_cmp", same)).toBe("UNG_106_010_020_cmp"); + }); +}); diff --git a/lib/shot-code.ts b/lib/shot-code.ts new file mode 100644 index 0000000..ce9ac5c --- /dev/null +++ b/lib/shot-code.ts @@ -0,0 +1,41 @@ +/** + * What a client is shown for a shot. + * + * `Shot.clientCode` is the naming alias some productions supply — their own + * code for the same shot. It already drives slates, overlay burn-ins and + * delivered file names (RenderPipeline2 §11.2); internal paths, comp names and + * every DB lookup stay on `shotCode`. + * + * The client portal is the other place the client's own naming belongs, and + * that includes task titles: they follow the house convention + * `UNG_102_019_010_cmp_TT_v004`, so the internal code is embedded in the + * string the client reads and copies. + * + * Apply these at the API boundary rather than at each render site, so nothing + * downstream has to remember which code it is holding. + */ + +export interface ShotCodeFields { + shotCode: string; + clientCode?: string | null; +} + +/** The shot's code as the client knows it; their alias if set, ours otherwise. */ +export function clientShotCode(shot: ShotCodeFields): string { + return shot.clientCode?.trim() || shot.shotCode; +} + +/** + * A task title with the internal shot code swapped for the client's alias. + * + * Titles are built from the shot code by convention, so this is a substring + * swap rather than a rename — a title that does not contain the code (a + * hand-edited one, say) is returned untouched rather than guessed at. + */ +export function clientTaskTitle(title: string, shot: ShotCodeFields): string { + const alias = shot.clientCode?.trim(); + if (!alias || !title || alias === shot.shotCode) return title; + // split/join rather than a regex: shot codes are user-supplied and would + // otherwise need escaping. + return title.split(shot.shotCode).join(alias); +}