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.