From ed9f9b13c17128cf038436c49369dac044d5bf2c Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:16:53 +0200 Subject: [PATCH] feat(client-portal): show shots and task titles in the client's own naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shot.clientCode already drove slates, overlay burn-ins and delivered file names, but the client portal — the one place the client actually reads — was still showing our internal codes. Task titles were the larger leak. They follow the house convention "UNG_106_010_020_cmp_TT_v004", so the internal code sat embedded in the string the client reads and copies, in three places on the shot list and again in the player header. Swapping only the heading would have left it on display directly underneath. lib/shot-code.ts resolves both: clientShotCode picks the alias when set, and clientTaskTitle swaps the code out of a title by substring, leaving a hand-edited title that does not carry the code untouched rather than guessing at it. Applied at the API boundary rather than at each render site, so the shot list, search, playlist labels, alt text, player header and every copy-to-clipboard button inherit it and nothing downstream has to remember which code it is holding. The internal code is no longer sent to the browser on these routes at all. Slack notifications deliberately keep the internal code: those go to our artists, who need the code that matches the comp and the SAN path. The two existing "clientCode || shotCode" expressions in preview.ts and deliveries.ts now use the same helper so slates, deliveries and the portal cannot drift apart. Shots with no clientCode are unchanged throughout. Co-Authored-By: Claude Opus 5 --- app/api/client/[token]/project/route.ts | 14 +++++- .../[token]/versions/[versionId]/route.ts | 13 +++++- lib/render-pipeline/deliveries.ts | 3 +- lib/render-pipeline/preview.ts | 3 +- lib/shot-code.test.ts | 45 +++++++++++++++++++ lib/shot-code.ts | 41 +++++++++++++++++ 6 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 lib/shot-code.test.ts create mode 100644 lib/shot-code.ts 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); +}