mod audio;
mod framerate;
mod pat_pmt;
mod pes;
mod streaming;
#[cfg(test)]
mod tests;
pub use streaming::TsStreamingDemuxer;
pub(crate) use streaming::demux_ts_streaming_init;
use anyhow::{Context, Result, bail};
use codec::frame::{ColorSpace, PixelFormat, StreamInfo};
use crate::demux::DemuxResult;
pub(super) const TS_PACKET: usize = 188;
pub(super) const TS_SYNC: u8 = 0x47;
pub(super) const STREAM_TYPE_MPEG2_VIDEO: u8 = 0x02;
pub(super) const STREAM_TYPE_H264: u8 = 0x1B;
pub(super) const STREAM_TYPE_HEVC: u8 = 0x24;
pub(super) const STREAM_TYPE_PES_PRIVATE: u8 = 0x06;
pub(super) const STREAM_TYPE_AAC_ADTS: u8 = 0x0F;
pub(super) const STREAM_TYPE_AC3: u8 = 0x81;
pub(super) const STREAM_TYPE_EAC3: u8 = 0x87;
pub(super) const DESC_TAG_REGISTRATION: u8 = 0x05;
pub(super) const REG_AC3: u32 = 0x41432D33; pub(super) const REG_EAC3: u32 = 0x45414333;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PatProgram {
pub(super) program_number: u16,
pub(super) pmt_pid: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioCodecKind {
AacAdts,
Ac3,
Eac3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VideoStreamInfo {
pub pid: u16,
pub stream_type: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AudioStreamInfo {
pub pid: u16,
pub stream_type: u8,
pub kind: AudioCodecKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramInfo {
pub program_number: u16,
pub pmt_pid: u16,
pub video_streams: Vec<VideoStreamInfo>,
pub audio_streams: Vec<AudioStreamInfo>,
}
pub(super) fn detect_packet_layout(data: &[u8]) -> Result<(usize, usize, usize)> {
if data.len() < TS_PACKET {
bail!("TS: file too small");
}
if data[0] == TS_SYNC && data.len() >= 2 * TS_PACKET && data[TS_PACKET] == TS_SYNC {
return Ok((data.len() / TS_PACKET, TS_PACKET, 0));
}
if data.len() >= 192 + 4 && data[4] == TS_SYNC && data[196] == TS_SYNC {
return Ok((data.len() / 192, 192, 4));
}
bail!("TS: could not locate 0x47 sync pattern at 188- or 192-byte intervals")
}
pub(super) fn ts_psi_payload(pkt: &[u8]) -> Option<&[u8]> {
let pusi = pkt[1] & 0x40 != 0;
let adaptation = (pkt[3] >> 4) & 0x03;
let has_payload = adaptation & 0x01 != 0;
let has_adaptation = adaptation & 0x02 != 0;
if !has_payload {
return None;
}
let mut offset = 4usize;
if has_adaptation {
if offset >= TS_PACKET {
return None;
}
let adap_len = pkt[offset] as usize;
offset += 1 + adap_len;
if offset > TS_PACKET {
return None;
}
}
if pusi {
if offset >= TS_PACKET {
return None;
}
let pointer = pkt[offset] as usize;
offset += 1 + pointer;
if offset >= TS_PACKET {
return None;
}
}
Some(&pkt[offset..])
}
pub(crate) fn demux_ts(data: &[u8]) -> Result<DemuxResult> {
let (packets, packet_stride, prefix_len) = detect_packet_layout(data)?;
if packets == 0 {
bail!("TS: file contains no TS packets");
}
let mut pmt_pid: Option<u16> = None;
let mut chosen_video: Option<VideoStreamInfo> = None;
let mut chosen_audio: Option<AudioStreamInfo> = None;
for i in 0..packets {
let start = i * packet_stride + prefix_len;
let pkt = &data[start..start + TS_PACKET];
if pkt[0] != TS_SYNC {
continue;
}
let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
if pmt_pid.is_none() && pid == 0 {
if let Some(payload) = ts_psi_payload(pkt)
&& let Some(p) = pat_pmt::parse_pat_first_pmt_pid(payload)
{
pmt_pid = Some(p);
}
continue;
}
if let (Some(pmt), None) = (pmt_pid, chosen_video)
&& pid == pmt
&& let Some(payload) = ts_psi_payload(pkt)
&& let Some((video_streams, audio_streams)) = pat_pmt::parse_pmt_streams(payload)
{
chosen_video = video_streams.into_iter().next();
chosen_audio = audio_streams.into_iter().next();
if chosen_video.is_some() {
break;
}
}
}
let video = chosen_video.context("TS: no video elementary stream found in PMT")?;
let video_pid = video.pid;
let codec = match video.stream_type {
STREAM_TYPE_MPEG2_VIDEO => "mpeg2",
STREAM_TYPE_H264 => "h264",
STREAM_TYPE_HEVC => "h265",
other => bail!("TS: unsupported stream_type 0x{:02X}", other),
}
.to_string();
let mut samples: Vec<Vec<u8>> = Vec::new();
let mut pending: Vec<u8> = Vec::new();
let mut have_first_start = false;
let mut first_pts: Option<u64> = None;
let mut last_pts: Option<u64> = None;
let mut ptses: Vec<u64> = Vec::new();
let flush = |pending: &mut Vec<u8>, samples: &mut Vec<Vec<u8>>| {
if !pending.is_empty() {
samples.push(std::mem::take(pending));
}
};
for i in 0..packets {
let start = i * packet_stride + prefix_len;
let pkt = &data[start..start + TS_PACKET];
if pkt[0] != TS_SYNC {
continue;
}
let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
if pid != video_pid {
continue;
}
let pusi = pkt[1] & 0x40 != 0;
let scramble = (pkt[3] >> 6) & 0x03;
if scramble != 0 {
continue;
} let adaptation = (pkt[3] >> 4) & 0x03;
let has_payload = adaptation & 0x01 != 0;
let has_adaptation = adaptation & 0x02 != 0;
if !has_payload {
continue;
}
let mut offset = 4usize;
if has_adaptation {
if offset >= TS_PACKET {
continue;
}
let adap_len = pkt[offset] as usize;
offset += 1 + adap_len;
if offset > TS_PACKET {
continue;
}
}
if offset >= TS_PACKET {
continue;
}
let payload = &pkt[offset..];
if pusi {
if have_first_start {
flush(&mut pending, &mut samples);
}
have_first_start = true;
let Some((es_start, pts)) = pes::parse_pes_header(payload) else {
have_first_start = false;
pending.clear();
continue;
};
if let Some(p) = pts {
if first_pts.is_none() {
first_pts = Some(p);
}
last_pts = Some(p);
ptses.push(p);
}
if es_start < payload.len() {
pending.extend_from_slice(&payload[es_start..]);
}
} else if have_first_start {
pending.extend_from_slice(payload);
}
}
flush(&mut pending, &mut samples);
if samples.is_empty() {
bail!("TS: reassembled zero video samples from PID {}", video_pid);
}
let duration = match (first_pts, last_pts) {
(Some(a), Some(b)) if b >= a => (b - a) as f64 / 90_000.0,
_ => 0.0,
};
let frame_rate = framerate::estimate_frame_rate_from_ptses(&ptses)
.or_else(|| {
if duration > 0.0 && samples.len() > 1 {
Some((samples.len() - 1) as f64 / duration)
} else {
None
}
})
.unwrap_or(30.0);
let (width, height) = codec::pixel_format::detect_dims(&codec, &samples).unwrap_or((0, 0));
if width == 0 || height == 0 {
tracing::warn!(
codec = codec.as_str(),
"TS demux: could not recover width/height from first sample — \
downstream encoder may reject the 0×0 config"
);
}
let info = StreamInfo {
codec: codec.clone(),
width,
height,
frame_rate,
duration,
pixel_format: PixelFormat::Yuv420p,
color_space: ColorSpace::Bt709,
total_frames: samples.len() as u64,
bitrate: 0,
color_metadata: Default::default(),
};
let detected_pf = codec::pixel_format::detect(&codec, &samples);
let info = StreamInfo {
pixel_format: detected_pf,
..info
};
let audio = chosen_audio.and_then(|info| {
match audio::extract_ts_audio(data, packets, packet_stride, prefix_len, info) {
Ok(track) => track,
Err(e) => {
tracing::warn!(
audio_pid = info.pid,
audio_kind = ?info.kind,
error = %e,
"TS audio extraction failed; emitting video-only"
);
None
}
}
});
Ok(DemuxResult {
codec,
info,
samples,
audio,
})
}