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:
twotalesanimation
2026-09-18 04:21:53 +02:00
parent 2dee1bf6fc
commit 400cf3f6ea
3 changed files with 170 additions and 18 deletions
+2
View File
@@ -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()
{
+100 -18
View File
@@ -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