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"
+ />
+
+