fix(connector): build shots at the shot's frame rate, not a hardcoded 24
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:
twotalesanimation
2026-08-14 13:10:10 +02:00
parent 41ee826700
commit 483db65eb3
6 changed files with 152 additions and 25 deletions
@@ -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)