fix(connector): build shots at the shot's frame rate, not a hardcoded 24
Deploy / deploy (push) Successful in 6m23s
Deploy / deploy (push) Successful in 6m23s
Shot.fps existed, the lookup API returned it, and one button honoured it — Set Start Timecode — while the shot builder ignored it entirely. Comps were created at 24 and every plate conformed to 24. DH2 is a 25 fps show. Its plates carry framesPerSecond 25/1, so comps built at 24 conform every plate to the wrong speed and compute every frame-from-timecode sum against the wrong rate. The builder now takes the rate from the shot record, which is the show's delivery rate and therefore the authority; a plate's own rate is whatever happens to be on disk. Import Renders follows the comp it imports into, and Import EXR follows the shot. Validation gains plate_fps, comparing the render against the timing plate. timecode_fps only compares the render to its manifest and both come from the comp, so a shot built at the wrong rate agrees with itself and passes; the plate is the independent witness. It is a WARN, never a FAIL: sampling DH2 found one plate at 50 fps against a 25 fps show, a deliberate over-crank, so a whole-number multiple is reported as exactly that and anything else as a probable rate mistake. Note this does not fix the data: all 175 DH2 shots still carry fps 24 from the create-time default and need correcting before their comps are rebuilt. Panel 2.4.0, worker 1.4.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,14 +26,14 @@ public class ValidationStageTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Writes a sequence numbered from 1001 with a plate-derived timecode.</summary>
|
||||
private void WriteSequence(int count, string startTimecode, int firstFrame = 1001)
|
||||
private void WriteSequence(int count, string startTimecode, int firstFrame = 1001, int fps = 24)
|
||||
{
|
||||
var start = Timecode.Parse(startTimecode)!.Value.ToFrames(24);
|
||||
var start = Timecode.Parse(startTimecode)!.Value.ToFrames(fps);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var tc = Timecode.FromFrames(start + i, 24);
|
||||
var tc = Timecode.FromFrames(start + i, fps);
|
||||
var path = Path.Combine(_dir, $"UNG_107_051_010_cmp_TT_v004.{firstFrame + i:D5}.exr");
|
||||
File.Move(ExrFixture.Write(tc.ToString(), 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), path);
|
||||
File.Move(ExrFixture.Write(tc.ToString(), 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, fps, 4), path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,66 @@ public class ValidationStageTests : IDisposable
|
||||
Assert.Contains("8-frame handle", tc.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APlateAtADifferentRateFailsPlateFps()
|
||||
{
|
||||
// The DH2 case: a 25 fps show whose shot records defaulted to 24, so
|
||||
// every comp was built at 24. The render agrees with its own manifest,
|
||||
// so only the plate can reveal it.
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 25, 4), plate);
|
||||
|
||||
var manifest = Manifest(_dir, "14:26:31:16", 1001);
|
||||
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
|
||||
|
||||
var outcome = NewStage().Run(manifest, _dir, new ServerConfig());
|
||||
var fpsCheck = Check(outcome, "plate_fps");
|
||||
Assert.Equal("WARN", fpsCheck.Status);
|
||||
Assert.Equal("25", fpsCheck.Expected);
|
||||
Assert.Equal("24", fpsCheck.Actual);
|
||||
Assert.Contains("wrong rate", fpsCheck.Message);
|
||||
|
||||
// timecode_fps alone would not have caught it: the render and the
|
||||
// manifest agree with each other at 24.
|
||||
Assert.Equal("PASS", Check(outcome, "timecode_fps").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOverCrankedPlateIsRecognisedRatherThanReportedAsAMistake()
|
||||
{
|
||||
// A 50 fps plate on a 25 fps show is deliberate slow motion, conformed
|
||||
// on purpose — real on DH2, and it must not read as a rate error.
|
||||
WriteSequence(105, "14:26:31:08", fps: 25);
|
||||
var plate = Path.Combine(_dir, "plate", "DH2_PL01.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 50, 4), plate);
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Fps = 25;
|
||||
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
|
||||
|
||||
var check = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "plate_fps");
|
||||
Assert.Equal("WARN", check.Status);
|
||||
Assert.Contains("over-crank", check.Message);
|
||||
Assert.DoesNotContain("wrong rate", check.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingRatesPassPlateFps()
|
||||
{
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), plate);
|
||||
|
||||
var manifest = Manifest(_dir, "14:26:31:16", 1001);
|
||||
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
|
||||
|
||||
Assert.Equal("PASS", Check(NewStage().Run(manifest, _dir, new ServerConfig()), "plate_fps").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GapsAreStillCaughtWithinTheRun()
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
every change that gets published. publish.ps1 appends the git SHA via
|
||||
SourceRevisionId, giving "1.1.0+feb1c7b".
|
||||
-->
|
||||
<Version>1.3.0</Version>
|
||||
<Version>1.4.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -350,6 +350,31 @@ public sealed class ValidationStage
|
||||
return;
|
||||
}
|
||||
|
||||
// plate_fps — the render's rate against the plate's own.
|
||||
//
|
||||
// timecode_fps only compares the render to the manifest, and both come
|
||||
// from the comp: a shot built at the wrong rate agrees with itself and
|
||||
// passes. The plate is the independent witness. A 25 fps show whose
|
||||
// shot records defaulted to 24 renders every shot at 24, and this is
|
||||
// what names that rather than leaving it to surface as an off-by-N
|
||||
// timecode error.
|
||||
if (reference.PlateFps is double plateFps && first.H.FramesPerSecond is double renderFps)
|
||||
{
|
||||
var ratio = renderFps > 0 ? plateFps / renderFps : 0;
|
||||
var overcrank = ratio > 1.5 && Math.Abs(ratio - Math.Round(ratio)) < 0.01;
|
||||
|
||||
checks.Add(Math.Abs(plateFps - renderFps) < 0.01
|
||||
? ValidationCheck.Pass("plate_fps", plateFps.ToString("0.###"), renderFps.ToString("0.###"))
|
||||
// Never a FAIL: a plate deliberately shot at a multiple of the
|
||||
// show rate is a legitimate over-crank, conformed on purpose.
|
||||
// Anything else is a rate mistake, and the wording says which.
|
||||
: ValidationCheck.Warn("plate_fps", plateFps.ToString("0.###"), renderFps.ToString("0.###"),
|
||||
overcrank
|
||||
? $"The plate is {plateFps:0.###} fps against a {renderFps:0.###} fps render — a {Math.Round(ratio)}x over-crank, conformed as expected."
|
||||
: $"The plate is {plateFps:0.###} fps but this rendered at {renderFps:0.###} — not a whole-number over-crank, so the comp was probably built at the wrong rate (check the shot's fps in the database).",
|
||||
new { platePath = reference.PlatePath, reference = reference.Kind, ratio }));
|
||||
}
|
||||
|
||||
var expectedFrames = reference.PlateTimecode.ToFrames(fps) + (renderFirstFrame - reference.PlateFirstFrame);
|
||||
var expected = Timecode.FromFrames(expectedFrames, fps);
|
||||
var actualFrames = renderTc.ToFrames(fps);
|
||||
@@ -415,7 +440,8 @@ public sealed class ValidationStage
|
||||
var frame = SequencePattern.FrameFromName(Path.GetFileName(local))
|
||||
?? timing.FrameStart
|
||||
?? renderFirstFrame;
|
||||
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames);
|
||||
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames,
|
||||
header.FramesPerSecond);
|
||||
}
|
||||
_log.LogWarning("Timing plate from manifest is unreadable: {Path}", local);
|
||||
}
|
||||
@@ -507,7 +533,7 @@ public sealed class ValidationStage
|
||||
if (header?.Timecode is null) return null;
|
||||
|
||||
var frame = SequencePattern.FrameFromName(Path.GetFileName(firstFile)) ?? m.FrameStart;
|
||||
return new TimecodeReference("derived", firstFile, header.Timecode.Value, frame, 0);
|
||||
return new TimecodeReference("derived", firstFile, header.Timecode.Value, frame, 0, header.FramesPerSecond);
|
||||
}
|
||||
|
||||
private static void AddCandidate(List<(string Dir, int Count)> candidates, string dir)
|
||||
@@ -554,7 +580,8 @@ public sealed class ValidationStage
|
||||
}
|
||||
|
||||
public sealed record TimecodeReference(
|
||||
string Kind, string? PlatePath, Timecode PlateTimecode, int PlateFirstFrame, int HandleFrames);
|
||||
string Kind, string? PlatePath, Timecode PlateTimecode, int PlateFirstFrame, int HandleFrames,
|
||||
double? PlateFps = null);
|
||||
|
||||
public sealed record ValidationOutcome(
|
||||
List<ValidationCheck> Checks, int FileCount, long TotalBytes, string? Checksum)
|
||||
|
||||
Reference in New Issue
Block a user