feat(validation): try the comp's other delivered plates before failing timecode
When the timing plate disagrees with the render, the worker now checks the manifest's other plates[] that sit under a plate search root (or beside the timing plate's footage folder). A match is a PASS naming that element; no match still FAILs against the timing plate, listing the alternates tried. Pre-renders are excluded because they agree with the render by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1073,6 +1073,8 @@ Timecode is the check this engine exists for. It is validated **against the sour
|
||||
| 2 | Plate re-derived on the worker from the footage root + shot code (manifestVersion 1, or a timing plate that no longer resolves). If elements disagree, the one matching the render's frame count wins; still ambiguous → WARN, never PASS | PASS / FAIL, `reference: "derived"` |
|
||||
| 3 | API `Shot.timecodeStart` **minus `handleFrames`** | **WARN only, never PASS** — `reference: "api"`, message states the plate could not be read |
|
||||
|
||||
**Several delivered elements.** When the rank-1 timing plate does not match, the worker tries the manifest's other `plates[]` before failing — the panel's longest-duration pick is not always the element the comp was anchored to. A match against one of them is a PASS naming that element; no match still FAILs against the timing plate, listing the others tried. Only delivered footage qualifies: plates under a configured plate search root, or (with none configured) alongside the timing plate in its footage folder. `plates[]` also holds pre-renders, which agree with the render by construction and must never rescue it.
|
||||
|
||||
Rank 3 is a diagnostic, not a verification: it depends on the show's handle count being correct for that specific plate, which is exactly the assumption the defects above violate.
|
||||
|
||||
**Comparison, given the plate reference:**
|
||||
|
||||
@@ -109,6 +109,74 @@ public class ValidationStageTests : IDisposable
|
||||
Assert.Contains("8-frame handle", tc.Message);
|
||||
}
|
||||
|
||||
/// <summary>Writes a one-frame plate into a footage-style element folder.</summary>
|
||||
private string WritePlate(string element, string timecode, int fps = 24)
|
||||
{
|
||||
var plate = Path.Combine(_dir, "footage", $"UNG_107_051_010_{element}_TT_v001", $"UNG_107_051_010_{element}_TT_v001.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write(timecode, 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, fps, 4), plate);
|
||||
return plate;
|
||||
}
|
||||
|
||||
private static ManifestSource Source(string timingPlate, params string[] plates) => new()
|
||||
{
|
||||
TimingPlate = new PlateRef { Path = timingPlate, FrameStart = 1001 },
|
||||
Plates = plates.Select(p => new PlateRef { Path = p, FrameStart = 1001 }).ToList(),
|
||||
HandleFrames = 8,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AnotherDeliveredPlatePassesWhenTheTimingPlateDoesNot()
|
||||
{
|
||||
// The comp was anchored to FG01, but the panel named BG01 (longest) as
|
||||
// the timing plate — the two carry unrelated camera timecodes.
|
||||
WriteSequence(105, "09:12:00:00");
|
||||
var bg = WritePlate("BG01", "14:26:31:08");
|
||||
var fg = WritePlate("FG01", "09:12:00:00");
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Source = Source(bg, bg, fg);
|
||||
|
||||
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
|
||||
Assert.Equal("PASS", tc.Status);
|
||||
Assert.Contains("UNG_107_051_010_FG01_TT_v001.01001.exr", tc.Message);
|
||||
Assert.Contains("BG01", tc.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMatchingPlateStillFailsAgainstTheTimingPlate()
|
||||
{
|
||||
WriteSequence(105, "14:26:31:16");
|
||||
var bg = WritePlate("BG01", "14:26:31:08");
|
||||
var fg = WritePlate("FG01", "09:12:00:00");
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Source = Source(bg, bg, fg);
|
||||
|
||||
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
|
||||
Assert.Equal("FAIL", tc.Status);
|
||||
Assert.Equal("14:26:31:08", tc.Expected);
|
||||
Assert.Contains("8-frame handle", tc.Message);
|
||||
Assert.Contains("1 other delivered plate", tc.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APreRenderIsNeverUsedAsTheFallbackPlate()
|
||||
{
|
||||
// plates[] is every file in the comp. A pre-render was rendered from
|
||||
// this comp, so it agrees with the render whether or not either is right.
|
||||
WriteSequence(105, "14:26:31:16");
|
||||
var bg = WritePlate("BG01", "14:26:31:08");
|
||||
var preRender = Path.Combine(_dir, "renders", "prerender", "UNG_107_051_010_PRERENDER_01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(preRender)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:16", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), preRender);
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Source = Source(bg, bg, preRender);
|
||||
|
||||
Assert.Equal("FAIL", Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APlateAtADifferentRateFailsPlateFps()
|
||||
{
|
||||
|
||||
@@ -350,6 +350,31 @@ public sealed class ValidationStage
|
||||
return;
|
||||
}
|
||||
|
||||
var deltaOf = (TimecodeReference r) =>
|
||||
renderTc.ToFrames(fps) - (r.PlateTimecode.ToFrames(fps) + (renderFirstFrame - r.PlateFirstFrame));
|
||||
|
||||
// A shot with several delivered elements: the panel names the longest
|
||||
// as the timing plate, but the comp may have been anchored to another
|
||||
// (BG01 and FG01 carry unrelated camera timecodes). When the timing
|
||||
// plate disagrees, the manifest's other delivered plates are tried
|
||||
// before failing. The comparison is unchanged — it is still a plate, so
|
||||
// a match is a real PASS and no match still FAILs against the timing plate.
|
||||
var timingPlate = reference;
|
||||
var alternatesTried = new List<object>();
|
||||
if (reference.Kind == "manifest" && deltaOf(reference) != 0)
|
||||
{
|
||||
foreach (var alternate in ResolveAlternatePlates(m, renderFirstFrame, reference))
|
||||
{
|
||||
var altDelta = deltaOf(alternate);
|
||||
alternatesTried.Add(new { platePath = alternate.PlatePath, plateTimecode = alternate.PlateTimecode.ToString(), deltaFrames = altDelta });
|
||||
if (altDelta == 0)
|
||||
{
|
||||
reference = alternate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plate_fps — the render's rate against the plate's own.
|
||||
//
|
||||
// timecode_fps only compares the render to the manifest, and both come
|
||||
@@ -377,8 +402,8 @@ public sealed class ValidationStage
|
||||
|
||||
var expectedFrames = reference.PlateTimecode.ToFrames(fps) + (renderFirstFrame - reference.PlateFirstFrame);
|
||||
var expected = Timecode.FromFrames(expectedFrames, fps);
|
||||
var actualFrames = renderTc.ToFrames(fps);
|
||||
var deltaFrames = actualFrames - expectedFrames;
|
||||
var deltaFrames = deltaOf(reference);
|
||||
var matchedAlternate = !ReferenceEquals(reference, timingPlate);
|
||||
var details = new
|
||||
{
|
||||
reference = reference.Kind,
|
||||
@@ -387,10 +412,14 @@ public sealed class ValidationStage
|
||||
plateFirstFrame = reference.PlateFirstFrame,
|
||||
renderFirstFrame,
|
||||
deltaFrames,
|
||||
timingPlatePath = timingPlate.PlatePath,
|
||||
matchedAlternate,
|
||||
alternatesTried,
|
||||
};
|
||||
|
||||
if (deltaFrames == 0)
|
||||
{
|
||||
var timingDelta = deltaOf(timingPlate);
|
||||
// The API fallback proves nothing on its own (§8.1a): it is the
|
||||
// editorial in-point minus a handle count that may not be this
|
||||
// plate's, so a match is reported as a WARN, never a PASS.
|
||||
@@ -398,7 +427,11 @@ public sealed class ValidationStage
|
||||
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
|
||||
"Matches the API timecode less handles, but the plate itself could not be read — unverified.", details)
|
||||
: ValidationCheck.Pass("timecode_plate_match", expected.ToString(), renderTc.ToString(),
|
||||
$"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}", details));
|
||||
$"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}" +
|
||||
(matchedAlternate
|
||||
? $" (the timing plate {Path.GetFileName(timingPlate.PlatePath ?? "")} is {(timingDelta > 0 ? "+" : "")}{timingDelta} frames off — the comp was anchored to this element instead)"
|
||||
: ""),
|
||||
details));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,7 +442,10 @@ public sealed class ValidationStage
|
||||
? $"That is exactly the {reference.HandleFrames}-frame handle: the comp was anchored to the editorial in-point instead of the plate's first frame."
|
||||
: renderTc.Hours == 0 && renderTc.Minutes == 0
|
||||
? "A near-zero timecode means the comp had no source timecode at all and AE derived it from the frame number."
|
||||
: "");
|
||||
: "") +
|
||||
(alternatesTried.Count > 0
|
||||
? $" Also checked {alternatesTried.Count} other delivered plate{(alternatesTried.Count == 1 ? "" : "s")} in the manifest: none match."
|
||||
: "");
|
||||
|
||||
checks.Add(reference.Kind == "api"
|
||||
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
|
||||
@@ -430,20 +466,9 @@ public sealed class ValidationStage
|
||||
var timing = m.Source?.TimingPlate;
|
||||
if (timing is not null && !string.IsNullOrWhiteSpace(timing.Path))
|
||||
{
|
||||
var local = mapper.Map(timing.Path);
|
||||
var header = File.Exists(local) ? ExrHeader.TryRead(local) : null;
|
||||
if (header?.Timecode is not null)
|
||||
{
|
||||
// The plate's own file numbering, never the comp's frame range:
|
||||
// the render's first frame is in output numbering, so anchoring
|
||||
// on a comp frame skews the comparison by the gap between them.
|
||||
var frame = SequencePattern.FrameFromName(Path.GetFileName(local))
|
||||
?? timing.FrameStart
|
||||
?? renderFirstFrame;
|
||||
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames,
|
||||
header.FramesPerSecond);
|
||||
}
|
||||
_log.LogWarning("Timing plate from manifest is unreadable: {Path}", local);
|
||||
var manifestPlate = ReadManifestPlate(timing, mapper, handleFrames, renderFirstFrame);
|
||||
if (manifestPlate is not null) return manifestPlate;
|
||||
_log.LogWarning("Timing plate from manifest is unreadable: {Path}", mapper.Map(timing.Path));
|
||||
}
|
||||
|
||||
// Rank 2 — derive the plate from a configured search root.
|
||||
@@ -469,6 +494,63 @@ public sealed class ValidationStage
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TimecodeReference? ReadManifestPlate(PlateRef plate, PathMapper mapper, int handleFrames, int renderFirstFrame)
|
||||
{
|
||||
var local = mapper.Map(plate.Path);
|
||||
var header = File.Exists(local) ? ExrHeader.TryRead(local) : null;
|
||||
if (header?.Timecode is null) return null;
|
||||
|
||||
// The plate's own file numbering, never the comp's frame range: the
|
||||
// render's first frame is in output numbering, so anchoring on a comp
|
||||
// frame skews the comparison by the gap between them.
|
||||
var frame = SequencePattern.FrameFromName(Path.GetFileName(local))
|
||||
?? plate.FrameStart
|
||||
?? renderFirstFrame;
|
||||
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames,
|
||||
header.FramesPerSecond);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The manifest's other delivered plates, tried when the timing plate does
|
||||
/// not match. Only real footage qualifies — under a configured plate search
|
||||
/// root or, with none configured, alongside the timing plate in its footage
|
||||
/// folder — because plates[] is every file in the comp, and a pre-render
|
||||
/// agrees with the render whether or not either is right.
|
||||
/// </summary>
|
||||
private IEnumerable<TimecodeReference> ResolveAlternatePlates(RenderManifest m, int renderFirstFrame, TimecodeReference timingPlate)
|
||||
{
|
||||
var plates = m.Source?.Plates;
|
||||
if (plates is null || plates.Count == 0 || timingPlate.PlatePath is null) yield break;
|
||||
|
||||
var mapper = new PathMapper(_options.PathMappings);
|
||||
static string AsFolder(string dir) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)) + Path.DirectorySeparatorChar;
|
||||
|
||||
var deliveredRoots = _options.PlateSearchRoots.Count > 0
|
||||
? _options.PlateSearchRoots.Select(r => AsFolder(mapper.Map(r))).ToList()
|
||||
// {footage}/{shot}_{element}/file.exr or {footage}/{shot}/{element}/file.exr:
|
||||
// two levels up from the timing plate is the folder its siblings share.
|
||||
: Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(timingPlate.PlatePath))) is { } footage
|
||||
? new List<string> { AsFolder(footage) }
|
||||
: new List<string>();
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(timingPlate.PlatePath) };
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plate.Path)) continue;
|
||||
var local = Path.GetFullPath(mapper.Map(plate.Path));
|
||||
if (!seen.Add(local)) continue;
|
||||
if (!deliveredRoots.Any(root => local.StartsWith(root, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
|
||||
var reference = ReadManifestPlate(plate, mapper, timingPlate.HandleFrames, renderFirstFrame);
|
||||
if (reference is null)
|
||||
{
|
||||
_log.LogWarning("Alternate plate from manifest is unreadable: {Path}", local);
|
||||
continue;
|
||||
}
|
||||
yield return reference;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rank 2: look for the shot's plate folders under the configured roots,
|
||||
/// mirroring the panel's two on-disk layouts ({shotCode}/{element}/ and
|
||||
|
||||
Reference in New Issue
Block a user