container/ts/mod.rs
1//! Minimal MPEG-2 Transport Stream demuxer.
2//!
3//! Scope: take a .ts / .m2ts byte stream, locate the PAT and a PMT,
4//! pick the first video elementary stream the PMT advertises, and
5//! return its PES payloads as one sample per access unit.
6//!
7//! PTS is carried through on the first PES packet that opens an AU;
8//! continuation packets accumulate bytes onto the current sample until
9//! the next `payload_unit_start_indicator=1` closes it.
10//!
11//! What's implemented:
12//! - PAT walk that surfaces every program in the file, with a default
13//! "first program" pick (matches legacy behaviour) and a
14//! `select_program(program_number)` API for callers that want one of
15//! the others (Squad-37).
16//! - PMT walk: video stream_types 0x02 (MPEG-2), 0x1B (H.264),
17//! 0x24 (HEVC) plus audio stream_types 0x0F (AAC-ADTS, Squad-27),
18//! 0x81 (AC-3, ATSC A/53), 0x87 (E-AC-3, ATSC A/53), and 0x06 (PES
19//! private) when the ES descriptor loop carries a registration_descriptor
20//! tagged "AC-3" / "EAC3" (DVB / ETSI TS 101 154) — Squad-37.
21//! - Encrypted streams (`transport_scrambling_control != 0` on the active
22//! video PID) trip a one-time typed warn and switch the demuxer into a
23//! drop-everything mode (Squad-37); previously the bytes were silently
24//! skipped on a per-packet basis which meant a partial-scramble error
25//! condition could still leak garbled samples.
26//!
27//! What's not implemented:
28//! - Full CRC validation of PAT/PMT (we trust what the bitstream gives
29//! us; a mis-CRCed file is already corrupt and will surface as wrong
30//! stream_type or truncated samples further down).
31//! - Multiple video streams within one program (we take the first).
32//! - Adaptation-field-only packets with payload=0 are passed over
33//! transparently.
34//! - BDAV 192-byte wrapper (the 4-byte timestamp prefix) — if present,
35//! we detect and strip it.
36//! - Common-Access (CA) tables: encrypted streams are dropped, not
37//! decrypted (we don't carry CA descriptors).
38
39mod audio;
40mod framerate;
41mod pat_pmt;
42mod pes;
43mod streaming;
44#[cfg(test)]
45mod tests;
46
47pub use streaming::TsStreamingDemuxer;
48pub(crate) use streaming::demux_ts_streaming_init;
49
50use anyhow::{Context, Result, bail};
51use codec::frame::{ColorSpace, PixelFormat, StreamInfo};
52
53use crate::demux::DemuxResult;
54
55// ---------------------------------------------------------------------------
56// Shared constants — visible to all sub-modules via `super::`.
57// ---------------------------------------------------------------------------
58
59pub(super) const TS_PACKET: usize = 188;
60pub(super) const TS_SYNC: u8 = 0x47;
61
62pub(super) const STREAM_TYPE_MPEG2_VIDEO: u8 = 0x02;
63pub(super) const STREAM_TYPE_H264: u8 = 0x1B;
64pub(super) const STREAM_TYPE_HEVC: u8 = 0x24;
65/// PES private stream_type. ETSI TS 101 154 (DVB) routes AC-3 / E-AC-3
66/// through this generic stream_type with a `registration_descriptor`
67/// (descriptor_tag = 0x05) tagged "AC-3" or "EAC3" carrying the actual
68/// codec identity. We only honour 0x06 entries that carry one of those
69/// two registrations — random PES-private streams (DVB subtitles, teletext)
70/// are dropped silently.
71pub(super) const STREAM_TYPE_PES_PRIVATE: u8 = 0x06;
72/// PMT stream_type for AAC carried as ADTS frames in PES packets.
73/// Defined in ISO/IEC 13818-1:2019 Table 2-34 — `0x0F` is
74/// "ISO/IEC 13818-7 Audio with ADTS transport syntax", which is the
75/// MPEG-2/MPEG-4 AAC ADTS form that broadcast / streaming MPEG-TS uses.
76pub(super) const STREAM_TYPE_AAC_ADTS: u8 = 0x0F;
77/// ATSC A/53 §3 / ATSC A/52 Annex A — AC-3 elementary streams in PES
78/// packets. Common in over-the-air ATSC broadcast captures (.ts / .trp).
79pub(super) const STREAM_TYPE_AC3: u8 = 0x81;
80/// ATSC A/53 §3 / ATSC A/52 Annex E — E-AC-3 elementary streams.
81pub(super) const STREAM_TYPE_EAC3: u8 = 0x87;
82
83/// PMT descriptor_tag for the registration_descriptor carrying a
84/// 4-character format identifier. ETSI TS 101 154 §F (DVB) registers
85/// `"AC-3"` (0x41432D33) and `"EAC3"` (0x45414333) for Dolby streams
86/// carried as PES-private (stream_type 0x06).
87pub(super) const DESC_TAG_REGISTRATION: u8 = 0x05;
88pub(super) const REG_AC3: u32 = 0x41432D33; // "AC-3"
89pub(super) const REG_EAC3: u32 = 0x45414333; // "EAC3"
90
91// ---------------------------------------------------------------------------
92// Shared public types
93// ---------------------------------------------------------------------------
94
95/// One PAT entry — `(program_number, pmt_pid)`. Entry with program=0 is
96/// the network_PID and is skipped by callers (it is not a real program).
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub(crate) struct PatProgram {
99 pub(super) program_number: u16,
100 pub(super) pmt_pid: u16,
101}
102
103/// Audio codec discriminator surfaced from the PMT walk. The PMT only
104/// tells us the codec family; the actual codec_private bytes (`asc` for
105/// AAC, `dac3` / `dec3` for AC-3 / E-AC-3) are derived in `extract_*` by
106/// reading the first frame of the elementary stream.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum AudioCodecKind {
109 /// ISO/IEC 13818-7 AAC carried as ADTS frames (stream_type 0x0F).
110 AacAdts,
111 /// ETSI TS 102 366 AC-3 (stream_type 0x81 OR 0x06 + registration "AC-3").
112 Ac3,
113 /// ETSI TS 102 366 E-AC-3 (stream_type 0x87 OR 0x06 + registration "EAC3").
114 Eac3,
115}
116
117/// Per-stream info gathered from one PMT entry.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct VideoStreamInfo {
120 pub pid: u16,
121 pub stream_type: u8,
122}
123
124/// Per-audio-stream info gathered from one PMT entry. `kind` is the
125/// codec family — extraction reads the first frame to derive
126/// `codec_private` / `sample_rate` / `channels`.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct AudioStreamInfo {
129 pub pid: u16,
130 pub stream_type: u8,
131 pub kind: AudioCodecKind,
132}
133
134/// One MPEG-TS program found in the PAT, after the corresponding PMT has
135/// been walked. `pmt_pid` is the bitstream-side PID where the PMT section
136/// lives; `video_streams` / `audio_streams` are the elementary streams
137/// the PMT advertises (video filtered to MPEG-2 / H.264 / HEVC; audio
138/// filtered to AAC-ADTS / AC-3 / E-AC-3 — exactly the codec families we
139/// can passthrough). A program with neither a recognised video nor a
140/// recognised audio stream is still surfaced so callers can see "this
141/// program exists, just contains things we can't carry".
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ProgramInfo {
144 pub program_number: u16,
145 pub pmt_pid: u16,
146 pub video_streams: Vec<VideoStreamInfo>,
147 pub audio_streams: Vec<AudioStreamInfo>,
148}
149
150// ---------------------------------------------------------------------------
151// Shared private helpers used by both entry points
152// ---------------------------------------------------------------------------
153
154/// Decide whether the file uses 188-byte (plain TS) or 192-byte (BDAV
155/// M2TS) packets. Returns (packet_count, stride, prefix_len).
156/// BDAV prepends a 4-byte TP_extra_header before each 188-byte TS
157/// packet, so stride=192 and prefix_len=4. For plain TS stride=188
158/// and prefix_len=0.
159pub(super) fn detect_packet_layout(data: &[u8]) -> Result<(usize, usize, usize)> {
160 if data.len() < TS_PACKET {
161 bail!("TS: file too small");
162 }
163 // Plain 188-byte: sync at 0, 188, 376...
164 if data[0] == TS_SYNC && data.len() >= 2 * TS_PACKET && data[TS_PACKET] == TS_SYNC {
165 return Ok((data.len() / TS_PACKET, TS_PACKET, 0));
166 }
167 // M2TS 192-byte: 4-byte prefix, then sync at 4, 196, 388...
168 if data.len() >= 192 + 4 && data[4] == TS_SYNC && data[196] == TS_SYNC {
169 return Ok((data.len() / 192, 192, 4));
170 }
171 bail!("TS: could not locate 0x47 sync pattern at 188- or 192-byte intervals")
172}
173
174/// Extract the PSI (PAT/PMT) section payload from a TS packet whose PID
175/// we already know carries PSI. Returns the raw section bytes or None
176/// when the packet has no payload or has a continuation we can't
177/// reassemble in a single-packet model.
178pub(super) fn ts_psi_payload(pkt: &[u8]) -> Option<&[u8]> {
179 let pusi = pkt[1] & 0x40 != 0;
180 let adaptation = (pkt[3] >> 4) & 0x03;
181 let has_payload = adaptation & 0x01 != 0;
182 let has_adaptation = adaptation & 0x02 != 0;
183 if !has_payload {
184 return None;
185 }
186 let mut offset = 4usize;
187 if has_adaptation {
188 if offset >= TS_PACKET {
189 return None;
190 }
191 let adap_len = pkt[offset] as usize;
192 offset += 1 + adap_len;
193 if offset > TS_PACKET {
194 return None;
195 }
196 }
197 // PSI packets with PUSI=1 carry a pointer_field byte telling us how
198 // many bytes to skip before the section starts. We take that first
199 // section only — subsequent sections in the same packet would need
200 // separate handling we don't need for PAT/PMT (usually one each).
201 if pusi {
202 if offset >= TS_PACKET {
203 return None;
204 }
205 let pointer = pkt[offset] as usize;
206 offset += 1 + pointer;
207 if offset >= TS_PACKET {
208 return None;
209 }
210 }
211 Some(&pkt[offset..])
212}
213
214// ---------------------------------------------------------------------------
215// Public entry point — legacy materialise-all demux
216// ---------------------------------------------------------------------------
217
218pub(crate) fn demux_ts(data: &[u8]) -> Result<DemuxResult> {
219 // Detect BDAV wrapper: 192-byte packets carry a 4-byte TP_extra
220 // header in front of each 188-byte TS packet. Stripping the 4-byte
221 // prefix brings us back to the canonical 188-byte form.
222 let (packets, packet_stride, prefix_len) = detect_packet_layout(data)?;
223 if packets == 0 {
224 bail!("TS: file contains no TS packets");
225 }
226
227 // First pass: find PAT (PID=0), then PMT, collect video + audio PID +
228 // stream_type. The PMT walk surfaces (video_streams, audio_streams)
229 // and we take the first of each. Squad-37 expanded recognised audio
230 // codec families to AAC-ADTS (0x0F), AC-3 (0x81 / 0x06+reg), and
231 // E-AC-3 (0x87 / 0x06+reg) — every other audio stream_type is
232 // dropped silently (matches MP4/MKV's "non-supported audio → drop"
233 // behaviour at the demuxer layer; the pipeline already knows how to
234 // emit video-only).
235 let mut pmt_pid: Option<u16> = None;
236 let mut chosen_video: Option<VideoStreamInfo> = None;
237 let mut chosen_audio: Option<AudioStreamInfo> = None;
238 for i in 0..packets {
239 let start = i * packet_stride + prefix_len;
240 let pkt = &data[start..start + TS_PACKET];
241 if pkt[0] != TS_SYNC {
242 continue;
243 }
244 let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
245 // PAT
246 if pmt_pid.is_none() && pid == 0 {
247 if let Some(payload) = ts_psi_payload(pkt)
248 && let Some(p) = pat_pmt::parse_pat_first_pmt_pid(payload)
249 {
250 pmt_pid = Some(p);
251 }
252 continue;
253 }
254 // PMT
255 if let (Some(pmt), None) = (pmt_pid, chosen_video)
256 && pid == pmt
257 && let Some(payload) = ts_psi_payload(pkt)
258 && let Some((video_streams, audio_streams)) = pat_pmt::parse_pmt_streams(payload)
259 {
260 chosen_video = video_streams.into_iter().next();
261 chosen_audio = audio_streams.into_iter().next();
262 if chosen_video.is_some() {
263 break;
264 }
265 }
266 }
267
268 let video = chosen_video.context("TS: no video elementary stream found in PMT")?;
269 let video_pid = video.pid;
270 let codec = match video.stream_type {
271 STREAM_TYPE_MPEG2_VIDEO => "mpeg2",
272 STREAM_TYPE_H264 => "h264",
273 STREAM_TYPE_HEVC => "h265",
274 other => bail!("TS: unsupported stream_type 0x{:02X}", other),
275 }
276 .to_string();
277
278 // Second pass: reassemble PES payloads for the video PID, one
279 // sample per `payload_unit_start_indicator`.
280 let mut samples: Vec<Vec<u8>> = Vec::new();
281 let mut pending: Vec<u8> = Vec::new();
282 let mut have_first_start = false;
283 let mut first_pts: Option<u64> = None;
284 let mut last_pts: Option<u64> = None;
285 // Collect every PTS so we can share the streaming path's
286 // `estimate_frame_rate_from_ptses` (median-of-deltas) — more
287 // robust than `(samples - 1) / duration`, which was off-by-one
288 // on boundary edge cases that the streaming scan also hit.
289 let mut ptses: Vec<u64> = Vec::new();
290
291 let flush = |pending: &mut Vec<u8>, samples: &mut Vec<Vec<u8>>| {
292 if !pending.is_empty() {
293 samples.push(std::mem::take(pending));
294 }
295 };
296
297 for i in 0..packets {
298 let start = i * packet_stride + prefix_len;
299 let pkt = &data[start..start + TS_PACKET];
300 if pkt[0] != TS_SYNC {
301 continue;
302 }
303 let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
304 if pid != video_pid {
305 continue;
306 }
307 let pusi = pkt[1] & 0x40 != 0;
308 let scramble = (pkt[3] >> 6) & 0x03;
309 if scramble != 0 {
310 continue;
311 } // encrypted; no way to decode
312 let adaptation = (pkt[3] >> 4) & 0x03;
313 let has_payload = adaptation & 0x01 != 0;
314 let has_adaptation = adaptation & 0x02 != 0;
315 if !has_payload {
316 continue;
317 }
318
319 let mut offset = 4usize;
320 if has_adaptation {
321 if offset >= TS_PACKET {
322 continue;
323 }
324 let adap_len = pkt[offset] as usize;
325 offset += 1 + adap_len;
326 if offset > TS_PACKET {
327 continue;
328 }
329 }
330 if offset >= TS_PACKET {
331 continue;
332 }
333 let payload = &pkt[offset..];
334
335 if pusi {
336 // New PES packet begins here — flush whatever we were
337 // accumulating, then parse the PES header to find PTS and
338 // the elementary-stream payload start.
339 if have_first_start {
340 flush(&mut pending, &mut samples);
341 }
342 have_first_start = true;
343
344 let Some((es_start, pts)) = pes::parse_pes_header(payload) else {
345 // Malformed PES; skip this packet, keep state.
346 have_first_start = false;
347 pending.clear();
348 continue;
349 };
350 if let Some(p) = pts {
351 if first_pts.is_none() {
352 first_pts = Some(p);
353 }
354 last_pts = Some(p);
355 ptses.push(p);
356 }
357 if es_start < payload.len() {
358 pending.extend_from_slice(&payload[es_start..]);
359 }
360 } else if have_first_start {
361 pending.extend_from_slice(payload);
362 }
363 }
364 flush(&mut pending, &mut samples);
365
366 if samples.is_empty() {
367 bail!("TS: reassembled zero video samples from PID {}", video_pid);
368 }
369
370 // PTS is 90 kHz. Duration stays span-based (last - first is the
371 // right answer for "how long does this stream play"). Frame rate
372 // switches to the median-of-deltas path for consistency with the
373 // streaming demuxer's init; falls back to the span/count calc and
374 // then 30.0 if the PTS window isn't populated enough for a median.
375 let duration = match (first_pts, last_pts) {
376 (Some(a), Some(b)) if b >= a => (b - a) as f64 / 90_000.0,
377 _ => 0.0,
378 };
379 let frame_rate = framerate::estimate_frame_rate_from_ptses(&ptses)
380 .or_else(|| {
381 if duration > 0.0 && samples.len() > 1 {
382 Some((samples.len() - 1) as f64 / duration)
383 } else {
384 None
385 }
386 })
387 .unwrap_or(30.0);
388
389 // TS carries no container-level width/height; the sample-entry /
390 // track-header equivalents that MP4/MKV/AVI/MOV all have don't
391 // exist here. We recover dims by parsing the first sample's SPS
392 // (H.264 / HEVC) or sequence header (MPEG-2). `detect_dims`
393 // returns None if the parse fails — fall back to 0 so downstream
394 // reporting still shows "unknown" rather than a fabricated value.
395 let (width, height) = codec::pixel_format::detect_dims(&codec, &samples).unwrap_or((0, 0));
396 if width == 0 || height == 0 {
397 tracing::warn!(
398 codec = codec.as_str(),
399 "TS demux: could not recover width/height from first sample — \
400 downstream encoder may reject the 0×0 config"
401 );
402 }
403
404 let info = StreamInfo {
405 codec: codec.clone(),
406 width,
407 height,
408 frame_rate,
409 duration,
410 pixel_format: PixelFormat::Yuv420p,
411 color_space: ColorSpace::Bt709,
412 total_frames: samples.len() as u64,
413 bitrate: 0,
414 color_metadata: Default::default(),
415 };
416
417 let detected_pf = codec::pixel_format::detect(&codec, &samples);
418 let info = StreamInfo {
419 pixel_format: detected_pf,
420 ..info
421 };
422
423 // Audio extraction. Squad-37 expanded the routing: AAC-ADTS goes
424 // through Squad-27's path; AC-3 / E-AC-3 use the new pure-Rust
425 // extractors that derive `dac3` / `dec3` from the first frame's
426 // sync header (Squad-26 helpers).
427 let audio = chosen_audio.and_then(|info| {
428 match audio::extract_ts_audio(data, packets, packet_stride, prefix_len, info) {
429 Ok(track) => track,
430 Err(e) => {
431 tracing::warn!(
432 audio_pid = info.pid,
433 audio_kind = ?info.kind,
434 error = %e,
435 "TS audio extraction failed; emitting video-only"
436 );
437 None
438 }
439 }
440 });
441
442 Ok(DemuxResult {
443 codec,
444 info,
445 samples,
446 audio,
447 })
448}