libfreemkv: rc.5.2 SOTL video — full Windows fps, opening-GOP proof, self-sufficient log-level 3

Three Silence-of-the-Lambs (R2 PAL SD-DVD) follow-ups for rc.5.2.

SUB-TASK 1 — Windows Explorer showed 12.5 fps (half) for the 576i25 track.
Root cause: the DefaultDecodedFieldDuration (20 ms field) element rc.5.1
added to "fix" Windows fps did the opposite. With FlagInterlaced=1 +
DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms, Explorer halved
to 12.5 fps and MediaInfo flipped to VFR. MakeMKV's correct rip omits the
field-duration element, keeps FlagInterlaced=1 + FieldOrder=TFF +
full-frame DefaultDuration (40 ms), and Explorer shows 25 fps / MediaInfo
CFR. Fix: MkvTrack::video now passes field_duration_ns == 0 so the element
is no longer written; the 1/DefaultDuration = 25 fps signal (the only one
tools trust) is the full-frame value. Interlace signalling (FlagInterlaced,
FieldOrder=TFF) is retained — MediaInfo reads scan type from the MPEG-2 ES
picture coding extension, so it still reports Interlaced / Top Field First.
Tests pin the new TrackEntry elements (element present/absent + values).

SUB-TASK 2 — opening "menu"/still-frame video. Traced the MPEG-2
opening-GOP path; the wrong/last seq header and PTS-floor-to-0 hypotheses
are RULED OUT with file:line evidence: codecPrivate is the FIRST sequence
header (read once at headers-ready, mkvstream.rs:115 + pipelined_stream.rs:289),
DVD VOBU structure guarantees each title opens on seq header + I-frame (no
mid-GOP open), the parser back-anchors leading still-frames to the disc's
real timeline (mpeg2.rs:296-303), and the muxer anchors base on the opening
keyframe's real PTS so the t=0 floor (mkv.rs:963) never corrupts it.
Regression tests pin all three (parser + muxer level).

SUB-TASK 3 — make --log-level 3 self-sufficient (diag.rs + minimal hooks).
(a) dump the ACTUAL MKV TrackEntry elements written per track
(tag=mkv.track: FlagInterlaced, FieldOrder, DefaultDuration, field duration,
Display dims, codecPrivate hex) so Windows-fps-class metadata is verifiable
from a log alone. (b) capture the first ~100 coded frames per track (raw)
to <output>.opening.bin with a per-frame summary line (tag=mkv.opening.frame:
track, key/delta, size, PTS) so opening-GOP/menu issues are diagnosable from
a future log without the disc. Both gated to log-level 3; normal runs open
no side file and record nothing.

CI gate (Rust 1.86): fmt --check, clippy -D warnings, and test --tests all
green.
This commit is contained in:
Matthew Jackson
2026-06-24 17:04:56 -07:00
parent f72a956b5b
commit 5b0976859f
6 changed files with 519 additions and 51 deletions
+55
View File
@@ -834,6 +834,61 @@ mod tests {
assert!(f[0].keyframe);
}
#[test]
fn opening_au_keeps_disc_pts_and_opening_seq_header_no_zero_floor() {
// SOTL SUB-TASK 2 regression (opening-GOP / still-frame open). A DVD
// title opens on a VOBU that begins with a sequence header + I-frame; the
// disc stamps that opening I-frame at its REAL (non-zero) timeline PTS,
// not 0. The parser must (a) emit the opening I-frame with that real PTS
// — never floored to 0 — and (b) capture THAT opening sequence header as
// codec_private (read at headers-ready, before any later AU). Proves the
// opening pictures are emitted with the correct seq header + PTS, ruling
// out the "wrong/last seq header" and "PTS floored to t=0" hypotheses.
let mut p = Mpeg2Parser::new();
// Opening AU: seq header (the codecPrivate) + GOP + I-frame TR0 carrying
// the disc's real opening PTS (2 s here, i.e. NOT zero). 25 fps PAL.
let mut a = make_seq_header(720, 576, 3, 3); // 16:9, 25 fps
a.extend_from_slice(&gop());
a.extend_from_slice(&make_picture_header_tr(PICTURE_TYPE_I, 0));
a.extend_from_slice(&[0xAA; 20]);
let mut frames = p.parse(&make_pes(a, Some(180_000))); // PTS = 2 s (90 kHz)
assert!(frames.is_empty(), "first AU waits for the next boundary");
// Second picture (no PTS) closes the opening AU: the I-frame emits and
// the opening sequence header is captured (headers-ready timing — the
// consumer reads codec_private once the first AU drains).
let mut b = make_picture_header_tr(3, 1);
b.extend_from_slice(&[0xBB; 20]);
frames.extend(p.parse(&make_pes(b, None)));
// codec_private is the OPENING sequence header (read at headers-ready,
// before any later AU could replace it).
let cp = p
.codec_private()
.expect("opening seq header captured at headers-ready");
assert_eq!(
&cp[..4],
&[0x00, 0x00, 0x01, SEQ_HEADER_CODE],
"codec_private is the opening sequence header"
);
assert_eq!(p.resolution(), Some((720, 576)), "576i opening header");
assert_eq!(p.frame_rate(), Some((25, 1)), "25 fps opening header");
frames.extend(p.flush());
assert_eq!(frames.len(), 2);
assert!(frames[0].keyframe, "opening picture is the I-frame");
assert_eq!(
frames[0].pts_ns, 2_000_000_000,
"opening I-frame keeps the disc's real PTS (2 s), NOT floored to 0"
);
assert_eq!(
frames[1].pts_ns, 2_040_000_000,
"next frame is one 40 ms interval later on the real timeline"
);
}
// --- Sequence header → codec_private ---
#[test]