diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs index a8423e22..59c04eb9 100644 --- a/src/mux/codec/h264.rs +++ b/src/mux/codec/h264.rs @@ -908,6 +908,106 @@ mod tests { ); } + // --- Pictures a decoder cannot reconstruct from the stream's own data --- + + /// One non-IDR picture AU: `hdr` = NAL header byte, `body` = slice-header byte + /// (0x88 I, 0x98 P, 0x9C B), at `frame` 24 fps frame periods (90 kHz). + fn pic_au(hdr: u8, body: u8, frame: i64) -> PesPacket { + make_pes(vec![0x00, 0x00, 0x01, hdr, body, 0x00], Some(frame * 3750)) + } + + /// An open GOP in decode order `I3 B1 B2 P6 B4 B5` (display frame numbers), + /// offset by `base` frames. B1/B2 precede the I in display order: leading + /// pictures predicted from the anchor BEFORE this GOP. + fn open_gop(base: i64) -> Vec { + vec![ + pic_au(0x61, 0x88, base + 3), + pic_au(0x01, 0x9C, base + 1), + pic_au(0x01, 0x9C, base + 2), + pic_au(0x41, 0x98, base + 6), + pic_au(0x01, 0x9C, base + 4), + pic_au(0x01, 0x9C, base + 5), + ] + } + + /// Emitted frames' display frame numbers. + fn frame_nos(frames: &[Frame]) -> Vec { + frames + .iter() + .map(|f| (f.pts_ns * 24 + 500_000_000) / 1_000_000_000) + .collect() + } + + #[test] + #[ignore = "defect: an open GOP at the stream start emits its leading B-pictures, whose forward reference precedes the title"] + fn an_open_gop_at_the_stream_start_drops_its_leading_b_pictures() { + let mut p = H264Parser::new(); + let f: Vec = open_gop(0).iter().flat_map(|x| p.parse(x)).collect(); + assert_eq!( + frame_nos(&f), + [3, 6, 4, 5], + "B1/B2 reference a picture the output does not hold" + ); + } + + #[test] + #[ignore = "defect: a join onto an open GOP emits its leading B-pictures, predicted from the previous clip's anchor"] + fn a_join_onto_an_open_gop_drops_its_leading_b_pictures() { + // Clip 1 ends on a complete open GOP; clip 2's PTS restarts far away, so + // its leading B1/B2 reference a picture of the clip the playlist skipped. + let mut p = H264Parser::new(); + let mut pes = vec![make_pes(vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], Some(0))]; + pes.extend(open_gop(0).into_iter().skip(3)); + pes.extend(open_gop(10_000)); + let f: Vec = pes.iter().flat_map(|x| p.parse(x)).collect(); + assert_eq!( + frame_nos(&f)[4..], + [10_003, 10_006, 10_004, 10_005], + "clip 2's leading Bs reference clip 1's tail, not their own source" + ); + } + + #[test] + #[ignore = "defect: inter pictures before the stream's first random-access picture are emitted"] + fn pictures_before_the_first_random_access_picture_are_dropped() { + let mut p = H264Parser::new(); + let mut pes = vec![pic_au(0x41, 0x98, 0), pic_au(0x01, 0x9C, 1)]; + pes.push(make_pes( + vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], + Some(2 * 3750), + )); + let f: Vec = pes.iter().flat_map(|x| p.parse(x)).collect(); + assert!(f[0].keyframe, "the first emitted picture is the IDR"); + } + + #[test] + #[ignore = "defect: after a gap the ResyncGate resumes on an open-GOP I-picture and passes its leading B-pictures, whose forward reference the gate dropped"] + fn a_gap_resync_onto_an_open_gop_emits_no_leading_b_pictures() { + // IDR, then a P that follows lost data (and the B predicted from it), then + // an open GOP continuing the same clock: its B1/B2 reference the dropped P. + let mut p = H264Parser::new(); + let mut pes = vec![make_pes( + vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], + Some(7 * 3750), + )]; + let mut lost = pic_au(0x41, 0x98, 9); + lost.discontinuity = true; + pes.push(lost); + pes.push(pic_au(0x01, 0x9C, 8)); + pes.extend(open_gop(10)); + let mut gate = crate::mux::resync::ResyncGate::new(); + let f: Vec = pes + .iter() + .flat_map(|x| p.parse(x)) + .filter(|f| gate.admit(true, f.discontinuity, f.keyframe)) + .collect(); + assert_eq!( + frame_nos(&f), + [7, 13, 16, 14, 15], + "B1/B2 reference the P the gate dropped" + ); + } + // The escaped slice header must be unescaped at the parse call site: a second slice with // first_mb_in_slice=65535 (00 00 03 escape) is intra only when read unescaped. #[test] diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index a0bf0dee..3bc2633a 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -2352,6 +2352,98 @@ mod tests { ); } + /// Whether `frames` hand a decoder a RASL picture it must decode: one that + /// follows a CRA (not a BLA, whose RASL a decoder discards) in decode order. + fn emits_decodable_rasl_after_cra(frames: &[Frame]) -> bool { + let mut last_irap = None; + for f in frames { + let t = nal_type_of(&nals_of(&f.data)[0]); + match t { + 16..=21 => last_irap = Some(t), + 8 | 9 if last_irap == Some(NAL_CRA_NUT) => return true, + _ => {} + } + } + false + } + + #[test] + #[ignore = "defect: a non-seamless join whose PTS jumps FORWARD is not detected, so its CRA stays CRA and its RASL decode against the previous clip"] + fn a_forward_pts_join_onto_a_cra_does_not_hand_its_rasl_to_the_decoder() { + // Clip 1: CRA + trailing pictures at ~0 s. Clip 2 starts 100 s later in + // the source clock (e.g. a PlayItem that skips part of a clip, or a clip + // whose STC runs ahead) with a CRA whose RASL reference a picture of + // the skipped material. + let mut p = HevcParser::new(); + let mut frames = Vec::new(); + frames.extend(p.parse(&make_pes(cra_au(&[0x01]), Some(0)))); + frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(3750)))); + frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(7500)))); + let t = 100 * 90_000; + let clip2 = frames.len(); + frames.extend(p.parse(&make_pes(cra_au(&[0x02]), Some(t)))); + frames.extend(p.parse(&make_pes(nal_bytes(9, &[0xE0]), Some(t - 7500)))); + frames.extend(p.parse(&make_pes(nal_bytes(8, &[0xE0]), Some(t - 3750)))); + assert!( + !emits_decodable_rasl_after_cra(&frames[clip2..]), + "clip 2's RASL reference a picture this output does not hold" + ); + } + + #[test] + fn rasl_after_a_seamless_join_cra_stay_decodable() { + // Characterises the seamless case: PTS continuous across the join, so the + // RASL reference the previous clip's tail, which the output holds. + let mut p = HevcParser::new(); + let mut frames = Vec::new(); + frames.extend(p.parse(&make_pes(cra_au(&[0x01]), Some(0)))); + frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(3750)))); + frames.extend(p.parse(&make_pes(cra_au(&[0x02]), Some(4 * 3750)))); + frames.extend(p.parse(&make_pes(nal_bytes(9, &[0xE0]), Some(2 * 3750)))); + assert!( + emits_decodable_rasl_after_cra(&frames), + "a seamless join keeps its CRA and RASL" + ); + } + + #[test] + #[ignore = "defect: after a gap the ResyncGate resumes on a CRA and passes its RASL, whose reference the gate dropped"] + fn a_gap_resync_onto_a_cra_does_not_hand_its_rasl_to_the_decoder() { + // IDR, a TRAIL_R that follows lost data, then a CRA (same clock) whose RASL + // reference the dropped TRAIL_R. The CRA is not first in the bitstream, so + // a decoder decodes the RASL. + let mut p = HevcParser::new(); + let mut lost = make_pes(nal_bytes(1, &[0xD0]), Some(3750)); + lost.discontinuity = true; + let pes = [ + make_pes(nal_bytes(19, &[0x80]), Some(0)), + lost, + make_pes(cra_au(&[0x02]), Some(4 * 3750)), + make_pes(nal_bytes(9, &[0xE0]), Some(2 * 3750)), + make_pes(nal_bytes(8, &[0xE0]), Some(3 * 3750)), + ]; + let mut gate = crate::mux::resync::ResyncGate::new(); + let frames: Vec = pes + .iter() + .flat_map(|x| p.parse(x)) + .filter(|f| gate.admit(true, f.discontinuity, f.keyframe)) + .collect(); + assert!( + !emits_decodable_rasl_after_cra(&frames), + "the RASL reference the picture the gate dropped" + ); + } + + #[test] + #[ignore = "defect: pictures before the stream's first IRAP are emitted with no reference"] + fn pictures_before_the_first_irap_are_dropped() { + let mut p = HevcParser::new(); + let mut frames = Vec::new(); + frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(0)))); + frames.extend(p.parse(&make_pes(cra_au(&[0x01]), Some(3750)))); + assert!(frames[0].keyframe, "the first emitted picture is the IRAP"); + } + /// Test 2: a CRA with NO boundary marker is left unchanged (CRA stays CRA). #[test] fn cra_without_boundary_unchanged() { diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index 61fc6ab2..dd7596bd 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -1055,6 +1055,41 @@ mod tests { ); } + #[test] + #[ignore = "defect: after a gap the ResyncGate resumes on an open GOP's I-picture and passes its leading B-pictures, whose forward reference the gate dropped"] + fn a_gap_resync_onto_an_open_gop_emits_no_leading_b_pictures() { + // GOP 1 (closed) loses data before P5: P5 and the Bs after it reference + // the lost picture. GOP 2 is open and PTS-continuous (no join): its leading + // B0/B1 reference GOP 1's P5, which the gate dropped. + let mut pes = gop_ibbp(seq_gop(true, false), Some(90_000)); + pes[3].discontinuity = true; + pes.extend(gop_ibbp(gop_flags(false, false), None)); + let mut gate = crate::mux::resync::ResyncGate::new(); + let out: Vec = run(pes) + .into_iter() + .filter(|f| gate.admit(true, f.discontinuity, f.keyframe)) + .collect(); + let types: Vec<_> = out + .iter() + .map(|f| f.coding.unwrap().coding_type()) + .collect(); + // GOP 1: I2 B0 B1 kept; P5 B3 B4 dropped. GOP 2: I2 kept, B0 B1 lack P5 + // (dropped), P5 B3 B4 decode from GOP 2's own anchors. + assert_eq!( + types, + [ + CodingType::I, + CodingType::B, + CodingType::B, + CodingType::I, + CodingType::P, + CodingType::B, + CodingType::B, + ], + "no emitted picture may reference one the gate dropped" + ); + } + // --- Sequence header parsing --- #[test] diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index 6b19ec75..6a8f0b40 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -558,6 +558,100 @@ mod tests { data } + // --- Pictures a decoder cannot reconstruct from the stream's own data --- + + /// A progressive advanced-profile entry-point I-picture AU (sequence header, + /// entry-point header with the given BROKEN_LINK / CLOSED_ENTRY, frame) + /// followed, in decode order, by B B P (SMPTE 421M ยง6.2.1: with CLOSED_ENTRY + /// 0 the Bs after the entry-point I may predict from the anchor before it). + fn entry_gop(broken_link: bool, closed_entry: bool) -> Vec> { + let seq = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xC0, 0, 0, 0, 0, 0]; + let ep0 = (u8::from(broken_link) << 7) | (u8::from(closed_entry) << 6); + let ep = [0x00, 0x00, 0x01, SC_ENTRY_POINT, ep0, 0x00]; + let pic = |ptype: u8| vec![0x00, 0x00, 0x01, SC_FRAME, ptype]; + vec![ + [&seq[..], &ep[..], &pic(0xC0)].concat(), + pic(0x80), + pic(0x80), + pic(0x00), + ] + } + + fn coding_types(aus: Vec>) -> Vec { + let mut p = Vc1Parser::new(); + aus.into_iter() + .flat_map(|au| p.parse(&make_pes(au, Some(0)))) + .map(|f| f.coding.unwrap().coding_type()) + .collect() + } + + #[test] + #[ignore = "defect: an open entry point (CLOSED_ENTRY=0) at the stream start emits its leading B-pictures, whose forward reference precedes the title"] + fn an_open_entry_point_at_the_stream_start_drops_its_leading_b_pictures() { + assert_eq!( + coding_types(entry_gop(false, false)), + [CodingType::I, CodingType::P] + ); + } + + #[test] + fn a_closed_entry_point_at_the_stream_start_keeps_every_picture() { + assert_eq!( + coding_types(entry_gop(false, true)), + [CodingType::I, CodingType::B, CodingType::B, CodingType::P] + ); + } + + #[test] + #[ignore = "defect: BROKEN_LINK mid-stream is not read; the Bs it marks undecodable are emitted"] + fn a_broken_link_drops_the_leading_b_pictures_mid_stream() { + let mut aus = entry_gop(false, true); + aus.extend(entry_gop(true, false)); + assert_eq!( + coding_types(aus), + [ + CodingType::I, + CodingType::B, + CodingType::B, + CodingType::P, + CodingType::I, + CodingType::P + ] + ); + } + + #[test] + #[ignore = "defect: after a gap the ResyncGate resumes on an open entry point and passes its leading B-pictures, whose forward reference the gate dropped"] + fn a_gap_resync_onto_an_open_entry_point_emits_no_leading_b_pictures() { + let mut aus = entry_gop(false, true); + aus.extend(entry_gop(false, false)); + let mut p = Vc1Parser::new(); + let mut gate = crate::mux::resync::ResyncGate::new(); + let types: Vec = aus + .into_iter() + .enumerate() + .flat_map(|(i, au)| { + let mut pes = make_pes(au, Some(0)); + // The first entry segment's P follows lost data. + pes.discontinuity = i == 3; + p.parse(&pes) + }) + .filter(|f| gate.admit(true, f.discontinuity, f.keyframe)) + .map(|f| f.coding.unwrap().coding_type()) + .collect(); + assert_eq!( + types, + [ + CodingType::I, + CodingType::B, + CodingType::B, + CodingType::I, + CodingType::P + ], + "the second entry's Bs reference the P the gate dropped" + ); + } + // --- sequence header detection --- #[test] diff --git a/src/mux/timeline.rs b/src/mux/timeline.rs index c7f52470..8b31cc73 100644 --- a/src/mux/timeline.rs +++ b/src/mux/timeline.rs @@ -955,6 +955,32 @@ mod tests { ); } + // A PlayItem's IN mark need not sit on a GOP's I-picture: a player decodes from the + // preceding entry point and suppresses display until IN. The plan trims by each frame's + // own PTS, in decode order, so it drops the I and keeps the pictures predicted from it. + #[test] + #[ignore = "defect: the seam plan drops a reference picture outside a clip's marks while keeping the pictures predicted from it"] + fn the_in_mark_never_keeps_a_picture_whose_reference_it_dropped() { + const F: i64 = 41_708_333; // one 23.976 fps frame (ns) + let clips = vec![crate::disc::Clip { + feed_span: Some((0, 1_000_000)), + clip_id: "00001".into(), + in_time: 45_000, // IN = 1 s, mid-GOP + out_time: 450_000, + duration_secs: 9.0, + source_packets: 0, + }]; + let mut plan = SeamPlan::from_clips(&clips).expect("one clip"); + // Decode order: I (display 0.958 s, before IN), then P predicted from it + // (display 1.083 s, after IN). + let i_kept = plan.place(S - F, 0, true, Some(0)).is_some(); + let p_kept = plan.place(S + 2 * F, 0, true, Some(100)).is_some(); + assert!( + i_kept || !p_kept, + "the P is kept but its reference I was dropped (i_kept={i_kept}, p_kept={p_kept})" + ); + } + #[test] fn a_repeated_clip_shares_one_span_and_is_still_trusted() { let mut clips = clips_with_spans();