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.
This commit is contained in:
@@ -321,6 +321,12 @@ pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
|||||||
// Segment
|
// Segment
|
||||||
pub const SEGMENT: u32 = 0x1853_8067;
|
pub const SEGMENT: u32 = 0x1853_8067;
|
||||||
|
|
||||||
|
// SeekHead
|
||||||
|
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
||||||
|
pub const SEEK: u32 = 0x4DBB;
|
||||||
|
pub const SEEK_ID: u32 = 0x53AB;
|
||||||
|
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||||
|
|
||||||
// Segment Info
|
// Segment Info
|
||||||
pub const INFO: u32 = 0x1549_A966;
|
pub const INFO: u32 = 0x1549_A966;
|
||||||
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
|
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
|
||||||
|
|||||||
+118
-1
@@ -239,7 +239,9 @@ impl crate::pes::Stream for M2tsStream {
|
|||||||
|
|
||||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||||
match &mut self.mode {
|
match &mut self.mode {
|
||||||
Mode::Write { muxer } => muxer.write_frame(frame.track, frame.pts, &frame.data),
|
Mode::Write { muxer } => {
|
||||||
|
muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
|
||||||
|
}
|
||||||
Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()),
|
Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,3 +285,118 @@ impl crate::pes::Stream for M2tsStream {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::disc::{
|
||||||
|
Codec, ColorSpace, ContentFormat, DiscTitle, FrameRate, HdrFormat, Resolution,
|
||||||
|
Stream as DiscStream, VideoStream,
|
||||||
|
};
|
||||||
|
use crate::pes::{PesFrame, Stream as PesStreamTrait};
|
||||||
|
|
||||||
|
const VIDEO_PID: u16 = 0x1011;
|
||||||
|
|
||||||
|
fn make_title() -> DiscTitle {
|
||||||
|
DiscTitle {
|
||||||
|
playlist: String::new(),
|
||||||
|
playlist_id: 0,
|
||||||
|
duration_secs: 0.0,
|
||||||
|
size_bytes: 0,
|
||||||
|
clips: Vec::new(),
|
||||||
|
streams: vec![DiscStream::Video(VideoStream {
|
||||||
|
pid: VIDEO_PID,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: FrameRate::F24,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Bt709,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
})],
|
||||||
|
chapters: Vec::new(),
|
||||||
|
extents: Vec::new(),
|
||||||
|
content_format: ContentFormat::BdTs,
|
||||||
|
codec_privates: vec![Some({
|
||||||
|
// Minimal hvcC with one VPS-like array entry.
|
||||||
|
let marker: &[u8] = &[0x40, 0x01, 0x0C, 0x01];
|
||||||
|
let mut hvcc = vec![0u8; 22];
|
||||||
|
hvcc.push(1); // numArrays
|
||||||
|
hvcc.push(32);
|
||||||
|
hvcc.extend_from_slice(&1u16.to_be_bytes()); // numNalus
|
||||||
|
hvcc.extend_from_slice(&(marker.len() as u16).to_be_bytes());
|
||||||
|
hvcc.extend_from_slice(marker);
|
||||||
|
hvcc
|
||||||
|
})],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_idr_pes_data() -> Vec<u8> {
|
||||||
|
// 4-byte length prefix + NAL: type 19 (IDR_W_RADL).
|
||||||
|
let mut nal = vec![(19u8 << 1) & 0x7E, 0x01];
|
||||||
|
for i in 0..200 {
|
||||||
|
nal.push((i & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(4 + nal.len());
|
||||||
|
out.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||||
|
out.extend_from_slice(&nal);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writer wrapper that shares an Arc<Mutex<Vec<u8>>> so the test can
|
||||||
|
/// inspect the bytes after the muxer drops.
|
||||||
|
struct SharedSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||||
|
impl Write for SharedSink {
|
||||||
|
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
|
||||||
|
self.0.lock().unwrap().extend_from_slice(b);
|
||||||
|
Ok(b.len())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m2ts_stream_forwards_keyframe_to_rai() {
|
||||||
|
let title = make_title();
|
||||||
|
let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
|
||||||
|
let sink = SharedSink(shared.clone());
|
||||||
|
let mut stream = M2tsStream::create(sink, &title).unwrap();
|
||||||
|
let frame = PesFrame {
|
||||||
|
track: 0,
|
||||||
|
pts: 0,
|
||||||
|
keyframe: true,
|
||||||
|
data: fake_idr_pes_data(),
|
||||||
|
};
|
||||||
|
stream.write(&frame).unwrap();
|
||||||
|
stream.finish().unwrap();
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
let buf = shared.lock().unwrap().clone();
|
||||||
|
|
||||||
|
// Skip FMKV metadata header via meta::read_header.
|
||||||
|
let mut cursor = std::io::Cursor::new(&buf);
|
||||||
|
let _meta = super::meta::read_header(&mut cursor)
|
||||||
|
.unwrap()
|
||||||
|
.expect("FMKV header present");
|
||||||
|
let header_end = cursor.position() as usize;
|
||||||
|
let ts_bytes = &buf[header_end..];
|
||||||
|
|
||||||
|
// Find first PUSI packet on VIDEO_PID; verify RAI in AF flags.
|
||||||
|
let pkt = ts_bytes
|
||||||
|
.chunks(192)
|
||||||
|
.find(|p| {
|
||||||
|
let h = &p[4..];
|
||||||
|
let pid = (((h[1] & 0x1F) as u16) << 8) | h[2] as u16;
|
||||||
|
pid == VIDEO_PID && (h[1] & 0x40) != 0
|
||||||
|
})
|
||||||
|
.expect("video PUSI packet present");
|
||||||
|
let h = &pkt[4..];
|
||||||
|
let afc = (h[3] >> 4) & 0x03;
|
||||||
|
assert!(afc & 0b10 != 0, "AF must be present");
|
||||||
|
let af_len = h[4] as usize;
|
||||||
|
assert!(af_len >= 1, "AF length must include flags byte");
|
||||||
|
let flags = h[5];
|
||||||
|
assert_eq!(flags & 0x40, 0x40, "RAI bit set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+160
-13
@@ -161,16 +161,21 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write one video PES frame. `data` is either length-prefixed
|
/// Write one video PES frame. `data` is either length-prefixed
|
||||||
/// NALUs (MKV-style) or already Annex B; both are accepted.
|
/// NALUs (MKV-style) or already Annex B; both are accepted. `keyframe`
|
||||||
pub fn write_video(&mut self, pts_ns: i64, data: &[u8]) -> io::Result<()> {
|
/// 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);
|
let pts_90k = self.base_relative_pts(pts_ns);
|
||||||
// PCR comes "before" the PTS it timestamps; clamp at 0 for the
|
// PCR comes "before" the PTS it timestamps; clamp at 0 for the
|
||||||
// first frame so we don't underflow.
|
// first frame so we don't underflow.
|
||||||
let pcr = pts_90k.saturating_sub(PCR_LEAD_90KHZ);
|
let pcr = pts_90k.saturating_sub(PCR_LEAD_90KHZ);
|
||||||
|
|
||||||
// Annex-B-ify the frame and prepend VPS/SPS/PPS once.
|
// 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);
|
let mut es = Vec::with_capacity(data.len() + 64);
|
||||||
if !self.params_written {
|
if keyframe && !self.params_written {
|
||||||
if let Some(cp) = &self.video_codec_private {
|
if let Some(cp) = &self.video_codec_private {
|
||||||
let payload = hvcc_payload(cp);
|
let payload = hvcc_payload(cp);
|
||||||
if !payload.is_empty() {
|
if !payload.is_empty() {
|
||||||
@@ -184,7 +189,7 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
es.extend_from_slice(&annex_b);
|
es.extend_from_slice(&annex_b);
|
||||||
|
|
||||||
let pes = build_video_pes(pts_90k, &es);
|
let pes = build_video_pes(pts_90k, &es);
|
||||||
self.write_pes(PID_VIDEO, &pes, Some(pcr))
|
self.write_pes(PID_VIDEO, &pes, Some(pcr), keyframe)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write one audio PES frame. Returns `Ok(())` and silently drops
|
/// Write one audio PES frame. Returns `Ok(())` and silently drops
|
||||||
@@ -197,7 +202,7 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
}
|
}
|
||||||
let pts_90k = self.base_relative_pts(pts_ns);
|
let pts_90k = self.base_relative_pts(pts_ns);
|
||||||
let pes = build_audio_pes(pts_90k, data);
|
let pes = build_audio_pes(pts_90k, data);
|
||||||
self.write_pes(PID_AUDIO, &pes, None)
|
self.write_pes(PID_AUDIO, &pes, None, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain the underlying writer. No TS-level trailer is mandatory —
|
/// Drain the underlying writer. No TS-level trailer is mandatory —
|
||||||
@@ -234,7 +239,13 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
/// The fit-the-tail logic on the last packet of the PES uses
|
/// The fit-the-tail logic on the last packet of the PES uses
|
||||||
/// stuffing rather than a separate small packet, which is the
|
/// stuffing rather than a separate small packet, which is the
|
||||||
/// standard MPEG-TS convention.
|
/// standard MPEG-TS convention.
|
||||||
fn write_pes(&mut self, pid: u16, pes: &[u8], pcr: Option<u64>) -> io::Result<()> {
|
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
|
// PSI cadence is enforced per TS packet — interleave a fresh
|
||||||
// PAT+PMT into the packet stream every PSI_INTERVAL_PACKETS so
|
// PAT+PMT into the packet stream every PSI_INTERVAL_PACKETS so
|
||||||
// long single-PES emissions (e.g. one 60 KB video frame) don't
|
// long single-PES emissions (e.g. one 60 KB video frame) don't
|
||||||
@@ -250,11 +261,21 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
&& (self.packets_written == 0
|
&& (self.packets_written == 0
|
||||||
|| self.video_packets_since_pcr >= PCR_INTERVAL_PACKETS);
|
|| self.video_packets_since_pcr >= PCR_INTERVAL_PACKETS);
|
||||||
|
|
||||||
let af_body: Vec<u8> = if attach_pcr {
|
// 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))
|
build_pcr_adaptation(pcr.unwrap_or(0))
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
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;
|
let remaining = pes.len() - offset;
|
||||||
// Capacity for payload given AF body and 1-byte AF length.
|
// Capacity for payload given AF body and 1-byte AF length.
|
||||||
@@ -510,7 +531,7 @@ fn build_pcr_adaptation(pcr_90k: u64) -> Vec<u8> {
|
|||||||
// elementary_stream_priority(1) | PCR_flag(1) | OPCR_flag(1) |
|
// elementary_stream_priority(1) | PCR_flag(1) | OPCR_flag(1) |
|
||||||
// splicing_point_flag(1) | transport_private_data_flag(1) |
|
// splicing_point_flag(1) | transport_private_data_flag(1) |
|
||||||
// adaptation_field_extension_flag(1) | PCR(48b).
|
// adaptation_field_extension_flag(1) | PCR(48b).
|
||||||
let mut af = vec![0x50]; // PCR_flag=1, random_access_indicator=1
|
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_base = pcr_90k & 0x1_FFFF_FFFF; // 33-bit
|
||||||
let pcr_ext: u16 = 0; // 9-bit, we keep it zero (no sub-tick precision)
|
let pcr_ext: u16 = 0; // 9-bit, we keep it zero (no sub-tick precision)
|
||||||
// Encode PCR: 33b base | 6b reserved | 9b extension = 48b
|
// Encode PCR: 33b base | 6b reserved | 9b extension = 48b
|
||||||
@@ -598,7 +619,7 @@ mod tests {
|
|||||||
let mut frame = Vec::new();
|
let mut frame = Vec::new();
|
||||||
frame.extend_from_slice(&4u32.to_be_bytes());
|
frame.extend_from_slice(&4u32.to_be_bytes());
|
||||||
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||||
mux.write_video(0, &frame).unwrap();
|
mux.write_video(0, true, &frame).unwrap();
|
||||||
mux.finish().unwrap();
|
mux.finish().unwrap();
|
||||||
drop(mux);
|
drop(mux);
|
||||||
|
|
||||||
@@ -619,7 +640,7 @@ mod tests {
|
|||||||
let mut frame = Vec::new();
|
let mut frame = Vec::new();
|
||||||
frame.extend_from_slice(&3u32.to_be_bytes());
|
frame.extend_from_slice(&3u32.to_be_bytes());
|
||||||
frame.extend_from_slice(&[0x40, 0x01, 0x0C]);
|
frame.extend_from_slice(&[0x40, 0x01, 0x0C]);
|
||||||
mux.write_video(0, &frame).unwrap();
|
mux.write_video(0, true, &frame).unwrap();
|
||||||
mux.write_audio(20_000_000, &[0x0B, 0x77, 0x12, 0x34])
|
mux.write_audio(20_000_000, &[0x0B, 0x77, 0x12, 0x34])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
mux.finish().unwrap();
|
mux.finish().unwrap();
|
||||||
@@ -641,7 +662,7 @@ mod tests {
|
|||||||
let mut frame = Vec::new();
|
let mut frame = Vec::new();
|
||||||
frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
|
frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
|
||||||
frame.extend_from_slice(&big);
|
frame.extend_from_slice(&big);
|
||||||
mux.write_video(0, &frame).unwrap();
|
mux.write_video(0, true, &frame).unwrap();
|
||||||
mux.finish().unwrap();
|
mux.finish().unwrap();
|
||||||
drop(mux);
|
drop(mux);
|
||||||
|
|
||||||
@@ -663,7 +684,7 @@ mod tests {
|
|||||||
let mut frame = Vec::new();
|
let mut frame = Vec::new();
|
||||||
frame.extend_from_slice(&3u32.to_be_bytes());
|
frame.extend_from_slice(&3u32.to_be_bytes());
|
||||||
frame.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
frame.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||||
mux.write_video(pts, &frame).unwrap();
|
mux.write_video(pts, pts == 0, &frame).unwrap();
|
||||||
}
|
}
|
||||||
mux.finish().unwrap();
|
mux.finish().unwrap();
|
||||||
drop(mux);
|
drop(mux);
|
||||||
@@ -678,4 +699,130 @@ mod tests {
|
|||||||
assert_eq!(w[1], (w[0] + 1) & 0x0F);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+495
-14
@@ -159,6 +159,12 @@ struct CuePoint {
|
|||||||
cluster_pos: u64, // relative to Segment start
|
cluster_pos: u64, // relative to Segment start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SeekHead entry that needs its 8-byte SeekPosition back-patched after Cues are written.
|
||||||
|
struct SeekPositionFixup {
|
||||||
|
target_id: u32,
|
||||||
|
value_offset: u64, // absolute file offset of the 8-byte SeekPosition value
|
||||||
|
}
|
||||||
|
|
||||||
/// MKV muxer. Call write_frame() for each frame, then finish() at the end.
|
/// MKV muxer. Call write_frame() for each frame, then finish() at the end.
|
||||||
pub struct MkvMuxer<W: Write + Seek> {
|
pub struct MkvMuxer<W: Write + Seek> {
|
||||||
writer: W,
|
writer: W,
|
||||||
@@ -170,6 +176,10 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
base_pts_ms: Option<i64>,
|
base_pts_ms: Option<i64>,
|
||||||
cues: Vec<CuePoint>,
|
cues: Vec<CuePoint>,
|
||||||
frame_count: u64,
|
frame_count: u64,
|
||||||
|
seek_fixups: Vec<SeekPositionFixup>,
|
||||||
|
info_offset: u64,
|
||||||
|
tracks_offset: u64,
|
||||||
|
chapters_offset: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// New cluster every 5 seconds.
|
/// New cluster every 5 seconds.
|
||||||
@@ -200,7 +210,34 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::write_unknown_size(&mut writer)?;
|
ebml::write_unknown_size(&mut writer)?;
|
||||||
let segment_start = writer.stream_position()?;
|
let segment_start = writer.stream_position()?;
|
||||||
|
|
||||||
|
// SeekHead with fixed-width SeekPosition placeholders. Order: Info, Tracks, [Chapters], Cues.
|
||||||
|
let mut seek_fixups: Vec<SeekPositionFixup> = Vec::new();
|
||||||
|
let seekhead_pos = ebml::start_master(&mut writer, ebml::SEEK_HEAD)?;
|
||||||
|
let mut targets: Vec<u32> = vec![ebml::INFO, ebml::TRACKS];
|
||||||
|
if !chapters.is_empty() {
|
||||||
|
targets.push(ebml::CHAPTERS);
|
||||||
|
}
|
||||||
|
targets.push(ebml::CUES);
|
||||||
|
let seek_id_be = (ebml::SEEK as u16).to_be_bytes();
|
||||||
|
let seek_inner_id_be = (ebml::SEEK_ID as u16).to_be_bytes();
|
||||||
|
let seek_pos_id_be = (ebml::SEEK_POSITION as u16).to_be_bytes();
|
||||||
|
for target_id in &targets {
|
||||||
|
writer.write_all(&[seek_id_be[0], seek_id_be[1], 0x92])?;
|
||||||
|
writer.write_all(&[seek_inner_id_be[0], seek_inner_id_be[1], 0x84])?;
|
||||||
|
writer.write_all(&target_id.to_be_bytes())?;
|
||||||
|
writer.write_all(&[seek_pos_id_be[0], seek_pos_id_be[1], 0x88])?;
|
||||||
|
let value_offset = writer.stream_position()?;
|
||||||
|
writer.write_all(&[0u8; 8])?;
|
||||||
|
seek_fixups.push(SeekPositionFixup {
|
||||||
|
target_id: *target_id,
|
||||||
|
value_offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ebml::end_master(&mut writer, seekhead_pos)?;
|
||||||
|
|
||||||
// Info
|
// Info
|
||||||
|
let info_start = writer.stream_position()?;
|
||||||
|
let info_offset = info_start - segment_start;
|
||||||
let info_pos = ebml::start_master(&mut writer, ebml::INFO)?;
|
let info_pos = ebml::start_master(&mut writer, ebml::INFO)?;
|
||||||
ebml::write_uint(&mut writer, ebml::TIMESTAMP_SCALE, 1_000_000)?; // 1ms precision
|
ebml::write_uint(&mut writer, ebml::TIMESTAMP_SCALE, 1_000_000)?; // 1ms precision
|
||||||
if duration_secs > 0.0 {
|
if duration_secs > 0.0 {
|
||||||
@@ -215,6 +252,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::end_master(&mut writer, info_pos)?;
|
ebml::end_master(&mut writer, info_pos)?;
|
||||||
|
|
||||||
// Tracks
|
// Tracks
|
||||||
|
let tracks_start = writer.stream_position()?;
|
||||||
|
let tracks_offset = tracks_start - segment_start;
|
||||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||||
for (i, track) in tracks.iter().enumerate() {
|
for (i, track) in tracks.iter().enumerate() {
|
||||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||||
@@ -302,7 +341,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::end_master(&mut writer, tracks_pos)?;
|
ebml::end_master(&mut writer, tracks_pos)?;
|
||||||
|
|
||||||
// Chapters
|
// Chapters
|
||||||
|
let mut chapters_offset: Option<u64> = None;
|
||||||
if !chapters.is_empty() {
|
if !chapters.is_empty() {
|
||||||
|
let chapters_start = writer.stream_position()?;
|
||||||
|
chapters_offset = Some(chapters_start - segment_start);
|
||||||
let chapters_pos = ebml::start_master(&mut writer, ebml::CHAPTERS)?;
|
let chapters_pos = ebml::start_master(&mut writer, ebml::CHAPTERS)?;
|
||||||
let edition_pos = ebml::start_master(&mut writer, ebml::EDITION_ENTRY)?;
|
let edition_pos = ebml::start_master(&mut writer, ebml::EDITION_ENTRY)?;
|
||||||
for (i, ch) in chapters.iter().enumerate() {
|
for (i, ch) in chapters.iter().enumerate() {
|
||||||
@@ -330,6 +372,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
base_pts_ms: None,
|
base_pts_ms: None,
|
||||||
cues: Vec::new(),
|
cues: Vec::new(),
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
|
seek_fixups,
|
||||||
|
info_offset,
|
||||||
|
tracks_offset,
|
||||||
|
chapters_offset,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,22 +391,22 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
let base = *self.base_pts_ms.get_or_insert(raw_ms);
|
let base = *self.base_pts_ms.get_or_insert(raw_ms);
|
||||||
let pts_ms = raw_ms - base;
|
let pts_ms = raw_ms - base;
|
||||||
|
|
||||||
// Start new cluster if needed
|
// Cluster boundaries must coincide with a video keyframe so every
|
||||||
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
|
// Cues entry resolves to a seekable IDR at the cluster start.
|
||||||
if self.cluster_open {
|
let is_video_key = keyframe && track_idx == 0;
|
||||||
// Close current cluster (it's a master with unknown size — we use known size)
|
let needs_new_cluster = !self.cluster_open
|
||||||
// Actually, for streaming we keep clusters open-ended. Just start a new one.
|
|| (is_video_key && (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS);
|
||||||
|
|
||||||
|
if needs_new_cluster {
|
||||||
|
if !is_video_key {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.start_cluster(pts_ms)?;
|
self.start_cluster(pts_ms)?;
|
||||||
|
self.cues.push(CuePoint {
|
||||||
// Add cue point at cluster start for keyframes (video track 0)
|
timestamp_ms: pts_ms,
|
||||||
if keyframe && track_idx == 0 {
|
track: track_idx + 1,
|
||||||
self.cues.push(CuePoint {
|
cluster_pos: self.cluster_pos - self.segment_start,
|
||||||
timestamp_ms: pts_ms,
|
});
|
||||||
track: track_idx + 1,
|
|
||||||
cluster_pos: self.cluster_pos - self.segment_start,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write SimpleBlock
|
// Write SimpleBlock
|
||||||
@@ -377,6 +423,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
self.end_cluster()?;
|
self.end_cluster()?;
|
||||||
|
|
||||||
// Write Cues
|
// Write Cues
|
||||||
|
let cues_start = self.writer.stream_position()?;
|
||||||
|
let cues_offset = cues_start - self.segment_start;
|
||||||
if !self.cues.is_empty() {
|
if !self.cues.is_empty() {
|
||||||
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
||||||
for cue in &self.cues {
|
for cue in &self.cues {
|
||||||
@@ -395,6 +443,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::end_master(&mut self.writer, cues_pos)?;
|
ebml::end_master(&mut self.writer, cues_pos)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Back-patch SeekHead SeekPosition values now that all element offsets are known.
|
||||||
|
for fixup in &self.seek_fixups {
|
||||||
|
let offset = match fixup.target_id {
|
||||||
|
ebml::INFO => self.info_offset,
|
||||||
|
ebml::TRACKS => self.tracks_offset,
|
||||||
|
ebml::CHAPTERS => self.chapters_offset.unwrap_or(0),
|
||||||
|
ebml::CUES => cues_offset,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
self.writer
|
||||||
|
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
||||||
|
self.writer.write_all(&offset.to_be_bytes())?;
|
||||||
|
}
|
||||||
|
self.writer.seek(std::io::SeekFrom::End(0))?;
|
||||||
|
|
||||||
self.writer.flush()?;
|
self.writer.flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -812,4 +875,422 @@ mod tests {
|
|||||||
"FlagForced element should not be present for non-forced subtitle"
|
"FlagForced element should not be present for non-forced subtitle"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Writer that lets the test inspect the buffer after `finish()` consumes the muxer.
|
||||||
|
struct SharedWriter(Arc<Mutex<Cursor<Vec<u8>>>>);
|
||||||
|
impl Write for SharedWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
self.0.lock().unwrap().write(buf)
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.0.lock().unwrap().flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Seek for SharedWriter {
|
||||||
|
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
|
||||||
|
self.0.lock().unwrap().seek(pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build interleaved frames at 24 fps video (IDR every gop_secs) + 48 kHz audio (1024 samples per frame).
|
||||||
|
fn frames_for(duration_secs: f64, gop_secs: f64) -> Vec<(usize, i64, bool, Vec<u8>)> {
|
||||||
|
let video_interval_ns: i64 = 1_000_000_000 / 24;
|
||||||
|
let audio_interval_ns: i64 = (1024i64 * 1_000_000_000) / 48_000;
|
||||||
|
let gop_frames = (gop_secs * 24.0).round() as i64;
|
||||||
|
|
||||||
|
let mut out: Vec<(usize, i64, bool, Vec<u8>)> = Vec::new();
|
||||||
|
let total_ns = (duration_secs * 1_000_000_000.0) as i64;
|
||||||
|
|
||||||
|
let mut vi: i64 = 0;
|
||||||
|
loop {
|
||||||
|
let pts = vi * video_interval_ns;
|
||||||
|
if pts >= total_ns {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let keyframe = vi % gop_frames == 0;
|
||||||
|
out.push((0, pts, keyframe, vec![0xAB; 64]));
|
||||||
|
vi += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ai: i64 = 0;
|
||||||
|
loop {
|
||||||
|
let pts = ai * audio_interval_ns;
|
||||||
|
if pts >= total_ns {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push((1, pts, true, vec![0xCD; 32]));
|
||||||
|
ai += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort_by_key(|f| f.1);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mux frames through a SharedWriter and return the final buffer.
|
||||||
|
fn mux_to_bytes(
|
||||||
|
tracks: &[MkvTrack],
|
||||||
|
chapters: &[Chapter],
|
||||||
|
frames: &[(usize, i64, bool, Vec<u8>)],
|
||||||
|
) -> (Vec<u8>, u64) {
|
||||||
|
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||||
|
let writer = SharedWriter(shared.clone());
|
||||||
|
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
|
||||||
|
for (t, pts, kf, data) in frames {
|
||||||
|
muxer.write_frame(*t, *pts, *kf, data).unwrap();
|
||||||
|
}
|
||||||
|
let frame_count = muxer.frame_count;
|
||||||
|
muxer.finish().unwrap();
|
||||||
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
|
(data, frame_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the Segment header in the buffer and return (segment_id_pos, segment_start_pos).
|
||||||
|
/// segment_start = position immediately after Segment's id + size bytes.
|
||||||
|
fn locate_segment(data: &[u8]) -> (usize, usize) {
|
||||||
|
let segment_id_pos = find_id(data, ebml::SEGMENT).expect("segment id not found");
|
||||||
|
// Segment is written via write_id + write_unknown_size: 4 byte id + 8 byte size
|
||||||
|
(segment_id_pos, segment_id_pos + 4 + 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk Segment's top-level children. Returns Vec<(id, data_start_offset, data_size)>
|
||||||
|
/// where data_start_offset is absolute file offset and data_size is the element body size.
|
||||||
|
fn segment_children(data: &[u8]) -> Vec<(u32, usize, u64)> {
|
||||||
|
let (_, seg_start) = locate_segment(data);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cursor = Cursor::new(&data[seg_start..]);
|
||||||
|
while (cursor.position() as usize) < data.len() - seg_start {
|
||||||
|
let pos_before = cursor.position();
|
||||||
|
let (id, size, hdr_len) = match ebml::read_element_header(&mut cursor) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let data_abs = seg_start + pos_before as usize + hdr_len;
|
||||||
|
out.push((id, data_abs, size));
|
||||||
|
// Skip the body to advance to the next element.
|
||||||
|
cursor
|
||||||
|
.seek(io::SeekFrom::Current(size as i64))
|
||||||
|
.expect("seek past element body");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find every Cluster: returns Vec<(cluster_data_start_abs, cluster_data_size, cluster_timestamp_ms)>.
|
||||||
|
fn find_clusters(data: &[u8]) -> Vec<(usize, u64, u64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (id, body_start, body_size) in segment_children(data) {
|
||||||
|
if id == ebml::CLUSTER {
|
||||||
|
let mut cursor = Cursor::new(&data[body_start..body_start + body_size as usize]);
|
||||||
|
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tid,
|
||||||
|
ebml::CLUSTER_TIMESTAMP,
|
||||||
|
"cluster must start with timestamp"
|
||||||
|
);
|
||||||
|
let ts = ebml::read_uint_val(&mut cursor, tsize as usize).unwrap();
|
||||||
|
out.push((body_start, body_size, ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the first SimpleBlock that appears in a cluster body slice.
|
||||||
|
/// Returns (track_num, flags_byte). track_num decoded from VINT.
|
||||||
|
fn first_simple_block(cluster_body: &[u8]) -> (u64, u8) {
|
||||||
|
let mut cursor = Cursor::new(cluster_body);
|
||||||
|
loop {
|
||||||
|
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
if id == ebml::SIMPLE_BLOCK {
|
||||||
|
let body_start = cursor.position() as usize;
|
||||||
|
// Decode track VINT.
|
||||||
|
let b0 = cluster_body[body_start];
|
||||||
|
let (track_num, vint_len) = if b0 & 0x80 != 0 {
|
||||||
|
((b0 & 0x7F) as u64, 1usize)
|
||||||
|
} else if b0 & 0x40 != 0 {
|
||||||
|
let b1 = cluster_body[body_start + 1];
|
||||||
|
((((b0 & 0x3F) as u64) << 8) | b1 as u64, 2)
|
||||||
|
} else {
|
||||||
|
panic!("unsupported track vint width");
|
||||||
|
};
|
||||||
|
let flags = cluster_body[body_start + vint_len + 2];
|
||||||
|
return (track_num, flags);
|
||||||
|
}
|
||||||
|
// Skip non-SimpleBlock child.
|
||||||
|
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the Cues element body into Vec<(cue_time, cue_track, cue_cluster_position)>.
|
||||||
|
fn parse_cues(data: &[u8]) -> Vec<(u64, u64, u64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let (cues_id, cues_body_start, cues_body_size) = segment_children(data)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(id, _, _)| *id == ebml::CUES)
|
||||||
|
.expect("cues element not found");
|
||||||
|
assert_eq!(cues_id, ebml::CUES);
|
||||||
|
let cues_body = &data[cues_body_start..cues_body_start + cues_body_size as usize];
|
||||||
|
let mut cursor = Cursor::new(cues_body);
|
||||||
|
while (cursor.position() as usize) < cues_body.len() {
|
||||||
|
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(id, ebml::CUE_POINT);
|
||||||
|
let cp_end = cursor.position() + size;
|
||||||
|
let mut cue_time = 0u64;
|
||||||
|
let mut cue_track = 0u64;
|
||||||
|
let mut cue_pos = 0u64;
|
||||||
|
while cursor.position() < cp_end {
|
||||||
|
let (sid, ssize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
match sid {
|
||||||
|
ebml::CUE_TIME => {
|
||||||
|
cue_time = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
|
||||||
|
}
|
||||||
|
ebml::CUE_TRACK_POSITIONS => {
|
||||||
|
let ctp_end = cursor.position() + ssize;
|
||||||
|
while cursor.position() < ctp_end {
|
||||||
|
let (iid, isize_, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
match iid {
|
||||||
|
ebml::CUE_TRACK => {
|
||||||
|
cue_track =
|
||||||
|
ebml::read_uint_val(&mut cursor, isize_ as usize).unwrap();
|
||||||
|
}
|
||||||
|
ebml::CUE_CLUSTER_POSITION => {
|
||||||
|
cue_pos =
|
||||||
|
ebml::read_uint_val(&mut cursor, isize_ as usize).unwrap();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
cursor.seek(io::SeekFrom::Current(isize_ as i64)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
cursor.seek(io::SeekFrom::Current(ssize as i64)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push((cue_time, cue_track, cue_pos));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the SeekHead body into Vec<(seek_id, seek_position)>.
|
||||||
|
fn parse_seekhead(data: &[u8]) -> Vec<(u32, u64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let (sh_id, sh_body_start, sh_body_size) = segment_children(data)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(id, _, _)| *id == ebml::SEEK_HEAD)
|
||||||
|
.expect("seekhead not found");
|
||||||
|
assert_eq!(sh_id, ebml::SEEK_HEAD);
|
||||||
|
let sh_body = &data[sh_body_start..sh_body_start + sh_body_size as usize];
|
||||||
|
let mut cursor = Cursor::new(sh_body);
|
||||||
|
while (cursor.position() as usize) < sh_body.len() {
|
||||||
|
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(id, ebml::SEEK);
|
||||||
|
let seek_end = cursor.position() + size;
|
||||||
|
let mut seek_id_val: u32 = 0;
|
||||||
|
let mut seek_pos_val: u64 = 0;
|
||||||
|
while cursor.position() < seek_end {
|
||||||
|
let (sid, ssize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
match sid {
|
||||||
|
ebml::SEEK_ID => {
|
||||||
|
let raw = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
|
||||||
|
seek_id_val = raw as u32;
|
||||||
|
}
|
||||||
|
ebml::SEEK_POSITION => {
|
||||||
|
seek_pos_val = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
cursor.seek(io::SeekFrom::Current(ssize as i64)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push((seek_id_val, seek_pos_val));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cluster_starts_only_on_video_keyframe() {
|
||||||
|
let tracks = [make_video_track(), make_audio_track()];
|
||||||
|
let frames = frames_for(30.0, 1.0);
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||||
|
let clusters = find_clusters(&data);
|
||||||
|
assert!(!clusters.is_empty(), "expected at least one cluster");
|
||||||
|
for (body_start, body_size, _ts) in clusters {
|
||||||
|
let body = &data[body_start..body_start + body_size as usize];
|
||||||
|
// Skip past the CLUSTER_TIMESTAMP element first.
|
||||||
|
let mut cursor = Cursor::new(body);
|
||||||
|
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
|
||||||
|
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
|
||||||
|
let after_ts = cursor.position() as usize;
|
||||||
|
let (track_num, flags) = first_simple_block(&body[after_ts..]);
|
||||||
|
assert_eq!(
|
||||||
|
track_num, 1,
|
||||||
|
"first block in cluster must be track 1 (video)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
flags & 0x80,
|
||||||
|
0x80,
|
||||||
|
"first block in cluster must have keyframe flag set, got 0x{:02X}",
|
||||||
|
flags
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cue_count_equals_cluster_count() {
|
||||||
|
let tracks = [make_video_track(), make_audio_track()];
|
||||||
|
let frames = frames_for(30.0, 1.0);
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||||
|
let clusters = find_clusters(&data);
|
||||||
|
let cues = parse_cues(&data);
|
||||||
|
assert_eq!(
|
||||||
|
clusters.len(),
|
||||||
|
cues.len(),
|
||||||
|
"cluster count {} != cue count {}",
|
||||||
|
clusters.len(),
|
||||||
|
cues.len()
|
||||||
|
);
|
||||||
|
// For 30s @ 5s min cluster duration with 1s GOP, expect 6 clusters / 6 cues.
|
||||||
|
assert_eq!(
|
||||||
|
clusters.len(),
|
||||||
|
6,
|
||||||
|
"expected 6 clusters for 30s @ 5s cluster duration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cue_positions_resolve_to_clusters() {
|
||||||
|
let tracks = [make_video_track(), make_audio_track()];
|
||||||
|
let frames = frames_for(30.0, 1.0);
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||||
|
let (_, seg_start) = locate_segment(&data);
|
||||||
|
let cues = parse_cues(&data);
|
||||||
|
assert!(!cues.is_empty());
|
||||||
|
for (_time, _track, pos) in cues {
|
||||||
|
let abs = seg_start + pos as usize;
|
||||||
|
let mut cursor = Cursor::new(&data[abs..]);
|
||||||
|
let (id, _size, _hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
id,
|
||||||
|
ebml::CLUSTER,
|
||||||
|
"cue position 0x{:X} did not resolve to a cluster",
|
||||||
|
pos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cue_times_match_cluster_timestamps() {
|
||||||
|
let tracks = [make_video_track(), make_audio_track()];
|
||||||
|
let frames = frames_for(30.0, 1.0);
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||||
|
let (_, seg_start) = locate_segment(&data);
|
||||||
|
let cues = parse_cues(&data);
|
||||||
|
for (time, _track, pos) in cues {
|
||||||
|
let abs = seg_start + pos as usize;
|
||||||
|
let mut cursor = Cursor::new(&data[abs..]);
|
||||||
|
let (id, size, _hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(id, ebml::CLUSTER);
|
||||||
|
let body_start = abs + (cursor.position() as usize);
|
||||||
|
let body = &data[body_start..body_start + size as usize];
|
||||||
|
let mut bc = Cursor::new(body);
|
||||||
|
let (tid, tsize, _) = ebml::read_element_header(&mut bc).unwrap();
|
||||||
|
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
|
||||||
|
let cluster_ts = ebml::read_uint_val(&mut bc, tsize as usize).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cluster_ts, time,
|
||||||
|
"cluster timestamp {} != cue time {}",
|
||||||
|
cluster_ts, time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seekhead_is_first_child_of_segment() {
|
||||||
|
let tracks = [make_video_track(), make_audio_track()];
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
|
||||||
|
let children = segment_children(&data);
|
||||||
|
assert!(!children.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
children[0].0,
|
||||||
|
ebml::SEEK_HEAD,
|
||||||
|
"first child of segment must be SeekHead, got id 0x{:X}",
|
||||||
|
children[0].0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seekhead_points_to_real_elements() {
|
||||||
|
let tracks = [make_video_track(), make_audio_track()];
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
|
||||||
|
let (_, seg_start) = locate_segment(&data);
|
||||||
|
let entries = parse_seekhead(&data);
|
||||||
|
let required = [ebml::INFO, ebml::TRACKS, ebml::CUES];
|
||||||
|
for &want_id in &required {
|
||||||
|
let entry = entries
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| *id == want_id)
|
||||||
|
.unwrap_or_else(|| panic!("seekhead missing entry for id 0x{:X}", want_id));
|
||||||
|
let abs = seg_start + entry.1 as usize;
|
||||||
|
let mut cursor = Cursor::new(&data[abs..]);
|
||||||
|
let (got_id, _, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
got_id, want_id,
|
||||||
|
"seekhead entry for 0x{:X} resolves to wrong id 0x{:X}",
|
||||||
|
want_id, got_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seekhead_omits_chapters_when_empty() {
|
||||||
|
let tracks = [make_video_track()];
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(5.0, 1.0));
|
||||||
|
let entries = parse_seekhead(&data);
|
||||||
|
assert_eq!(
|
||||||
|
entries.len(),
|
||||||
|
3,
|
||||||
|
"expected 3 seek entries (Info, Tracks, Cues), got {}",
|
||||||
|
entries.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entries.iter().all(|(id, _)| *id != ebml::CHAPTERS),
|
||||||
|
"seekhead should not contain Chapters entry when chapters are empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_first_keyframe_frames_dropped() {
|
||||||
|
let tracks = [make_video_track()];
|
||||||
|
let frames = vec![
|
||||||
|
(0usize, 0i64, false, vec![0x11; 16]),
|
||||||
|
(0usize, 41_000_000i64, true, vec![0x22; 16]),
|
||||||
|
];
|
||||||
|
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
|
||||||
|
assert_eq!(frame_count, 1, "muxer.frame_count must equal 1");
|
||||||
|
let clusters = find_clusters(&data);
|
||||||
|
assert_eq!(clusters.len(), 1, "expected exactly one cluster");
|
||||||
|
let (body_start, body_size, _ts) = clusters[0];
|
||||||
|
let body = &data[body_start..body_start + body_size as usize];
|
||||||
|
let mut cursor = Cursor::new(body);
|
||||||
|
// Skip CLUSTER_TIMESTAMP.
|
||||||
|
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
|
||||||
|
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
|
||||||
|
let mut sb_count = 0;
|
||||||
|
while (cursor.position() as usize) < body.len() {
|
||||||
|
let (id, sz, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||||
|
if id == ebml::SIMPLE_BLOCK {
|
||||||
|
sb_count += 1;
|
||||||
|
}
|
||||||
|
cursor.seek(io::SeekFrom::Current(sz as i64)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(sb_count, 1, "expected exactly one SimpleBlock in output");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+272
-35
@@ -42,28 +42,39 @@ impl<W: Write> TsMuxer<W> {
|
|||||||
/// Write a PES frame as BD-TS packets.
|
/// Write a PES frame as BD-TS packets.
|
||||||
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
|
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
|
||||||
/// and is converted to Annex B for transport stream.
|
/// and is converted to Annex B for transport stream.
|
||||||
pub fn write_frame(&mut self, track: usize, pts_ns: i64, data: &[u8]) -> io::Result<()> {
|
pub fn write_frame(
|
||||||
|
&mut self,
|
||||||
|
track: usize,
|
||||||
|
pts_ns: i64,
|
||||||
|
keyframe: bool,
|
||||||
|
data: &[u8],
|
||||||
|
) -> io::Result<()> {
|
||||||
if track >= self.pids.len() {
|
if track >= self.pids.len() {
|
||||||
return Ok(()); // unknown track, skip
|
return Ok(()); // unknown track, skip
|
||||||
}
|
}
|
||||||
let base = *self.base_pts_ns.get_or_insert(pts_ns);
|
|
||||||
let pts_ns = pts_ns - base;
|
|
||||||
|
|
||||||
let pid = self.pids[track];
|
let pid = self.pids[track];
|
||||||
let is_video = (0x1011..=0x101F).contains(&pid);
|
let is_video = (0x1011..=0x101F).contains(&pid);
|
||||||
|
|
||||||
// For video: convert length-prefixed NALUs to Annex B (start codes)
|
// Drop non-key video before any keyframe — decoder has no IDR or
|
||||||
// On first keyframe, prepend parameter sets from codec_private
|
// parameter sets to anchor on.
|
||||||
|
if is_video && !keyframe && !self.params_written[track] {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = *self.base_pts_ns.get_or_insert(pts_ns);
|
||||||
|
let pts_ns = pts_ns - base;
|
||||||
|
|
||||||
|
// For video: convert length-prefixed NALUs to Annex B (start codes).
|
||||||
|
// Prepend codec_private parameter sets on the FIRST keyframe only.
|
||||||
let es_data = if is_video && !data.is_empty() {
|
let es_data = if is_video && !data.is_empty() {
|
||||||
let mut annex_b = Vec::new();
|
let mut annex_b = Vec::new();
|
||||||
// Prepend codec_private parameter sets on first keyframe
|
if keyframe && !self.params_written[track] {
|
||||||
if !self.params_written[track] {
|
|
||||||
if let Some(ref cp) = self.codec_privates[track] {
|
if let Some(ref cp) = self.codec_privates[track] {
|
||||||
if let Some(params) = hvcc_to_annex_b(cp) {
|
if let Some(params) = hvcc_to_annex_b(cp) {
|
||||||
annex_b.extend_from_slice(¶ms);
|
annex_b.extend_from_slice(¶ms);
|
||||||
self.params_written[track] = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.params_written[track] = true;
|
||||||
}
|
}
|
||||||
annex_b.extend_from_slice(&length_prefixed_to_annex_b(data));
|
annex_b.extend_from_slice(&length_prefixed_to_annex_b(data));
|
||||||
annex_b
|
annex_b
|
||||||
@@ -85,8 +96,24 @@ impl<W: Write> TsMuxer<W> {
|
|||||||
let mut first = true;
|
let mut first = true;
|
||||||
while offset < pes_packet.len() {
|
while offset < pes_packet.len() {
|
||||||
let remaining = pes_packet.len() - offset;
|
let remaining = pes_packet.len() - offset;
|
||||||
let payload_len = remaining.min(TS_PAYLOAD);
|
|
||||||
let need_stuffing = payload_len < TS_PAYLOAD;
|
// Invariant: TP_extra(4) + TS_header(4) + AF(af_bytes) + payload(payload_len) = 192,
|
||||||
|
// i.e. af_bytes + payload_len = TS_PAYLOAD (184).
|
||||||
|
// RAI on first packet of a keyframe video PES requires AF with flags=0x40.
|
||||||
|
let want_rai = first && keyframe && is_video;
|
||||||
|
|
||||||
|
// Pick payload_len and af_bytes per case.
|
||||||
|
let (af_bytes, payload_len): (usize, usize) = if want_rai {
|
||||||
|
// Minimum AF = 2 bytes (length=1, flags=0x40). Payload caps at 182.
|
||||||
|
let max_payload = TS_PAYLOAD - 2;
|
||||||
|
let p = remaining.min(max_payload);
|
||||||
|
(TS_PAYLOAD - p, p)
|
||||||
|
} else if remaining >= TS_PAYLOAD {
|
||||||
|
(0, TS_PAYLOAD) // no AF, full payload
|
||||||
|
} else {
|
||||||
|
// Stuffing-only AF, payload = remaining.
|
||||||
|
(TS_PAYLOAD - remaining, remaining)
|
||||||
|
};
|
||||||
|
|
||||||
// TP_extra_header (4 bytes — arrival time, set to 0)
|
// TP_extra_header (4 bytes — arrival time, set to 0)
|
||||||
let tp_extra = [0u8; 4];
|
let tp_extra = [0u8; 4];
|
||||||
@@ -102,38 +129,45 @@ impl<W: Write> TsMuxer<W> {
|
|||||||
ts_header[1] |= 0x40; // PUSI
|
ts_header[1] |= 0x40; // PUSI
|
||||||
}
|
}
|
||||||
ts_header[2] = pid as u8;
|
ts_header[2] = pid as u8;
|
||||||
ts_header[3] = 0x10 | cc; // no adaptation, has payload
|
ts_header[3] = if af_bytes > 0 {
|
||||||
|
0x30 | cc // AF + payload
|
||||||
|
} else {
|
||||||
|
0x10 | cc // payload only
|
||||||
|
};
|
||||||
|
|
||||||
if need_stuffing {
|
self.writer.write_all(&tp_extra)?;
|
||||||
// Adaptation field for stuffing
|
self.writer.write_all(&ts_header)?;
|
||||||
let stuff_len = TS_PAYLOAD - payload_len;
|
|
||||||
ts_header[3] = 0x30 | cc; // adaptation + payload
|
|
||||||
|
|
||||||
self.writer.write_all(&tp_extra)?;
|
if af_bytes > 0 {
|
||||||
self.writer.write_all(&ts_header)?;
|
|
||||||
|
|
||||||
// Write adaptation field: length byte + flags byte + 0xFF padding
|
|
||||||
// stuff_len == 1: AF length = 0 (just the length byte, no flags)
|
|
||||||
// stuff_len >= 2: AF length = stuff_len-1, flags = 0, rest 0xFF
|
|
||||||
static STUFF_FF: [u8; 184] = [0xFF; 184];
|
static STUFF_FF: [u8; 184] = [0xFF; 184];
|
||||||
if stuff_len == 1 {
|
if want_rai {
|
||||||
self.writer.write_all(&[0u8])?; // adaptation_field_length = 0
|
// RAI AF: length byte + flags(0x40) + (af_bytes - 2) stuffing.
|
||||||
|
let af_len_field = (af_bytes - 1) as u8;
|
||||||
|
self.writer.write_all(&[af_len_field])?;
|
||||||
|
self.writer.write_all(&[0x40u8])?;
|
||||||
|
let stuff = af_bytes - 2;
|
||||||
|
if stuff > 0 {
|
||||||
|
self.writer.write_all(&STUFF_FF[..stuff])?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.writer.write_all(&[(stuff_len - 1) as u8])?; // AF length
|
// Stuffing-only AF.
|
||||||
self.writer.write_all(&[0u8])?; // flags
|
// af_bytes == 1: length=0, no flags.
|
||||||
if stuff_len > 2 {
|
// af_bytes >= 2: length = af_bytes-1, flags=0, rest 0xFF.
|
||||||
self.writer.write_all(&STUFF_FF[..stuff_len - 2])?;
|
if af_bytes == 1 {
|
||||||
|
self.writer.write_all(&[0u8])?;
|
||||||
|
} else {
|
||||||
|
self.writer.write_all(&[(af_bytes - 1) as u8])?;
|
||||||
|
self.writer.write_all(&[0u8])?;
|
||||||
|
if af_bytes > 2 {
|
||||||
|
self.writer.write_all(&STUFF_FF[..af_bytes - 2])?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.writer
|
|
||||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
|
||||||
} else {
|
|
||||||
self.writer.write_all(&tp_extra)?;
|
|
||||||
self.writer.write_all(&ts_header)?;
|
|
||||||
self.writer
|
|
||||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.writer
|
||||||
|
.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||||
|
|
||||||
offset += payload_len;
|
offset += payload_len;
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
@@ -257,3 +291,206 @@ fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const BD_PACKET_SIZE: usize = 192;
|
||||||
|
const VIDEO_PID: u16 = 0x1011;
|
||||||
|
|
||||||
|
/// Parsed BD-TS packet (192 bytes total: 4 TP_extra + 4 TS header + 184 body).
|
||||||
|
struct TsPacket {
|
||||||
|
pid: u16,
|
||||||
|
pusi: bool,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
cc: u8,
|
||||||
|
/// Adaptation field body (length byte stripped) when present.
|
||||||
|
af: Option<Vec<u8>>,
|
||||||
|
/// Payload bytes (after AF, if any).
|
||||||
|
payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk 192-byte BD-TS packets.
|
||||||
|
fn parse_bd_ts(buf: &[u8]) -> Vec<TsPacket> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for chunk in buf.chunks(BD_PACKET_SIZE) {
|
||||||
|
if chunk.len() != BD_PACKET_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Skip TP_extra_header (4 bytes), parse TS header.
|
||||||
|
let h = &chunk[4..];
|
||||||
|
assert_eq!(h[0], 0x47, "bad sync byte");
|
||||||
|
let pusi = (h[1] & 0x40) != 0;
|
||||||
|
let pid = (((h[1] & 0x1F) as u16) << 8) | h[2] as u16;
|
||||||
|
let afc = (h[3] >> 4) & 0x03;
|
||||||
|
let cc = h[3] & 0x0F;
|
||||||
|
let body = &h[4..]; // 184 bytes
|
||||||
|
|
||||||
|
let (af, payload) = match afc {
|
||||||
|
0b01 => (None, body.to_vec()),
|
||||||
|
0b11 => {
|
||||||
|
let af_len = body[0] as usize;
|
||||||
|
let af_body = body[1..1 + af_len].to_vec();
|
||||||
|
let payload = body[1 + af_len..].to_vec();
|
||||||
|
(Some(af_body), payload)
|
||||||
|
}
|
||||||
|
0b10 => {
|
||||||
|
let af_len = body[0] as usize;
|
||||||
|
(Some(body[1..1 + af_len].to_vec()), Vec::new())
|
||||||
|
}
|
||||||
|
_ => (None, Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
out.push(TsPacket {
|
||||||
|
pid,
|
||||||
|
pusi,
|
||||||
|
cc,
|
||||||
|
af,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a fake HEVC NAL with a 4-byte length prefix.
|
||||||
|
/// nal_type=19/20 are IDR; 1 is non-key (TRAIL_N/R).
|
||||||
|
fn fake_hevc_nal(nal_type: u8, body_len: usize) -> Vec<u8> {
|
||||||
|
let mut nal = Vec::with_capacity(2 + body_len);
|
||||||
|
// 2-byte NAL header: forbidden_zero(1)=0 | nal_unit_type(6) | layer_id(6)=0 | tid_plus1(3)=1
|
||||||
|
nal.push((nal_type & 0x3F) << 1);
|
||||||
|
nal.push(0x01);
|
||||||
|
for i in 0..body_len {
|
||||||
|
nal.push((i & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
let mut framed = Vec::with_capacity(4 + nal.len());
|
||||||
|
framed.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||||
|
framed.extend_from_slice(&nal);
|
||||||
|
framed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyframe_param_threads_through() {
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||||
|
let idr = fake_hevc_nal(19, 100);
|
||||||
|
mux.write_frame(0, 0, true, &idr).unwrap();
|
||||||
|
let p = fake_hevc_nal(1, 80);
|
||||||
|
mux.write_frame(0, 41_000_000, false, &p).unwrap();
|
||||||
|
mux.finish().unwrap();
|
||||||
|
}
|
||||||
|
assert!(!sink.is_empty());
|
||||||
|
let packets = parse_bd_ts(&sink);
|
||||||
|
assert!(packets.iter().any(|p| p.pid == VIDEO_PID && p.pusi));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rai_set_on_first_packet_of_keyframe_pes() {
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||||
|
let idr = fake_hevc_nal(19, 200);
|
||||||
|
mux.write_frame(0, 0, true, &idr).unwrap();
|
||||||
|
mux.finish().unwrap();
|
||||||
|
}
|
||||||
|
let packets = parse_bd_ts(&sink);
|
||||||
|
let first_pusi = packets
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.pid == VIDEO_PID && p.pusi)
|
||||||
|
.expect("video PUSI packet exists");
|
||||||
|
let af = first_pusi.af.as_ref().expect("AF present on keyframe PES");
|
||||||
|
assert!(!af.is_empty(), "AF body has flags byte");
|
||||||
|
assert_eq!(af[0] & 0x40, 0x40, "RAI bit set");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rai_clear_on_non_keyframe_pes() {
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||||
|
let idr = fake_hevc_nal(19, 100);
|
||||||
|
mux.write_frame(0, 0, true, &idr).unwrap();
|
||||||
|
let p = fake_hevc_nal(1, 100);
|
||||||
|
mux.write_frame(0, 41_000_000, false, &p).unwrap();
|
||||||
|
mux.finish().unwrap();
|
||||||
|
}
|
||||||
|
let packets = parse_bd_ts(&sink);
|
||||||
|
// Second PUSI packet on the video PID belongs to the non-key frame.
|
||||||
|
let pusi_video: Vec<&TsPacket> = packets
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.pid == VIDEO_PID && p.pusi)
|
||||||
|
.collect();
|
||||||
|
assert!(pusi_video.len() >= 2, "two PUSI packets expected");
|
||||||
|
let second = pusi_video[1];
|
||||||
|
match &second.af {
|
||||||
|
None => {}
|
||||||
|
Some(af) if af.is_empty() => {} // length=0 case
|
||||||
|
Some(af) => assert_eq!(af[0] & 0x40, 0, "RAI must be clear on non-key PES"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_private_prepended_only_on_first_keyframe() {
|
||||||
|
// Build a minimal hvcC with one recognizable NAL.
|
||||||
|
let marker: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE];
|
||||||
|
let mut hvcc = vec![0u8; 22];
|
||||||
|
hvcc.push(1); // numArrays
|
||||||
|
hvcc.push(32); // VPS NAL type byte (high bits arbitrary)
|
||||||
|
hvcc.extend_from_slice(&1u16.to_be_bytes()); // numNalus
|
||||||
|
hvcc.extend_from_slice(&(marker.len() as u16).to_be_bytes());
|
||||||
|
hvcc.extend_from_slice(marker);
|
||||||
|
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||||
|
mux.set_codec_private(0, hvcc);
|
||||||
|
// Non-IDR before any IDR: should be dropped.
|
||||||
|
let p = fake_hevc_nal(1, 50);
|
||||||
|
mux.write_frame(0, 0, false, &p).unwrap();
|
||||||
|
// IDR: should carry codec_private NALs prepended.
|
||||||
|
let idr = fake_hevc_nal(19, 50);
|
||||||
|
mux.write_frame(0, 41_000_000, true, &idr).unwrap();
|
||||||
|
mux.finish().unwrap();
|
||||||
|
}
|
||||||
|
let packets = parse_bd_ts(&sink);
|
||||||
|
// Concatenate all video PID payloads in emission order.
|
||||||
|
let video_bytes: Vec<u8> = packets
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.pid == VIDEO_PID)
|
||||||
|
.flat_map(|p| p.payload.clone())
|
||||||
|
.collect();
|
||||||
|
// marker bytes must appear in the stream (codec_private was prepended).
|
||||||
|
let pos_marker = video_bytes
|
||||||
|
.windows(marker.len())
|
||||||
|
.position(|w| w == marker)
|
||||||
|
.expect("codec_private marker bytes present in TS payload");
|
||||||
|
// Find IDR body byte (0x26 = (19<<1)). pos_idr must be AFTER marker.
|
||||||
|
let idr_header = (19u8 << 1) & 0x7E;
|
||||||
|
let pos_idr = video_bytes
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == idr_header)
|
||||||
|
.expect("IDR NAL header present in TS payload");
|
||||||
|
assert!(
|
||||||
|
pos_marker < pos_idr,
|
||||||
|
"codec_private must precede IDR in TS payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_key_before_first_keyframe_dropped() {
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||||
|
let p = fake_hevc_nal(1, 80);
|
||||||
|
mux.write_frame(0, 0, false, &p).unwrap();
|
||||||
|
mux.finish().unwrap();
|
||||||
|
}
|
||||||
|
// Nothing should be emitted for that PID.
|
||||||
|
let packets = parse_bd_ts(&sink);
|
||||||
|
assert!(
|
||||||
|
!packets.iter().any(|p| p.pid == VIDEO_PID),
|
||||||
|
"non-key before first keyframe must be dropped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user