diff --git a/RenderWorker/VFXReviewWorker.Tests/PathMapperTests.cs b/RenderWorker/VFXReviewWorker.Tests/PathMapperTests.cs index 1b3d33f..f1f552f 100644 --- a/RenderWorker/VFXReviewWorker.Tests/PathMapperTests.cs +++ b/RenderWorker/VFXReviewWorker.Tests/PathMapperTests.cs @@ -70,6 +70,59 @@ public class PrepareOutputDirTests } } + /// + /// The version folder holds the preview MOV/MP4 built from the same + /// basename. A shot re-rendered at the same version — the "No preview" + /// workflow, where the EXRs are re-exported unscaled and the earlier MOV is + /// inherited as the review media — must not have that MOV cleared out from + /// under it before aerender starts. + /// + [Fact] + public void KeepsPreviewMediaSharingThePatternPrefix() + { + var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"), "old"); + File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.mov"), "delivery mov"); + File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.mp4"), "review mp4"); + + AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr"); + + Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"))); + Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.mov"))); + Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.mp4"))); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void ClearsOnlyThisVersionsFrames() + { + var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"), "old"); + File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v005.1001.exr"), "other version"); + File.WriteAllText(Path.Combine(dir, "OTHERSHOT_cmp_TT_v004.1001.exr"), "other shot"); + + AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr"); + + Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"))); + Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v005.1001.exr"))); + Assert.True(File.Exists(Path.Combine(dir, "OTHERSHOT_cmp_TT_v004.1001.exr"))); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + [Fact] public void CreatesMissingDirectory() { diff --git a/RenderWorker/VFXReviewWorker/AerenderRunner.cs b/RenderWorker/VFXReviewWorker/AerenderRunner.cs index a748288..92df34d 100644 --- a/RenderWorker/VFXReviewWorker/AerenderRunner.cs +++ b/RenderWorker/VFXReviewWorker/AerenderRunner.cs @@ -234,26 +234,30 @@ public sealed class AerenderRunner } /// - /// §7.3: outputDir is created before launch; a pre-existing non-empty dir - /// can only hold output from a previous crashed attempt of this same - /// immutable version. Safety guard: only files matching the job's own - /// output pattern prefix are ever deleted. + /// §7.3: outputDir is created before launch; a pre-existing dir can hold + /// frames from a previous crashed attempt, which aerender would otherwise + /// leave interleaved with this attempt's. Safety guard: only files matching + /// the job's own output pattern — its prefix *and* its extension — are ever + /// deleted. + /// + /// The extension is not optional. The version folder is shared with the + /// preview stage, whose MOV and MP4 are built from the same basename + /// (preview.ts: `{outputDir}/{base}.mov`), so prefix alone also matches + /// them. A version folder can legitimately be rendered into twice — the + /// output path is keyed on the shot's manually-set version, not on the + /// export — and on a no-preview re-render (Export.skipPreview) the media + /// that gets inherited is precisely the MOV sitting in this folder. + /// Deleting it left the export pointing at a file that no longer existed, + /// and delivery silently packaged the EXRs without their viewing copy. /// - /// Filename prefix of an output pattern ("X.[####].exr" → "X"). - internal static string OutputPatternPrefix(string outputPattern) - { - var bracket = outputPattern.IndexOf('['); - return bracket > 0 ? outputPattern[..bracket].TrimEnd('.') : Path.GetFileNameWithoutExtension(outputPattern); - } - internal static void PrepareOutputDir(string outputDirLocal, string outputPattern) { Directory.CreateDirectory(outputDirLocal); - var prefix = OutputPatternPrefix(outputPattern); - if (string.IsNullOrEmpty(prefix)) return; + var pattern = SequencePattern.FromOutputPattern(outputPattern); + if (string.IsNullOrEmpty(pattern.Prefix)) return; foreach (var file in Directory.EnumerateFiles(outputDirLocal)) { - if (Path.GetFileName(file).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + if (pattern.MatchesOutputFile(Path.GetFileName(file))) { try { File.Delete(file); } catch { /* locked leftovers surface as an aerender error */ } } diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs index b5a8d67..50c1c24 100644 --- a/RenderWorker/VFXReviewWorker/JobExecutor.cs +++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs @@ -76,12 +76,15 @@ public sealed class JobExecutor { long totalBytes = 0; int fileCount = 0; - var prefix = AerenderRunner.OutputPatternPrefix(manifest.OutputPattern); + // Prefix and extension both: the preview MOV/MP4 for this + // version share the basename and live in this same folder, and + // counting them as frames would inflate the EXR stats. + var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern); try { foreach (var f in Directory.EnumerateFiles(outputDirLocal)) { - if (!Path.GetFileName(f).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + if (!pattern.MatchesOutputFile(Path.GetFileName(f))) continue; fileCount++; totalBytes += new FileInfo(f).Length; } @@ -94,8 +97,8 @@ public sealed class JobExecutor // range, template mismatch, silently swallowed error). // Until Phase 3 validation exists, an empty output dir must // never become READY_FOR_QC. - _log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*\" files exist in {Dir} — reporting failure", - job.Id, prefix, outputDirLocal); + _log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*{Ext}\" files exist in {Dir} — reporting failure", + job.Id, pattern.Prefix, pattern.Extension, outputDirLocal); _spooler.Enqueue("fail", job.Id, new Dictionary { ["machineId"] = machineId, diff --git a/RenderWorker/VFXReviewWorker/ValidationStage.cs b/RenderWorker/VFXReviewWorker/ValidationStage.cs index 24f1f33..3756f19 100644 --- a/RenderWorker/VFXReviewWorker/ValidationStage.cs +++ b/RenderWorker/VFXReviewWorker/ValidationStage.cs @@ -645,6 +645,16 @@ public sealed class SequencePattern return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding }; } + /// + /// Whether a filename belongs to *this* render's output: the pattern's own + /// prefix and extension. A version folder also holds the preview MOV and + /// MP4, which are built from the same basename and so share the prefix — + /// only the extension separates them from the frames. + /// + public bool MatchesOutputFile(string fileName) => + fileName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) + && fileName.EndsWith(Extension, StringComparison.OrdinalIgnoreCase); + public int? FrameNumberOf(string fileName) => FrameFromName(fileName); /// `UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.