fix(worker): stop PrepareOutputDir deleting the version's preview MOV/MP4
Deploy / deploy (push) Successful in 6m33s
Deploy / deploy (push) Successful in 6m33s
PrepareOutputDir cleared every file in the output dir whose name started
with the output pattern's prefix. The preview stage writes its MOV and MP4
into that same folder from the same basename ({outputDir}/{base}.mov), so
they matched the prefix and were deleted before aerender started.
The dir is not private to one export: outputDir is keyed on the shot's
manually-set shotVersion, not on the export, so re-queuing without a
Version Up renders into the folder again. That is exactly the "No preview"
workflow - export scaled, reverse the scale, re-render only the EXRs and
inherit the earlier MOV - and inheritPreviewMedia then handed the new
export a deliveryMovPath pointing at the file the render had just removed.
Nothing surfaced it: the review MP4 is an uploaded blob and still played,
and DeliveryStage treats a missing MOV as non-fatal, so the package
silently shipped EXRs with no viewing copy.
Match on prefix and extension instead, via a new
SequencePattern.MatchesOutputFile - the predicate ValidationStage and
DeliveryStage already applied inline. Crashed-attempt cleanup is unchanged;
only files with a different extension are now spared.
JobExecutor's post-render stats loop matched on prefix alone too. With the
media no longer deleted it would have counted the MOV/MP4 as frames and
failed validation's file_count check, so it uses the same matcher.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,59 @@ public class PrepareOutputDirTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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()
|
||||
{
|
||||
|
||||
@@ -234,26 +234,30 @@ public sealed class AerenderRunner
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §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.
|
||||
/// </summary>
|
||||
/// <summary>Filename prefix of an output pattern ("X.[####].exr" → "X").</summary>
|
||||
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 */ }
|
||||
}
|
||||
|
||||
@@ -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<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
|
||||
@@ -645,6 +645,16 @@ public sealed class SequencePattern
|
||||
return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool MatchesOutputFile(string fileName) =>
|
||||
fileName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& fileName.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int? FrameNumberOf(string fileName) => FrameFromName(fileName);
|
||||
|
||||
/// <summary>`UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.</summary>
|
||||
|
||||
Reference in New Issue
Block a user