Skip to main content

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}