Files
libfreemkv/src/mux/m2ts_mux/mod.rs
T
Matthew Jackson 1eb6910bdb Harden mux + decrypt paths; fail-loud on unresolvable keys
mp4 demuxer (untrusted input): bound every allocation sized from a box
field (stsz/stco/stsc counts, stts/ctts run-lengths, per-sample and moov
sizes, plus an absolute cap so a sparse file can't inflate file_len);
guard the parse_stsd slice and a zero mdhd timescale; cap track count so
the per-track PID can't overflow; rewrite read_moov to handle size==0 /
size<8 / 64-bit largesize; parse esds/AudioSpecificConfig for AAC; write
tkhd duration in the movie timescale.

decrypt: resolve_mux_key_map now fails loud on an extent no key can
classify instead of inheriting the previous extent's key, so a keymap
never silently carries a wrong key; the sweep/patch key-fetch recovery
fails loud when a unit is still unresolved after the retry.

AACS: reject inverted forensic segments in both range builders; compare
the forensic index in u16 space so an out-of-range value can't truncate
onto a valid u8 index. RECOVERED_ERROR no longer latches the damage zone,
preserving the 30s wedge cooldown for a following hard error.

audio: AAC/MP2/MP3/FLAC carry the last PTS across a PES with no timestamp;
the DTS-HD extension-sync search is bounded to after the core; the MP4
16.16 sample-rate field saturates. demux_sink records the video reference
before the kind filter so audio:// / sub:// keep multi-clip PTS continuity
and the DELAY tag.

Remove a dead error variant and the AACS-unsupported-video code; codec
comments cite the primary format specs; assorted doc/naming fixes and
regression tests throughout.
2026-07-23 12:02:43 -07:00

1367 lines
57 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Standard MPEG-TS (188-byte packets) muxer — sequential-only.
//!
//! Distinct from `super::tsmux::TsMuxer` (BD-TS with 192-byte packets
//! and the 4-byte TP_extra_header). This muxer emits the ITU-T H.222.0 /
//! ISO/IEC 13818-1 wire format that any conformant transport-stream
//! demuxer or player consumes out of the box. Use it for plain
//! `.ts` / `.m2ts` files over a
//! [`SequentialSink`](crate::io::sink::SequentialSink), and for
//! MPEG-TS-over-UDP via [`UdpSocketSink`](crate::io::sink::UdpSocketSink).
//!
//! ## Wire format
//!
//! Every output packet is exactly 188 bytes:
//!
//! ```text
//! sync_byte:8 = 0x47
//! transport_error_indicator:1 = 0
//! payload_unit_start_indicator:1
//! transport_priority:1 = 0
//! PID:13
//! transport_scrambling_control:2 = 0
//! adaptation_field_control:2
//! continuity_counter:4
//! [adaptation field, if signalled]
//! [payload, if signalled]
//! ```
//!
//! ## Wiring
//!
//! Single program (PMT PID `0x1000`, program number `1`):
//! - PAT on PID `0x0000`
//! - PMT on PID `0x1000`
//! - Video (HEVC, stream_type `0x24`) on PID `0x0100`
//! - First audio (AC3, stream_type `0x81`) on PID `0x0101`
//! (or TrueHD, stream_type `0x83`, if hinted)
//!
//! ## Scope vs. full TS
//!
//! This is a deliberately minimal viable muxer:
//! - One program, one video track, optionally one audio track.
//! - PAT + PMT re-emitted every `PSI_INTERVAL_PACKETS` packets so a
//! mid-stream receiver can lock on within ~100 ms at typical
//! UHD bitrates.
//! - PCR clock derived from video PTS (`pts - PCR_LEAD_90KHZ`),
//! attached to the video PID's adaptation field every
//! `PCR_INTERVAL_PACKETS` packets.
//! - No language / descriptor tags, no SCTE-35 markers, no per-PID
//! PMT version bumps, no SDT/EIT. Sufficient for a conformant
//! demuxer to play this back, not for full broadcast deployment.
use std::io::{self, Write};
mod packet;
use packet::{Packet, PacketWriter};
/// PID `0x0000` — PAT, mandated by spec.
const PID_PAT: u16 = 0x0000;
/// PID for the single program's PMT. `0x1000` is the conventional
/// choice; anything outside reserved/null ranges works.
const PID_PMT: u16 = 0x1000;
/// PID for the video elementary stream.
const PID_VIDEO: u16 = 0x0100;
/// PID for the (optional) first audio elementary stream.
const PID_AUDIO: u16 = 0x0101;
/// Null PID — reserved by spec; never used here.
#[allow(dead_code)]
const PID_NULL: u16 = 0x1FFF;
/// Re-emit PAT/PMT every N TS packets. ~250 × 188 B = 47 KB; at
/// 80 Mb/s UHD that's ~5 ms — well under typical receiver lock budget.
const PSI_INTERVAL_PACKETS: u64 = 250;
/// Re-stamp PCR every N TS packets carrying the video PID. Spec MAX
/// is 100 ms; 40 packets × 188 B at moderate bitrate keeps us under.
const PCR_INTERVAL_PACKETS: u64 = 40;
/// PCR lead time — the PCR must precede the PTS of the first byte of
/// the picture it timestamps. 200 ms in 90 kHz ticks.
const PCR_LEAD_90KHZ: u64 = 90_000 / 5;
// PMT stream-type codes. These are the same elementary-stream coding-type
// registry as the parse side; the single source of truth is `consts::coding_type`.
use crate::consts::coding_type;
/// HEVC stream-type code, ISO/IEC 13818-1 Table 2-34 (2015 amendment).
const STREAM_TYPE_HEVC: u8 = coding_type::HEVC;
/// AC-3 / E-AC-3. Not an ISO assignment — sits in the user-private
/// 0x80-0xFF range and is the Blu-ray Disc Association / ATSC A/52
/// convention.
const STREAM_TYPE_AC3: u8 = coding_type::AC3;
/// Dolby TrueHD. Also a private/BD-conventional value in the
/// user-private 0x80-0xFF range, not an ISO assignment.
const STREAM_TYPE_TRUEHD: u8 = coding_type::TRUEHD;
/// Audio codec hint for [`M2tsMux::new`] / [`M2tsMux::set_audio`]. The
/// muxer needs to know the codec to pick the right PMT `stream_type`
/// and the right PES stream_id; it doesn't decode the audio.
#[derive(Debug, Clone, Copy)]
pub enum AudioCodec {
/// AC-3 / E-AC-3, stream_type `0x81`, PES stream_id `0xBD`.
Ac3,
/// Dolby TrueHD, stream_type `0x83`, PES stream_id `0xBD`.
TrueHd,
}
impl AudioCodec {
fn stream_type(self) -> u8 {
match self {
AudioCodec::Ac3 => STREAM_TYPE_AC3,
AudioCodec::TrueHd => STREAM_TYPE_TRUEHD,
}
}
}
/// Sequential MPEG-TS muxer with one video and optional one audio track.
pub struct M2tsMux<W: Write> {
out: PacketWriter<W>,
/// hvcC parameter set bytes to prepend to the first video PES.
/// Optional — if the upstream frames already carry inline params,
/// callers can omit this.
video_codec_private: Option<Vec<u8>>,
/// Set on first video frame: have we emitted VPS/SPS/PPS?
params_written: bool,
/// Audio codec, if an audio track is configured. `None` = video-only.
audio: Option<AudioCodec>,
/// First seen PTS (90 kHz). All subsequent PTS / PCR values are
/// relative to this so output streams start near t=0 and don't
/// confuse downstream parsers that don't tolerate huge starting
/// timestamps.
base_pts_90k: Option<u64>,
/// Per-PID continuity counter, 4 bits, monotonically increasing.
cc_video: u8,
cc_audio: u8,
cc_pat: u8,
cc_pmt: u8,
/// Total packets written, used to gate PSI / PCR cadence.
packets_written: u64,
/// Video packets written since last PCR, used to gate PCR cadence.
video_packets_since_pcr: u64,
/// Set once the first video TS packet has been emitted. Forces a PCR
/// onto the very first video PES so a receiver tuning at stream start
/// has a clock reference (the PMT advertises the video PID as PCR_PID).
first_video_written: bool,
}
impl<W: Write> M2tsMux<W> {
/// Construct a muxer wrapping `writer`. By default audio is
/// disabled; call [`set_audio`](Self::set_audio) before the first
/// frame to enable.
pub fn new(writer: W) -> Self {
Self {
out: PacketWriter::new(writer),
video_codec_private: None,
params_written: false,
audio: None,
base_pts_90k: None,
cc_video: 0,
cc_audio: 0,
cc_pat: 0,
cc_pmt: 0,
packets_written: 0,
video_packets_since_pcr: 0,
first_video_written: false,
}
}
/// Provide the `HEVCDecoderConfigurationRecord` for video so the
/// muxer prepends VPS/SPS/PPS Annex B NALs at stream start.
pub fn set_video_codec_private(&mut self, hvcc: Vec<u8>) {
self.video_codec_private = Some(hvcc);
}
/// Enable a single audio track. Must be called before
/// [`write_audio`](Self::write_audio).
pub fn set_audio(&mut self, codec: AudioCodec) {
self.audio = Some(codec);
}
/// Write one video PES frame. `data` is either length-prefixed
/// NALUs (MKV-style) or already Annex B; both are accepted. `keyframe`
/// drives the random_access_indicator bit on the first packet of this
/// PES (and gates codec_private NAL prepending — those only attach to
/// the first keyframe).
pub fn write_video(&mut self, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ true);
// PCR comes "before" the PTS it timestamps; clamp at 0 for the
// first frame so we don't underflow.
let pcr = pts_90k.saturating_sub(PCR_LEAD_90KHZ);
// Annex-B-ify the frame and prepend VPS/SPS/PPS once, on the
// FIRST keyframe (not first frame — non-key frames before the
// first keyframe can't carry params usefully).
let mut es = Vec::with_capacity(data.len() + 64);
if keyframe && !self.params_written {
if let Some(cp) = &self.video_codec_private {
if let Some(params) = super::hevc::hvcc_to_annex_b(cp) {
es.extend_from_slice(&params);
}
}
self.params_written = true;
}
// Append the Annex-B form directly into the pre-sized `es`
// buffer rather than materializing an intermediate Vec.
super::hevc::append_length_prefixed_as_annex_b(&mut es, data);
let pes = build_video_pes(pts_90k, &es);
self.write_pes(PID_VIDEO, &pes, Some(pcr), keyframe)
}
/// Write one audio PES frame. Returns `Ok(())` and silently drops
/// the frame if no audio track was configured — the design assumes
/// the upstream picks tracks and won't ship audio to a video-only
/// muxer, but defending against it keeps the API a single shape.
pub fn write_audio(&mut self, pts_ns: i64, data: &[u8]) -> io::Result<()> {
if self.audio.is_none() {
return Ok(());
}
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ false);
let pes = build_audio_pes(pts_90k, data);
self.write_pes(PID_AUDIO, &pes, None, false)
}
/// Drain the underlying writer. No TS-level trailer is mandatory —
/// receivers detect end-of-stream from socket close or file EOF.
pub fn finish(&mut self) -> io::Result<()> {
self.out.flush()
}
/// Convert input PTS (nanoseconds) to 90 kHz ticks rebased on the
/// stream's PTS origin. The origin is seeded ONLY by the first video
/// frame (`may_seed_base == true`); audio frames never seed it. This
/// keeps the audio/video offset intact: a leading audio frame can't
/// pull the base up and collapse the first/lowest-PTS video frame to 0.
/// Frames earlier than the base saturate to 0.
fn base_relative_pts(&mut self, pts_ns: i64, may_seed_base: bool) -> u64 {
let raw_90k = if pts_ns > 0 {
// Widen to u128 so adversarial timestamps can't overflow the
// intermediate multiply (pts_ns * 9 exceeds u64 above
// ~2.05e18 ns), then clamp to the 33-bit PTS range.
(((pts_ns as u128) * 9 / 100_000) as u64) & 0x1_FFFF_FFFF
} else {
0
};
if may_seed_base {
self.base_pts_90k.get_or_insert(raw_90k);
}
let base = self.base_pts_90k.unwrap_or(raw_90k);
// Modular 33-bit subtraction. The 90 kHz PTS clock is a 33-bit field
// that wraps every 2^33 ticks (~26.5 h). A plain `saturating_sub` would
// collapse ANY frame whose (33-bit-masked) tick lands below `base` to
// PTS 0 — including a frame across a legitimate clock wrap (raw wraps
// past 0 and lands far below base), flat-lining timing for that span.
// Wrap the difference into the 33-bit range, then interpret it as a
// signed 33-bit delta: a small magnitude in the LOWER half is genuine
// forward progression (incl. across a wrap) and is kept; a value in the
// UPPER half means the frame is truly BEFORE the base (a small backward
// step — e.g. a leading audio frame ahead of the first video keyframe),
// which still floors to 0 per the documented behavior.
let delta = raw_90k.wrapping_sub(base) & 0x1_FFFF_FFFF;
// Signed 33-bit: the sign bit is bit 32 (value 2^32), so the entire
// upper half [2^32, 2^33) is negative (frame before base) and floors
// to 0. delta == 2^32 is the most-negative value (-2^32), hence `>=`.
if delta >= (1 << 32) { 0 } else { delta }
}
/// Emit one PES payload as a chain of TS packets on `pid`. If `pcr`
/// is provided the first packet carries an adaptation field with
/// the PCR. PAT/PMT are re-emitted every `PSI_INTERVAL_PACKETS`.
///
/// Packet-size math (TS = 188 bytes, header = 4 bytes):
/// - 184 B remain after the header for the adaptation-field area
/// plus the payload area.
/// - With AF body of `b` bytes and `s` stuffing bytes: AF total =
/// `1 + b + s` (the leading `1` is the `adaptation_field_length`
/// byte itself). Payload = `184 - (1 + b + s)`.
/// - With no AF area at all: payload = `184`.
///
/// The fit-the-tail logic on the last packet of the PES uses
/// stuffing rather than a separate small packet, which is the
/// standard MPEG-TS convention.
fn write_pes(
&mut self,
pid: u16,
pes: &[u8],
pcr: Option<u64>,
is_keyframe_video: bool,
) -> io::Result<()> {
// PSI cadence is enforced per TS packet — interleave a fresh
// PAT+PMT into the packet stream every PSI_INTERVAL_PACKETS so
// long single-PES emissions (e.g. one 60 KB video frame) don't
// starve receivers tuning in mid-stream.
let mut offset = 0;
let mut first = true;
while offset < pes.len() {
self.maybe_emit_psi()?;
// PCR cadence is enforced per VIDEO TS packet, NOT per PES. A single
// UHD HEVC I-frame is one PES spanning thousands of TS packets; if
// PCR could only ride the PES's first packet, the clock would go
// un-restamped for the whole frame — a multi-second gap far beyond
// the 40-packet / ~100 ms bound, which strict T-STD validators treat
// as a clock discontinuity. So re-stamp whenever the per-packet
// counter reaches the interval (or on the very first video packet of
// the stream), regardless of whether this is the PES start. Re-using
// the PES's own `pcr` for a mid-PES packet keeps the gap bounded.
let attach_pcr = (pid == PID_VIDEO)
&& (pcr.is_some())
&& (!self.first_video_written
|| self.video_packets_since_pcr >= PCR_INTERVAL_PACKETS);
// RAI rides only the FIRST packet of a keyframe video PES.
let attach_rai = first && is_keyframe_video && pid == PID_VIDEO;
let mut af_body: Vec<u8> = if attach_pcr {
build_pcr_adaptation(pcr.unwrap_or(0))
} else {
Vec::new()
};
if attach_rai {
if af_body.is_empty() {
af_body.push(0x40); // flags: RAI only
} else {
af_body[0] |= 0x40; // OR RAI into existing PCR flags
}
}
let remaining = pes.len() - offset;
// Capacity for payload given AF body and 1-byte AF length.
// When AF body is empty we can still skip the AF entirely
// and get the full 184 B; only invoke the AF when we'd
// otherwise need stuffing.
//
// Per ISO/IEC 13818-1 Table 2-6, when an adaptation field is
// present its first body byte is the mandatory 8-bit flags
// byte. A stuffing-only field still needs that flags byte
// (all flags 0) — omitting it would make a strict decoder
// read the first 0xFF stuffing byte as flags (PCR_flag=1,
// …) and parse a phantom PCR out of the stuffing/payload.
let (af_present, payload_len, stuffing): (bool, usize, usize) = if !af_body.is_empty() {
// AF is mandatory (PCR, RAI, …). 1 byte length + body +
// stuffing + payload = 184.
let max_payload = 184 - 1 - af_body.len();
let p = remaining.min(max_payload);
let s = max_payload - p;
(true, p, s)
} else if remaining >= 184 {
// Full payload packet — no AF at all.
(false, 184, 0)
} else {
// Last (small) packet — stuff via an AF whose body is a
// single zero-flags byte. 1 byte length + 1 flags byte +
// stuffing + payload = 184, so payload caps at 182.
let max_payload = 182;
let p = remaining.min(max_payload);
let s = max_payload - p;
af_body.push(0x00); // zero-flags byte
(true, p, s)
};
let cc = self.advance_cc(pid);
let mut packet = Packet::new();
packet.set_header(pid, first, true, af_present, cc);
if af_present {
packet.append_adaptation(&af_body, stuffing)?;
}
packet.append_payload(&pes[offset..offset + payload_len])?;
self.out.write_packet(&packet)?;
self.packets_written += 1;
if pid == PID_VIDEO {
self.first_video_written = true;
if attach_pcr {
self.video_packets_since_pcr = 0;
} else {
self.video_packets_since_pcr += 1;
}
}
offset += payload_len;
first = false;
}
Ok(())
}
fn advance_cc(&mut self, pid: u16) -> u8 {
let slot = match pid {
PID_VIDEO => &mut self.cc_video,
PID_AUDIO => &mut self.cc_audio,
PID_PAT => &mut self.cc_pat,
PID_PMT => &mut self.cc_pmt,
_ => return 0,
};
let cc = *slot;
*slot = (*slot + 1) & 0x0F;
cc
}
fn maybe_emit_psi(&mut self) -> io::Result<()> {
if self.packets_written == 0 || self.packets_written % PSI_INTERVAL_PACKETS == 0 {
self.emit_pat()?;
self.emit_pmt()?;
}
Ok(())
}
fn emit_pat(&mut self) -> io::Result<()> {
let payload = build_pat(PID_PMT);
let cc = self.advance_cc(PID_PAT);
let mut packet = Packet::new();
packet.set_header(PID_PAT, true, true, false, cc);
packet.append_payload(&payload)?;
packet.pad_to_188();
self.out.write_packet(&packet)?;
self.packets_written += 1;
Ok(())
}
fn emit_pmt(&mut self) -> io::Result<()> {
let payload = build_pmt(self.audio);
let cc = self.advance_cc(PID_PMT);
let mut packet = Packet::new();
packet.set_header(PID_PMT, true, true, false, cc);
packet.append_payload(&payload)?;
packet.pad_to_188();
self.out.write_packet(&packet)?;
self.packets_written += 1;
Ok(())
}
}
/// Build a PES packet for a video access unit.
fn build_video_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> {
build_pes_packet(
crate::consts::pes_stream_id::VIDEO,
pts_90k,
es,
/* length_in_header */ false,
)
}
/// Build a PES packet for an audio access unit.
fn build_audio_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> {
// Audio PES: a bounded length is written whenever the PES fits in a
// u16 (the common case), so receivers don't have to scan for the next
// start code. For an access unit larger than ~64 KiB (rare — e.g. a
// large TrueHD frame) the length field falls back to the unbounded
// (0x0000) form, which most demuxers tolerate for private_stream_1.
build_pes_packet(
crate::consts::pes_stream_id::PRIVATE_STREAM_1,
pts_90k,
es,
/* length_in_header */ true,
)
}
fn build_pes_packet(stream_id: u8, pts_90k: u64, es: &[u8], length_in_header: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(es.len() + 14);
out.extend_from_slice(&[0x00, 0x00, 0x01, stream_id]);
// PES_packet_length: total bytes after this field. 3 flag bytes + 5
// PTS bytes + es.len(). Zero means "unbounded" — used for video
// where PES can exceed u16.
let pes_len = 8 + es.len();
if length_in_header && pes_len <= u16::MAX as usize {
out.extend_from_slice(&(pes_len as u16).to_be_bytes());
} else {
out.extend_from_slice(&[0x00, 0x00]);
}
// Flags byte 1: 10 = MPEG-2 marker, then scrambling/priority/etc 0.
out.push(0x80);
// Flags byte 2: PTS flag (bit 7).
out.push(0x80);
// PES_header_data_length = 5 (just PTS).
out.push(5);
// PTS bytes — 33-bit timestamp split across 5 bytes with marker bits.
let pts = pts_90k & 0x1_FFFF_FFFF;
out.push(0x21 | (((pts >> 29) & 0x0E) as u8));
out.push(((pts >> 22) & 0xFF) as u8);
out.push(0x01 | (((pts >> 14) & 0xFE) as u8));
out.push(((pts >> 7) & 0xFF) as u8);
out.push(0x01 | (((pts << 1) & 0xFE) as u8));
out.extend_from_slice(es);
out
}
/// Build the PAT payload (section, with pointer_field).
fn build_pat(pmt_pid: u16) -> Vec<u8> {
let mut section = Vec::new();
section.push(0x00); // table_id = PAT
// section_syntax_indicator(1) | '0'(1) | reserved(2) | section_length(12)
// section_length covers from end of this field through CRC.
// Body: transport_stream_id(2) + version/cni(1) + section/last_section(2) + program(4) = 9 bytes,
// plus CRC(4) = 13. Encoded big-endian.
section.extend_from_slice(&[0xB0, 13]);
section.extend_from_slice(&[0x00, 0x01]); // transport_stream_id = 1
section.push(0xC1); // reserved | version=0 | current_next=1
section.push(0x00); // section_number
section.push(0x00); // last_section_number
section.extend_from_slice(&[0x00, 0x01]); // program_number = 1
// reserved(3) | network_PID/program_map_PID(13)
let pid_bytes = (0xE000u16 | (pmt_pid & 0x1FFF)).to_be_bytes();
section.extend_from_slice(&pid_bytes);
let crc = mpegts_crc32(&section);
section.extend_from_slice(&crc.to_be_bytes());
// Prepend pointer_field=0 (section starts immediately).
let mut payload = Vec::with_capacity(section.len() + 1);
payload.push(0x00);
payload.extend_from_slice(&section);
payload
}
/// Build the PMT payload (section, with pointer_field).
fn build_pmt(audio: Option<AudioCodec>) -> Vec<u8> {
let mut section = Vec::new();
section.push(0x02); // table_id = PMT
// section_length filled in after we know the body size.
let len_placeholder = section.len();
section.extend_from_slice(&[0xB0, 0x00]);
section.extend_from_slice(&1u16.to_be_bytes()); // program_number
section.push(0xC1); // reserved | version=0 | current_next=1
section.push(0x00); // section_number
section.push(0x00); // last_section_number
// reserved(3) | PCR_PID(13)
let pcr_pid = (0xE000u16 | (PID_VIDEO & 0x1FFF)).to_be_bytes();
section.extend_from_slice(&pcr_pid);
// program_info_length = 0
section.extend_from_slice(&[0xF0, 0x00]);
// Video elementary stream entry.
section.push(STREAM_TYPE_HEVC);
let v_pid = (0xE000u16 | (PID_VIDEO & 0x1FFF)).to_be_bytes();
section.extend_from_slice(&v_pid);
section.extend_from_slice(&[0xF0, 0x00]); // ES_info_length = 0
if let Some(codec) = audio {
section.push(codec.stream_type());
let a_pid = (0xE000u16 | (PID_AUDIO & 0x1FFF)).to_be_bytes();
section.extend_from_slice(&a_pid);
section.extend_from_slice(&[0xF0, 0x00]);
}
// Now patch section_length: covers everything after the length field
// through the CRC, so (current body size - 3 bytes consumed by
// table_id + 2 length bytes) + 4 (CRC).
let section_len = section.len() - 3 + 4;
section[len_placeholder] = 0xB0 | ((section_len >> 8) as u8 & 0x0F);
section[len_placeholder + 1] = section_len as u8;
let crc = mpegts_crc32(&section);
section.extend_from_slice(&crc.to_be_bytes());
let mut payload = Vec::with_capacity(section.len() + 1);
payload.push(0x00);
payload.extend_from_slice(&section);
payload
}
/// Build the adaptation field carrying a PCR (no other flags).
fn build_pcr_adaptation(pcr_90k: u64) -> Vec<u8> {
// adaptation_field_length is set by `Packet::append_adaptation`
// — this function returns just the field body.
//
// Layout: discontinuity_indicator(1) | random_access(1) |
// elementary_stream_priority(1) | PCR_flag(1) | OPCR_flag(1) |
// splicing_point_flag(1) | transport_private_data_flag(1) |
// adaptation_field_extension_flag(1) | PCR(48b).
let mut af = vec![0x10]; // PCR_flag=1; RAI is OR'd in by the caller when applicable
let pcr_base = pcr_90k & 0x1_FFFF_FFFF; // 33-bit
let pcr_ext: u16 = 0; // 9-bit, we keep it zero (no sub-tick precision)
// Encode PCR: 33b base | 6b reserved | 9b extension = 48b
af.push((pcr_base >> 25) as u8);
af.push((pcr_base >> 17) as u8);
af.push((pcr_base >> 9) as u8);
af.push((pcr_base >> 1) as u8);
af.push(((pcr_base << 7) as u8 & 0x80) | 0x7E | ((pcr_ext >> 8) as u8 & 0x01));
af.push(pcr_ext as u8);
af
}
/// MPEG-TS CRC-32 (poly 0x04C11DB7, init 0xFFFFFFFF, no reflection, no
/// final XOR). Implementation: bitwise so we don't need a table —
/// PSI sections are tiny, the cost is negligible.
fn mpegts_crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &b in data {
crc ^= (b as u32) << 24;
for _ in 0..8 {
if crc & 0x8000_0000 != 0 {
crc = (crc << 1) ^ 0x04C1_1DB7;
} else {
crc <<= 1;
}
}
}
crc
}
#[cfg(test)]
mod tests {
use super::*;
/// All emitted bytes must align to 188-byte packet boundaries and
/// every packet must start with `0x47`.
fn assert_ts_well_formed(buf: &[u8]) {
assert_eq!(
buf.len() % 188,
0,
"stream not packet-aligned: {} bytes",
buf.len()
);
for (i, chunk) in buf.chunks(188).enumerate() {
assert_eq!(chunk[0], 0x47, "packet {} missing sync byte", i);
}
}
fn extract_pids(buf: &[u8]) -> Vec<u16> {
buf.chunks(188)
.map(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]))
.collect()
}
#[test]
fn crc32_is_self_validating() {
// The MPEG-TS CRC has the property that prepending a single
// bit-flip changes the output; running it over its own input +
// CRC yields a fixed magic constant (the CRC residue). Rather
// than hardcoding sample bytes, verify the underlying algorithm
// by checking that two distinct inputs produce distinct CRCs
// and that the same input is deterministic.
let a = [
0u8, 0xB0, 0x0D, 0x00, 0x01, 0xC1, 0x00, 0x00, 0x00, 0x01, 0xE1, 0x00,
];
let mut b = a;
b[5] ^= 0x01; // flip one bit
let crc_a = mpegts_crc32(&a);
let crc_b = mpegts_crc32(&b);
assert_ne!(crc_a, crc_b);
assert_eq!(crc_a, mpegts_crc32(&a)); // deterministic
// Sanity: all-zero input ⇒ CRC = 0 (init XORs but the
// shift/feedback cancels for zero data after init drains).
// We don't assert exact value — that depends on poly choice —
// but check it's not the same as for non-zero data.
let crc_zero = mpegts_crc32(&[0u8; 12]);
assert_ne!(crc_zero, crc_a);
}
#[test]
fn video_only_mux_emits_pat_pmt_then_video() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
// One small video frame, no codec_private (so no params NAL inline).
let mut frame = Vec::new();
frame.extend_from_slice(&4u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(0, true, &frame).unwrap();
mux.finish().unwrap();
drop(mux);
assert_ts_well_formed(&sink);
let pids = extract_pids(&sink);
// First two packets: PAT, PMT. At least one video packet after.
assert_eq!(pids[0], PID_PAT);
assert_eq!(pids[1], PID_PMT);
assert!(pids.iter().any(|p| *p == PID_VIDEO));
}
#[test]
fn first_video_pes_carries_pcr() {
// A receiver tuning at stream start needs the clock reference the
// PMT promises (video PID = PCR_PID). The very first video PES must
// therefore carry a PCR even though PAT+PMT precede it.
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
let mut frame = Vec::new();
frame.extend_from_slice(&4u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(0, true, &frame).unwrap();
mux.finish().unwrap();
drop(mux);
// First PUSI video packet must carry an AF with the PCR flag (0x10).
let pkt = sink
.chunks(188)
.find(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO && (p[1] & 0x40) != 0)
.expect("video PUSI packet exists");
let af = af_body(pkt).expect("first video PES must carry an adaptation field");
assert!(!af.is_empty(), "AF flags byte present");
assert_eq!(af[0] & 0x10, 0x10, "PCR flag set on first video PES");
}
#[test]
fn audio_track_appears_in_pmt_and_stream() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
mux.set_audio(AudioCodec::Ac3);
// Video + audio frame pair.
let mut frame = Vec::new();
frame.extend_from_slice(&3u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C]);
mux.write_video(0, true, &frame).unwrap();
mux.write_audio(20_000_000, &[0x0B, 0x77, 0x12, 0x34])
.unwrap();
mux.finish().unwrap();
drop(mux);
assert_ts_well_formed(&sink);
let pids = extract_pids(&sink);
assert!(pids.iter().any(|p| *p == PID_VIDEO));
assert!(pids.iter().any(|p| *p == PID_AUDIO));
}
#[test]
fn psi_re_emits_at_interval() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
// Build a frame large enough to span > PSI_INTERVAL_PACKETS TS packets.
// 184 B payload per packet ⇒ ~250 packets = ~46 KB elementary stream.
let big: Vec<u8> = (0..(60 * 1024)).map(|i| (i & 0xff) as u8).collect();
let mut frame = Vec::new();
frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
frame.extend_from_slice(&big);
mux.write_video(0, true, &frame).unwrap();
mux.finish().unwrap();
drop(mux);
assert_ts_well_formed(&sink);
let pids = extract_pids(&sink);
// Count PAT/PMT pairs — must be at least 2 given the input size.
let pat_count = pids.iter().filter(|p| **p == PID_PAT).count();
let pmt_count = pids.iter().filter(|p| **p == PID_PMT).count();
assert!(pat_count >= 2, "expected ≥2 PAT, got {}", pat_count);
assert!(pmt_count >= 2, "expected ≥2 PMT, got {}", pmt_count);
}
#[test]
fn continuity_counter_increments_per_pid() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
// Three small video frames to get a sequence of video TS packets.
for pts in [0i64, 40_000_000, 80_000_000] {
let mut frame = Vec::new();
frame.extend_from_slice(&3u32.to_be_bytes());
frame.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
mux.write_video(pts, pts == 0, &frame).unwrap();
}
mux.finish().unwrap();
drop(mux);
// Collect CCs for video packets in order.
let ccs: Vec<u8> = sink
.chunks(188)
.filter(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO)
.map(|p| p[3] & 0x0F)
.collect();
for w in ccs.windows(2) {
assert_eq!(w[1], (w[0] + 1) & 0x0F);
}
}
/// Return the adaptation field body (length byte stripped) for one
/// 188-byte TS packet, or None when the packet has no AF.
fn af_body(packet: &[u8]) -> Option<Vec<u8>> {
let afc = (packet[3] >> 4) & 0x03;
if afc & 0b10 == 0 {
return None;
}
let af_len = packet[4] as usize;
if af_len == 0 {
return Some(Vec::new());
}
Some(packet[5..5 + af_len].to_vec())
}
#[test]
fn stuffing_only_tail_packet_is_spec_valid() {
// A short final PES packet must stuff via an adaptation field
// whose first body byte is the mandatory zero-flags byte (per
// ISO/IEC 13818-1 Table 2-6), never a bare 0xFF stuffing byte
// that a decoder would misread as PCR/OPCR/etc. flags.
//
// Use an AUDIO PES (no PCR, no RAI on its tail) so the only AF on
// the last packet is the stuffing-only field under test. The PES
// is sized so its final TS packet is short (< 184 payload bytes).
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
mux.set_audio(AudioCodec::Ac3);
// Drive a keyframe first so the stream is well-formed, then the
// audio frame whose tail is short.
let mut vframe = Vec::new();
vframe.extend_from_slice(&4u32.to_be_bytes());
vframe.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(0, true, &vframe).unwrap();
// 200-byte audio payload → PES > 184 → spills into a short tail.
let audio: Vec<u8> = (0..200u32).map(|i| (i & 0xFF) as u8).collect();
mux.write_audio(20_000_000, &audio).unwrap();
mux.finish().unwrap();
drop(mux);
assert_ts_well_formed(&sink);
// Find every audio packet that carries an adaptation field; the
// short tail packet is one of them. Each such AF must have a
// length >= 1 and a zero-flags body byte (not 0xFF).
let mut saw_stuffing_af = false;
for pkt in sink.chunks(188) {
let pid = u16::from_be_bytes([pkt[1] & 0x1F, pkt[2]]);
if pid != PID_AUDIO {
continue;
}
let afc = (pkt[3] >> 4) & 0x03;
if afc & 0b10 == 0 {
continue; // no AF on this packet
}
let af_len = pkt[4] as usize;
assert!(
af_len >= 1,
"stuffing AF must include the mandatory flags byte"
);
// First AF body byte is the flags byte — must be zero, never
// a 0xFF stuffing byte masquerading as flags.
assert_eq!(
pkt[5], 0x00,
"stuffing-only AF flags byte must be 0x00, not 0x{:02X}",
pkt[5]
);
// adaptation_field_length + payload must fill exactly 184.
// (4 header + 1 AF-length + af_len + payload = 188.)
assert!(af_len <= 183, "AF length overflows the 184-byte body");
saw_stuffing_af = true;
}
assert!(
saw_stuffing_af,
"expected at least one audio packet with a stuffing AF"
);
}
#[test]
fn rai_set_on_keyframe_pes_packet() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
let mut frame = Vec::new();
frame.extend_from_slice(&4u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(0, true, &frame).unwrap();
mux.finish().unwrap();
drop(mux);
// Find the first PUSI packet on PID_VIDEO.
let pkt = sink
.chunks(188)
.find(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO && (p[1] & 0x40) != 0)
.expect("video PUSI packet exists");
let af = af_body(pkt).expect("AF present on first packet of keyframe video PES");
assert!(!af.is_empty(), "AF flags byte present");
assert_eq!(af[0] & 0x40, 0x40, "RAI bit set");
}
#[test]
fn pcr_packet_without_keyframe_has_rai_clear() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
// First frame is the keyframe (gates codec_private; also gets PCR).
let mut frame0 = Vec::new();
frame0.extend_from_slice(&4u32.to_be_bytes());
frame0.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(0, true, &frame0).unwrap();
// Push enough non-key video frames to cross PCR_INTERVAL_PACKETS
// video packets so a later PCR-bearing packet exists.
// Each frame is ~50 KB → ~270 packets, well over 40.
let big: Vec<u8> = (0..(50 * 1024)).map(|i| (i & 0xff) as u8).collect();
for i in 1..3 {
let mut frame = Vec::new();
frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
frame.extend_from_slice(&big);
mux.write_video((i as i64) * 40_000_000, false, &frame)
.unwrap();
}
mux.finish().unwrap();
drop(mux);
// The first video packet carries PCR + RAI (keyframe PES start). RAI
// rides only the FIRST packet of a KEYFRAME PES; every OTHER PCR-bearing
// video packet — the mid-PES re-stamps and the non-keyframe PES starts —
// must have RAI clear. Skip the very first video packet (the keyframe
// RAI carrier) and assert the first remaining PCR-bearing packet is RAI
// clear.
let video_pkts: Vec<&[u8]> = sink
.chunks(188)
.filter(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO)
.collect();
assert!(
video_pkts.len() >= 2,
"expected ≥2 video packets, got {}",
video_pkts.len()
);
// Find a later one with AF that carries PCR (flags & 0x10 set).
let later_pcr = video_pkts
.iter()
.skip(1)
.find_map(|p| {
let af = af_body(p)?;
if !af.is_empty() && (af[0] & 0x10) != 0 {
Some(af)
} else {
None
}
})
.expect("a later PCR-bearing video packet exists");
assert_eq!(
later_pcr[0] & 0x40,
0,
"RAI must be clear on a non-keyframe-start PCR packet"
);
}
/// Regression: PCR must be re-stamped MID-PES, not only at PES boundaries.
/// A single large video frame (one PES) spans far more than
/// PCR_INTERVAL_PACKETS TS packets — a UHD I-frame. PCR-bearing video
/// packets must recur at least every PCR_INTERVAL_PACKETS video packets
/// across that one PES; before the fix only the PES's first packet carried
/// PCR, leaving a multi-second clock gap for the whole frame.
#[test]
fn pcr_restamped_mid_pes_within_interval() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
// ONE big frame → ONE PES spanning ~330 packets (≫ 40).
let big: Vec<u8> = (0..(60 * 1024)).map(|i| (i & 0xff) as u8).collect();
let mut frame = Vec::new();
frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
frame.extend_from_slice(&big);
mux.write_video(0, true, &frame).unwrap();
mux.finish().unwrap();
drop(mux);
assert_ts_well_formed(&sink);
// Walk every video TS packet in order; record which ones carry a PCR
// (AF present with PCR_flag 0x10). The packet INDEX (among video
// packets) of consecutive PCR carriers must never advance by more than
// PCR_INTERVAL_PACKETS.
let mut video_idx = 0usize;
let mut pcr_indices: Vec<usize> = Vec::new();
let mut total_video = 0usize;
for pkt in sink.chunks(188) {
let pid = u16::from_be_bytes([pkt[1] & 0x1F, pkt[2]]);
if pid != PID_VIDEO {
continue;
}
total_video += 1;
if let Some(af) = af_body(pkt) {
if !af.is_empty() && (af[0] & 0x10) != 0 {
pcr_indices.push(video_idx);
}
}
video_idx += 1;
}
assert!(
total_video > PCR_INTERVAL_PACKETS as usize,
"test needs a PES spanning more than one PCR interval, got {total_video} video packets"
);
// More than one PCR across the single PES (the whole point of the fix).
assert!(
pcr_indices.len() >= 2,
"PCR must be re-stamped mid-PES, but only {} PCR-bearing packet(s) \
appeared across {} video packets of one PES",
pcr_indices.len(),
total_video
);
// First PCR is on the first video packet.
assert_eq!(pcr_indices[0], 0, "first video packet must carry PCR");
// No gap between consecutive PCRs exceeds the interval. The counter is
// post-incremented and PCR attaches on `>= PCR_INTERVAL_PACKETS`, so the
// packet index gap is `PCR_INTERVAL_PACKETS + 1` (40 packets carrying no
// PCR, then the re-stamp packet) — the spec "every 40 packets" bound.
let max_gap = PCR_INTERVAL_PACKETS + 1;
for w in pcr_indices.windows(2) {
assert!(
(w[1] - w[0]) as u64 <= max_gap,
"PCR gap {} exceeds the {}-packet bound",
w[1] - w[0],
max_gap
);
}
let tail = total_video - 1 - *pcr_indices.last().unwrap();
assert!(
tail as u64 <= max_gap,
"trailing run after the last PCR ({tail}) exceeds the {max_gap}-packet bound"
);
}
#[test]
fn keyframe_video_with_pcr_combines_flags() {
// The FIRST video PES is always a keyframe carrying BOTH a PCR (forced
// at stream start so the receiver has the clock the PMT promises) AND a
// RAI (keyframe) — exercising the flag-OR path that combines RAI into the
// PCR adaptation-field flags byte (0x10 | 0x40 = 0x50). (PCR cadence is
// now enforced per video packet, NOT per PES boundary, so a LATER
// keyframe PES start no longer deterministically lands on a PCR-due
// packet; the combine path is pinned here on the guaranteed first PES.)
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
let mut small = Vec::new();
small.extend_from_slice(&4u32.to_be_bytes());
small.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(0, true, &small).unwrap();
mux.finish().unwrap();
drop(mux);
let video_pusi: Vec<&[u8]> = sink
.chunks(188)
.filter(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO && (p[1] & 0x40) != 0)
.collect();
assert!(!video_pusi.is_empty(), "a video PES start exists");
let af = af_body(video_pusi[0]).expect("AF present on first keyframe PES");
assert!(!af.is_empty(), "AF flags byte present");
assert_eq!(af[0], 0x50, "flags == RAI | PCR on the first keyframe PES");
}
// ════════════════════════════════════════════════════════════════════
// Added hardening tests
// ════════════════════════════════════════════════════════════════════
/// Find the first packet on `pid` (optionally requiring PUSI).
fn find_pkt(buf: &[u8], pid: u16, pusi: bool) -> Option<&[u8]> {
buf.chunks(188).find(|p| {
u16::from_be_bytes([p[1] & 0x1F, p[2]]) == pid && (!pusi || (p[1] & 0x40) != 0)
})
}
/// Extract a PSI section (after the pointer_field) from a PUSI PSI
/// packet: payload starts at byte 4 (no AF on PSI here), first payload
/// byte is pointer_field, section follows.
fn psi_section(pkt: &[u8]) -> &[u8] {
let pointer = pkt[4] as usize;
&pkt[5 + pointer..]
}
// ── MPEG-TS CRC-32 (poly 0x04C11DB7) self-validation ──────────────────
#[test]
fn crc32_residue_over_section_plus_crc_is_zero() {
// Defining property of the MPEG-TS CRC (ISO 13818-1 Annex B): running
// the CRC over a message WITH its appended 4-byte CRC yields a fixed
// residue. For this poly/init (no final XOR) the residue over
// [data || crc(data)] is 0. This pins the algorithm independent of
// any sample vector.
let data = [
0x00u8, 0xB0, 0x0D, 0x00, 0x01, 0xC1, 0x00, 0x00, 0x00, 0x01, 0xE1, 0x00,
];
let crc = mpegts_crc32(&data);
// Known-answer vector for CRC-32/MPEG-2 (poly 0x04C11DB7, init
// 0xFFFFFFFF, no reflection, no final XOR — ISO/IEC 13818-1 Annex B),
// independently computed. This pins the polynomial, not just internal
// consistency.
assert_eq!(crc, 0xE8F9_5E7D, "CRC-32/MPEG-2 known-answer vector");
let mut with_crc = data.to_vec();
with_crc.extend_from_slice(&crc.to_be_bytes());
assert_eq!(
mpegts_crc32(&with_crc),
0,
"CRC residue over message+CRC must be 0"
);
}
#[test]
fn emitted_pat_pmt_crc_is_valid() {
// The PAT and PMT the muxer emits must carry a correct CRC-32 over
// the section (table_id .. end of body). A receiver that validates
// CRC would otherwise drop the table.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = M2tsMux::new(&mut sink);
mux.set_audio(AudioCodec::Ac3);
let mut frame = Vec::new();
frame.extend_from_slice(&4u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(0, true, &frame).unwrap();
mux.finish().unwrap();
}
for pid in [PID_PAT, PID_PMT] {
let pkt = find_pkt(&sink, pid, true).expect("PSI packet present");
let sec = psi_section(pkt);
// section_length covers bytes after the 2-byte length field,
// i.e. (table_id + 2 length bytes) + section_length = whole
// section incl. CRC.
let section_len = (((sec[1] & 0x0F) as usize) << 8) | sec[2] as usize;
let total = 3 + section_len;
assert!(sec.len() >= total, "section fits in payload");
assert_eq!(
mpegts_crc32(&sec[..total]),
0,
"PID {pid:#06x} section CRC must validate (residue 0)"
);
}
}
// ── PAT / PMT structure ───────────────────────────────────────────────
#[test]
fn pat_points_at_pmt_pid() {
// PAT program loop entry: program_number(2) + reserved(3)|PID(13).
// The single program must point at PID_PMT.
let pat = build_pat(PID_PMT);
let sec = &pat[1..]; // skip pointer_field
assert_eq!(sec[0], 0x00, "table_id = PAT");
// Body: tsid(2)@3 cni(1)@5 sec#(1)@6 last(1)@7 program(4)@8..12.
let prog_num = u16::from_be_bytes([sec[8], sec[9]]);
let pmt_pid = u16::from_be_bytes([sec[10] & 0x1F, sec[11]]);
assert_eq!(prog_num, 1, "program_number 1");
assert_eq!(pmt_pid, PID_PMT, "PAT points at PMT PID");
}
#[test]
fn pmt_advertises_video_and_audio_stream_types() {
// PMT must list HEVC video (stream_type 0x24) and, when audio is
// configured, the audio stream_type. Stream-type codes per ISO
// 13818-1 Table 2-34 / BD convention.
let pmt = build_pmt(Some(AudioCodec::Ac3));
let sec = &pmt[1..]; // skip pointer_field
assert_eq!(sec[0], 0x02, "table_id = PMT");
let section_len = (((sec[1] & 0x0F) as usize) << 8) | sec[2] as usize;
let prog_info_len = (((sec[10] & 0x0F) as usize) << 8) | sec[11] as usize;
let mut pos = 12 + prog_info_len;
let end = 3 + section_len - 4; // exclude CRC
let mut types = Vec::new();
while pos + 5 <= end {
types.push(sec[pos]);
let es_info = (((sec[pos + 3] & 0x0F) as usize) << 8) | sec[pos + 4] as usize;
pos += 5 + es_info;
}
assert!(types.contains(&STREAM_TYPE_HEVC), "HEVC video in PMT");
assert!(types.contains(&STREAM_TYPE_AC3), "AC-3 audio in PMT");
}
#[test]
fn pmt_video_only_omits_audio_entry() {
// Video-only PMT must list exactly one ES entry (video) — no audio.
let pmt = build_pmt(None);
let sec = &pmt[1..];
let section_len = (((sec[1] & 0x0F) as usize) << 8) | sec[2] as usize;
let prog_info_len = (((sec[10] & 0x0F) as usize) << 8) | sec[11] as usize;
let mut pos = 12 + prog_info_len;
let end = 3 + section_len - 4;
let mut count = 0;
while pos + 5 <= end {
count += 1;
let es_info = (((sec[pos + 3] & 0x0F) as usize) << 8) | sec[pos + 4] as usize;
pos += 5 + es_info;
}
assert_eq!(count, 1, "video-only PMT has exactly one ES entry");
}
#[test]
fn truehd_audio_uses_stream_type_0x83() {
// TrueHD maps to stream_type 0x83 (BD convention).
let pmt = build_pmt(Some(AudioCodec::TrueHd));
let sec = &pmt[1..];
let section_len = (((sec[1] & 0x0F) as usize) << 8) | sec[2] as usize;
let end = 3 + section_len - 4;
let mut pos = 12; // prog_info_len is 0 in this muxer
let mut found = false;
while pos + 5 <= end {
if sec[pos] == STREAM_TYPE_TRUEHD {
found = true;
}
let es_info = (((sec[pos + 3] & 0x0F) as usize) << 8) | sec[pos + 4] as usize;
pos += 5 + es_info;
}
assert!(found, "TrueHD stream_type 0x83 must appear in PMT");
}
#[test]
fn pmt_pcr_pid_is_video_pid() {
// PMT PCR_PID field (reserved(3)|PCR_PID(13) at section bytes 8..10)
// must be the video PID — the PCR rides the video adaptation field.
let pmt = build_pmt(None);
let sec = &pmt[1..];
let pcr_pid = u16::from_be_bytes([sec[8] & 0x1F, sec[9]]);
assert_eq!(pcr_pid, PID_VIDEO, "PCR_PID advertised as the video PID");
}
// ── PCR encoding ──────────────────────────────────────────────────────
#[test]
fn pcr_base_round_trips_through_adaptation_field() {
// build_pcr_adaptation packs a 33-bit PCR base across 6 bytes:
// base[32:25],[24:17],[16:9],[8:1] then bit0 in top of byte 5.
// (ISO 13818-1 §2.4.3.5.) Decode it back and compare.
let pcr: u64 = 0x1_2345_6789 & ((1 << 33) - 1);
let af = build_pcr_adaptation(pcr);
assert_eq!(af[0], 0x10, "PCR_flag set, others clear");
let base = ((af[1] as u64) << 25)
| ((af[2] as u64) << 17)
| ((af[3] as u64) << 9)
| ((af[4] as u64) << 1)
| ((af[5] as u64 >> 7) & 0x01);
assert_eq!(base, pcr, "PCR base must round-trip through the AF");
}
#[test]
fn first_video_pcr_leads_pts_by_lead_time() {
// The PCR on the first video PES = pts_90k - PCR_LEAD_90KHZ, clamped
// at 0. With pts_ns large enough not to clamp, decode the PCR and the
// PTS and verify the lead. PCR_LEAD_90KHZ = 18000 (200 ms).
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = M2tsMux::new(&mut sink);
let mut frame = Vec::new();
frame.extend_from_slice(&4u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
// 1s → 90000 ticks; base is this same frame, so relative PTS=0
// and PCR clamps to 0. Use a single frame: PTS rebases to 0,
// so PCR = 0.saturating_sub(lead) = 0.
mux.write_video(1_000_000_000, true, &frame).unwrap();
mux.finish().unwrap();
}
let pkt = find_pkt(&sink, PID_VIDEO, true).unwrap();
let af = af_body(pkt).unwrap();
// PCR present.
assert_eq!(af[0] & 0x10, 0x10);
let base = ((af[1] as u64) << 25)
| ((af[2] as u64) << 17)
| ((af[3] as u64) << 9)
| ((af[4] as u64) << 1)
| ((af[5] as u64 >> 7) & 0x01);
// Single frame rebases its own PTS to 0; PCR = 0 - lead clamped to 0.
assert_eq!(base, 0, "first frame PCR clamps to 0 (no underflow)");
}
// ── base_relative_pts overflow / saturation ───────────────────────────
#[test]
fn extreme_pts_does_not_overflow_and_clamps_to_33bit() {
// base_relative_pts widens to u128 then masks to 33 bits. An
// adversarial i64::MAX ns must not overflow and the encoded PTS must
// stay within the 33-bit field. With a single video frame the base
// is itself, so relative PTS is 0 — proving no panic on the path.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = M2tsMux::new(&mut sink);
let mut frame = Vec::new();
frame.extend_from_slice(&4u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(i64::MAX, true, &frame).unwrap();
mux.finish().unwrap();
}
assert_ts_well_formed(&sink);
let pkt = find_pkt(&sink, PID_VIDEO, true).unwrap();
// Reach the PES PTS: payload after AF. AF area = 1 (length) + af_len.
let af_len = pkt[4] as usize;
let pes = &pkt[4 + 1 + af_len..];
// PES: 00 00 01 E0 00 00 80 80 05 PTS[5]. PTS at pes[9..14].
let pts = ((((pes[9] >> 1) & 0x07) as u64) << 30)
| ((pes[10] as u64) << 22)
| (((pes[11] >> 1) as u64) << 15)
| ((pes[12] as u64) << 7)
| ((pes[13] >> 1) as u64);
assert!(pts < (1u64 << 33), "PTS stays within the 33-bit field");
}
#[test]
fn base_relative_pts_wraps_across_33bit_clock_rollover() {
// Regression: a real 90 kHz clock wrap must NOT collapse to PTS 0.
// Seed base near the top of the 33-bit range; a later frame whose tick
// has wrapped past 0 lands far below base. The OLD `saturating_sub`
// returned 0 (flat-lining timing); modular subtraction must return the
// true small forward delta.
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
// Force the base to 2^33 - 100 directly (a value reachable only after
// ~26.5 h of stream; set it rather than ripping that long).
mux.base_pts_90k = Some((1u64 << 33) - 100);
// pts_ns = 1ms → raw_90k = 1_000_000 * 9 / 100_000 = 90 ticks (wrapped
// past 0, far below the near-max base).
let pts_ns = 1_000_000i64;
let rel = mux.base_relative_pts(pts_ns, /* may_seed_base */ false);
// 90 - (2^33 - 100) mod 2^33 = 190 ticks forward across the wrap (NOT 0).
assert_eq!(
rel, 190,
"a 33-bit clock wrap must produce the true forward delta, not 0"
);
// And a frame genuinely a little BEFORE the base still floors to 0
// (documented pre-base behavior — e.g. leading audio). base = 200 ticks,
// frame at 90 ticks (< base) → backward step → floor to 0.
mux.base_pts_90k = Some(200);
let rel0 = mux.base_relative_pts(1_000_000i64, false); // raw_90k = 90 < 200
assert_eq!(rel0, 0, "a frame before the base must still floor to 0");
}
#[test]
fn negative_pts_ns_encodes_zero() {
// base_relative_pts treats pts_ns <= 0 as raw 0. A negative input
// must encode PTS 0, not a wrapped value.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = M2tsMux::new(&mut sink);
let mut frame = Vec::new();
frame.extend_from_slice(&4u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
mux.write_video(-5, true, &frame).unwrap();
mux.finish().unwrap();
}
let pkt = find_pkt(&sink, PID_VIDEO, true).unwrap();
let af_len = pkt[4] as usize;
let pes = &pkt[4 + 1 + af_len..];
let pts = ((((pes[9] >> 1) & 0x07) as u64) << 30)
| ((pes[10] as u64) << 22)
| (((pes[11] >> 1) as u64) << 15)
| ((pes[12] as u64) << 7)
| ((pes[13] >> 1) as u64);
assert_eq!(pts, 0, "negative pts_ns encodes PTS 0");
}
// ── audio without configured track ────────────────────────────────────
#[test]
fn write_audio_without_track_is_silently_dropped() {
// write_audio on a video-only muxer must drop the frame (no audio
// PID configured) without error — and emit no audio PID packets.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = M2tsMux::new(&mut sink);
let mut frame = Vec::new();
frame.extend_from_slice(&3u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C]);
mux.write_video(0, true, &frame).unwrap();
mux.write_audio(0, &[0x0B, 0x77]).unwrap(); // no track → dropped
mux.finish().unwrap();
}
let pids = extract_pids(&sink);
assert!(
!pids.iter().any(|p| *p == PID_AUDIO),
"no audio track configured → no audio PID emitted"
);
}
// ── empty stream ──────────────────────────────────────────────────────
#[test]
fn finish_without_frames_emits_nothing() {
// A muxer with no frames written emits no packets (PSI is gated on
// write paths). finish() must be a clean no-op.
let mut sink: Vec<u8> = Vec::new();
let mut mux = M2tsMux::new(&mut sink);
mux.finish().unwrap();
drop(mux);
assert!(sink.is_empty(), "no frames → no output");
}
#[test]
fn pat_always_on_pid_zero() {
// ISO 13818-1 mandates the PAT on PID 0x0000.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = M2tsMux::new(&mut sink);
let mut frame = Vec::new();
frame.extend_from_slice(&3u32.to_be_bytes());
frame.extend_from_slice(&[0x40, 0x01, 0x0C]);
mux.write_video(0, true, &frame).unwrap();
mux.finish().unwrap();
}
assert_eq!(
extract_pids(&sink)[0],
0x0000,
"first packet is PAT on PID 0"
);
}
}