//! 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 { out: PacketWriter, /// 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>, /// 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, /// 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, /// 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 M2tsMux { /// 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) { 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(¶ms); } } 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, 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 = 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 { 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 { 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 { // 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 { 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 { 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(§ion); 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(§ion); payload } /// Build the PMT payload (section, with pointer_field). fn build_pmt(audio: Option) -> Vec { 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(§ion); section.extend_from_slice(&crc.to_be_bytes()); let mut payload = Vec::with_capacity(section.len() + 1); payload.push(0x00); payload.extend_from_slice(§ion); payload } /// Build the adaptation field carrying a PCR (no other flags). fn build_pcr_adaptation(pcr_90k: u64) -> Vec { // 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 { 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 = 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 = 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 = 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 = (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 = 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 = 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> { 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 = 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 = 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 = (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 = 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 = (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"); } }