From ff6004a567e3ce69aa62c87f21246fb7812ba4d5 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 15 Apr 2026 03:33:29 +0000 Subject: [PATCH] Unified Stream trait: read() and write() on one type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream trait: read() returns PesFrame, write() accepts PesFrame. A stream is a stream — you read from it or write to it. No separate Input/Output traits. API: libfreemkv::input(url) and libfreemkv::output(url, title, codecs) Returns Box. --- .github/workflows/ci.yml | 10 +++ Cargo.toml | 4 ++ benches/sgio_read.rs | 71 +++++++++++++++++++ examples/iso_dump.rs | 70 +++++++++++++++++++ src/aacs/handshake.rs | 5 +- src/aacs/keys.rs | 12 ++-- src/disc/bluray.rs | 8 +-- src/disc/dvd.rs | 39 ++--------- src/drive/capture.rs | 36 ++++++---- src/drive/linux.rs | 11 ++- src/drive/macos.rs | 4 +- src/event.rs | 19 +++++ src/ifo.rs | 77 +++++++-------------- src/lib.rs | 2 +- src/mux/codec/h264.rs | 12 ++-- src/mux/codec/vc1.rs | 3 +- src/mux/disc.rs | 13 +++- src/mux/iso.rs | 10 ++- src/mux/isowriter.rs | 2 +- src/mux/meta.rs | 93 +++++-------------------- src/mux/mkv.rs | 47 ++----------- src/mux/mkvout.rs | 15 ++-- src/mux/mod.rs | 2 +- src/mux/network.rs | 11 +-- src/mux/pesout.rs | 107 ++++++++++++++++------------- src/mux/ps.rs | 1 - src/mux/resolve.rs | 10 +-- src/pes.rs | 45 ++++++------ src/platform/mt1959/mod.rs | 16 +++-- src/udf.rs | 44 ++++++++++++ tests/crypto_tests.rs | 77 +++++++++++++++------ tests/disc_tests.rs | 137 +++++++++++++++++++++++++++++++++++-- tests/streams.rs | 76 ++++++++++---------- 33 files changed, 689 insertions(+), 400 deletions(-) create mode 100644 benches/sgio_read.rs create mode 100644 examples/iso_dump.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c9e6ad..3b845f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,16 @@ on: pull_request: jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@1.86.0 + with: + components: clippy, rustfmt + - run: cargo fmt --check + - run: cargo clippy -- -D warnings + test: runs-on: ubuntu-latest steps: diff --git a/Cargo.toml b/Cargo.toml index 566eab0..fc638be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,7 @@ libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" +[[bench]] +name = "sgio_read" +harness = false + diff --git a/benches/sgio_read.rs b/benches/sgio_read.rs new file mode 100644 index 0000000..fdaff49 --- /dev/null +++ b/benches/sgio_read.rs @@ -0,0 +1,71 @@ +// Mimics ISO dump exactly — read + write + progress +use libfreemkv::Drive; +use std::io::Write; +use std::path::Path; +use std::time::Instant; + +fn main() { + let device = std::env::args() + .skip(1) + .find(|a| !a.starts_with('-')) + .unwrap_or_else(|| { + let drives = libfreemkv::find_drives(); + if drives.is_empty() { + eprintln!("No drives found"); + std::process::exit(1); + } + drives[0].device_path().to_string() + }); + + let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| { + eprintln!("Cannot open {}: {}", device, e); + std::process::exit(1); + }); + eprintln!("wait_ready..."); + let _ = drive.wait_ready(); + eprintln!("read_capacity..."); + let cap = drive.read_capacity().unwrap(); + eprintln!("capacity: {} sectors", cap); + + let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path()); + let mut buf = vec![0u8; batch as usize * 2048]; + + // Open /dev/null writer like ISO dump does + let file = std::fs::File::create("/dev/null").unwrap(); + let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file); + + eprintln!("Reading 1000 batches ({:.1} MB) with write + progress...", + 1000.0 * batch as f64 * 2048.0 / 1_048_576.0); + + let start = Instant::now(); + let mut ok = 0u32; + let mut fail = 0u32; + let mut bytes: u64 = 0; + + for i in 0..1000u32 { + let lba = i * batch as u32; + match drive.read(lba, batch, &mut buf) { + Ok(_) => { + writer.write_all(&buf).unwrap(); + ok += 1; + } + Err(e) => { + fail += 1; + if fail <= 5 { eprintln!(" FAIL LBA {}: {}", lba, e); } + buf.fill(0); + writer.write_all(&buf).unwrap(); + } + } + bytes += buf.len() as u64; + + if i % 50 == 0 && i > 0 { + let elapsed = start.elapsed().as_secs_f64(); + let mb = bytes as f64 / 1_048_576.0; + eprint!("\r {:.1} MB | {:.1} MB/s ", mb, mb / elapsed); + } + } + + let elapsed = start.elapsed().as_secs_f64(); + let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0; + eprintln!("\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s", ok, fail, mb, elapsed, mb / elapsed); +} diff --git a/examples/iso_dump.rs b/examples/iso_dump.rs new file mode 100644 index 0000000..ce43625 --- /dev/null +++ b/examples/iso_dump.rs @@ -0,0 +1,70 @@ +// Minimal ISO dumper — find exact stall point +use libfreemkv::Drive; +use std::io::{BufWriter, Write}; +use std::path::Path; +use std::time::Instant; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("Usage: iso_dump "); + std::process::exit(1); + } + + let mut drive = Drive::open(Path::new(&args[1])).unwrap(); + drive.wait_ready().unwrap(); + let _ = drive.init(); + let _ = drive.probe_disc(); + + // AACS handshake — required to read past the protected area + eprint!("Scanning disc... "); + let _ = libfreemkv::Disc::scan(&mut drive, &libfreemkv::ScanOptions::default()); + eprintln!("OK"); + + let cap = drive.read_capacity().unwrap(); + let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path()); + + eprintln!("Device: {} | {} sectors | batch {}", args[1], cap, batch); + + let file = std::fs::File::create(&args[2]).unwrap(); + let mut w = BufWriter::with_capacity(4 * 1024 * 1024, file); + let mut buf = vec![0u8; batch as usize * 2048]; + let mut lba: u32 = 0; + let start = Instant::now(); + let mut last = Instant::now(); + let mut bytes: u64 = 0; + let mut last_bytes: u64 = 0; + + while lba < cap { + let count = ((cap - lba) as u16).min(batch); + let n = count as usize * 2048; + + // Tiny yield between reads — test if pacing prevents firmware throttle + std::thread::yield_now(); + let t0 = Instant::now(); + let ok = drive.read(lba, count, &mut buf[..n]).is_ok(); + let read_ms = t0.elapsed().as_millis(); + + // Flag slow reads + if read_ms > 2000 { + eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok); + } + + if !ok { buf[..n].fill(0); } + w.write_all(&buf[..n]).unwrap(); + lba += count as u32; + bytes += n as u64; + + if last.elapsed().as_millis() >= 1000 { + let delta = bytes - last_bytes; + let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0; + let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0; + let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0; + eprint!("\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ", pct, lba, speed, avg, bytes as f64 / 1e9); + last_bytes = bytes; + last = Instant::now(); + } + } + w.flush().unwrap(); + eprintln!("\nDone: {:.1} GB in {:.0}s", bytes as f64 / 1e9, start.elapsed().as_secs_f64()); +} diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index 1f9e676..e1e787d 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -1050,10 +1050,7 @@ pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 1 } /// Read data keys after successful authentication (for AACS 2.0 bus encryption). -pub fn read_data_keys( - session: &mut Drive, - auth: &mut AacsAuth, -) -> Result<([u8; 16], [u8; 16])> { +pub fn read_data_keys(session: &mut Drive, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> { // REPORT DISC STRUCTURE format 0x84 let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36); let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsDataKey)?; diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 8c6dc96..49d7bdf 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -188,9 +188,13 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt // Try each processing key against each UV/cvalue pair for pk in processing_keys { for i in 0..num_uvs { - if (i + 1) * 16 > cvalues.len() { continue; } + if (i + 1) * 16 > cvalues.len() { + continue; + } let record_start = i * 5; - if record_start + 5 > uvs.len() { continue; } + if record_start + 5 > uvs.len() { + continue; + } let _u_mask_shift = uvs[record_start]; let uv = &uvs[record_start + 1..record_start + 5]; let cv = &cvalues[i * 16..(i + 1) * 16]; @@ -448,9 +452,7 @@ const MKB_PACK_SIZE: usize = 32772; /// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). /// Returns the concatenated MKB data from all packs. -pub fn read_mkb_from_drive( - session: &mut crate::drive::Drive, -) -> crate::error::Result> { +pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result> { use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; let cdb = [ diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 5d16bee..fcb2308 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -104,8 +104,8 @@ impl Disc { 1 | 6 | 7 => Some(Stream::Video(VideoStream { pid: s.pid, codec, - resolution: format_resolution(s.video_format, s.video_rate), - frame_rate: format_framerate(s.video_rate), + resolution: Resolution::from_video_format(s.video_format), + frame_rate: FrameRate::from_video_rate(s.video_rate), hdr: match s.dynamic_range { 1 => HdrFormat::Hdr10, 2 => HdrFormat::DolbyVision, @@ -137,9 +137,9 @@ impl Disc { Some(Stream::Audio(AudioStream { pid: s.pid, codec, - channels: format_channels(s.audio_format), + channels: AudioChannels::from_audio_format(s.audio_format), language: s.language.clone(), - sample_rate: format_samplerate(s.audio_rate), + sample_rate: SampleRate::from_audio_rate(s.audio_rate), secondary: s.stream_type == 5, label: String::new(), })) diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 99915b8..8f13438 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -20,20 +20,13 @@ impl Disc { let mut title_number: u16 = 0; for ts in &dvd_info.title_sets { - // Map DvdVideoAttr to Stream::Video - let video_codec = match ts.video.codec.as_str() { - "mpeg2" => Codec::Mpeg2, - "mpeg1" => Codec::Mpeg2, // treat MPEG-1 as MPEG-2 for container purposes - _ => Codec::Mpeg2, - }; - let video_stream = Stream::Video(VideoStream { pid: 0xE0, // DVD video PID (standard MPEG PS video stream) - codec: video_codec, - resolution: ts.video.resolution.clone(), + codec: ts.video.codec, + resolution: ts.video.resolution, frame_rate: match ts.video.standard.as_str() { - "PAL" => "25".to_string(), - _ => "29.97".to_string(), + "PAL" => FrameRate::F25, + _ => FrameRate::F29_97, }, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, @@ -47,31 +40,13 @@ impl Disc { .iter() .enumerate() .map(|(i, a)| { - let codec = match a.codec.as_str() { - "ac3" => Codec::Ac3, - "dts" => Codec::Dts, - "lpcm" => Codec::Lpcm, - "mpeg1" | "mpeg2" => Codec::Mpeg2, - _ => Codec::Unknown(0), - }; - let channels = match a.channels { - 1 => "mono".to_string(), - 2 => "stereo".to_string(), - 6 => "5.1".to_string(), - 8 => "7.1".to_string(), - n => format!("{n}ch"), - }; - let sample_rate = match a.sample_rate { - 48000 => "48kHz".to_string(), - 96000 => "96kHz".to_string(), - sr => format!("{}kHz", sr / 1000), - }; + let codec = a.codec; Stream::Audio(AudioStream { pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs codec, - channels, + channels: AudioChannels::from_count(a.channels), language: a.language.clone(), - sample_rate, + sample_rate: SampleRate::from_hz(a.sample_rate), secondary: false, label: String::new(), }) diff --git a/src/drive/capture.rs b/src/drive/capture.rs index 766880e..6c35185 100644 --- a/src/drive/capture.rs +++ b/src/drive/capture.rs @@ -67,8 +67,8 @@ pub fn capture_drive_data(session: &mut Drive) -> Result { } // Vendor-specific READ_BUFFER queries - let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer - let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK + let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer + let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK // Standard queries let rpc_state = session.report_key_rpc_state(); @@ -87,18 +87,30 @@ pub fn capture_drive_data(session: &mut Drive) -> Result { /// Mask a string for privacy (letters->A, digits->0). pub fn mask_string(s: &str) -> String { - s.chars().map(|c| { - if c.is_ascii_alphabetic() { 'A' } - else if c.is_ascii_digit() { '0' } - else { c } - }).collect() + s.chars() + .map(|c| { + if c.is_ascii_alphabetic() { + 'A' + } else if c.is_ascii_digit() { + '0' + } else { + c + } + }) + .collect() } /// Mask bytes for privacy. pub fn mask_bytes(data: &[u8]) -> Vec { - data.iter().map(|&b| { - if b.is_ascii_alphabetic() { b'A' } - else if b.is_ascii_digit() { b'0' } - else { b } - }).collect() + data.iter() + .map(|&b| { + if b.is_ascii_alphabetic() { + b'A' + } else if b.is_ascii_digit() { + b'0' + } else { + b + } + }) + .collect() } diff --git a/src/drive/linux.rs b/src/drive/linux.rs index 71e27bb..faf4e27 100644 --- a/src/drive/linux.rs +++ b/src/drive/linux.rs @@ -10,6 +10,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> { if !std::path::Path::new(&path).exists() { continue; } + // Skip stale device nodes — sysfs entry must exist + let sysfs = format!("/sys/class/scsi_generic/sg{i}/device/model"); + if !std::path::Path::new(&sysfs).exists() { + continue; + } if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) { if let Ok(id) = DriveId::from_drive(transport.as_mut()) { if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 { @@ -21,6 +26,7 @@ pub fn find_drives() -> Vec<(String, DriveId)> { drives } +#[allow(dead_code)] pub fn resolve_device(path: &str) -> Result<(String, Option)> { if path.contains("/sg") { if !std::path::Path::new(path).exists() { @@ -39,9 +45,8 @@ pub fn resolve_device(path: &str) -> Result<(String, Option)> { && sg_id.product_id == sr_id.product_id && sg_id.serial_number == sr_id.serial_number { - let warning = format!( - "{path} is a block device (sr) — using {sg_path} (sg) for raw access" - ); + let warning = + format!("{path} is a block device (sr) — using {sg_path} (sg) for raw access"); return Ok((sg_path, Some(warning))); } } diff --git a/src/drive/macos.rs b/src/drive/macos.rs index a90fc7e..d9a6223 100644 --- a/src/drive/macos.rs +++ b/src/drive/macos.rs @@ -7,7 +7,9 @@ pub fn find_drives() -> Vec<(String, DriveId)> { let mut drives = Vec::new(); for i in 0..16 { let path = format!("/dev/disk{}", i); - if !std::path::Path::new(&path).exists() { continue; } + if !std::path::Path::new(&path).exists() { + continue; + } match crate::scsi::open(std::path::Path::new(&path)) { Ok(mut transport) => { if let Ok(id) = DriveId::from_drive(transport.as_mut()) { diff --git a/src/event.rs b/src/event.rs index 54c676c..16b8cb5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -25,6 +25,25 @@ pub struct Event { /// Types of events the lib can fire. #[derive(Debug)] pub enum EventKind { + // ── Init sequence events ──────────────────────────────────────── + + /// Drive opened successfully. + DriveOpened { device: String }, + + /// Drive is ready (disc spun up). + DriveReady, + + /// Firmware init completed. + InitComplete { success: bool }, + + /// Disc probe completed. + ProbeComplete { success: bool }, + + /// Disc scan completed. + ScanComplete { titles: usize }, + + // ── Read events ───────────────────────────────────────────────── + /// Bytes successfully read and written to output. BytesRead { /// Bytes written so far. diff --git a/src/ifo.rs b/src/ifo.rs index 77a0e6d..29b20c2 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -7,6 +7,7 @@ //! The parser reads IFO files via UDF and extracts enough information //! to build DiscTitle structs (parallel to MPLS for Blu-ray). +use crate::disc::{Codec, Resolution}; use crate::error::{Error, Result}; use crate::sector::SectorReader; use crate::udf::UdfFs; @@ -61,8 +62,8 @@ pub struct DvdCell { #[derive(Debug, Clone)] #[allow(dead_code)] pub struct DvdVideoAttr { - pub codec: String, - pub resolution: String, + pub codec: Codec, + pub resolution: Resolution, pub aspect: String, pub standard: String, } @@ -70,7 +71,7 @@ pub struct DvdVideoAttr { /// DVD audio stream attributes. #[derive(Debug, Clone)] pub struct DvdAudioAttr { - pub codec: String, + pub codec: Codec, pub channels: u8, pub sample_rate: u32, pub language: String, @@ -349,41 +350,15 @@ fn parse_video_attr(data: &[u8]) -> Result { _ => "4:3", }; - let resolution = match (b0 >> 4) & 0x03 { - 0 => { - if standard == "PAL" { - "720x576" - } else { - "720x480" - } - } - 1 => { - if standard == "PAL" { - "704x576" - } else { - "704x480" - } - } - 2 => { - if standard == "PAL" { - "352x576" - } else { - "352x480" - } - } - 3 => { - if standard == "PAL" { - "352x288" - } else { - "352x240" - } - } - _ => "720x480", + let resolution = if standard == "PAL" { + Resolution::R576i + } else { + Resolution::R480i }; Ok(DvdVideoAttr { - codec: "mpeg2".to_string(), - resolution: resolution.to_string(), + codec: Codec::Mpeg2, + resolution, aspect: aspect.to_string(), standard: standard.to_string(), }) @@ -396,12 +371,12 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result { let coding_mode = (b0 >> 5) & 0x07; let codec = match coding_mode { - 0 => "ac3", - 2 => "mpeg1", - 3 => "mpeg2", - 4 => "lpcm", - 6 => "dts", - _ => "unknown", + 0 => Codec::Ac3, + 2 => Codec::Mpeg1, + 3 => Codec::Mp2, + 4 => Codec::Lpcm, + 6 => Codec::Dts, + _ => Codec::Unknown(coding_mode), }; let sample_rate_flag = (b0 >> 3) & 0x03; @@ -434,7 +409,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result { }; Ok(DvdAudioAttr { - codec: codec.to_string(), + codec, channels, sample_rate, language, @@ -688,15 +663,15 @@ mod tests { assert_eq!(title.cells.len(), 1); let video = DvdVideoAttr { - codec: "mpeg2".to_string(), - resolution: "720x480".to_string(), + codec: Codec::Mpeg2, + resolution: Resolution::R480i, aspect: "16:9".to_string(), standard: "NTSC".to_string(), }; - assert_eq!(video.codec, "mpeg2"); + assert_eq!(video.codec, Codec::Mpeg2); let audio = DvdAudioAttr { - codec: "ac3".to_string(), + codec: Codec::Ac3, channels: 6, sample_rate: 48000, language: "en".to_string(), @@ -730,8 +705,8 @@ mod tests { let attr = parse_video_attr(&data).unwrap(); assert_eq!(attr.standard, "NTSC"); assert_eq!(attr.aspect, "16:9"); - assert_eq!(attr.resolution, "720x480"); - assert_eq!(attr.codec, "mpeg2"); + assert_eq!(attr.resolution, Resolution::R480i); + assert_eq!(attr.codec, Codec::Mpeg2); } #[test] @@ -743,7 +718,7 @@ mod tests { let attr = parse_video_attr(&data).unwrap(); assert_eq!(attr.standard, "PAL"); assert_eq!(attr.aspect, "4:3"); - assert_eq!(attr.resolution, "720x576"); + assert_eq!(attr.resolution, Resolution::R576i); } #[test] @@ -759,7 +734,7 @@ mod tests { data[3] = b'n'; let attr = parse_audio_attr(&data, 0).unwrap(); - assert_eq!(attr.codec, "ac3"); + assert_eq!(attr.codec, Codec::Ac3); assert_eq!(attr.sample_rate, 48000); assert_eq!(attr.channels, 6); assert_eq!(attr.language, "en"); @@ -777,7 +752,7 @@ mod tests { data[3] = b'r'; let attr = parse_audio_attr(&data, 0).unwrap(); - assert_eq!(attr.codec, "dts"); + assert_eq!(attr.codec, Codec::Dts); assert_eq!(attr.sample_rate, 96000); assert_eq!(attr.channels, 2); assert_eq!(attr.language, "fr"); diff --git a/src/lib.rs b/src/lib.rs index 4a6cf36..7b707e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,7 +110,7 @@ pub use mux::MkvStream; pub use mux::NetworkStream; pub use mux::NullStream; pub use mux::StdioStream; -pub use mux::{open_input, open_output, open_pes_input, open_pes_output, parse_url, InputOptions, StreamUrl}; +pub use mux::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl}; pub use scsi::ScsiTransport; pub use sector::SectorReader; pub use speed::DriveSpeed; diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs index 310503d..fd43267 100644 --- a/src/mux/codec/h264.rs +++ b/src/mux/codec/h264.rs @@ -111,12 +111,12 @@ impl CodecParser for H264Parser { // pictureParameterSetNALUnit = pps let mut record = vec![ - 1, // configurationVersion - sps[1], // profile - sps[2], // compatibility - sps[3], // level - 0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3) - 0xE1, // 3 bits reserved (111) + 5 bits numSPS (1) + 1, // configurationVersion + sps[1], // profile + sps[2], // compatibility + sps[3], // level + 0xFF, // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3) + 0xE1, // 3 bits reserved (111) + 5 bits numSPS (1) (sps.len() >> 8) as u8, sps.len() as u8, ]; diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index 2398f04..5632cff 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -172,7 +172,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> { } fn find_next_sc(data: &[u8], from: usize) -> Option { - (from..data.len().saturating_sub(2)).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01) + (from..data.len().saturating_sub(2)) + .find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01) } #[cfg(test)] diff --git a/src/mux/disc.rs b/src/mux/disc.rs index bbc4eba..047ba8f 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -333,8 +333,8 @@ impl IOStream for DiscStream { } } -impl crate::pes::InputStream for DiscStream { - fn next_frame(&mut self) -> io::Result> { +impl crate::pes::Stream for DiscStream { + fn read(&mut self) -> io::Result> { // Return buffered frame if available if let Some(frame) = self.pending_frames.pop_front() { return Ok(Some(frame)); @@ -399,6 +399,15 @@ impl crate::pes::InputStream for DiscStream { } } + fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "disc is read-only")) + } + + fn finish(&mut self) -> io::Result<()> { + self.drive.unlock_tray(); + Ok(()) + } + fn info(&self) -> &DiscTitle { &self.title } diff --git a/src/mux/iso.rs b/src/mux/iso.rs index 5fd2c17..6d38d4c 100644 --- a/src/mux/iso.rs +++ b/src/mux/iso.rs @@ -256,8 +256,8 @@ impl IsoStream { } } -impl crate::pes::InputStream for IsoStream { - fn next_frame(&mut self) -> io::Result> { +impl crate::pes::Stream for IsoStream { + fn read(&mut self) -> io::Result> { // Return buffered frame if let Some(frame) = self.pending_frames.pop_front() { return Ok(Some(frame)); @@ -301,6 +301,12 @@ impl crate::pes::InputStream for IsoStream { } } + fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "ISO is read-only for PES")) + } + + fn finish(&mut self) -> io::Result<()> { Ok(()) } + fn info(&self) -> &crate::disc::DiscTitle { &self.disc_title } diff --git a/src/mux/isowriter.rs b/src/mux/isowriter.rs index 5ec81f7..e47cf38 100644 --- a/src/mux/isowriter.rs +++ b/src/mux/isowriter.rs @@ -412,7 +412,7 @@ impl IsoWriter { icb[ad_offset..ad_offset + 4].copy_from_slice(&(extent_len as u32).to_le_bytes()); icb[ad_offset + 4..ad_offset + 8].copy_from_slice(§or_pos.to_le_bytes()); ad_offset += 8; // each short_ad is 8 bytes - let extent_sectors = ((extent_len + SECTOR_SIZE - 1) / SECTOR_SIZE) as u32; + let extent_sectors = extent_len.div_ceil(SECTOR_SIZE) as u32; sector_pos += extent_sectors; remaining -= extent_len; } diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 6f1591c..8f36913 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -3,9 +3,7 @@ //! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...] //! Other tools skip the header during TS sync recovery (scan for 0x47). -use crate::disc::{ - AudioStream, Codec, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream, -}; +use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream}; use serde::{Deserialize, Serialize}; use std::io::{self, Read, Seek, SeekFrom, Write}; @@ -84,25 +82,25 @@ impl M2tsMeta { .map(|s| match s { Stream::Video(v) => MetaStream::Video { pid: v.pid, - codec: codec_to_str(v.codec), - resolution: v.resolution.clone(), - frame_rate: v.frame_rate.clone(), - hdr: hdr_to_str(v.hdr), + codec: v.codec.id().into(), + resolution: v.resolution.to_string(), + frame_rate: v.frame_rate.to_string(), + hdr: v.hdr.id().into(), label: v.label.clone(), secondary: v.secondary, }, Stream::Audio(a) => MetaStream::Audio { pid: a.pid, - codec: codec_to_str(a.codec), - channels: a.channels.clone(), + codec: a.codec.id().into(), + channels: a.channels.to_string(), language: a.language.clone(), - sample_rate: a.sample_rate.clone(), + sample_rate: a.sample_rate.to_string(), label: a.label.clone(), secondary: a.secondary, }, Stream::Subtitle(s) => MetaStream::Subtitle { pid: s.pid, - codec: codec_to_str(s.codec), + codec: s.codec.id().into(), language: s.language.clone(), forced: s.forced, }, @@ -133,10 +131,10 @@ impl M2tsMeta { secondary, } => Stream::Video(VideoStream { pid: *pid, - codec: str_to_codec(codec), - resolution: resolution.clone(), - frame_rate: frame_rate.clone(), - hdr: str_to_hdr(hdr), + codec: codec.parse().unwrap(), + resolution: resolution.parse().unwrap(), + frame_rate: frame_rate.parse().unwrap(), + hdr: hdr.parse().unwrap(), color_space: ColorSpace::Bt709, secondary: *secondary, label: label.clone(), @@ -151,10 +149,10 @@ impl M2tsMeta { secondary, } => Stream::Audio(AudioStream { pid: *pid, - codec: str_to_codec(codec), - channels: channels.clone(), + codec: codec.parse().unwrap(), + channels: channels.parse().unwrap(), language: language.clone(), - sample_rate: sample_rate.clone(), + sample_rate: sample_rate.parse().unwrap(), secondary: *secondary, label: label.clone(), }), @@ -165,7 +163,7 @@ impl M2tsMeta { forced, } => Stream::Subtitle(SubtitleStream { pid: *pid, - codec: str_to_codec(codec), + codec: codec.parse().unwrap(), language: language.clone(), forced: *forced, codec_data: None, @@ -277,58 +275,5 @@ pub fn read_header_from_stream(r: &mut impl Read) -> io::Result Ok(Some(meta)) } -// Codec string conversion (compact, no English — just codec identifiers) -fn codec_to_str(c: Codec) -> String { - match c { - Codec::Hevc => "hevc", - Codec::H264 => "h264", - Codec::Vc1 => "vc1", - Codec::Mpeg2 => "mpeg2", - Codec::TrueHd => "truehd", - Codec::DtsHdMa => "dtshd_ma", - Codec::DtsHdHr => "dtshd_hr", - Codec::Dts => "dts", - Codec::Ac3 => "ac3", - Codec::Ac3Plus => "eac3", - Codec::Lpcm => "lpcm", - Codec::Pgs => "pgs", - Codec::DvdSub => "dvdsub", - Codec::Unknown(_) => "unknown", - } - .into() -} - -fn str_to_codec(s: &str) -> Codec { - match s { - "hevc" => Codec::Hevc, - "h264" => Codec::H264, - "vc1" => Codec::Vc1, - "mpeg2" => Codec::Mpeg2, - "truehd" => Codec::TrueHd, - "dtshd_ma" => Codec::DtsHdMa, - "dtshd_hr" => Codec::DtsHdHr, - "dts" => Codec::Dts, - "ac3" => Codec::Ac3, - "eac3" => Codec::Ac3Plus, - "lpcm" => Codec::Lpcm, - "pgs" => Codec::Pgs, - _ => Codec::Unknown(0), - } -} - -fn hdr_to_str(h: HdrFormat) -> String { - match h { - HdrFormat::Sdr => "sdr", - HdrFormat::Hdr10 => "hdr10", - HdrFormat::DolbyVision => "dv", - } - .into() -} - -fn str_to_hdr(s: &str) -> HdrFormat { - match s { - "hdr10" => HdrFormat::Hdr10, - "dv" => HdrFormat::DolbyVision, - _ => HdrFormat::Sdr, - } -} +// Serialization uses Codec::id() / HdrFormat::id() and Display impls. +// Deserialization uses FromStr impls (.parse()) on each enum. diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 0f8442d..1f6e53b 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -35,7 +35,7 @@ impl MkvTrack { Codec::Mpeg2 => "V_MPEG2", _ => "V_MPEG2", }; - let (w, h) = parse_resolution(&v.resolution); + let (w, h) = v.resolution.pixels(); Self { track_type: ebml::TRACK_TYPE_VIDEO, codec_id, @@ -61,8 +61,8 @@ impl MkvTrack { Codec::Lpcm => "A_PCM/INT/BIG", _ => "A_AC3", }; - let sr = parse_sample_rate(&a.sample_rate); - let ch = parse_channels(&a.channels); + let sr = a.sample_rate.hz(); + let ch = a.channels.count(); Self { track_type: ebml::TRACK_TYPE_AUDIO, codec_id, @@ -414,45 +414,8 @@ impl MkvMuxer { // Helpers // ============================================================ -fn parse_resolution(s: &str) -> (u32, u32) { - if s.contains("2160") { - (3840, 2160) - } else if s.contains("1080") { - (1920, 1080) - } else if s.contains("720") { - (1280, 720) - } else if s.contains("576") { - (720, 576) - } else if s.contains("480") { - (720, 480) - } else { - (1920, 1080) - } -} - -fn parse_sample_rate(s: &str) -> f64 { - if s.contains("192") { - 192_000.0 - } else if s.contains("96") { - 96000.0 - } else { - 48000.0 - } -} - -fn parse_channels(s: &str) -> u8 { - if s.contains("7.1") { - 8 - } else if s.contains("5.1") { - 6 - } else if s.contains("stereo") || s.contains("2.0") { - 2 - } else if s.contains("mono") { - 1 - } else { - 6 - } -} +// Old parse_resolution/parse_sample_rate/parse_channels removed — +// Resolution::pixels(), SampleRate::hz(), AudioChannels::count() replace them. #[cfg(test)] mod tests { diff --git a/src/mux/mkvout.rs b/src/mux/mkvout.rs index 33fbe6b..1454dbc 100644 --- a/src/mux/mkvout.rs +++ b/src/mux/mkvout.rs @@ -6,11 +6,12 @@ use super::mkv::{MkvMuxer, MkvTrack}; use super::WriteSeek; use crate::disc::DiscTitle; -use crate::pes::{OutputStream, PesFrame}; +use crate::pes::PesFrame; use std::io; pub struct MkvOutputStream { muxer: Option>>, + title: DiscTitle, } impl MkvOutputStream { @@ -43,12 +44,16 @@ impl MkvOutputStream { &title.chapters, )?; - Ok(Self { muxer: Some(muxer) }) + Ok(Self { muxer: Some(muxer), title: title.clone() }) } } -impl OutputStream for MkvOutputStream { - fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> { +impl crate::pes::Stream for MkvOutputStream { + fn read(&mut self) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "MKV output is write-only")) + } + + fn write(&mut self, frame: &PesFrame) -> io::Result<()> { if let Some(ref mut muxer) = self.muxer { muxer.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data) } else { @@ -63,4 +68,6 @@ impl OutputStream for MkvOutputStream { Ok(()) } } + + fn info(&self) -> &DiscTitle { &self.title } } diff --git a/src/mux/mod.rs b/src/mux/mod.rs index a1f8f78..0d2a71e 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -45,7 +45,7 @@ pub use m2ts::M2tsStream; pub use mkvstream::MkvStream; pub use network::NetworkStream; pub use null::NullStream; -pub use resolve::{open_input, open_output, open_pes_input, open_pes_output, parse_url, InputOptions, StreamUrl}; +pub use resolve::{input, output, open_input, open_output, parse_url, InputOptions, StreamUrl}; pub use stdio::StdioStream; use crate::disc::DiscTitle; diff --git a/src/mux/network.rs b/src/mux/network.rs index c7cd9bf..867aa23 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -148,7 +148,8 @@ impl Read for NetworkStream { mod tests { use super::*; use crate::disc::{ - AudioStream, Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream, + AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat, + Resolution, SampleRate, Stream, VideoStream, }; use std::io::{Read, Write}; use std::net::TcpListener; @@ -165,8 +166,8 @@ mod tests { Stream::Video(VideoStream { pid: 0x1011, codec: Codec::Hevc, - resolution: "2160p".into(), - frame_rate: "23.976".into(), + resolution: Resolution::R2160p, + frame_rate: FrameRate::F23_976, hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt2020, secondary: false, @@ -175,9 +176,9 @@ mod tests { Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::TrueHd, - channels: "7.1".into(), + channels: AudioChannels::Surround71, language: "eng".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: "English".into(), }), diff --git a/src/mux/pesout.rs b/src/mux/pesout.rs index d5d00ea..791e7b1 100644 --- a/src/mux/pesout.rs +++ b/src/mux/pesout.rs @@ -1,19 +1,15 @@ -//! PES output adapters — every output format muxes from PES frames. -//! -//! Each output knows its own format: -//! - M2TS: PES → BD-TS packets → file (via TsMuxer) -//! - Null: discard -//! - Stdio: raw frame data to stdout -//! - Network: PES → BD-TS → TCP (via TsMuxer) +//! PES output streams — each writes its own format from PES frames. use super::tsmux::TsMuxer; use crate::disc::DiscTitle; -use crate::pes::{OutputStream, PesFrame}; +use crate::pes::PesFrame; use std::io::{self, Write}; -/// M2TS output — PES frames → BD-TS packets → file. +// ── M2TS ──────────────────────────────────────────────────────────────────── + pub struct M2tsOutputStream { muxer: TsMuxer>, + title: DiscTitle, } impl M2tsOutputStream { @@ -21,79 +17,94 @@ impl M2tsOutputStream { let file = std::fs::File::create(path) .map_err(|e| io::Error::new(e.kind(), format!("m2ts://{}: {}", path, e)))?; let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file); - let pids = Self::extract_pids(title); - Ok(Self { - muxer: TsMuxer::new(writer, &pids), - }) - } - - fn extract_pids(title: &DiscTitle) -> Vec { - title.streams.iter().map(|s| match s { - crate::disc::Stream::Video(v) => v.pid, - crate::disc::Stream::Audio(a) => a.pid, - crate::disc::Stream::Subtitle(s) => s.pid, - }).collect() + let pids = extract_pids(title); + Ok(Self { muxer: TsMuxer::new(writer, &pids), title: title.clone() }) } } -impl OutputStream for M2tsOutputStream { - fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> { +impl crate::pes::Stream for M2tsOutputStream { + fn read(&mut self) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "M2TS output is write-only")) + } + fn write(&mut self, frame: &PesFrame) -> io::Result<()> { self.muxer.write_frame(frame.track, frame.pts, &frame.data) } - fn finish(&mut self) -> io::Result<()> { - self.muxer.finish_ref() - } + fn finish(&mut self) -> io::Result<()> { self.muxer.finish_ref() } + fn info(&self) -> &DiscTitle { &self.title } } -/// Null output — discards all frames. -pub struct NullOutputStream; +// ── Null ──────────────────────────────────────────────────────────────────── -impl OutputStream for NullOutputStream { - fn write_frame(&mut self, _frame: &PesFrame) -> io::Result<()> { Ok(()) } +pub struct NullOutputStream { title: DiscTitle } + +impl NullOutputStream { + pub fn new(title: &DiscTitle) -> Self { Self { title: title.clone() } } +} + +impl crate::pes::Stream for NullOutputStream { + fn read(&mut self) -> io::Result> { Ok(None) } + fn write(&mut self, _: &PesFrame) -> io::Result<()> { Ok(()) } fn finish(&mut self) -> io::Result<()> { Ok(()) } + fn info(&self) -> &DiscTitle { &self.title } } -/// Stdio output — writes raw frame data to stdout. +// ── Stdio ─────────────────────────────────────────────────────────────────── + pub struct StdioOutputStream { writer: io::BufWriter, + title: DiscTitle, } impl StdioOutputStream { - pub fn new() -> Self { - Self { writer: io::BufWriter::new(io::stdout()) } + pub fn new(title: &DiscTitle) -> Self { + Self { writer: io::BufWriter::new(io::stdout()), title: title.clone() } } } -impl OutputStream for StdioOutputStream { - fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> { +impl crate::pes::Stream for StdioOutputStream { + fn read(&mut self) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "stdio output is write-only")) + } + fn write(&mut self, frame: &PesFrame) -> io::Result<()> { self.writer.write_all(&frame.data) } - fn finish(&mut self) -> io::Result<()> { - self.writer.flush() - } + fn finish(&mut self) -> io::Result<()> { self.writer.flush() } + fn info(&self) -> &DiscTitle { &self.title } } -/// Network output — PES frames → BD-TS → TCP. +// ── Network ───────────────────────────────────────────────────────────────── + pub struct NetworkOutputStream { muxer: TsMuxer>, + title: DiscTitle, } impl NetworkOutputStream { pub fn connect(addr: &str, title: &DiscTitle) -> io::Result { let stream = std::net::TcpStream::connect(addr)?; let writer = io::BufWriter::with_capacity(256 * 1024, stream); - let pids = M2tsOutputStream::extract_pids(title); - Ok(Self { - muxer: TsMuxer::new(writer, &pids), - }) + let pids = extract_pids(title); + Ok(Self { muxer: TsMuxer::new(writer, &pids), title: title.clone() }) } } -impl OutputStream for NetworkOutputStream { - fn write_frame(&mut self, frame: &PesFrame) -> io::Result<()> { +impl crate::pes::Stream for NetworkOutputStream { + fn read(&mut self) -> io::Result> { + Err(io::Error::new(io::ErrorKind::Unsupported, "network output is write-only")) + } + fn write(&mut self, frame: &PesFrame) -> io::Result<()> { self.muxer.write_frame(frame.track, frame.pts, &frame.data) } - fn finish(&mut self) -> io::Result<()> { - self.muxer.finish_ref() - } + fn finish(&mut self) -> io::Result<()> { self.muxer.finish_ref() } + fn info(&self) -> &DiscTitle { &self.title } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +fn extract_pids(title: &DiscTitle) -> Vec { + title.streams.iter().map(|s| match s { + crate::disc::Stream::Video(v) => v.pid, + crate::disc::Stream::Audio(a) => a.pid, + crate::disc::Stream::Subtitle(s) => s.pid, + }).collect() } diff --git a/src/mux/ps.rs b/src/mux/ps.rs index e9ac7fd..0ea5c97 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -82,7 +82,6 @@ impl PsDemuxer { let mut pos = 0; while let Some(sc) = find_start_code(&self.buffer, pos) { - if sc + 3 >= self.buffer.len() { // Not enough bytes to read the start code ID. break; diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 99e6981..b51fc3c 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -305,7 +305,7 @@ pub struct InputOptions { // ── PES-based open ────────────────────────────────────────────────────────── /// Open a PES input stream (produces PES frames). -pub fn open_pes_input(url: &str, opts: &InputOptions) -> io::Result> { +pub fn input(url: &str, opts: &InputOptions) -> io::Result> { let parsed = parse_url(url); match parsed { StreamUrl::Iso { ref path } => { @@ -369,11 +369,11 @@ pub fn open_pes_input(url: &str, opts: &InputOptions) -> io::Result>], -) -> io::Result> { +) -> io::Result> { let parsed = parse_url(url); match parsed { StreamUrl::Mkv { ref path } => { @@ -393,10 +393,10 @@ pub fn open_pes_output( Ok(Box::new(super::pesout::NetworkOutputStream::connect(addr, title)?)) } StreamUrl::Stdio => { - Ok(Box::new(super::pesout::StdioOutputStream::new())) + Ok(Box::new(super::pesout::StdioOutputStream::new(title))) } StreamUrl::Null => { - Ok(Box::new(super::pesout::NullOutputStream)) + Ok(Box::new(super::pesout::NullOutputStream::new(title))) } StreamUrl::Disc { .. } => { Err(io::Error::new(io::ErrorKind::Unsupported, "disc:// is read-only")) diff --git a/src/pes.rs b/src/pes.rs index bd0f0ab..86f546f 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -1,11 +1,10 @@ -//! PES frame — the universal intermediate format. +//! Stream — read PES frames in, write PES frames out. //! -//! Every input stream produces PES frames. Every output stream consumes them. -//! The pipeline just moves frames: input.next_frame() → output.write_frame(). +//! A stream is a stream. You read() from it or write() to it. +//! The stream handles its own format internally. //! -//! A PES frame is one unit of elementary stream data: a video frame, -//! an audio frame, a subtitle packet. It carries a track ID, timestamp, -//! and the raw codec data. +//! disc.read() → PES frame (sectors → decrypt → demux internally) +//! mkv.write(frame) → MKV file (mux internally) /// One frame of elementary stream data. #[derive(Debug, Clone)] @@ -32,27 +31,23 @@ impl PesFrame { } } -/// Input stream — produces PES frames from any source. -pub trait InputStream { - /// Get the next frame. Returns None at end of stream. - fn next_frame(&mut self) -> std::io::Result>; +/// A stream. Read from it or write to it. Not both. +pub trait Stream { + /// Read the next frame. Returns None at end of stream. + fn read(&mut self) -> std::io::Result>; - /// Stream metadata (tracks, duration, etc). - fn info(&self) -> &crate::disc::DiscTitle; - - /// Codec initialization data for a track (SPS/PPS for HEVC, etc). - /// Returns None until enough frames have been parsed. - fn codec_private(&self, track: usize) -> Option>; - - /// True when codec_private is available for all video tracks. - fn headers_ready(&self) -> bool; -} - -/// Output stream — consumes PES frames to any destination. -pub trait OutputStream { - /// Write one frame. - fn write_frame(&mut self, frame: &PesFrame) -> std::io::Result<()>; + /// Write a frame. + fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>; /// Finalize (flush, write index, close). fn finish(&mut self) -> std::io::Result<()>; + + /// Stream metadata. + fn info(&self) -> &crate::disc::DiscTitle; + + /// Codec initialization data for a track (SPS/PPS, etc). + fn codec_private(&self, _track: usize) -> Option> { None } + + /// True when codec_private is available for all video tracks. + fn headers_ready(&self) -> bool { true } } diff --git a/src/platform/mt1959/mod.rs b/src/platform/mt1959/mod.rs index a2d050c..85c0b2d 100644 --- a/src/platform/mt1959/mod.rs +++ b/src/platform/mt1959/mod.rs @@ -178,7 +178,7 @@ impl Mt1959 { fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> { let mut unlocked = false; - for _attempt in 0..6 { + for _attempt in 0..3 { match self.do_unlock(scsi) { Ok(_) => { unlocked = true; @@ -188,15 +188,17 @@ impl Mt1959 { return Err(Error::UnlockFailed); } Err(_) => { - let ok = if self.mode == MODE_A { + let loaded = if self.mode == MODE_A { variant_a::load_firmware(self, scsi).is_ok() } else { variant_b::load_firmware(self, scsi).is_ok() }; - if ok { - unlocked = true; - break; + if !loaded { + continue; } + // Firmware upload resets the drive. Give it time to + // fully recover before retrying unlock. + std::thread::sleep(std::time::Duration::from_secs(10)); } } } @@ -319,7 +321,9 @@ impl PlatformDriver for Mt1959 { fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> { if !self.unlocked { - self.run_init(scsi)?; + // Don't retry init here — if init() failed, probing can't work either. + // Retrying causes repeated USB bus resets on BU40N. + return Ok(()); } if self.probed { return Ok(()); diff --git a/src/udf.rs b/src/udf.rs index e03de9d..d756813 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -187,6 +187,50 @@ impl UdfFs { Ok(merged) } + /// All sector ranges that contain data (metadata + all files including STREAM). + /// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps. + pub fn all_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result> { + let mut ranges = Vec::new(); + + // UDF structure sectors + let meta_end = self.metadata_start + self.metadata_sectors; + ranges.push((0, meta_end)); + + // Walk entire tree including STREAM directories + self.collect_all_file_ranges(reader, &self.root, &mut ranges)?; + + // Merge overlapping/adjacent ranges and sort + ranges.sort_by_key(|r| r.0); + let merged = merge_ranges(&ranges); + Ok(merged) + } + + fn collect_all_file_ranges( + &self, + reader: &mut dyn SectorReader, + entry: &DirEntry, + ranges: &mut Vec<(u32, u32)>, + ) -> Result<()> { + for child in &entry.entries { + if child.is_dir { + self.collect_all_file_ranges(reader, child, ranges)?; + } else { + // Include the ICB sector + ranges.push((self.meta_to_abs(child.meta_lba), 1)); + + // Include ALL file data extents (large m2ts files have many) + if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) { + for (data_lba, data_len) in extents { + let abs_start = self.partition_start + data_lba; + let sector_count = (data_len as u64).div_ceil(2048) as u32; + ranges.push((abs_start, sector_count)); + } + } + } + } + Ok(()) + } + fn collect_file_ranges( &self, reader: &mut dyn SectorReader, diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index 711b72e..2dc089f 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -363,8 +363,7 @@ fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) { /// The standard AACS IV, copied here independently so we are NOT importing /// the library's constant — this IS the cross-validation reference value. const CROSS_AACS_IV: [u8; 16] = [ - 0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, - 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78, + 0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78, ]; /// Build a plaintext aligned unit with TS sync markers and recognisable @@ -373,8 +372,8 @@ const CROSS_AACS_IV: [u8; 16] = [ #[test] fn aacs_cross_validation_encrypt_then_decrypt() { let unit_key: [u8; 16] = [ - 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, - 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10, + 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, + 0x10, ]; let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN]; @@ -403,7 +402,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() { for i in 0..16 { dk[i] = derived[i] ^ header[i]; } - ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]); + ref_aes_cbc_encrypt( + &dk, + &CROSS_AACS_IV, + &mut plaintext[16..aacs::ALIGNED_UNIT_LEN], + ); // Sanity: ciphertext should differ assert_ne!( @@ -414,7 +417,10 @@ fn aacs_cross_validation_encrypt_then_decrypt() { // -- Decrypt with the library -- let ok = aacs::decrypt_unit(&mut plaintext, &unit_key); - assert!(ok, "decrypt_unit returned false (TS sync verification failed)"); + assert!( + ok, + "decrypt_unit returned false (TS sync verification failed)" + ); assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared"); // Compare (byte 0 flag was cleared) @@ -432,8 +438,8 @@ fn aacs_cross_validation_encrypt_then_decrypt() { #[test] fn aacs_cross_validation_alternate_key() { let unit_key: [u8; 16] = [ - 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, ]; let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN]; @@ -452,7 +458,11 @@ fn aacs_cross_validation_alternate_key() { for i in 0..16 { dk[i] = derived[i] ^ header[i]; } - ref_aes_cbc_encrypt(&dk, &CROSS_AACS_IV, &mut plaintext[16..aacs::ALIGNED_UNIT_LEN]); + ref_aes_cbc_encrypt( + &dk, + &CROSS_AACS_IV, + &mut plaintext[16..aacs::ALIGNED_UNIT_LEN], + ); assert!(aacs::decrypt_unit(&mut plaintext, &unit_key)); @@ -469,8 +479,8 @@ fn aacs_cross_validation_alternate_key() { #[test] fn aacs_bus_decrypt_cross_validation() { let read_data_key: [u8; 16] = [ - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, - 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + 0x00, ]; let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN]; @@ -552,10 +562,22 @@ fn css_roundtrip_with_snapshot() { #[test] fn css_roundtrip_multiple_keys() { let cases: &[([u8; 5], [u8; 5])] = &[ - ([0x00, 0x00, 0x00, 0x00, 0x00], [0x00, 0x00, 0x00, 0x00, 0x00]), - ([0xFF, 0xFF, 0xFF, 0xFF, 0xFF], [0xFF, 0xFF, 0xFF, 0xFF, 0xFF]), - ([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]), - ([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]), + ( + [0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00], + ), + ( + [0xFF, 0xFF, 0xFF, 0xFF, 0xFF], + [0xFF, 0xFF, 0xFF, 0xFF, 0xFF], + ), + ( + [0x01, 0x02, 0x03, 0x04, 0x05], + [0xAA, 0xBB, 0xCC, 0xDD, 0xEE], + ), + ( + [0xAB, 0xCD, 0xEF, 0x01, 0x23], + [0x12, 0x34, 0x56, 0x78, 0x9A], + ), ]; for (idx, (key, seed)) in cases.iter().enumerate() { @@ -594,11 +616,26 @@ fn css_roundtrip_multiple_keys() { #[test] fn css_stevenson_attack_validates_cracked_key() { let candidates: &[([u8; 5], [u8; 5])] = &[ - ([0x42, 0x13, 0x37, 0xBE, 0xEF], [0x11, 0x22, 0x33, 0x44, 0x55]), - ([0x01, 0x02, 0x03, 0x04, 0x05], [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]), - ([0x10, 0x20, 0x30, 0x40, 0x50], [0x05, 0x06, 0x07, 0x08, 0x09]), - ([0xAB, 0xCD, 0xEF, 0x01, 0x23], [0x12, 0x34, 0x56, 0x78, 0x9A]), - ([0x55, 0xAA, 0x55, 0xAA, 0x55], [0x00, 0x00, 0x00, 0x00, 0x00]), + ( + [0x42, 0x13, 0x37, 0xBE, 0xEF], + [0x11, 0x22, 0x33, 0x44, 0x55], + ), + ( + [0x01, 0x02, 0x03, 0x04, 0x05], + [0xAA, 0xBB, 0xCC, 0xDD, 0xEE], + ), + ( + [0x10, 0x20, 0x30, 0x40, 0x50], + [0x05, 0x06, 0x07, 0x08, 0x09], + ), + ( + [0xAB, 0xCD, 0xEF, 0x01, 0x23], + [0x12, 0x34, 0x56, 0x78, 0x9A], + ), + ( + [0x55, 0xAA, 0x55, 0xAA, 0x55], + [0x00, 0x00, 0x00, 0x00, 0x00], + ), ]; let mut any_cracked = false; diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 8554a7e..e1e2733 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -143,9 +143,15 @@ fn scan_options_with_keydb_pathbuf() { // ── detect_format integration tests ─────────────────────────────────────── -use libfreemkv::{Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream}; +use libfreemkv::{ + Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat, Resolution, Stream, VideoStream, +}; -fn title_with_video(codec: Codec, resolution: &str, content_format: ContentFormat) -> DiscTitle { +fn title_with_video( + codec: Codec, + resolution: Resolution, + content_format: ContentFormat, +) -> DiscTitle { DiscTitle { playlist: "00800.mpls".into(), playlist_id: 800, @@ -155,8 +161,8 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma streams: vec![Stream::Video(VideoStream { pid: 0x1011, codec, - resolution: resolution.into(), - frame_rate: "23.976".into(), + resolution, + frame_rate: FrameRate::F23_976, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, secondary: false, @@ -191,13 +197,13 @@ fn disc_title_duration_display_edge_cases() { #[test] fn content_format_default_bdts() { - let t = title_with_video(Codec::H264, "1080p", ContentFormat::BdTs); + let t = title_with_video(Codec::H264, Resolution::R1080p, ContentFormat::BdTs); assert_eq!(t.content_format, ContentFormat::BdTs); } #[test] fn content_format_dvd_mpegps() { - let t = title_with_video(Codec::Mpeg2, "480i", ContentFormat::MpegPs); + let t = title_with_video(Codec::Mpeg2, Resolution::R480i, ContentFormat::MpegPs); assert_eq!(t.content_format, ContentFormat::MpegPs); } @@ -368,6 +374,125 @@ fn resolve_encryption_no_aacs_dir() { assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir"); } +// ── Batch count arithmetic tests ────────────────────────────────────────── +// Regression tests for the u16 truncation bug: when (remaining as u16) was +// used instead of remaining.min(batch as u32) as u16, any remaining count +// that was a multiple of 65536 would truncate to 0, causing an infinite loop. + +/// Simulates the fixed batch count calculation from pipe.rs / drive.rs +fn safe_batch_count(remaining: u32, batch_sectors: u16) -> u16 { + remaining.min(batch_sectors as u32) as u16 +} + +/// Simulates the BUGGY calculation that caused the infinite loop +fn buggy_batch_count(remaining: u32, batch_sectors: u16) -> u16 { + (remaining as u16).min(batch_sectors) +} + +#[test] +fn batch_count_normal() { + // Normal case: remaining > batch_sectors + assert_eq!(safe_batch_count(1000, 60), 60); + assert_eq!(safe_batch_count(47533152, 60), 60); +} + +#[test] +fn batch_count_last_batch() { + // Last batch: remaining < batch_sectors + assert_eq!(safe_batch_count(30, 60), 30); + assert_eq!(safe_batch_count(1, 60), 1); +} + +#[test] +fn batch_count_exact_boundary() { + // Exact boundary: remaining == batch_sectors + assert_eq!(safe_batch_count(60, 60), 60); +} + +#[test] +fn batch_count_u16_overflow_regression() { + // THE BUG: remaining is a multiple of 65536 → truncates to 0 + // 47513600 = 725 * 65536, lower 16 bits = 0 + let remaining: u32 = 47533152 - 19552; // = 47513600 + assert_eq!(remaining, 47513600); + assert_eq!(remaining % 65536, 0, "remaining should be multiple of 65536"); + + // Buggy version produces 0 → infinite loop + assert_eq!(buggy_batch_count(remaining, 60), 0); + + // Fixed version produces 60 + assert_eq!(safe_batch_count(remaining, 60), 60); +} + +#[test] +fn batch_count_other_u16_overflow_values() { + // Other multiples of 65536 + assert_eq!(safe_batch_count(65536, 60), 60); + assert_eq!(safe_batch_count(131072, 60), 60); + assert_eq!(safe_batch_count(65536 * 100, 60), 60); + + // Verify buggy version fails on all of these + assert_eq!(buggy_batch_count(65536, 60), 0); + assert_eq!(buggy_batch_count(131072, 60), 0); + assert_eq!(buggy_batch_count(65536 * 100, 60), 0); +} + +#[test] +fn batch_count_near_u16_boundary() { + // Values just below and above 65536 + assert_eq!(safe_batch_count(65535, 60), 60); + assert_eq!(safe_batch_count(65536, 60), 60); + assert_eq!(safe_batch_count(65537, 60), 60); + + // Buggy: 65535 as u16 = 65535, min(60) = 60 (OK by accident) + assert_eq!(buggy_batch_count(65535, 60), 60); + // Buggy: 65536 as u16 = 0, min(60) = 0 (BUG) + assert_eq!(buggy_batch_count(65536, 60), 0); + // Buggy: 65537 as u16 = 1, min(60) = 1 (wrong but doesn't loop) + assert_eq!(buggy_batch_count(65537, 60), 1); +} + +#[test] +fn batch_count_real_disc_sizes() { + let batch: u16 = 60; + + // DVD-5: ~2,295,104 sectors + assert_eq!(safe_batch_count(2295104, batch), 60); + + // BD-25: ~12,219,392 sectors + assert_eq!(safe_batch_count(12219392, batch), 60); + + // BD-50: ~24,438,784 sectors + assert_eq!(safe_batch_count(24438784, batch), 60); + + // UHD BD-66: ~33,554,432 sectors + assert_eq!(safe_batch_count(33554432, batch), 60); + + // UHD BD-100: ~47,533,152 sectors + assert_eq!(safe_batch_count(47533152, batch), 60); + + // Last few sectors of each + assert_eq!(safe_batch_count(52, batch), 52); + assert_eq!(safe_batch_count(3, batch), 3); +} + +#[test] +fn batch_count_zero_remaining() { + // Zero remaining should produce 0 (loop exits before this) + assert_eq!(safe_batch_count(0, 60), 0); +} + +#[test] +fn batch_count_max_batch_sizes() { + // Test with different batch sizes used by detect_max_batch_sectors + for &batch in &[3u16, 6, 9, 30, 60, 120, 240, 510] { + // Large remaining should always return batch + assert_eq!(safe_batch_count(47533152, batch), batch); + // Small remaining should return remaining + assert_eq!(safe_batch_count(1, batch), 1); + } +} + #[test] fn resolve_encryption_no_keydb() { // A UDF image with /AACS directory but no keydb path -> aacs is None diff --git a/tests/streams.rs b/tests/streams.rs index 5a615fe..e200dd8 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -15,8 +15,8 @@ fn sample_disc_title() -> DiscTitle { Stream::Video(VideoStream { pid: 0x1011, codec: Codec::Hevc, - resolution: "2160p".into(), - frame_rate: "23.976".into(), + resolution: Resolution::R2160p, + frame_rate: FrameRate::F23_976, hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709, secondary: false, @@ -25,18 +25,18 @@ fn sample_disc_title() -> DiscTitle { Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::TrueHd, - channels: "7.1".into(), + channels: AudioChannels::Surround71, language: "eng".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: "English Atmos".into(), }), Stream::Audio(AudioStream { pid: 0x1101, codec: Codec::Ac3, - channels: "5.1".into(), + channels: AudioChannels::Surround51, language: "fra".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: "French".into(), }), @@ -201,7 +201,7 @@ fn m2ts_meta_roundtrip() { // Check video if let Stream::Video(v) = &restored.streams[0] { assert_eq!(v.codec, Codec::Hevc); - assert_eq!(v.resolution, "2160p"); + assert_eq!(v.resolution, Resolution::R2160p); assert_eq!(v.label, "Main"); } else { panic!("expected video"); @@ -413,8 +413,8 @@ fn meta_codec_roundtrip() { streams.push(Stream::Video(VideoStream { pid: (0x1011 + i) as u16, codec, - resolution: "1080p".into(), - frame_rate: "23.976".into(), + resolution: Resolution::R1080p, + frame_rate: FrameRate::F23_976, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, secondary: false, @@ -425,9 +425,9 @@ fn meta_codec_roundtrip() { streams.push(Stream::Audio(AudioStream { pid: (0x1100 + i) as u16, codec, - channels: "5.1".into(), + channels: AudioChannels::Surround51, language: "eng".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: String::new(), })); @@ -509,8 +509,8 @@ fn meta_all_stream_types() { Stream::Video(VideoStream { pid: 0x1011, codec: Codec::Hevc, - resolution: "2160p".into(), - frame_rate: "23.976".into(), + resolution: Resolution::R2160p, + frame_rate: FrameRate::F23_976, hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709, secondary: false, @@ -519,9 +519,9 @@ fn meta_all_stream_types() { Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::TrueHd, - channels: "7.1".into(), + channels: AudioChannels::Surround71, language: "eng".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: "Primary Audio".into(), }), @@ -535,9 +535,9 @@ fn meta_all_stream_types() { Stream::Audio(AudioStream { pid: 0x1110, codec: Codec::Ac3, - channels: "stereo".into(), + channels: AudioChannels::Stereo, language: "eng".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: true, label: "Commentary".into(), }), @@ -553,7 +553,7 @@ fn meta_all_stream_types() { // Video preserved if let Stream::Video(v) = &restored.streams[0] { assert_eq!(v.codec, Codec::Hevc); - assert_eq!(v.resolution, "2160p"); + assert_eq!(v.resolution, Resolution::R2160p); assert_eq!(v.label, "Primary"); assert!(!v.secondary); } else { @@ -563,7 +563,7 @@ fn meta_all_stream_types() { // Primary audio preserved if let Stream::Audio(a) = &restored.streams[1] { assert_eq!(a.codec, Codec::TrueHd); - assert_eq!(a.channels, "7.1"); + assert_eq!(a.channels, AudioChannels::Surround71); assert!(!a.secondary); } else { panic!("expected audio"); @@ -642,9 +642,9 @@ fn mkvstream_roundtrip_bdts() { streams: vec![Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::Ac3, - channels: "5.1".into(), + channels: AudioChannels::Surround51, language: "eng".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: "English".into(), })], @@ -688,8 +688,8 @@ fn mkvstream_meta_preserves_all_streams() { Stream::Video(VideoStream { pid: 0x1011, codec: Codec::H264, - resolution: "1080p".into(), - frame_rate: "23.976".into(), + resolution: Resolution::R1080p, + frame_rate: FrameRate::F23_976, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, secondary: false, @@ -698,18 +698,18 @@ fn mkvstream_meta_preserves_all_streams() { Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::Ac3, - channels: "5.1".into(), + channels: AudioChannels::Surround51, language: "eng".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: "English".into(), }), Stream::Audio(AudioStream { pid: 0x1101, codec: Codec::DtsHdMa, - channels: "7.1".into(), + channels: AudioChannels::Surround71, language: "fra".into(), - sample_rate: "48kHz".into(), + sample_rate: SampleRate::S48, secondary: false, label: "French".into(), }), @@ -781,8 +781,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { streams: vec![Stream::Video(VideoStream { pid: 0x1011, codec: Codec::H264, - resolution: "1080p".into(), - frame_rate: "23.976".into(), + resolution: Resolution::R1080p, + frame_rate: FrameRate::F23_976, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, secondary: false, @@ -803,7 +803,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { // SPS NAL (type 7): minimal valid SPS es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code es_data.push(0x67); // NAL type 7 (SPS), nal_ref_idc=3 - // Minimal SPS payload: profile_idc=66 (Baseline), constraint flags, level_idc=30 + // Minimal SPS payload: profile_idc=66 (Baseline), constraint flags, level_idc=30 es_data.extend_from_slice(&[ 0x42, 0xC0, 0x1E, // profile=66, constraint_set0=1, level=30 0xD9, 0x00, 0xA0, 0x47, 0xFE, 0x88, // minimal SPS rbsp @@ -817,7 +817,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { // IDR NAL (type 5): keyframe es_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); // start code es_data.push(0x65); // NAL type 5 (IDR), nal_ref_idc=3 - // Some IDR slice data + // Some IDR slice data es_data.extend_from_slice(&[0x88, 0x84, 0x00, 0x21, 0xFF, 0xFE, 0xF6, 0xE2]); // Pad to reasonable size es_data.extend_from_slice(&[0x00; 64]); @@ -961,7 +961,11 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { let data = output2.lock().unwrap().clone().into_inner(); // Verify output starts with EBML magic (0x1A45DFA3) - assert!(data.len() >= 4, "MKV output too small: {} bytes", data.len()); + assert!( + data.len() >= 4, + "MKV output too small: {} bytes", + data.len() + ); assert_eq!( &data[0..4], &[0x1A, 0x45, 0xDF, 0xA3], @@ -970,17 +974,13 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { // Verify output contains a Tracks element (0x1654AE6B) let tracks_needle = [0x16, 0x54, 0xAE, 0x6B]; - let has_tracks = data - .windows(4) - .any(|w| w == tracks_needle); + let has_tracks = data.windows(4).any(|w| w == tracks_needle); assert!(has_tracks, "output should contain Tracks element"); // Verify codecPrivate is non-empty (not all zeros) // CodecPrivate element ID is 0x63A2 let cp_needle = [0x63, 0xA2]; - let cp_pos = data - .windows(2) - .position(|w| w == cp_needle); + let cp_pos = data.windows(2).position(|w| w == cp_needle); if let Some(pos) = cp_pos { // After the ID, there's a size VINT, then the data let after_id = pos + 2;