media_doctor/diagnostics/
codec_signalling.rs1use 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#[derive(Debug, Default, Clone, Copy)]
64struct Seen {
65 any_au: bool,
66 structured: bool,
67}
68
69#[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
140fn 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 #[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 #[test]
183 fn declared_h264_with_non_nal_payload_is_flagged() {
184 const VIDEO_PID: u16 = 0x101;
185 let garbage: &[u8] = &[0xAA; 32]; 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 #[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 #[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 #[test]
256 fn declared_aac_with_real_adts_payload_not_flagged() {
257 const AUDIO_PID: u16 = 0x102;
258 let header = transmux::build_adts_header(1, 4, 2, 7); 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 #[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}