feat(pipeline): per-shot client naming alias for slates, overlays, and delivery
Deploy / deploy (push) Successful in 7m25s
Deploy / deploy (push) Successful in 7m25s
Clients sometimes ship footage under a naming convention that doesn't match our own shot codes. Adds an optional Shot.clientCode, editable on the Slate tab: when set, it replaces shotCode only in what the client actually sees — slate identity, overlay burn-in, and delivered EXR/MOV file and folder names in Netflix packages — with our own version string flatly appended (no "_cmp_TT_" studio jargon mixed in). Internal render paths, EXR filenames on the SAN, comp names, and DB lookups are untouched. Shots without a client code render identically to before. Delivery packages denormalize clientCode at build time, same as the existing shotCode/versionString snapshot, so a later shot edit can't retroactively change already-built delivery history. The worker now renames delivered EXR frames and the delivery MOV using the existing frame-number parser (SequencePattern) so only the base name changes — frame numbers and padding survive exactly. This is the one piece of the change that could plausibly ship the wrong bytes, so it's covered by new unit tests (renaming, padding, MOV rename, checksum-survives-rename, and a graceful fallback for an unparseable frame number) rather than resting on manual verification alone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -238,4 +238,104 @@ public class DeliveryStageTests : IDisposable
|
||||
|
||||
Assert.True(second.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoClientCodeKeepsTheInternalRenderFilenames()
|
||||
{
|
||||
// Regression pin: DeliveredBaseName defaults to "" for a shot with no
|
||||
// client code, and delivery must render byte-identical to before this
|
||||
// feature existed.
|
||||
WriteFrames(2);
|
||||
var manifest = Manifest(fileCount: 2);
|
||||
Assert.Equal("", manifest.Items[0].DeliveredBaseName);
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01002.exr")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCodeRenamesDeliveredFramesPreservingNumberAndPadding()
|
||||
{
|
||||
WriteFrames(3, startFrame: 1001);
|
||||
var manifest = Manifest(fileCount: 3);
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var deliveredDir = Path.Combine(_packageDir, "SHOT_001");
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01001.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01002.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01003.exr")));
|
||||
// The internal shotCode-based name must not leak into the delivery.
|
||||
Assert.False(File.Exists(Path.Combine(deliveredDir, "SHOT_001_cmp_TT_v001.01001.exr")));
|
||||
Assert.Equal(3, Directory.GetFiles(deliveredDir, "*.exr").Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCodeRenamesTheDeliveryMov()
|
||||
{
|
||||
WriteFrames(2);
|
||||
var movSrc = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.mov");
|
||||
File.WriteAllText(movSrc, "mov bytes");
|
||||
|
||||
var manifest = Manifest(fileCount: 2);
|
||||
manifest.IncludeMovs = true;
|
||||
manifest.Items[0].DeliveryMovPath = movSrc;
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var deliveredDir = Path.Combine(_packageDir, "SHOT_001");
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.mov")));
|
||||
Assert.Equal(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.mov"), result.Items[0].MovPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCodeRenameStillVerifiesAgainstTheValidationTimeChecksum()
|
||||
{
|
||||
// Renaming must not change file *content*, so a checksum recorded at
|
||||
// validation time (against the internal name) has to still match the
|
||||
// delivered (renamed) files — the hash is content-based, not name-based.
|
||||
WriteFrames(3);
|
||||
var manifest = Manifest(copyMode: "always-copy");
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
var first = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
Assert.True(first.Ok);
|
||||
|
||||
Directory.Delete(_packageDir, recursive: true);
|
||||
var replay = Manifest(checksum: first.Items[0].Checksum, copyMode: "always-copy");
|
||||
replay.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
var second = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, replay, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnparseableFrameNumberFallsBackToTheSourceFilename()
|
||||
{
|
||||
// A file that doesn't match the render's own numbering convention
|
||||
// (shouldn't happen for a real render, but must never crash a delivery)
|
||||
// is delivered under its original name rather than failing the item.
|
||||
Directory.CreateDirectory(_sourceDir);
|
||||
File.WriteAllText(Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001_no_frame_number.exr"), "junk");
|
||||
var manifest = Manifest(fileCount: 1);
|
||||
manifest.Items[0].OutputPattern = "SHOT_001_cmp_TT_v001_no_frame_number.exr";
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001_no_frame_number.exr")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,11 +109,18 @@ public sealed class DeliveryStage
|
||||
|
||||
Directory.CreateDirectory(shotDir);
|
||||
|
||||
// Delivered names carry the client-facing base (or the studio's own
|
||||
// convention when no client code is set), not the internal render
|
||||
// filename — only the frame number and extension survive from the
|
||||
// source. A frame whose number can't be parsed falls back to the
|
||||
// source filename verbatim rather than failing the whole delivery.
|
||||
var destNames = sources.ToDictionary(s => s, s => DeliveredFrameName(s, item, pattern));
|
||||
|
||||
long totalBytes = 0;
|
||||
var usedCopy = false;
|
||||
foreach (var src in sources)
|
||||
{
|
||||
var dest = Path.Combine(shotDir, Path.GetFileName(src));
|
||||
var dest = Path.Combine(shotDir, destNames[src]);
|
||||
if (Place(src, dest, alwaysCopy, out var copied) is string placeError)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, placeError);
|
||||
@@ -134,7 +141,7 @@ public sealed class DeliveryStage
|
||||
|
||||
if (usedCopy || alwaysHash)
|
||||
{
|
||||
digest = SequenceChecksum(sources.Select(s => Path.Combine(shotDir, Path.GetFileName(s))));
|
||||
digest = SequenceChecksum(sources.Select(s => Path.Combine(shotDir, destNames[s])));
|
||||
if (item.Checksum is { Length: > 0 } && !string.Equals(digest, item.Checksum, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DeliveryItemResult.Failed(item,
|
||||
@@ -174,7 +181,10 @@ public sealed class DeliveryStage
|
||||
var movSrc = mapper.Map(item.DeliveryMovPath!);
|
||||
if (File.Exists(movSrc))
|
||||
{
|
||||
movDest = Path.Combine(shotDir, Path.GetFileName(movSrc));
|
||||
var movName = string.IsNullOrWhiteSpace(item.DeliveredBaseName)
|
||||
? Path.GetFileName(movSrc)
|
||||
: item.DeliveredBaseName + Path.GetExtension(movSrc);
|
||||
movDest = Path.Combine(shotDir, movName);
|
||||
if (Place(movSrc, movDest, alwaysCopy, out var movCopied) is string movError)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, movError);
|
||||
@@ -192,7 +202,7 @@ public sealed class DeliveryStage
|
||||
|
||||
foreach (var src in sources)
|
||||
{
|
||||
var name = Path.GetFileName(src);
|
||||
var name = destNames[src];
|
||||
manifestFiles.Add(new
|
||||
{
|
||||
shotCode = item.ShotCode,
|
||||
@@ -252,6 +262,31 @@ public sealed class DeliveryStage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The delivered filename for one source frame: DeliveredBaseName with the
|
||||
/// frame number (re-padded to the render's own convention) and extension —
|
||||
/// never the internal render filename it was copied from. Falls back to
|
||||
/// the source filename verbatim (never fatal) when there's no client-facing
|
||||
/// base to use, or the frame number can't be parsed out of the source name.
|
||||
/// </summary>
|
||||
internal string DeliveredFrameName(string src, DeliveryManifestItem item, SequencePattern pattern)
|
||||
{
|
||||
var sourceName = Path.GetFileName(src);
|
||||
if (string.IsNullOrWhiteSpace(item.DeliveredBaseName))
|
||||
{
|
||||
return sourceName;
|
||||
}
|
||||
var frame = pattern.FrameNumberOf(sourceName);
|
||||
if (frame is null)
|
||||
{
|
||||
_log.LogWarning("Could not parse a frame number out of {Name} for {Shot} — delivering under its original name",
|
||||
sourceName, item.ShotCode);
|
||||
return sourceName;
|
||||
}
|
||||
var padding = Math.Max(1, pattern.Padding);
|
||||
return $"{item.DeliveredBaseName}.{frame.Value.ToString().PadLeft(padding, '0')}{pattern.Extension}";
|
||||
}
|
||||
|
||||
private static string SequenceChecksum(IEnumerable<string> files)
|
||||
{
|
||||
var state = new XxHash64.State(0);
|
||||
|
||||
@@ -129,6 +129,13 @@ public sealed class DeliveryManifestItem
|
||||
public string? Checksum { get; set; }
|
||||
public string? DeliveryMovPath { get; set; }
|
||||
public string ShotDir { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Server-computed: "{clientCode}_{versionString}" when the shot has a
|
||||
/// client naming code, else the studio's own "{shotCode}_cmp_TT_{versionString}".
|
||||
/// The delivered EXR sequence and MOV are named from this, not the
|
||||
/// internal render filename they're copied from.
|
||||
/// </summary>
|
||||
public string DeliveredBaseName { get; set; } = "";
|
||||
/// <summary>When this export's validation ran — lets a linked package detect a source changed since QC.</summary>
|
||||
public DateTimeOffset? ValidatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -498,6 +498,7 @@ export async function updateShotSlate(
|
||||
slateDescription?: string | null;
|
||||
slateVfxScope?: string | null;
|
||||
slateSubmissionNote?: string | null;
|
||||
clientCode?: string | null;
|
||||
}
|
||||
) {
|
||||
const session = await auth();
|
||||
@@ -516,6 +517,7 @@ export async function updateShotSlate(
|
||||
slateDescription: data.slateDescription !== undefined ? (data.slateDescription?.trim() || null) : undefined,
|
||||
slateVfxScope: data.slateVfxScope !== undefined ? (data.slateVfxScope?.trim() || null) : undefined,
|
||||
slateSubmissionNote: data.slateSubmissionNote !== undefined ? (data.slateSubmissionNote?.trim() || null) : undefined,
|
||||
clientCode: data.clientCode !== undefined ? (data.clientCode?.trim() || null) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -48,10 +48,16 @@ export function ShotSlateTab({ shot, projectName, onSaved }: Props) {
|
||||
const [slateDescription, setSlateDescription] = useState(shot.slateDescription ?? shot.description ?? "");
|
||||
const [slateVfxScope, setSlateVfxScope] = useState(shot.slateVfxScope ?? "");
|
||||
const [slateSubmissionNote, setSlateSubmissionNote] = useState(shot.slateSubmissionNote ?? "");
|
||||
const [clientCode, setClientCode] = useState(shot.clientCode ?? "");
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}/${String(today.getMonth() + 1).padStart(2, "0")}/${String(today.getDate()).padStart(2, "0")}`;
|
||||
const versionName = `${shot.shotCode}_cmp_TT_${shot.shotVersion ?? "v001"}`;
|
||||
// Client code, when set, replaces shotCode in what the client actually
|
||||
// sees (slate, overlay burn-in, delivered names) — just their own naming
|
||||
// with our version appended, no "_cmp_TT_" studio jargon mixed in.
|
||||
const versionName = clientCode.trim()
|
||||
? `${clientCode.trim()}_${shot.shotVersion ?? "v001"}`
|
||||
: `${shot.shotCode}_cmp_TT_${shot.shotVersion ?? "v001"}`;
|
||||
const frames =
|
||||
shot.frameStart != null && shot.frameEnd != null
|
||||
? String(shot.frameEnd - shot.frameStart + 1)
|
||||
@@ -65,6 +71,7 @@ export function ShotSlateTab({ shot, projectName, onSaved }: Props) {
|
||||
slateDescription: slateDescription.trim() || null,
|
||||
slateVfxScope: slateVfxScope.trim() || null,
|
||||
slateSubmissionNote: slateSubmissionNote.trim() || null,
|
||||
clientCode: clientCode.trim() || null,
|
||||
});
|
||||
toast({ title: "Slate saved" });
|
||||
onSaved();
|
||||
@@ -105,6 +112,15 @@ export function ShotSlateTab({ shot, projectName, onSaved }: Props) {
|
||||
</SlateRow>
|
||||
|
||||
{/* Editable rows */}
|
||||
<SlateRow label="Client Code">
|
||||
<Input
|
||||
value={clientCode}
|
||||
onChange={(e) => setClientCode(e.target.value)}
|
||||
placeholder={`Leave blank to use ${shot.shotCode}`}
|
||||
className="h-7 bg-zinc-900 border-zinc-700 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 placeholder:font-sans"
|
||||
/>
|
||||
</SlateRow>
|
||||
|
||||
<SlateRow label="Shot Type">
|
||||
<Input
|
||||
value={slateType}
|
||||
|
||||
@@ -224,7 +224,7 @@ export async function createDeliveryPackage(input: CreateDeliveryInput, userId:
|
||||
...(input.exportIds?.length ? { id: { in: input.exportIds } } : {}),
|
||||
...(input.episode ? { shot: { episode: input.episode } } : {}),
|
||||
},
|
||||
include: { shot: { select: { id: true, shotCode: true, episode: true } } },
|
||||
include: { shot: { select: { id: true, shotCode: true, clientCode: true, episode: true } } },
|
||||
orderBy: { shotId: "asc" },
|
||||
});
|
||||
|
||||
@@ -293,6 +293,7 @@ export async function createDeliveryPackage(input: CreateDeliveryInput, userId:
|
||||
exportId: e.id,
|
||||
shotId: e.shotId,
|
||||
shotCode: e.shot.shotCode,
|
||||
clientCode: e.shot.clientCode,
|
||||
versionString: e.versionString,
|
||||
})),
|
||||
});
|
||||
@@ -341,6 +342,13 @@ export interface DeliveryManifestItem {
|
||||
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;
|
||||
}
|
||||
@@ -417,11 +425,16 @@ export async function buildDeliveryJobManifest(
|
||||
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: item.shotCode,
|
||||
shotCode: item.clientCode || item.shotCode,
|
||||
versionString: item.versionString,
|
||||
}),
|
||||
deliveredBaseName: item.clientCode
|
||||
? `${item.clientCode}_${item.versionString}`
|
||||
: `${item.shotCode}_cmp_TT_${item.versionString}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
|
||||
where: { id: exportRow.shotId },
|
||||
select: {
|
||||
shotCode: true,
|
||||
clientCode: true,
|
||||
episode: true,
|
||||
scene: true,
|
||||
description: true,
|
||||
@@ -89,6 +90,11 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
|
||||
// preview that is quietly the wrong colour.
|
||||
const templates = await resolveProjectTemplates(shot.project);
|
||||
const base = `${shot.shotCode}_cmp_TT_${exportRow.versionString}`;
|
||||
// Client code, when set, replaces shotCode in everything the client sees —
|
||||
// slate identity and (via ctx.slate.versionName) the overlay burn-in — but
|
||||
// never in internal paths: movOutput/mp4Output below stay on `base`. Just
|
||||
// their own naming with our version appended, no "_cmp_TT_" studio jargon.
|
||||
const clientBase = shot.clientCode ? `${shot.clientCode}_${exportRow.versionString}` : base;
|
||||
|
||||
return {
|
||||
stage: "PREVIEW",
|
||||
@@ -111,14 +117,14 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
|
||||
slateScopeProp: cfg.slateScopeProp,
|
||||
slateSubmissionProp: cfg.slateSubmissionProp,
|
||||
slate: {
|
||||
versionName: base,
|
||||
versionName: clientBase,
|
||||
date: slateDate(),
|
||||
// The slate's own description is authored on the Shot's Slate tab and
|
||||
// falls back to the general shot description — the same precedence the
|
||||
// tab shows as its placeholder.
|
||||
description: shot.slateDescription ?? shot.description,
|
||||
notes: shot.notes,
|
||||
shotCode: shot.shotCode,
|
||||
shotCode: shot.clientCode || shot.shotCode,
|
||||
// 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,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Client naming alias: substituted for shotCode only on slates, overlay
|
||||
-- burn-ins, and delivered file/folder names. Internal paths, comp names and
|
||||
-- DB lookups always use shotCode.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "shots" ADD COLUMN "clientCode" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "delivery_items" ADD COLUMN "clientCode" TEXT;
|
||||
@@ -392,6 +392,10 @@ model EpisodeDueDate {
|
||||
model Shot {
|
||||
id String @id @default(cuid())
|
||||
shotCode String
|
||||
/// Client-supplied naming alias, used only for slates, overlay burn-ins,
|
||||
/// and delivered file/folder names — never for internal paths, comp names
|
||||
/// or DB lookups, which always use shotCode. Null = use shotCode as today.
|
||||
clientCode String?
|
||||
scene String @default("")
|
||||
episode String?
|
||||
shotNumber Int @default(0)
|
||||
@@ -1117,6 +1121,10 @@ model DeliveryItem {
|
||||
export Export @relation(fields: [exportId], references: [id])
|
||||
shotId String
|
||||
shotCode String
|
||||
/// Snapshot of Shot.clientCode at package build time — same immutability
|
||||
/// rule as shotCode/versionString: a later shot edit must not retroactively
|
||||
/// change already-built delivery history.
|
||||
clientCode String?
|
||||
versionString String
|
||||
exrPath String? // path inside the package
|
||||
movPath String?
|
||||
|
||||
@@ -148,6 +148,8 @@ export interface VersionWithDetails {
|
||||
export interface ShotWithDetails {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
/** Client-supplied naming alias — slates/overlays/delivery only, never internal paths. */
|
||||
clientCode: string | null;
|
||||
scene: string;
|
||||
episode: string | null;
|
||||
shotNumber: number;
|
||||
|
||||
Reference in New Issue
Block a user