Skip to main content

media_doctor/diagnostics/
codec_signalling.rs

1//! `CodecSignallingCheck` — PMT `stream_type` vs. the actual elementary
2//! codec (issue #567).
3//!
4//! ISO/IEC 13818-1 Table 2-34 lets a PMT declare a `stream_type` for each
5//! elementary PID (e.g. `0x1B` = H.264, `0x24` = HEVC, `0x0F` = AAC ADTS).
6//! This check walks the raw elementary-stream bytes of every PMT-declared
7//! `0x1B`/`0x24`/`0x0F` PID (via [`transmux::iter_annexb_nals`] for video,
8//! [`transmux::parse_adts_header`] for audio — reused, not duplicated) and
9//! flags a PID whose bytes never once look like the declared codec's framing
10//! at all: zero Annex B NAL units anywhere on a declared H.264/HEVC PID, or
11//! zero valid ADTS sync anywhere on a declared AAC-ADTS PID.
12//!
13//! # Why "any framing at all", not a full decode or a `TsDemux` track
14//!
15//! Two stronger designs were tried and rejected against this crate's own
16//! committed real fixtures (the `PtsCheck`-lesson hard gate: a check that
17//! fires on real clean content is rejected, no exceptions):
18//!
19//! - Requiring a fully **decodable SPS** on the PID: real short/trimmed
20//!   broadcast captures (e.g. `fixtures/ts/france-pcr-discontinuity.ts`'s
21//!   secondary-program video PIDs, `0x00DC`/`0x026C`/`0x02D0`) carry many
22//!   genuine H.264 access units — slice/SEI/AUD NALs, in wire order — with
23//!   **no parameter-set refresh anywhere inside the captured window**, since
24//!   SPS/PPS repeat on their own cadence independent of clip length. Full
25//!   decode is not a matter of the ES having framing, only of how the clip
26//!   happened to be cut.
27//! - Requiring [`transmux::TsDemux`] to resolve a track with a matching
28//!   `source_pid`: `TsDemux`'s incremental multi-track engine has its own
29//!   preconditions (PCR/continuity resync, per-AU duration resolution,
30//!   PMT-declaration-order promotion) that a real capture can fail to
31//!   satisfy for reasons that have nothing to do with codec signalling
32//!   correctness — verified false positives on exactly the fixtures above,
33//!   plus `fixtures/ts/m6-*.ts` (a PMT-declared video PID with zero packets
34//!   at all in a trimmed capture).
35//!
36//! "Any NAL/ADTS framing at all" is the weakest bar that still catches the
37//! issue's literal example — "stream_type says AVC but the ES isn't NAL"
38//! (raw bytes, a different transport syntax entirely, or no ES data shaped
39//! like a bitstream) — without turning a short real capture's parameter-set
40//! cadence, or a demuxer's unrelated multi-track scheduling, into a false
41//! signalling-mismatch finding.
42//!
43//! A PID that never carries **any** access unit at all (also seen in this
44//! crate's own fixtures — a PMT entry for a PID absent from a trimmed
45//! capture) is likewise not flagged: there is no bitstream to judge, so
46//! there is nothing to disagree with the PMT's claim.
47
48use alloc::collections::btree_map::BTreeMap;
49use alloc::vec::Vec;
50
51use dvb_si::tables::pmt::StreamType;
52use transmux::{iter_annexb_nals, parse_adts_header};
53
54use crate::Diagnostic;
55use crate::Report;
56use crate::diagnostics::codec_common::{
57    collect_pmt_streams, for_each_access_unit, pids_with_stream_type,
58};
59use crate::report::{Finding, Location, Severity};
60
61/// Per-PID tracking: has any access unit been observed at all, and did any
62/// of them look like the declared codec's framing.
63#[derive(Debug, Default, Clone, Copy)]
64struct Seen {
65    any_au: bool,
66    structured: bool,
67}
68
69/// Cross-validates PMT `stream_type` (`0x1B`/`0x24`/`0x0F`) against the
70/// actual elementary-stream framing.
71#[derive(Debug, Clone, Copy)]
72pub struct CodecSignallingCheck;
73
74impl Diagnostic for CodecSignallingCheck {
75    fn run(&self, ts: &[u8], report: &mut Report) {
76        let declared = collect_pmt_streams(ts);
77        let video_pids: Vec<u16> = pids_with_stream_type(&declared, StreamType::H264)
78            .into_iter()
79            .chain(pids_with_stream_type(&declared, StreamType::Hevc))
80            .collect();
81        let audio_pids = pids_with_stream_type(&declared, StreamType::AacAdts);
82        if video_pids.is_empty() && audio_pids.is_empty() {
83            return;
84        }
85
86        let mut video_seen: BTreeMap<u16, Seen> =
87            video_pids.iter().map(|&p| (p, Seen::default())).collect();
88        let mut audio_seen: BTreeMap<u16, Seen> =
89            audio_pids.iter().map(|&p| (p, Seen::default())).collect();
90
91        for_each_access_unit(
92            ts,
93            |pid| video_pids.contains(&pid) || audio_pids.contains(&pid),
94            |payload, _packet_index, pid| {
95                if let Some(seen) = video_seen.get_mut(&pid) {
96                    seen.any_au = true;
97                    if iter_annexb_nals(payload).next().is_some() {
98                        seen.structured = true;
99                    }
100                } else if let Some(seen) = audio_seen.get_mut(&pid) {
101                    seen.any_au = true;
102                    if has_adts_sync(payload) {
103                        seen.structured = true;
104                    }
105                }
106            },
107        );
108
109        for (&pid, seen) in &video_seen {
110            if seen.any_au && !seen.structured {
111                report.push(Finding::new(
112                    Severity::Error,
113                    Location::new(0, pid),
114                    "codec-signalling-mismatch",
115                    alloc::format!(
116                        "PMT declares a NAL video codec on PID 0x{pid:04X} but its elementary \
117                         stream never contains a single Annex B start code — the stream_type \
118                         claim and the bitstream disagree (ISO/IEC 13818-1 Table 2-34)",
119                    ),
120                ));
121            }
122        }
123        for (&pid, seen) in &audio_seen {
124            if seen.any_au && !seen.structured {
125                report.push(Finding::new(
126                    Severity::Error,
127                    Location::new(0, pid),
128                    "codec-signalling-mismatch",
129                    alloc::format!(
130                        "PMT declares stream_type AAC-ADTS (0x0F) on PID 0x{pid:04X} but its \
131                         elementary stream never contains a valid ADTS sync — the stream_type \
132                         claim and the bitstream disagree (ISO/IEC 13818-1 Table 2-34)",
133                    ),
134                ));
135            }
136        }
137    }
138}
139
140/// Scan `payload` for a byte offset at which a well-formed ADTS header
141/// parses (ISO/IEC 13818-7 §6.2, via [`transmux::parse_adts_header`]) — not
142/// assumed to sit at offset 0 so this tolerates leading PES stuffing.
143fn has_adts_sync(payload: &[u8]) -> bool {
144    const ADTS_MIN: usize = 7;
145    if payload.len() < ADTS_MIN {
146        return false;
147    }
148    (0..=payload.len() - ADTS_MIN).any(|off| {
149        payload[off] == 0xFF
150            && (payload[off + 1] & 0xF0) == 0xF0
151            && parse_adts_header(&payload[off..]).is_ok()
152    })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::diagnostics::codec_common::tests::{build_pat_pmt_ts, build_pes, make_pes_packet};
159    use crate::report::Report;
160
161    /// A PMT declaring H.264 on a PID that never carries any access unit at
162    /// all (absent from the capture, e.g. a trimmed real fixture) must NOT be
163    /// flagged — there is no bitstream to disagree with the PMT.
164    #[test]
165    fn declared_pid_with_no_es_at_all_not_flagged() {
166        let ts = build_pat_pmt_ts(&[(0x101, StreamType::H264)]);
167        let mut report = Report::new();
168        CodecSignallingCheck.run(&ts, &mut report);
169        assert!(
170            report
171                .findings()
172                .iter()
173                .all(|f| f.rule_id != "codec-signalling-mismatch"),
174            "a declared PID with zero access units must not be flagged, got {:?}",
175            report.findings()
176        );
177    }
178
179    /// PMT declares H.264 on a PID that DOES carry PES traffic, but the
180    /// payload has no Annex-B start codes at all (not NAL-structured) — the
181    /// literal "PMT says AVC but the ES isn't NAL" case from the issue.
182    #[test]
183    fn declared_h264_with_non_nal_payload_is_flagged() {
184        const VIDEO_PID: u16 = 0x101;
185        let garbage: &[u8] = &[0xAA; 32]; // no 00 00 01 start codes anywhere
186        let mut ts = build_pat_pmt_ts(&[(VIDEO_PID, StreamType::H264)]);
187        ts.extend_from_slice(&make_pes_packet(VIDEO_PID, 0, &build_pes(0xE0, garbage)));
188
189        let mut report = Report::new();
190        CodecSignallingCheck.run(&ts, &mut report);
191        assert!(
192            report
193                .findings()
194                .iter()
195                .any(|f| f.rule_id == "codec-signalling-mismatch" && f.location.pid == VIDEO_PID),
196            "expected codec-signalling-mismatch for a non-NAL H.264 PID, got {:?}",
197            report.findings()
198        );
199    }
200
201    /// PMT declares H.264 on a PID carrying a real-shaped NAL access unit
202    /// (SPS+PPS+slice) — must not be flagged.
203    #[test]
204    fn declared_h264_with_real_nal_payload_not_flagged() {
205        const VIDEO_PID: u16 = 0x101;
206        let sps: &[u8] = &[
207            0x67, 0x64, 0x00, 0x0D, 0xAD, 0xC8, 0xBF, 0xFE, 0x03, 0xC1, 0x41, 0xF9,
208        ];
209        let pps: &[u8] = &[0x68, 0xCE, 0x38, 0x80];
210        let slice: &[u8] = &[0x65, 0x88, 0x84, 0x00];
211
212        let mut au = Vec::new();
213        for nal in [sps, pps, slice] {
214            au.extend_from_slice(&[0x00, 0x00, 0x01]);
215            au.extend_from_slice(nal);
216        }
217        let mut ts = build_pat_pmt_ts(&[(VIDEO_PID, StreamType::H264)]);
218        ts.extend_from_slice(&make_pes_packet(VIDEO_PID, 0, &build_pes(0xE0, &au)));
219
220        let mut report = Report::new();
221        CodecSignallingCheck.run(&ts, &mut report);
222        assert!(
223            report
224                .findings()
225                .iter()
226                .all(|f| f.rule_id != "codec-signalling-mismatch"),
227            "a real NAL-structured AVC access unit must not be flagged, got {:?}",
228            report.findings()
229        );
230    }
231
232    /// PMT declares AAC-ADTS on a PID whose payload never contains a valid
233    /// ADTS sync — flagged.
234    #[test]
235    fn declared_aac_with_non_adts_payload_is_flagged() {
236        const AUDIO_PID: u16 = 0x102;
237        let garbage: &[u8] = &[0x00; 32];
238        let mut ts = build_pat_pmt_ts(&[(AUDIO_PID, StreamType::AacAdts)]);
239        ts.extend_from_slice(&make_pes_packet(AUDIO_PID, 0, &build_pes(0xC0, garbage)));
240
241        let mut report = Report::new();
242        CodecSignallingCheck.run(&ts, &mut report);
243        assert!(
244            report
245                .findings()
246                .iter()
247                .any(|f| f.rule_id == "codec-signalling-mismatch" && f.location.pid == AUDIO_PID),
248            "expected codec-signalling-mismatch for a non-ADTS AAC PID, got {:?}",
249            report.findings()
250        );
251    }
252
253    /// PMT declares AAC-ADTS on a PID carrying a real ADTS-framed frame —
254    /// must not be flagged.
255    #[test]
256    fn declared_aac_with_real_adts_payload_not_flagged() {
257        const AUDIO_PID: u16 = 0x102;
258        // A minimal valid ADTS header (AAC-LC, 44.1kHz, stereo), built via
259        // transmux's own builder rather than hand-encoded.
260        let header = transmux::build_adts_header(1, 4, 2, 7); // frame_len=7 (header only)
261        let mut ts = build_pat_pmt_ts(&[(AUDIO_PID, StreamType::AacAdts)]);
262        ts.extend_from_slice(&make_pes_packet(AUDIO_PID, 0, &build_pes(0xC0, &header)));
263
264        let mut report = Report::new();
265        CodecSignallingCheck.run(&ts, &mut report);
266        assert!(
267            report
268                .findings()
269                .iter()
270                .all(|f| f.rule_id != "codec-signalling-mismatch"),
271            "a real ADTS-framed AAC PID must not be flagged, got {:?}",
272            report.findings()
273        );
274    }
275
276    /// Real committed captures must never be flagged — the ultimate
277    /// clean-negative gate (mirrors the crate-wide hard gate this issue
278    /// requires; see also `media-doctor/tests/codec_v2_real_captures.rs`).
279    #[test]
280    fn real_captures_not_flagged() {
281        for rel in [
282            "ts/h264/baseline.ts",
283            "ts/hevc/main.ts",
284            "ts/h264_aac.ts",
285            "ts/france-pcr-discontinuity.ts",
286            "ts/m6-single.ts",
287        ] {
288            let path = alloc::format!("{}/../fixtures/{rel}", env!("CARGO_MANIFEST_DIR"));
289            let ts = std::fs::read(&path).unwrap_or_else(|e| panic!("read fixture {path}: {e}"));
290            let mut report = Report::new();
291            CodecSignallingCheck.run(&ts, &mut report);
292            assert!(
293                report
294                    .findings()
295                    .iter()
296                    .all(|f| f.rule_id != "codec-signalling-mismatch"),
297                "real capture {rel} must not be flagged, got {:?}",
298                report.findings()
299            );
300        }
301    }
302}