diff --git a/RenderPipeline2.md b/RenderPipeline2.md index 4cd12c9..3c7c18f 100644 --- a/RenderPipeline2.md +++ b/RenderPipeline2.md @@ -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:** diff --git a/RenderWorker/VFXReviewWorker.Tests/ValidationStageTests.cs b/RenderWorker/VFXReviewWorker.Tests/ValidationStageTests.cs index 473204d..e93589c 100644 --- a/RenderWorker/VFXReviewWorker.Tests/ValidationStageTests.cs +++ b/RenderWorker/VFXReviewWorker.Tests/ValidationStageTests.cs @@ -109,6 +109,74 @@ public class ValidationStageTests : IDisposable Assert.Contains("8-frame handle", tc.Message); } + /// Writes a one-frame plate into a footage-style element folder. + 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() { diff --git a/RenderWorker/VFXReviewWorker/ValidationStage.cs b/RenderWorker/VFXReviewWorker/ValidationStage.cs index 3756f19..5f3b4f7 100644 --- a/RenderWorker/VFXReviewWorker/ValidationStage.cs +++ b/RenderWorker/VFXReviewWorker/ValidationStage.cs @@ -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(); + 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); + } + + /// + /// 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. + /// + private IEnumerable 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 { AsFolder(footage) } + : new List(); + + var seen = new HashSet(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; + } + } + /// /// Rank 2: look for the shot's plate folders under the configured roots, /// mirroring the panel's two on-disk layouts ({shotCode}/{element}/ and