diff --git a/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs b/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs index 52a227d..19e32e9 100644 --- a/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs +++ b/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs @@ -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"))); + } } diff --git a/RenderWorker/VFXReviewWorker/DeliveryStage.cs b/RenderWorker/VFXReviewWorker/DeliveryStage.cs index f95cd88..4824a9d 100644 --- a/RenderWorker/VFXReviewWorker/DeliveryStage.cs +++ b/RenderWorker/VFXReviewWorker/DeliveryStage.cs @@ -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 } } + /// + /// 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. + /// + 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 files) { var state = new XxHash64.State(0); diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs index 8143cc7..95b6236 100644 --- a/RenderWorker/VFXReviewWorker/Dtos.cs +++ b/RenderWorker/VFXReviewWorker/Dtos.cs @@ -129,6 +129,13 @@ public sealed class DeliveryManifestItem public string? Checksum { get; set; } public string? DeliveryMovPath { get; set; } public string ShotDir { get; set; } = ""; + /// + /// 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. + /// + public string DeliveredBaseName { get; set; } = ""; /// When this export's validation ran — lets a linked package detect a source changed since QC. public DateTimeOffset? ValidatedAt { get; set; } } diff --git a/actions/shots.ts b/actions/shots.ts index 6f05876..4ae1696 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -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, }, }); diff --git a/components/shots/ShotSlateTab.tsx b/components/shots/ShotSlateTab.tsx index 3f4b314..e15b627 100644 --- a/components/shots/ShotSlateTab.tsx +++ b/components/shots/ShotSlateTab.tsx @@ -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) { {/* Editable rows */} + + 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" + /> + +