Files
libfreemkv/src/mux/m2ts_mux/mod.rs
T
matthew 398430af09 mux: keyframe-align MKV clusters + SeekHead; set TS RAI on keyframe PES
MKV: cluster boundaries now require a video keyframe in addition to the
5s minimum, so every cluster has a CuePoint at its start. Pre-first-
keyframe frames are dropped. Adds a SeekHead at Segment start with
fixed-width back-patched SeekPositions for Info/Tracks/Chapters/Cues.

Before this change a 2h26m UHD rip had 52 CuePoints across ~1750
clusters and a 16.5-minute gap between adjacent seek entries; players
seeking inside that gap had to scan from the prior cue. After, one
CuePoint per cluster.

TS (tsmux production path + m2ts_mux): PesFrame.keyframe is plumbed
end-to-end. Codec-private parameter sets are prepended on the first
keyframe (not the first frame); non-key video before any keyframe is
dropped. The first TS packet of a keyframe video PES carries an
adaptation field with random_access_indicator=1. m2ts_mux previously
hardcoded RAI=1 on every PCR packet; that is now gated on the current
PES being a keyframe video PES, combining correctly with PCR when both
land on the same packet.

Adds 17 tests covering keyframe alignment, cue count/position/timing,
SeekHead correctness, RAI set/clear, codec_private gating, non-key drop,
and PCR+RAI combination.
2026-05-17 15:31:16 -07:00

829 lines
32 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 IETF / ISO/IEC
//! 13818-1 wire format that ffmpeg, VLC, and `m2tsindex` consume
//! 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 "ffmpeg can 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;
/// Stream-type codes from ISO/IEC 13818-1 Table 2-29 + later amendments.
const STREAM_TYPE_HEVC: u8 = 0x24;
const STREAM_TYPE_AC3: u8 = 0x81;
const STREAM_TYPE_TRUEHD: u8 = 0x83;
/// 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,
}
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,
}
}
/// 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);
// 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 {
let payload = hvcc_payload(cp);
if !payload.is_empty() {
let params = super::hevc::length_prefixed_to_annex_b(&payload);
es.extend_from_slice(&params);
}
}
self.params_written = true;
}
let annex_b = super::hevc::length_prefixed_to_annex_b(data);
es.extend_from_slice(&annex_b);
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);
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
/// first frame's PTS. Saturating at 0 keeps the math friendly when
/// frames arrive slightly out of decode order.
fn base_relative_pts(&mut self, pts_ns: i64) -> u64 {
let raw_90k = if pts_ns > 0 {
(pts_ns as u64) * 9 / 100_000
} else {
0
};
let base = *self.base_pts_90k.get_or_insert(raw_90k);
raw_90k.saturating_sub(base)
}
/// 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()?;
let attach_pcr = first
&& (pid == PID_VIDEO)
&& (pcr.is_some())
&& (self.packets_written == 0
|| 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.
let (af_present, payload_len, stuffing): (bool, usize, usize) = if !af_body.is_empty() {
// AF is mandatory (PCR). 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 empty AF.
// 1 byte length + 0 body + stuffing + payload = 184.
let max_payload = 183;
let p = remaining.min(max_payload);
let s = max_payload - p;
(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]);
debug_assert_eq!(packet.len(), 188, "packet not 188 bytes");
self.out.write_packet(&packet)?;
self.packets_written += 1;
if pid == PID_VIDEO {
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(())
}
}
/// Extract the raw hvcC bytes for handoff to `length_prefixed_to_annex_b`.
/// hvcC layout: 22-byte fixed header, then `numOfArrays` arrays of
/// `(nalType, numNalus, [nalLength:u16, NAL bytes]…)`. We convert this
/// directly to a length-prefixed byte stream (NAL length is u16 in
/// hvcC; widen to u32 for the standard length-prefixed encoding).
fn hvcc_payload(hvcc: &[u8]) -> Vec<u8> {
if hvcc.len() < 23 {
return Vec::new();
}
let num_arrays = hvcc[22] as usize;
let mut out = Vec::new();
let mut offset = 23;
for _ in 0..num_arrays {
if offset + 3 > hvcc.len() {
break;
}
offset += 1;
let num_nalus = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
offset += 2;
for _ in 0..num_nalus {
if offset + 2 > hvcc.len() {
break;
}
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
offset += 2;
if offset + nal_len > hvcc.len() {
break;
}
out.extend_from_slice(&(nal_len as u32).to_be_bytes());
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
offset += nal_len;
}
}
out
}
/// Build a PES packet for a video access unit.
fn build_video_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> {
build_pes_packet(0xE0, 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: length is fillable when it fits in u16. We always
// write the length so receivers don't have to scan for the next
// start code.
build_pes_packet(0xBD, 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 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 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 PUSI video packet carries PCR + RAI (keyframe).
// A later video PUSI packet with AF + PCR but NOT keyframe must
// have RAI clear.
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.len() >= 2,
"expected ≥2 video PES starts, got {}",
video_pusi.len()
);
// Find a later one with AF that carries PCR (flags & 0x10 set).
let later_pcr = video_pusi
.iter()
.skip(1)
.find_map(|p| {
let af = af_body(p)?;
if !af.is_empty() && (af[0] & 0x10) != 0 {
Some(af)
} else {
None
}
})
.expect("later PCR-bearing PUSI exists");
assert_eq!(
later_pcr[0] & 0x40,
0,
"RAI must be clear on non-keyframe PCR packet"
);
}
#[test]
fn keyframe_video_with_pcr_combines_flags() {
// PCR attaches only when video_packets_since_pcr >=
// PCR_INTERVAL_PACKETS (40). The very first video packet emits a
// PAT+PMT first, so packets_written != 0 and attach_pcr is false on
// frame 0. We push: keyframe (no PCR) → many non-key (drives the
// PCR counter past the interval) → second keyframe (PCR + RAI).
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();
// ~50 KB ≈ 270 packets — well over PCR_INTERVAL_PACKETS.
let big: Vec<u8> = (0..(50 * 1024)).map(|i| (i & 0xff) as u8).collect();
let mut big_frame = Vec::new();
big_frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
big_frame.extend_from_slice(&big);
mux.write_video(40_000_000, false, &big_frame).unwrap();
// Now a second keyframe — must combine RAI (keyframe) and PCR
// (counter exceeded).
mux.write_video(80_000_000, true, &small).unwrap();
mux.finish().unwrap();
drop(mux);
// Collect video PUSI packets and find the third (second keyframe).
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.len() >= 3, "three video PES starts expected");
let af = af_body(video_pusi[2]).expect("AF present");
assert!(!af.is_empty(), "AF flags byte present");
assert_eq!(af[0], 0x50, "flags == RAI | PCR");
}
}