use std::collections::BTreeMap;
use super::{ascii, byte, u16_be, u32_off};
use crate::error::BdError;
use crate::primitives::Pid;
use crate::stream::{
TsAspectRatio, TsAudioStream, TsChannelLayout, TsFrameRate, TsGraphicsStream, TsSampleRate,
TsStream, TsStreamType, TsTextStream, TsVideoFormat, TsVideoStream,
};
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TsStreamClip {
pub angle_index: i32,
pub name: String,
pub time_in: f64,
pub time_out: f64,
pub relative_time_in: f64,
pub relative_time_out: f64,
pub length: f64,
pub relative_length: f64,
pub file_size: u64,
pub interleaved_file_size: u64,
pub payload_bytes: u64,
pub packet_count: u64,
pub packet_seconds: f64,
pub chapters: Vec<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TsStreamClipFile {
pub file_type: String,
pub is_valid: bool,
pub name: String,
pub streams: BTreeMap<u16, TsStream>,
}
impl TsStreamClipFile {
pub fn scan(name: &str, data: &[u8]) -> Result<Self, BdError> {
let file_type = ascii(data, 0, 8)?;
if !matches!(file_type.as_str(), "HDMV0100" | "HDMV0200" | "HDMV0240" | "HDMV0300") {
return Err(BdError::UnknownFileType(file_type));
}
let clip_index = u32_off(data, 12)?;
let clip_length = u32_off(data, clip_index)?;
let clip_start = clip_index.saturating_add(4);
let clip_end = clip_start.saturating_add(clip_length);
let clip_data = data.get(clip_start..clip_end).ok_or(BdError::UnexpectedEof)?;
let num_prog = byte(clip_data, 1)?;
let mut streams = BTreeMap::new();
let mut prog_offset: usize = 2;
for _ in 0..num_prog {
let stream_count = byte(clip_data, prog_offset.saturating_add(6))?;
let mut stream_offset = prog_offset.saturating_add(8);
for _ in 0..stream_count {
let pid = u16_be(clip_data, stream_offset)?;
let info = stream_offset.saturating_add(2);
let len = byte(clip_data, info)?;
let type_byte = byte(clip_data, info.saturating_add(1))?;
let stream_type = TsStreamType::from_u8(type_byte);
if let Some(mut stream) = build_clip_stream(clip_data, info, stream_type)? {
stream.base_mut().pid = Pid::new(pid);
stream.base_mut().stream_type = stream_type;
streams.insert(pid, stream);
}
stream_offset = info.saturating_add(usize::from(len).wrapping_add(1));
}
prog_offset = stream_offset;
}
Ok(Self { file_type, is_valid: true, name: name.to_ascii_uppercase(), streams })
}
}
fn build_clip_stream(
clip_data: &[u8],
info: usize,
stream_type: TsStreamType,
) -> Result<Option<TsStream>, BdError> {
Ok(match stream_type {
#[expect(
clippy::match_same_arms,
reason = "MVC is a recognised coding type, deliberately not the Unknown default"
)]
TsStreamType::MvcVideo => None,
TsStreamType::HevcVideo
| TsStreamType::AvcVideo
| TsStreamType::Mpeg1Video
| TsStreamType::Mpeg2Video
| TsStreamType::Vc1Video => {
let format = byte(clip_data, info.saturating_add(2))?;
let aspect = byte(clip_data, info.saturating_add(3))?;
let mut stream = TsVideoStream::default();
stream.set_video_format(TsVideoFormat::from_u8(format.wrapping_shr(4)));
stream.set_frame_rate(TsFrameRate::from_u8(format & 0x0F));
stream.aspect_ratio = TsAspectRatio::from_u8(aspect.wrapping_shr(4));
Some(TsStream::Video(stream))
}
TsStreamType::Ac3Audio
| TsStreamType::Ac3PlusAudio
| TsStreamType::Ac3PlusSecondaryAudio
| TsStreamType::Ac3TrueHdAudio
| TsStreamType::DtsAudio
| TsStreamType::DtsHdAudio
| TsStreamType::DtsHdMasterAudio
| TsStreamType::DtsHdSecondaryAudio
| TsStreamType::LpcmAudio
| TsStreamType::Mpeg1Audio
| TsStreamType::Mpeg2Audio
| TsStreamType::Mpeg2AacAudio
| TsStreamType::Mpeg4AacAudio => {
let format = byte(clip_data, info.saturating_add(2))?;
let language = ascii(clip_data, info.saturating_add(3), 3)?;
let mut stream = TsAudioStream {
channel_layout: TsChannelLayout::from_u8(format.wrapping_shr(4)),
sample_rate: TsAudioStream::convert_sample_rate(TsSampleRate::from_u8(
format & 0x0F,
)),
..TsAudioStream::default()
};
stream.base.set_language_code(&language);
Some(TsStream::Audio(stream))
}
TsStreamType::InteractiveGraphics | TsStreamType::PresentationGraphics => {
let language = ascii(clip_data, info.saturating_add(2), 3)?;
let mut stream = TsGraphicsStream::default();
stream.base.set_language_code(&language);
Some(TsStream::Graphics(stream))
}
TsStreamType::Subtitle => {
let language = ascii(clip_data, info.saturating_add(3), 3)?;
let mut stream = TsTextStream::default();
stream.base.set_language_code(&language);
Some(TsStream::Text(stream))
}
TsStreamType::Unknown => None,
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use proptest::prelude::{any, proptest};
use super::{TsStreamClip, TsStreamClipFile};
use crate::primitives::Pid;
use crate::stream::{
TsAspectRatio, TsAudioStream, TsChannelLayout, TsFrameRate, TsGraphicsStream, TsStream,
TsStreamType, TsTextStream, TsVideoFormat, TsVideoStream,
};
fn program_header(stream_count: usize) -> Vec<u8> {
let mut header = 0x0102_0304_u32.to_be_bytes().to_vec(); header.extend_from_slice(&0x0100_u16.to_be_bytes()); header.push(u8::try_from(stream_count).unwrap());
header.push(1); header
}
fn entry_bytes(pid: u16, coding_type: u8, payload: [u8; 4]) -> Vec<u8> {
let mut entry = pid.to_be_bytes().to_vec();
entry.push(5); entry.push(coding_type);
entry.extend_from_slice(&payload);
entry
}
fn build_clpi(magic: [u8; 8], entries: &[(u16, u8, [u8; 4])]) -> Vec<u8> {
let mut clip_data = vec![0_u8]; clip_data.push(1); clip_data.extend(program_header(entries.len())); for &(pid, coding_type, payload) in entries {
clip_data.extend(entry_bytes(pid, coding_type, payload));
}
let mut buf = Vec::new();
buf.extend_from_slice(&magic); buf.extend_from_slice(&[0_u8; 4]); buf.extend_from_slice(&16_u32.to_be_bytes()); buf.extend_from_slice(&u32::try_from(clip_data.len()).unwrap().to_be_bytes()); buf.extend_from_slice(&clip_data); buf
}
#[test]
fn scan_parses_every_stream_kind() {
let entries = [
(0x1011_u16, 0x1B_u8, [0x62, 0x30, 0x00, 0x00]), (0x1100, 0x86, [0x61, b'd', b'e', b'u']), (0x1200, 0x90, [b'e', b'n', b'g', 0x00]), (0x1A00, 0x92, [0x00, b'f', b'r', b'a']), (0x1B00, 0x20, [0x00, 0x00, 0x00, 0x00]), (0x1C00, 0x99, [0x00, 0x00, 0x00, 0x00]), ];
let buf = build_clpi(*b"HDMV0300", &entries);
let file = TsStreamClipFile::scan("00000.clpi", &buf).unwrap();
assert_eq!(file.file_type, "HDMV0300");
assert!(file.is_valid);
assert_eq!(file.name, "00000.CLPI");
let mut expected: BTreeMap<u16, TsStream> = BTreeMap::new();
let mut video = TsVideoStream::default();
video.set_video_format(TsVideoFormat::Videoformat1080p); video.set_frame_rate(TsFrameRate::Framerate24); video.aspect_ratio = TsAspectRatio::Aspect16_9; video.base.pid = Pid::new(0x1011);
video.base.stream_type = TsStreamType::AvcVideo;
expected.insert(0x1011, TsStream::Video(video));
let mut audio = TsAudioStream {
channel_layout: TsChannelLayout::ChannellayoutMulti, sample_rate: 48_000, ..TsAudioStream::default()
};
audio.base.set_language_code("deu");
audio.base.pid = Pid::new(0x1100);
audio.base.stream_type = TsStreamType::DtsHdMasterAudio;
expected.insert(0x1100, TsStream::Audio(audio));
let mut graphics = TsGraphicsStream::default();
graphics.base.set_language_code("eng");
graphics.base.pid = Pid::new(0x1200);
graphics.base.stream_type = TsStreamType::PresentationGraphics;
expected.insert(0x1200, TsStream::Graphics(graphics));
let mut text = TsTextStream::default();
text.base.set_language_code("fra");
text.base.pid = Pid::new(0x1A00);
text.base.stream_type = TsStreamType::Subtitle;
expected.insert(0x1A00, TsStream::Text(text));
assert_eq!(file.streams, expected);
}
#[test]
fn scan_accepts_all_four_magics() {
for magic in [*b"HDMV0100", *b"HDMV0200", *b"HDMV0240", *b"HDMV0300"] {
let buf = build_clpi(magic, &[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
let file = TsStreamClipFile::scan("x.clpi", &buf).unwrap();
assert_eq!(file.streams.len(), 1);
}
}
#[test]
fn scan_walks_every_program_sequence() {
let mut clip_data = vec![0_u8, 2]; clip_data.extend(program_header(1));
clip_data.extend(entry_bytes(0x1011, 0x1B, [0x62, 0x30, 0, 0]));
clip_data.extend(program_header(1));
clip_data.extend(entry_bytes(0x1100, 0x86, [0x61, b'd', b'e', b'u']));
let file = TsStreamClipFile::scan("p.clpi", &wrap_clip_data(&clip_data)).unwrap();
let pids: Vec<u16> = file.streams.keys().copied().collect();
assert_eq!(pids, vec![0x1011, 0x1100]);
assert_eq!(
file.streams.get(&0x1100).map(TsStream::stream_type),
Some(TsStreamType::DtsHdMasterAudio)
);
}
#[test]
fn a_pid_redeclared_by_a_later_program_overwrites_the_earlier_entry() {
let mut clip_data = vec![0_u8, 2];
clip_data.extend(program_header(1));
clip_data.extend(entry_bytes(0x1011, 0x1B, [0x62, 0x30, 0, 0])); clip_data.extend(program_header(1));
clip_data.extend(entry_bytes(0x1011, 0x24, [0x62, 0x30, 0, 0])); let file = TsStreamClipFile::scan("p.clpi", &wrap_clip_data(&clip_data)).unwrap();
assert_eq!(file.streams.len(), 1);
assert_eq!(
file.streams.get(&0x1011).map(TsStream::stream_type),
Some(TsStreamType::HevcVideo)
);
}
#[test]
fn a_clip_with_zero_program_sequences_has_no_streams() {
let mut clip_data = vec![0_u8, 0]; clip_data.extend_from_slice(&[0_u8; 6]);
clip_data.push(1); clip_data.push(0);
clip_data.extend(entry_bytes(0x1011, 0x1B, [0x62, 0x30, 0, 0]));
let file = TsStreamClipFile::scan("p.clpi", &wrap_clip_data(&clip_data)).unwrap();
assert!(file.streams.is_empty());
}
#[test]
fn scan_rejects_unknown_file_type() {
let buf = build_clpi(*b"XXXX0100", &[]);
assert_eq!(
TsStreamClipFile::scan("bad.clpi", &buf).unwrap_err().to_string(),
"unknown file type: XXXX0100"
);
}
#[test]
fn scan_rejects_truncated_inputs() {
let eof = "unexpected end of input";
assert_eq!(TsStreamClipFile::scan("e", &[]).unwrap_err().to_string(), eof);
assert_eq!(TsStreamClipFile::scan("e", b"HDMV0100").unwrap_err().to_string(), eof);
let mut buf = b"HDMV0100".to_vec();
buf.extend_from_slice(&[0_u8; 4]); buf.extend_from_slice(&100_u32.to_be_bytes()); assert_eq!(TsStreamClipFile::scan("e", &buf).unwrap_err().to_string(), eof);
let mut buf = b"HDMV0100".to_vec();
buf.extend_from_slice(&[0_u8; 4]);
buf.extend_from_slice(&16_u32.to_be_bytes()); buf.extend_from_slice(&100_u32.to_be_bytes()); assert_eq!(TsStreamClipFile::scan("e", &buf).unwrap_err().to_string(), eof);
let mut buf = b"HDMV0100".to_vec();
buf.extend_from_slice(&[0_u8; 4]);
buf.extend_from_slice(&16_u32.to_be_bytes());
buf.extend_from_slice(&1_u32.to_be_bytes()); buf.push(0); assert_eq!(TsStreamClipFile::scan("e", &buf).unwrap_err().to_string(), eof);
let mut buf = b"HDMV0100".to_vec();
buf.extend_from_slice(&[0_u8; 4]);
buf.extend_from_slice(&16_u32.to_be_bytes());
buf.extend_from_slice(&5_u32.to_be_bytes()); buf.extend_from_slice(&[0, 1, 0, 0, 0]); assert_eq!(TsStreamClipFile::scan("e", &buf).unwrap_err().to_string(), eof);
let mut buf = b"HDMV0100".to_vec();
buf.extend_from_slice(&[0_u8; 4]);
buf.extend_from_slice(&16_u32.to_be_bytes());
buf.extend_from_slice(&10_u32.to_be_bytes()); let mut header = vec![0_u8; 10];
header.splice(1..2, [1_u8]); header.splice(8..9, [1_u8]); buf.extend_from_slice(&header);
assert_eq!(TsStreamClipFile::scan("e", &buf).unwrap_err().to_string(), eof);
}
fn wrap_clip_data(clip_data: &[u8]) -> Vec<u8> {
let clip_index: u32 = 16;
let mut buf = Vec::new();
buf.extend_from_slice(b"HDMV0300");
buf.extend_from_slice(&[0_u8; 4]);
buf.extend_from_slice(&clip_index.to_be_bytes());
buf.extend_from_slice(&u32::try_from(clip_data.len()).unwrap().to_be_bytes());
buf.extend_from_slice(clip_data);
buf
}
fn one_entry_truncated(coding_type: u8, clip_data_len: usize) -> Vec<u8> {
let mut full = vec![0_u8; 10];
full.splice(1..2, [1_u8]); full.splice(8..9, [1_u8]); full.extend_from_slice(&0x1011_u16.to_be_bytes()); full.push(5); full.push(coding_type);
full.extend_from_slice(&[0_u8; 8]); full.truncate(clip_data_len);
wrap_clip_data(&full)
}
#[test]
fn scan_rejects_truncation_within_each_stream_entry() {
let cases = [
(0x1B_u8, 10_usize), (0x1B, 12), (0x1B, 13), (0x1B, 14), (0x1B, 15), (0x81, 14), (0x81, 15), (0x90, 14), (0x92, 14), ];
for (coding_type, clip_data_len) in cases {
let buf = one_entry_truncated(coding_type, clip_data_len);
assert_eq!(
TsStreamClipFile::scan("t.clpi", &buf).unwrap_err().to_string(),
"unexpected end of input",
"type {coding_type:#04X} len {clip_data_len}"
);
}
}
#[test]
fn ts_stream_clip_model_defaults_and_clone() {
let clip = TsStreamClip {
name: "00000.M2TS".to_owned(),
time_in: 600.0,
time_out: 608.125,
length: 8.125,
chapters: vec![600.0],
..TsStreamClip::default()
};
assert_eq!(clip.name, "00000.M2TS");
assert_eq!(clip.angle_index, 0);
assert_eq!(clip.length.to_bits(), 8.125_f64.to_bits());
assert_eq!(clip.chapters, vec![600.0]);
assert_eq!(clip.clone(), clip);
let d = TsStreamClip::default();
assert_eq!(d.name, "");
assert_eq!(d.file_size, 0);
assert!(d.chapters.is_empty());
assert!(format!("{d:?}").starts_with("TsStreamClip"));
}
proptest! {
#[test]
fn scan_never_panics_on_arbitrary_input(data in any::<Vec<u8>>()) {
drop(TsStreamClipFile::scan("fuzz.clpi", &data));
}
#[test]
fn scan_round_trips_built_clips(
pid in any::<u16>(),
format in any::<u8>(),
aspect in any::<u8>(),
) {
let buf = build_clpi(*b"HDMV0300", &[(pid, 0x1B, [format, aspect, 0, 0])]);
let file = TsStreamClipFile::scan("p.clpi", &buf).unwrap();
let stream = file.streams.get(&pid).unwrap();
assert_eq!(stream.pid(), Pid::new(pid));
assert_eq!(stream.stream_type(), TsStreamType::AvcVideo);
}
}
}