diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c348dd..551759e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - `list_drives` on Linux without sysfs keeps an sg node unless INQUIRY says it is not optical or open() fails with no device (ENOENT/ENXIO/ENODEV) or no permission (EACCES/EPERM), so a wedged or busy drive is still listed. - Dropping a transport or `Drive` unlocks the tray only if it locked it. - `Error::ImageTruncated` displays as `E: have/want`. +- `json://` audio `sample_rate` is now a number in Hz (`null` when unknown) instead of a display string such as `"48kHz"`; a new `sample_rates` array lists every rate a stream carries (e.g. `[48000, 96000]` for a 48/96 kHz combo). +- `network://` / `stdio://` FMKV headers use version 2 when a track carries decoder timing (Opus CodecDelay/SeekPreRoll), adding per-frame DiscardPadding; streams without timing stay version 1. ### Removed @@ -28,6 +30,9 @@ ### Fixed - TrueHD channel labels keep the LFE split (3.0 was labelled "2.1", 7.0 "6.1"), and a 6-channel presentation counts its Lvh/Rvh pair as 2. An older network:// receiver parses the new layout strings as unknown and omits the MKV Channels element for those tracks; keep both ends on the same version. +- `mp4://` DTS tracks write the stream's maximum rate in `ddts` and read back as 96 kHz where the source is 96 kHz. +- `mkv://` PCM tracks without BitDepth: bounded read-ahead and a multi-block depth estimate. +- `network://` receive arms TCP keepalive on every accepted socket; a rejected keepalive option only warns. ## [1.7.7] — 2026-09-26 diff --git a/Cargo.lock b/Cargo.lock index f9502188..ddcb8120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -328,9 +328,11 @@ dependencies = [ "memchr", "rayon", "roxmltree", + "rustix", "serde", "serde_json", "sha1", + "socket2", "tempfile", "tracing", "zip", @@ -626,6 +628,16 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "syn" version = "2.0.119" diff --git a/Cargo.toml b/Cargo.toml index 9f0d65ff..d62c8e3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,8 @@ rip = [ "dep:crossbeam-channel", "dep:rayon", "dep:memchr", + "dep:socket2", + "dep:rustix", ] [dependencies] @@ -62,6 +64,12 @@ aes = { version = "0.9", optional = true } freemkv-unlock = { path = "../freemkv-unlock", optional = true } zip = { version = "8", default-features = false, features = ["deflate"], optional = true } base64 = { version = "0.23", optional = true } +# Safe TCP keepalive setup for `network://` receive (dead-peer detection); +# `all` provides TcpKeepalive::with_retries. +socket2 = { version = "0.6", features = ["all"], optional = true } +# Safe readiness poll (poll/WSAPoll) so a halt-aware `network://` receive never +# arms SO_RCVTIMEO, which on Windows can drop data when it cancels a recv. +rustix = { version = "1", default-features = false, features = ["std", "event"], optional = true } # Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion # bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL` # — untrusted disc bytes — into authoritative titles/clips/chapters. A real diff --git a/src/error.rs b/src/error.rs index 63ea118c..9edb5d57 100644 --- a/src/error.rs +++ b/src/error.rs @@ -198,6 +198,10 @@ pub const E_DIR_IMAGE_FANOUT: u16 = 9068; pub const E_SEAM_PLAN_DROPPED_MOST: u16 = 9069; /// A sink finished having written no frames at all. pub const E_SINK_WROTE_NOTHING: u16 = 9070; +/// A write or finish reached a stream that is already finished or whose output failed. +pub const E_STREAM_CLOSED: u16 = 9071; +/// Per-track metadata was set after the stream header was already written. +pub const E_STREAM_HEADER_WRITTEN: u16 = 9072; pub const E_M2TS_PACKET_MALFORMED: u16 = 9021; /// A `network://` output target resolved to no address that is safe to /// connect to (every resolved IP was loopback / private / link-local / @@ -841,6 +845,10 @@ pub enum Error { }, /// A sink finished having written no frames at all. SinkWroteNothing, + /// See [`E_STREAM_CLOSED`]. + StreamClosed, + /// See [`E_STREAM_HEADER_WRITTEN`]. + StreamHeaderWritten, } impl Error { @@ -972,6 +980,8 @@ impl Error { Error::DirImageFanout { .. } => E_DIR_IMAGE_FANOUT, Error::SeamPlanDroppedMost { .. } => E_SEAM_PLAN_DROPPED_MOST, Error::SinkWroteNothing => E_SINK_WROTE_NOTHING, + Error::StreamClosed => E_STREAM_CLOSED, + Error::StreamHeaderWritten => E_STREAM_HEADER_WRITTEN, Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED, Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE, } @@ -1572,6 +1582,8 @@ mod tests { } .code(), Error::SinkWroteNothing.code(), + Error::StreamClosed.code(), + Error::StreamHeaderWritten.code(), Error::ShortImageRead { lba: 0, expected: 1, @@ -2006,6 +2018,8 @@ mod tests { (Error::MkvLacingInvalid, E_MKV_LACING_INVALID), (Error::MkvSourceInvalid, E_MKV_SOURCE_INVALID), (Error::MkvUnencodable, E_MKV_UNENCODABLE), + (Error::StreamClosed, E_STREAM_CLOSED), + (Error::StreamHeaderWritten, E_STREAM_HEADER_WRITTEN), (Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK), (Error::Mp4Invalid, E_MP4_INVALID), (Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE), diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index e9864de0..1bab962b 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -286,6 +286,9 @@ struct PgsSupWriter { // Delay a duration-derived clear until the next frame, so an original // clear (or replacement PCS) at that timestamp takes precedence. pending_clear: Option<(i64, u16, u16)>, + // Bytes of truncated trailing segments not written (a partial segment would + // desync the `.sup` framing); reported at finish. + truncated_bytes: u64, } // ── PGS / HDMV segment framing constants ───────────────────────────────────── @@ -320,13 +323,14 @@ fn ns_to_90k(pts_ns: i64) -> u32 { impl PgsSupWriter { /// Walk the concatenated segments in `data`, emitting each with a `PG` - /// header carrying `pts90k`/`dts90k`. Returns bytes written. + /// header carrying `pts90k`/`dts90k`. Returns `(bytes written, trailing + /// bytes of a truncated segment left unwritten)`. fn emit_segments( data: &[u8], pts90k: u32, dts90k: u32, w: &mut dyn Write, - ) -> io::Result { + ) -> io::Result<(usize, usize)> { let mut pos = 0; let mut written = 0; // Each PGS segment in the payload is: type(1) + size(2 BE) + size bytes. @@ -343,7 +347,7 @@ impl PgsSupWriter { written += SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + size; pos = seg_end; } - Ok(written) + Ok((written, data.len() - pos)) } // Synthetic "clear" display set (empty PCS + END) re-emitted at `display_pts + duration`; @@ -402,14 +406,16 @@ impl EsWriter for PgsSupWriter { if end < pts_ns || (end == pts_ns && !is_pcs) { let clear = Self::synthetic_clear_display_set(width, height); let pts = ns_to_90k(end); - written += Self::emit_segments(&clear, pts, pts, w)?; + written += Self::emit_segments(&clear, pts, pts, w)?.0; self.pending_clear = None; } else if is_pcs { // A real clear/replacement at or before the computed end wins. self.pending_clear = None; } } - written += Self::emit_segments(&f.data, pts90, pts90, w)?; + let (n, truncated) = Self::emit_segments(&f.data, pts90, pts90, w)?; + written += n; + self.truncated_bytes += truncated as u64; // Retain fallback support for old MKVs that only carry durations, and // for a final display without a clear. Never synthesize a clear OF a // clear (nor of a standalone WDS/END segment). @@ -427,6 +433,13 @@ impl EsWriter for PgsSupWriter { let pts = ns_to_90k(end); Self::emit_segments(&clear, pts, pts, w)?; } + if self.truncated_bytes > 0 { + tracing::warn!( + target: "mux", + bytes = self.truncated_bytes, + "PGS .sup: truncated trailing segments were not written" + ); + } Ok(()) } } @@ -1347,7 +1360,7 @@ mod tests { // One segment: type=0x16, size=2, payload=[0xDE,0xAD]. let payload = [SEG_PCS, 0x00, 0x02, 0xDE, 0xAD]; let mut out = Vec::new(); - let written = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap(); + let (written, _) = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap(); assert_eq!(&out[0..2], &SUP_MAGIC); assert_eq!(&out[2..6], &0x10u32.to_be_bytes()); // PTS assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); // DTS @@ -1581,10 +1594,18 @@ mod tests { let mut bytes = Vec::new(); assert_eq!( PgsSupWriter::emit_segments(data, 0, 0, &mut bytes).unwrap(), - 0 + (0, data.len()) ); assert!(bytes.is_empty()); } + // A frame ending in a truncated segment is accounted, not silently lost. + let mut writer = PgsSupWriter::default(); + let mut frame = sup_frame(0, None, true); + frame.data.extend_from_slice(&[SEG_PCS, 0, 9, 1]); + writer + .write_frame(&mut Vec::new(), &frame, frame.pts) + .unwrap(); + assert_eq!(writer.truncated_bytes, 4); } #[test] @@ -2065,6 +2086,56 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // `audio://` where every AUDIO frame falls outside the marks but video frames + // map: filtered video must not count as written, so the export fails loudly. + #[test] + fn a_filtered_export_whose_persisted_track_was_emptied_fails() { + let dir = tempdir(); + let mut title = title_with( + vec![video_stream(Codec::H264), audio_stream(Codec::Ac3, "eng")], + vec![None, None], + ); + title.clips = (0..2u32) + .map(|i| crate::disc::Clip { + feed_span: None, + clip_id: format!("0000{i}"), + in_time: (100 + 100 * i) * 45_000, + out_time: (200 + 100 * i) * 45_000, + duration_secs: 100.0, + source_packets: 0, + }) + .collect(); + let opts = DemuxOptions { + base: "Empty".to_string(), + kind_filter: Some(TrackKind::Audio), + export_chapters: false, + ..Default::default() + }; + let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap(); + let frame = |track: usize, pts: i64| PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track, + pts, + keyframe: true, + data: vec![0x0B, 0x77], + duration_ns: None, + }; + for s in [150i64, 151, 152] { + let _ = Stream::write(&mut sink, &frame(0, s * 1_000_000_000)); + } + for s in [0i64, 1] { + let _ = Stream::write(&mut sink, &frame(1, s * 1_000_000_000)); + } + let err = Stream::finish(&mut sink).expect_err("no audio was written"); + assert_eq!( + crate::error::error_code(&err), + Some(crate::error::E_SINK_WROTE_NOTHING) + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn sink_read_returns_write_only() { let dir = tempdir(); diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index e28ccc0d..c4eed9cd 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -4,8 +4,9 @@ //! consumer thread so feed and codec parse pipeline instead of serialise. //! //! [`DemuxThread::spawn_zero_copy`] returns a handle plus a -//! `Receiver`; dropping the handle closes the channel and -//! `Drop::drop` joins the worker. +//! `Receiver`. `Drop::drop` joins the worker, which exits only once +//! that receiver is gone: drop the receiver FIRST (an owner declares it before +//! the handle), or a worker blocked on a full channel deadlocks the join. use crate::halt::Halt; use crossbeam_channel::{Receiver, Sender, bounded}; @@ -586,4 +587,38 @@ mod tests { "recycle_rx recv must fail after recycle_tx drop" ); } + + // PipelinedPesStream must drop its receiver before this handle (field order): + // a worker blocked on the full channel then exits instead of deadlocking. + #[test] + fn dropping_a_stream_with_a_blocked_worker_does_not_hang() { + let (pf_tx, pf_rx) = bounded::>>(16); + let (rc_tx, _rc_rx) = bounded::>(16); + let pid = 0x1011; + let ts = super::super::ts::TsDemuxer::new(&[pid]); + let (dt, rx) = + DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, Some(ts), None).unwrap(); + for i in 0..8u8 { + let mut chunk = bdts_pes_packet(pid, &[i]); + chunk.extend(bdts_pes_packet(pid, &[i, i])); + pf_tx.send(Ok(chunk)).unwrap(); + } + drop(pf_tx); + std::thread::sleep(Duration::from_millis(200)); + let stream = super::super::pipelined_stream::PipelinedPesStream::new( + dt, + rx, + crate::disc::DiscTitle::empty(), + Vec::new(), + Vec::new(), + ); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + drop(stream); + let _ = done_tx.send(()); + }); + done_rx + .recv_timeout(Duration::from_secs(10)) + .expect("drop must not deadlock on the blocked demux worker"); + } } diff --git a/src/mux/driver.rs b/src/mux/driver.rs index e2ed48e9..4979dea8 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -22,7 +22,8 @@ use crate::sector::{FileSectorSource, KeyFetch, SectorSource}; use crate::session::DiscSession; use super::resolve::{ - InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url, resolve_mux_key_map, + InputOptions, StreamUrl, build_iso_pipeline, input_with_halt, output, parse_url, + resolve_mux_key_map, }; use super::videomap::{Medium, SourceInfo}; @@ -112,11 +113,11 @@ pub enum MuxInput<'a> { key_map: Option>, }, /// Any URL-addressed source (`iso://`, `mkv://`, `m2ts://`, `network://`, - /// stdio) opened via [`input`]. + /// stdio) opened via [`input`](super::resolve::input). Url { /// The source URL. url: &'a str, - /// Input options forwarded to [`input`]. + /// Input options forwarded to [`input`](super::resolve::input). opts: InputOptions, }, } @@ -245,7 +246,22 @@ pub fn mux_stream( title: in_opts.title_index.unwrap_or(0), ..SourceInfo::default() }; - let stream = input(url, &in_opts)?; + // A Stop while `network://` waits for its sender is a clean interrupt. + let stream = match input_with_halt(url, &in_opts, Some(halt)) { + Ok(s) => s, + Err(e) if crate::error::is_halt(&e) => { + return Ok(MuxOutcome { + completed: false, + output_opened: false, + bytes_written: 0, + errors: 0, + lost_bytes: 0, + streams: 0, + undelivered_streams: Vec::new(), + }); + } + Err(e) => return Err(e), + }; let source = SourceInfo { playlist: stream.info().playlist.clone(), ..source @@ -507,6 +523,21 @@ fn reader_event_fn(events: Arc) -> crate::sector::prefetched::Eve }) } +// Join the write consumer after the pump. A send that hit its deadline means the +// consumer is wedged, so the join gets only the short grace, not JOIN_TIMEOUT. +fn finish_pumped( + pipe: Pipeline, + halt: &Halt, + send_timed_out: bool, +) -> Result { + if send_timed_out { + let wedged = Halt::new(); + wedged.cancel(); + return pipe.finish_with_halt(Some(&wedged)); + } + pipe.finish_with_halt(Some(halt)) +} + // Whether a finished mux counts as COMPLETED: interrupted, finalize_failed, or halt_cancelled // each force `false`. Pure fn so this mapping is unit-tested directly. fn mux_run_completed(interrupted: bool, finalize_failed: bool, halt_cancelled: bool) -> bool { @@ -704,11 +735,14 @@ fn drive_mux( // every `POLL_INTERVAL`, so Ctrl-C / `/api/stop` stays responsive. let deadline = send_deadline; let mut interrupted = false; + // A send refused with no halt and a healthy consumer ran out its deadline. + let mut send_timed_out = false; // Buffered header frames first, in order. for frame in buffered { if pipe.send_with_halt(frame, halt, deadline).is_err() { interrupted = true; + send_timed_out = !halt.is_cancelled() && !pipe.consumer_failed(); break; } // Feed write-side progress during the drain exactly as the steady-state @@ -732,6 +766,7 @@ fn drive_mux( } if pipe.send_with_halt(frame, halt, deadline).is_err() { interrupted = true; + send_timed_out = !halt.is_cancelled() && !pipe.consumer_failed(); break; } events.on_write_progress(bytes.load(Ordering::Relaxed), total_bytes); @@ -785,7 +820,7 @@ fn drive_mux( // the container. On halt/wedge this returns an error variant, translated // to `completed = false` rather than a hard failure. let (bytes_written, undelivered_streams, finalize_failed) = - match pipe.finish_with_halt(Some(halt)) { + match finish_pumped(pipe, halt, send_timed_out) { Ok((b, undelivered)) => (b, undelivered, false), Err(Error::Halted | Error::PipelineJoinTimeout) => { (bytes.load(Ordering::Relaxed), Vec::new(), true) @@ -796,7 +831,8 @@ fn drive_mux( tracing::warn!( target: "mux", streams = ?undelivered_streams, - "output sink could not deliver every planned stream; the file does not match the pre-mux plan (surfaced as MuxOutcome::undelivered_streams)" + "output sink could not deliver every planned stream; the file does not match \ + the pre-mux plan (surfaced as MuxOutcome::undelivered_streams)" ); } @@ -2715,4 +2751,136 @@ mod tests { "a non-DVD passes decrypt_keys() through unchanged" ); } + + // A consumer wedged past the send deadline must not hold the finish for the + // full JOIN_TIMEOUT (600 s). + #[test] + fn a_send_timeout_bounds_the_final_join() { + // Only the first write wedges, returning after finish has given up; the + // leaked consumer must then neither apply the queued item nor close. + struct Stuck { + closed: Arc, + woke: std::sync::mpsc::Sender<()>, + first: bool, + applied: Arc, + } + impl Sink for Stuck { + type Output = (); + fn apply(&mut self, _: u32) -> Result { + self.applied.fetch_add(1, Ordering::SeqCst); + if std::mem::take(&mut self.first) { + std::thread::sleep(Duration::from_secs(7)); + let _ = self.woke.send(()); + } + Ok(Flow::Continue) + } + fn close(self) -> Result<(), Error> { + self.closed.store(true, Ordering::SeqCst); + Ok(()) + } + } + let closed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let applied = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (woke_tx, woke) = std::sync::mpsc::channel(); + let stuck = Stuck { + closed: closed.clone(), + woke: woke_tx, + first: true, + applied: applied.clone(), + }; + let pipe = Pipeline::spawn(1, stuck).unwrap(); + let halt = Halt::new(); + let mut timed_out = false; + for i in 0..4 { + if pipe + .send_with_halt(i, &halt, Duration::from_millis(50)) + .is_err() + { + timed_out = true; + break; + } + } + assert!(timed_out, "the stuck consumer trips the send deadline"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(finish_pumped(pipe, &halt, true).is_err()); + }); + let failed = rx + .recv_timeout(Duration::from_secs(30)) + .expect("the join must be bounded by the grace, not JOIN_TIMEOUT"); + assert!(failed); + woke.recv_timeout(Duration::from_secs(30)) + .expect("the wedged write returns"); + std::thread::sleep(Duration::from_millis(500)); + assert!( + !closed.load(Ordering::SeqCst), + "an abandoned consumer must not finalise the output late" + ); + assert_eq!( + applied.load(Ordering::SeqCst), + 1, + "nor apply the queued item" + ); + } + + // An Opus-style CodecDelay/SeekPreRoll on an mkv:// source must reach the + // mkv:// output through the driver (the sink's set_track_timing wiring). + #[test] + fn track_timing_propagates_from_source_to_mkv_output() { + use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream}; + use crate::mux::mkvstream::MkvStream; + let dir = tempfile::tempdir().expect("tempdir"); + let src = dir.path().join("src.mkv"); + let dst = dir.path().join("dst.mkv"); + let mut title = DiscTitle { + streams: vec![crate::disc::Stream::Video(VideoStream { + pid: 0x1011, + codec: Codec::H264, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F24, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })], + ..DiscTitle::empty() + }; + title.codec_privates = vec![Some(vec![0x01, 0x64, 0x00, 0x1F, 0xFF, 0xE1])]; + let timing = crate::pes::TrackTiming { + codec_delay_ns: 6_500_000, + seek_preroll_ns: 80_000_000, + }; + let mut w = MkvStream::create(Box::new(std::fs::File::create(&src).unwrap()), &title, None) + .unwrap(); + w.set_track_timing(0, timing).unwrap(); + w.write(&PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track: 0, + pts: 0, + keyframe: true, + data: vec![0xA1; 16], + duration_ns: None, + }) + .unwrap(); + w.finish().unwrap(); + + let stream = Box::new(MkvStream::open(std::fs::File::open(&src).unwrap()).unwrap()); + let out = drive_mux( + stream, + &format!("mkv://{}", dst.display()), + &Halt::new(), + &SpyEvents::new(), + None, + Duration::from_secs(60), + None, + ) + .expect("mkv remux runs"); + assert!(out.completed); + let back = MkvStream::open(std::fs::File::open(&dst).unwrap()).unwrap(); + assert_eq!(back.track_timing(0), timing); + } } diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index 426550e9..2bd4fda4 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -563,6 +563,11 @@ pub const BLOCK_ADD_ID: u32 = 0xEE; /// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe /// video frames that must live in a BlockGroup to carry an MVC BlockAdditional. pub const REFERENCE_BLOCK: u32 = 0xFB; +/// DiscardPadding (RFC 9559 §5.1.3.5.7): signed ns of decoded audio to drop. +pub const DISCARD_PADDING: u32 = 0x75A2; +/// CodecDelay / SeekPreRoll (RFC 9559 §5.1.4.1.18/19), TrackEntry children, ns. +pub const CODEC_DELAY: u32 = 0x56AA; +pub const SEEK_PRE_ROLL: u32 = 0x56BB; // Audio pub const AUDIO: u32 = 0xE1; @@ -629,6 +634,8 @@ pub const CODEC_EAC3: &str = "A_EAC3"; pub const CODEC_TRUEHD: &str = "A_TRUEHD"; pub const CODEC_DTS: &str = "A_DTS"; pub const CODEC_PCM_BE: &str = "A_PCM/INT/BIG"; +/// Little-endian integer PCM (read side only; converted to big-endian). +pub const CODEC_PCM_LE: &str = "A_PCM/INT/LIT"; /// AAC. The generic registered ID; the AudioSpecificConfig travels in /// CodecPrivate, so no profile suffix is needed (and the `A_AAC/MPEG4/*` /// suffixed forms are legacy). diff --git a/src/mux/fvi_sink.rs b/src/mux/fvi_sink.rs index 42dd66be..e165e0b6 100644 --- a/src/mux/fvi_sink.rs +++ b/src/mux/fvi_sink.rs @@ -4,7 +4,7 @@ //! *video-index* record per coded picture of the title's primary video //! track, instead of muxing frames into a container. //! -//! On-disk shape: the freemkv FVI format — JSON Lines, a header object on line 1, then one +//! On-disk shape: the freemkv FVI format — JSON Lines, a header object on line 1, then one //! record per picture. The sink is purely additive: it does NOT touch the MKV mux path. use crate::disc::{DiscTitle, Stream as DiscStream}; @@ -17,7 +17,7 @@ use std::fs::File; use std::io::{self, BufWriter, Write}; use std::path::Path; -/// Write the FVI header row into `w`. +/// Write the FVI header row into `w`. fn write_fvi_header(w: &mut dyn Write, h: &MapHeader) -> io::Result<()> { let mut source = serde_json::json!({ "medium": h.source.medium.as_str(), diff --git a/src/mux/interop_tests.rs b/src/mux/interop_tests.rs index 21621b96..4a33e35a 100644 --- a/src/mux/interop_tests.rs +++ b/src/mux/interop_tests.rs @@ -66,6 +66,30 @@ fn generate( vfr: bool, bframes: bool, multi_audio: bool, +) -> PathBuf { + generate_with( + dir, + video, + audio, + transport, + vfr, + bframes, + multi_audio, + false, + ) +} + +// `video_late`: video starts 0.3 s in, so the 0.1 s audio precedes the first IDR. +#[allow(clippy::too_many_arguments)] +fn generate_with( + dir: &Path, + video: &str, + audio: Option<&str>, + transport: bool, + vfr: bool, + bframes: bool, + multi_audio: bool, + video_late: bool, ) -> PathBuf { let path = dir.join(if transport { "input.m2ts" } else { "input.mkv" }); let mut command = Command::new("ffmpeg"); @@ -145,6 +169,9 @@ fn generate( "vfr", ]); } + if video_late { + command.args(["-vf", "setpts=PTS+0.3/TB"]); + } if transport { command.args(["-f", "mpegts", "-mpegts_m2ts_mode", "0"]); } @@ -477,8 +504,7 @@ fn ffmpeg_mkv_roundtrip_preserves_vfr_and_multiple_audio_tracks() { } // Discovered while building the gate; retain executable, strict reproducers. -// These deliberately do NOT have the ffmpeg_ prefix: run ffmpeg_ explicitly -// to investigate them. Promote each into the QA gate when its fix lands. +// They carry the ffmpeg_ prefix, so the QA gate runs them with the rest. #[test] #[ignore = "requires ffmpeg and ffprobe"] @@ -539,3 +565,22 @@ fn ffmpeg_generic_transport_audio_discovery() { validate(dir.path(), &input, &output, 2, false); } } + +#[test] +#[ignore = "requires ffmpeg and ffprobe"] +fn ffmpeg_audio_before_the_first_video_frame_keeps_its_offset() { + let dir = fixture_dir("audio-first"); + let input = generate_with( + dir.path(), + "libx264", + Some("ac3"), + true, + false, + false, + false, + true, + ); + let output = dir.path().join("output.mkv"); + remux(&input, &output, true); + validate(dir.path(), &input, &output, 2, false); +} diff --git a/src/mux/m2ts_mux/mod.rs b/src/mux/m2ts_mux/mod.rs index 91c8897d..cce55c4f 100644 --- a/src/mux/m2ts_mux/mod.rs +++ b/src/mux/m2ts_mux/mod.rs @@ -119,6 +119,8 @@ pub struct M2tsMux { /// onto the very first video PES so a receiver tuning at stream start /// has a clock reference (the PMT advertises the video PID as PCR_PID). first_video_written: bool, + /// Last PCR stamped (90 kHz); the PCR is held monotonic against it. + last_pcr_90k: u64, } impl M2tsMux { @@ -140,6 +142,7 @@ impl M2tsMux { packets_written: 0, video_packets_since_pcr: 0, first_video_written: false, + last_pcr_90k: 0, } } @@ -173,11 +176,16 @@ impl M2tsMux { /// 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). + /// + /// Frames carry no DTS, so the PCR leads the PTS by `PCR_LEAD_90KHZ` and is + /// held monotonic; reordering deeper than that lead can still let it pass a + /// B-frame's PTS. pub fn write_video(&mut self, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> { let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ true); - // PCR comes "before" the PTS it timestamps; clamp at 0 for the - // first frame so we don't underflow. - let pcr = pts_90k.saturating_sub(PCR_LEAD_90KHZ); + let pcr = pts_90k + .saturating_sub(PCR_LEAD_90KHZ) + .max(self.last_pcr_90k); + self.last_pcr_90k = pcr; // Annex-B-ify the frame and prepend VPS/SPS/PPS once, on the // FIRST keyframe (not first frame — non-key frames before the @@ -256,7 +264,11 @@ impl M2tsMux { 0 }; if may_seed_base { - self.base_pts_90k.get_or_insert(raw_90k); + // Seeded one headroom early (see tsmux) so a slightly earlier frame + // arriving second keeps its offset. + let headroom = (super::tsmux::ORIGIN_HEADROOM_NS as u64) * 9 / 100_000; + self.base_pts_90k + .get_or_insert(raw_90k.wrapping_sub(headroom) & 0x1_FFFF_FFFF); } let base = self.base_pts_90k.unwrap_or(raw_90k); // Modular 33-bit subtraction: the PTS clock wraps every 2^33 ticks @@ -569,6 +581,9 @@ fn mpegts_crc32(data: &[u8]) -> u32 { mod tests { use super::*; + // The origin headroom in 90 kHz ticks: the seeding frame's encoded PTS. + const H: u64 = super::super::tsmux::ORIGIN_HEADROOM_NS as u64 * 9 / 100_000; + // Golden vector for an audio PES header (ISO/IEC 13818-1 §2.4.3.7). #[test] fn audio_pes_header_is_byte_exact_and_bounded_unlike_video() { @@ -1280,9 +1295,7 @@ mod tests { let mut frame = Vec::new(); frame.extend_from_slice(&4u32.to_be_bytes()); frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]); - // 1s → 90000 ticks; base is this same frame, so relative PTS=0 - // and PCR clamps to 0. Use a single frame: PTS rebases to 0, - // so PCR = 0.saturating_sub(lead) = 0. + // A single frame: it seeds the origin one headroom below itself. mux.write_video(1_000_000_000, true, &frame).unwrap(); mux.finish().unwrap(); } @@ -1295,16 +1308,16 @@ mod tests { | ((af[3] as u64) << 9) | ((af[4] as u64) << 1) | ((af[5] as u64 >> 7) & 0x01); - // Single frame rebases its own PTS to 0; PCR = 0 - lead clamped to 0. - assert_eq!(base, 0, "first frame PCR clamps to 0 (no underflow)"); + // The seeding frame sits one headroom past the origin; PCR leads it. + assert_eq!(base, H - PCR_LEAD_90KHZ, "first frame PCR leads its PTS"); } // ── base_relative_pts overflow / saturation ─────────────────────────── #[test] fn extreme_pts_does_not_overflow_and_clamps_to_33bit() { - // Two frames are required: a single frame always rebases to PTS 0, making - // the only assertion the vacuous `0 < 2^33`. Bit 32 is unreachable (the + // Two frames are required: a single frame always encodes the headroom, making + // the only assertion vacuous. Bit 32 is unreachable (the // signed-33-bit delta rule floors the upper half to 0), so this pins encoding. let decode_pts = |pkt: &[u8]| -> u64 { // Payload after the AF: AF area = 1 (length byte) + af_len. @@ -1328,7 +1341,8 @@ mod tests { { let mut mux = M2tsMux::new(&mut sink); mux.write_video(0, true, &frame).unwrap(); - mux.write_video(477_218_477 * 100_000, true, &frame) + // Less one headroom (10_000 x 100 us), which the origin already adds. + mux.write_video((477_218_477 - 10_000) * 100_000, true, &frame) .unwrap(); mux.finish().unwrap(); } @@ -1398,7 +1412,7 @@ mod tests { #[test] fn negative_pts_ns_encodes_zero() { // base_relative_pts treats pts_ns <= 0 as raw 0. A negative input - // must encode PTS 0, not a wrapped value. + // must encode as t=0 (the headroom), not a wrapped value. let mut sink: Vec = Vec::new(); { let mut mux = M2tsMux::new(&mut sink); @@ -1416,7 +1430,7 @@ mod tests { | (((pes[11] >> 1) as u64) << 15) | ((pes[12] as u64) << 7) | ((pes[13] >> 1) as u64); - assert_eq!(pts, 0, "negative pts_ns encodes PTS 0"); + assert_eq!(pts, H, "negative pts_ns is treated as 0, the seeding frame"); } #[test] @@ -1454,12 +1468,12 @@ mod tests { assert_eq!(pkts.len(), 2, "one PUSI packet per small audio frame"); assert_eq!( decode_audio_pts(pkts[0]), - 0, - "the first (audio) frame seeds the origin → 0" + H, + "the first (audio) frame seeds the origin one headroom below it" ); assert_eq!( decode_audio_pts(pkts[1]), - 90_000, + H + 90_000, "the second audio frame (1s later) must keep its spacing, not collapse to 0" ); } @@ -1518,4 +1532,49 @@ mod tests { "first packet is PAT on PID 0" ); } + + // Same for the 188-byte muxer: a frame shortly before the seeding one keeps + // its offset instead of flooring to the origin. + #[test] + fn a_frame_shortly_before_the_seeding_frame_keeps_its_offset() { + let mut sink: Vec = Vec::new(); + let mut mux = M2tsMux::new(&mut sink); + let audio = mux.base_relative_pts(100_000_000, true); + let video = mux.base_relative_pts(50_000_000, true); + assert_eq!(audio - video, 4_500); + } + + fn all_pcrs(buf: &[u8]) -> Vec { + find_all_pkts(buf, PID_VIDEO, false) + .into_iter() + .filter_map(af_body) + .filter(|af| af.first().is_some_and(|f| f & 0x10 != 0)) + .map(|af| { + ((af[1] as u64) << 25) + | ((af[2] as u64) << 17) + | ((af[3] as u64) << 9) + | ((af[4] as u64) << 1) + | ((af[5] as u64 >> 7) & 0x01) + }) + .collect() + } + + // B-frames arrive with PTS below an earlier P-frame's: the PCR must still + // never step backwards (ISO/IEC 13818-1 §2.4.2.2). + #[test] + fn pcr_never_goes_backwards_with_b_frames() { + let mut frame = Vec::new(); + frame.extend_from_slice(&8000u32.to_be_bytes()); + frame.extend(std::iter::repeat_n(0x41u8, 8000)); + let mut sink: Vec = Vec::new(); + { + let mut mux = M2tsMux::new(&mut sink); + for (i, ms) in [80i64, 200, 120, 160].into_iter().enumerate() { + mux.write_video(ms * 1_000_000, i == 0, &frame).unwrap(); + } + } + let pcrs = all_pcrs(&sink); + assert!(pcrs.len() >= 4, "{pcrs:?}"); + assert!(pcrs.windows(2).all(|w| w[1] >= w[0]), "{pcrs:?}"); + } } diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 0579912e..27114b74 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -26,8 +26,10 @@ const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00]; /// Highest header format version this build understands. A header tagged with /// a newer version is rejected so older readers cleanly refuse incompatible -/// formats instead of silently mis-parsing them as v1. -const SUPPORTED_VERSION: u8 = 1; +/// formats instead of silently mis-parsing them as v1. Version 2 = every PES +/// frame after the header carries an 8-byte DiscardPadding extension; it is +/// written only when a track has decoder timing, so other streams stay v1. +const SUPPORTED_VERSION: u8 = 2; /// Index of the version byte within [`MAGIC`]. const VERSION_BYTE: usize = 5; @@ -47,6 +49,24 @@ pub struct M2tsMeta { pub duration: f64, /// Stream descriptors. pub streams: Vec, + /// Per-track decoder timing (Opus CodecDelay/SeekPreRoll); non-default only. + /// Readers that predate it ignore the field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub timings: Vec, + /// Frames on the wire carry the DiscardPadding extension (header v2). Taken + /// from the version byte, not the JSON. + #[serde(skip)] + pub frame_padding: bool, +} + +/// Decoder timing for one stream index, carried in the FMKV header. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct MetaTiming { + pub track: usize, + #[serde(default)] + pub codec_delay_ns: u64, + #[serde(default)] + pub seek_preroll_ns: u64, } /// A single stream descriptor in the metadata. @@ -165,9 +185,40 @@ impl M2tsMeta { title: title.playlist.clone(), duration: title.duration_secs, streams, + timings: Vec::new(), + frame_padding: false, } } + /// Attach per-track decoder timing; any non-default timing switches the + /// stream to header v2 so DiscardPadding travels with each frame. + pub fn with_timings(mut self, timings: &[crate::pes::TrackTiming]) -> Self { + self.timings = timings + .iter() + .enumerate() + .filter(|(_, t)| **t != crate::pes::TrackTiming::default()) + .map(|(track, t)| MetaTiming { + track, + codec_delay_ns: t.codec_delay_ns, + seek_preroll_ns: t.seek_preroll_ns, + }) + .collect(); + self.frame_padding = !self.timings.is_empty(); + self + } + + /// Decoder timing of stream `track` (default when the header has none). + pub fn timing(&self, track: usize) -> crate::pes::TrackTiming { + self.timings + .iter() + .find(|t| t.track == track) + .map(|t| crate::pes::TrackTiming { + codec_delay_ns: t.codec_delay_ns, + seek_preroll_ns: t.seek_preroll_ns, + }) + .unwrap_or_default() + } + /// Convert back to a library Title (for remux). pub fn to_title(&self) -> DiscTitle { let streams = self @@ -310,7 +361,11 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> { let padded_len = raw_len.div_ceil(BD_SOURCE_PACKET_BYTES) * BD_SOURCE_PACKET_BYTES; let padding = padded_len - raw_len; - w.write_all(&MAGIC)?; + let mut magic = MAGIC; + if meta.frame_padding { + magic[VERSION_BYTE] = 2; + } + w.write_all(&magic)?; w.write_all(&json_len.to_be_bytes())?; w.write_all(&json)?; if padding > 0 { @@ -367,8 +422,9 @@ pub fn read_header(r: &mut impl Read) -> io::Result> { let mut json_buf = vec![0u8; json_len]; r.read_exact(&mut json_buf)?; - let meta: M2tsMeta = + let mut meta: M2tsMeta = serde_json::from_slice(&json_buf).map_err(|_| crate::error::Error::NoMetadata)?; + meta.frame_padding = magic[VERSION_BYTE] >= 2; // Skip padding to next 192-byte boundary (at most BD_SOURCE_PACKET_BYTES-1 bytes → // a stack buffer, no heap allocation). @@ -625,7 +681,7 @@ mod tests { // The version byte lives at index 5. A regression that shifted the // version byte would make every header read the wrong version. assert_eq!(&MAGIC[0..4], b"FMKV"); - assert_eq!(MAGIC[VERSION_BYTE], SUPPORTED_VERSION); + assert_eq!(MAGIC[VERSION_BYTE], 1, "the base (v1) magic"); assert_eq!(VERSION_BYTE, 5); assert_eq!(MAGIC.len(), 8); } @@ -813,4 +869,23 @@ mod tests { assert_eq!(back.playlist, "The Movie"); assert_eq!(back.duration_secs, 7384.5); } + + // Timing switches the header to v2 (older readers refuse it cleanly); no + // timing keeps the v1 wire byte-identical. + #[test] + fn timing_round_trips_and_selects_the_header_version() { + let t = video_title(HdrFormat::Sdr, ColorSpace::Bt709); + let timing = crate::pes::TrackTiming { + codec_delay_ns: 1, + seek_preroll_ns: 2, + }; + for (timings, version) in [(vec![], 1u8), (vec![timing], 2)] { + let mut buf = Vec::new(); + write_header(&mut buf, &M2tsMeta::from_title(&t).with_timings(&timings)).unwrap(); + assert_eq!(buf[VERSION_BYTE], version); + let back = read_header(&mut io::Cursor::new(&buf)).unwrap().unwrap(); + assert_eq!(back.frame_padding, version == 2); + assert_eq!(back.timing(0), timings.first().copied().unwrap_or_default()); + } + } } diff --git a/src/mux/meta_sink.rs b/src/mux/meta_sink.rs index 315e3ba8..5925343e 100644 --- a/src/mux/meta_sink.rs +++ b/src/mux/meta_sink.rs @@ -144,6 +144,18 @@ pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value { doc } +// Every rate a stream carries, in Hz: a BD combo (48/96, 48/192) lists both, +// Unknown is empty. +fn sample_rates(rate: crate::disc::SampleRate) -> Vec { + use crate::disc::SampleRate::*; + match rate { + S48_96 => vec![48_000, 96_000], + S48_192 => vec![48_000, 192_000], + Unknown => Vec::new(), + r => vec![r.hz() as u32], + } +} + /// Compact id for an audio stream's editorial purpose (no localized prose — /// the app maps it to display text). Mirrors the `Codec::id()` convention. fn purpose_id(p: crate::disc::LabelPurpose) -> &'static str { @@ -157,6 +169,22 @@ fn purpose_id(p: crate::disc::LabelPurpose) -> &'static str { } } +// `doc[key][i]` as a mutable object, or `dummy` (reset to an object) if absent. +fn entry<'a>( + doc: &'a mut serde_json::Value, + key: &str, + i: usize, + dummy: &'a mut serde_json::Value, +) -> &'a mut serde_json::Value { + match doc.get_mut(key).and_then(|v| v.get_mut(i)) { + Some(e) if e.is_object() => e, + _ => { + *dummy = serde_json::json!({}); + dummy + } + } +} + /// Merge the per-stream fields `TitleProfile` drops back into the `json://` /// document, straight from the source Title. Walks `title.streams` in declared /// order with a per-kind cursor so each raw stream lines up with the profile's @@ -165,10 +193,12 @@ fn enrich_streams(doc: &mut serde_json::Value, title: &DiscTitle) { use crate::disc::Stream; use serde_json::json; let (mut vi, mut ai, mut si) = (0usize, 0usize, 0usize); + // `doc[key][i]` panics on a missing array; skip an entry the profile lacks. + let mut dummy = serde_json::Value::Null; for s in &title.streams { match s { Stream::Video(v) => { - let e = &mut doc["video"][vi]; + let e = entry(doc, "video", vi, &mut dummy); e["pid"] = json!(v.pid); e["color_space"] = json!(v.color_space.id()); e["display_aspect"] = match v.display_aspect { @@ -199,15 +229,18 @@ fn enrich_streams(doc: &mut serde_json::Value, title: &DiscTitle) { vi += 1; } Stream::Audio(a) => { - let e = &mut doc["audio"][ai]; + let e = entry(doc, "audio", ai, &mut dummy); e["pid"] = json!(a.pid); e["secondary"] = json!(a.secondary); - e["sample_rate"] = json!(a.sample_rate.to_string()); + // Numeric Hz (null when unknown), not the "48kHz" display form. + let hz = a.sample_rate.hz() as u32; + e["sample_rate"] = if hz == 0 { json!(null) } else { json!(hz) }; + e["sample_rates"] = json!(sample_rates(a.sample_rate)); e["purpose"] = json!(purpose_id(a.purpose)); ai += 1; } Stream::Subtitle(sub) => { - let e = &mut doc["subtitles"][si]; + let e = entry(doc, "subtitles", si, &mut dummy); e["pid"] = json!(sub.pid); e["descriptive_service"] = json!(sub.qualifier == crate::disc::LabelQualifier::DescriptiveService); @@ -446,7 +479,8 @@ mod tests { let a = &v["audio"][0]; assert_eq!(a["pid"], 0x1100); assert_eq!(a["secondary"], true); - assert_eq!(a["sample_rate"], "48kHz"); + assert_eq!(a["sample_rate"], 48_000); + assert_eq!(a["sample_rates"], serde_json::json!([48_000])); assert_eq!(a["purpose"], "commentary"); let s = &v["subtitles"][0]; @@ -497,4 +531,34 @@ mod tests { ); let _ = std::fs::remove_file(&jpath); } + + // A document missing the per-kind arrays must not panic the enrichment. + #[test] + fn enriching_a_document_without_stream_arrays_does_not_panic() { + let mut t = DiscTitle::empty(); + t.streams = vec![crate::disc::Stream::Audio(crate::disc::AudioStream { + pid: 0x1100, + codec: crate::disc::Codec::Ac3, + channels: crate::disc::AudioChannels::Stereo, + language: "eng".into(), + sample_rate: crate::disc::SampleRate::Unknown, + secondary: false, + purpose: crate::disc::LabelPurpose::Normal, + label: String::new(), + })]; + let mut doc = serde_json::json!({}); + enrich_streams(&mut doc, &t); + assert_eq!( + title_json(&t)["audio"][0]["sample_rate"], + serde_json::Value::Null + ); + } + + #[test] + fn combo_sample_rates_keep_both_rates() { + use crate::disc::SampleRate; + assert_eq!(sample_rates(SampleRate::S48_96), vec![48_000, 96_000]); + assert_eq!(sample_rates(SampleRate::S48_192), vec![48_000, 192_000]); + assert!(sample_rates(SampleRate::Unknown).is_empty()); + } } diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 66bf69e9..54bb2d51 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -548,6 +548,9 @@ pub struct MkvMuxer { /// track type (mirroring `track_is_video`) so the invariant holds for every /// subtitle track — including a sparse forced-narrative second track. track_is_subtitle: Vec, + /// Per-track-index flag: true for an `S_VOBSUB` track, whose SPU carries its + /// own display duration. + track_is_vobsub: Vec, /// Index of the PRIMARY video track — the first track whose type is video. /// This (not the literal index 0) is the clip-boundary epoch driver: the /// M2TS/PMT path orders streams by PMT declaration order and may list an @@ -570,6 +573,11 @@ pub struct MkvMuxer { /// `finish()` via `frame_count == 0`, not this counter; a PARTIAL drop is normal and is /// only logged, not an error — `finish()` is this field's only reader, so keep that log. dropped_pre_cluster: u64, + /// Ticks the timeline origin sits before the first video keyframe. + origin_lead_ticks: i64, + /// Per VobSub track: file offset of an open-ended block's 3-byte + /// BlockDuration value, and that block's timestamp in ticks. + vobsub_open_end: std::collections::HashMap, seek_fixups: Vec, /// Absolute file offset of the CUES SeekHead entry (a fixed 21-byte Seek /// element). When `finish()` writes no Cues element (zero cue points), this @@ -705,10 +713,48 @@ struct InterlacedRewrite { // AUs. const TIMESTAMP_SCALE_NS: i64 = 100_000; -// Last-resort BlockDuration (1 s) for a SUBTITLE frame that reaches the muxer with -// no duration. Parsers (PGS) already synthesize one; this only fires if another -// subtitle path forgets, keeping every subtitle block durated (issue #52). -const MIN_SUBTITLE_BLOCK_DURATION_NS: i64 = 1_000_000_000; +// BlockDuration for a durationless PGS block (issue #52): a PGS cue ends at the +// next display set, so a long bound only caps a cue that is already replaced. +const PGS_FALLBACK_BLOCK_DURATION_NS: i64 = 30_000_000_000; + +// A VobSub SPU with no stop command ends at the next SPU on its track (patched +// in place), or after this cap. 100_000 ticks keeps the field 3 bytes wide. +const VOBSUB_OPEN_END_TICKS: u64 = 100_000; + +// VobSub SP_DCSQ delays count 1024 ticks of the 90 kHz clock. +const SPU_DELAY_NS: u64 = 1024 * 1_000_000_000 / 90_000; + +// Display time of a VobSub SPU: the delay of its first STP_DSP (0x02) command, +// walking the SP_DCSQ chain. `None` when the SPU has no stop or is malformed. +fn vobsub_display_ns(spu: &[u8]) -> Option { + let be16 = |at: usize| -> Option { + Some(u16::from_be_bytes([*spu.get(at)?, *spu.get(at + 1)?]) as usize) + }; + let mut dcsq = be16(2)?; + // Each DCSQ is at least 5 bytes, so a well-formed chain has bounded length. + for _ in 0..spu.len() / 5 + 1 { + let delay = be16(dcsq)? as u64; + let next = be16(dcsq + 2)?; + let mut at = dcsq + 4; + loop { + let args = match *spu.get(at)? { + 0x02 => return Some(delay * SPU_DELAY_NS), + 0x00 | 0x01 => 0, + 0x03 | 0x04 => 2, + 0x05 => 6, + 0x06 => 4, + 0x07 => be16(at + 1)?, + _ => break, + }; + at += 1 + args; + } + if next == dcsq { + return None; + } + dcsq = next; + } + None +} // Nominal new-cluster interval (2 s) in TimestampScale ticks; a keyframe only opens a new // cluster once this much has elapsed. @@ -898,10 +944,10 @@ impl MkvMuxer { ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?; if let Some(timing) = timings.get(i) { if timing.codec_delay_ns > 0 { - ebml::write_uint(&mut writer, 0x56AA, timing.codec_delay_ns)?; + ebml::write_uint(&mut writer, ebml::CODEC_DELAY, timing.codec_delay_ns)?; } if timing.seek_preroll_ns > 0 { - ebml::write_uint(&mut writer, 0x56BB, timing.seek_preroll_ns)?; + ebml::write_uint(&mut writer, ebml::SEEK_PRE_ROLL, timing.seek_preroll_ns)?; } } @@ -1167,6 +1213,10 @@ impl MkvMuxer { .iter() .map(|t| t.track_type == ebml::TRACK_TYPE_SUBTITLE) .collect(), + track_is_vobsub: tracks + .iter() + .map(|t| t.codec_id == ebml::CODEC_VOBSUB) + .collect(), primary_video_track: tracks .iter() .position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO), @@ -1175,6 +1225,8 @@ impl MkvMuxer { cues: Vec::new(), frame_count: 0, dropped_pre_cluster: 0, + origin_lead_ticks: 0, + vobsub_open_end: std::collections::HashMap::new(), seek_fixups, cues_seek_entry_pos, info_offset, @@ -1225,6 +1277,22 @@ impl MkvMuxer { Ok(true) } + // Overwrite a 3-byte big-endian value at `pos`, then return to the end. + fn patch_u24(&mut self, pos: u64, val: u64) -> io::Result<()> { + let end = self.writer.stream_position()?; + self.writer.seek(io::SeekFrom::Start(pos))?; + self.writer.write_all(&val.to_be_bytes()[5..])?; + self.writer.seek(io::SeekFrom::Start(end))?; + Ok(()) + } + + /// Put the timeline origin `lead_ns` before the first video keyframe (the + /// earliest audio/video frame), and count `dropped` frames that lay before it. + pub(crate) fn set_origin_lead_ns(&mut self, lead_ns: i64, dropped: u64) { + self.origin_lead_ticks = lead_ns.max(0) / TIMESTAMP_SCALE_NS; + self.dropped_pre_cluster += dropped; + } + /// Attach an opening-frame capture (`--log-level 3`). The capture writes the /// first ~100 coded frames per track to `.opening.bin` and logs a /// per-frame summary, so opening-GOP / menu issues are diagnosable from a @@ -1379,8 +1447,9 @@ impl MkvMuxer { self.dropped_pre_cluster += 1; return Ok(()); } - self.base_pts_ticks = Some(raw_ticks); - raw_ticks + let base = raw_ticks - self.origin_lead_ticks; + self.base_pts_ticks = Some(base); + base } }; // Floor at 0: a frame earlier than base (pre-keyframe audio, or a stream @@ -1452,10 +1521,27 @@ impl MkvMuxer { .get(track_idx) .copied() .unwrap_or(false); + let is_vobsub = self + .track_is_vobsub + .get(track_idx) + .copied() + .unwrap_or(false); + if is_vobsub && let Some((pos, start)) = self.vobsub_open_end.remove(&track_idx) { + let ticks = ((pts_ticks - start).max(1) as u64).min(VOBSUB_OPEN_END_TICKS); + self.patch_u24(pos, ticks)?; + } + let mut vobsub_open = false; let duration_ticks = match duration_ticks { Some(dt) => Some(dt), + None if is_vobsub => match vobsub_display_ns(data).filter(|&ns| ns > 0) { + Some(ns) => Some((ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64), + None => { + vobsub_open = true; + Some(VOBSUB_OPEN_END_TICKS) + } + }, None if is_subtitle => { - Some((MIN_SUBTITLE_BLOCK_DURATION_NS / TIMESTAMP_SCALE_NS).max(1) as u64) + Some((PGS_FALLBACK_BLOCK_DURATION_NS / TIMESTAMP_SCALE_NS) as u64) } None => None, }; @@ -1533,6 +1619,11 @@ impl MkvMuxer { }, } } + // BlockDuration is the group's last element: its 3 value bytes end the write. + if vobsub_open { + let end = self.writer.stream_position()?; + self.vobsub_open_end.insert(track_idx, (end - 3, pts_ticks)); + } // Recorded per track (not a single global slot) so a later non-keyframe // references a keyframe on its OWN track — a shared slot produced cross-track // references on a multi-video-track title (MVC base+EL, or a two-angle disc). @@ -1983,7 +2074,7 @@ impl MkvMuxer { buf.push(flags); buf.extend_from_slice(data); if discard_padding_ns != 0 { - ebml::write_int(buf, 0x75A2, discard_padding_ns)?; + ebml::write_int(buf, ebml::DISCARD_PADDING, discard_padding_ns)?; } if let Some(dt) = duration_ticks { ebml::write_uint(buf, ebml::BLOCK_DURATION, dt)?; @@ -5072,6 +5163,63 @@ mod tests { assert!(groups.iter().any(|g| g.track == 2), "sparse track present"); } + // A VobSub SPU: 4-byte header, 2 pixel bytes, then a STA_DSP DCSQ (delay 0) + // chained to a STP_DSP DCSQ at `stop_delay` (units of 1024/90000 s). + fn vobsub_spu(stop_delay: u16) -> Vec { + let mut spu = vec![0, 18, 0, 6, 0xAB, 0xCD]; + spu.extend_from_slice(&[0, 0, 0, 12, 0x01, 0xFF]); + spu.extend_from_slice(&stop_delay.to_be_bytes()); + spu.extend_from_slice(&[0, 12, 0x02, 0xFF]); + spu + } + + #[test] + fn vobsub_block_duration_comes_from_the_spu_stop_command() { + let mut track = make_subtitle_track(); + track.codec_id = ebml::CODEC_VOBSUB; + // 0x0100 * 1024 / 90 kHz = 2.9127 s -> 29_127 ticks at 0.1 ms. + let data = mux_with_durations(&[track], &[(0, 0, true, vobsub_spu(0x0100), None)]); + let groups = all_block_groups(&data); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].duration, 29_127, "SPU stop time, not a 1 s guess"); + } + + // A VobSub SPU with no stop command ends at the next SPU on its track, else + // after a 10 s cap. + #[test] + fn vobsub_without_a_stop_ends_at_the_next_spu_or_the_cap() { + let mut track = make_subtitle_track(); + track.codec_id = ebml::CODEC_VOBSUB; + // One DCSQ at offset 6: delay 0, next = itself, STA_DSP, CMD_END. + let spu = vec![0, 12, 0, 6, 0xAB, 0xCD, 0, 0, 0, 6, 0x01, 0xFF]; + let data = mux_with_durations( + &[track], + &[ + (0, 0, true, spu.clone(), None), + (0, 2_000_000_000, true, spu, None), + ], + ); + let groups = all_block_groups(&data); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].duration, 20_000, "ends at the next SPU"); + assert_eq!(groups[1].duration, 100_000, "last one capped at 10 s"); + } + + #[test] + fn a_durationless_pgs_block_is_not_cut_to_one_second() { + let data = mux_with_durations( + &[make_subtitle_track()], + &[(0, 0, true, vec![0x16, 0xAA], None)], + ); + let groups = all_block_groups(&data); + assert_eq!(groups.len(), 1); + assert!( + groups[0].duration > 10_000, + "a self-terminating cue must not be hidden after 1 s (got {} ticks)", + groups[0].duration + ); + } + // Cluster boundary (CLUSTER_DURATION_TICKS): a new cluster opens on a video // keyframe once >= the cluster duration has elapsed since the open cluster's // timestamp; one just under the boundary stays in the current cluster. diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index a4041157..5857c470 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -103,10 +103,12 @@ const MAX_PENDING_FRAMES: usize = 4096; // hundreds of KB, so 4096 of them exceeds a gigabyte). Far above any real // audio-only prefix, and finite. const MAX_PENDING_BYTES: usize = 64 << 20; +// open() read-ahead cap while inferring PCM depths (plus at most one block). +const PCM_PROBE_BYTES: usize = 64 << 20; enum Mode { Write(WriteMode), - Read(ReadState), + Read(Box), } // MKV write state with DEFERRED muxer construction: the track header @@ -118,13 +120,17 @@ enum WriteMode { /// Header written; muxing live. Boxed (MkvMuxer is large) to keep the enum /// small (clippy::large_enum_variant). Active(Box>>), - /// Sentinel held in `self.mode` while the muxer is being built (across the - /// Pending → Active swap). It is also the terminal state left behind after - /// `finish()` swaps the muxer out, and the degraded state left behind if - /// `activate()` fails partway (the first error still surfaces via `?`). In - /// that terminal state a subsequent `write()` no-ops (`Ok(())`) and `finish()` - /// does not re-finalize. + /// Sentinel held while the muxer is being built (across the Pending → Active + /// swap); also the terminal state after a successful `finish()`. Building, + /// Header write or finalize failed: the file is truncated, so every later + /// `write()` / `finish()` errors instead of reporting success. + Failed, +} + +// Error for writing into a muxer that is finished or whose output already failed. +fn muxer_unusable() -> io::Error { + crate::error::Error::StreamClosed.into() } /// Everything needed to build the muxer, held until the first coded picture @@ -146,6 +152,41 @@ struct PendingMux { /// only frame count. Maintained on push; `buffered` is drained exactly once, /// at activation, after which neither field is consulted again. buffered_bytes: usize, + /// How far before the first IDR the timeline origin sits (mkvmerge-style). + origin_lead_ns: i64, + /// Audio/video frames dropped for lying before the reachable origin. + dropped_pre_origin: u64, +} + +// Furthest an origin may precede the first IDR: its cluster opens at the IDR, +// and a block's signed 16-bit relative timestamp reaches ~3.27 s back. +const MAX_ORIGIN_LEAD_NS: i64 = 3_000_000_000; + +impl PendingMux { + // Origin = earliest buffered audio/video frame at or after `in_ns` (Blu-ray + // clip IN), within MAX_ORIGIN_LEAD_NS of the IDR. Audio/video before it are + // dropped (counted); subtitles are kept and clamp to the origin. + fn set_origin(&mut self, idr_pts: i64, in_ns: Option) { + let floor = idr_pts.saturating_sub(MAX_ORIGIN_LEAD_NS); + let is_av = |tracks: &[MkvTrack], t: usize| { + tracks + .get(t) + .is_some_and(|t| t.track_type != ebml::TRACK_TYPE_SUBTITLE) + }; + let origin = self + .buffered + .iter() + .filter(|(f, _)| is_av(&self.tracks, f.track)) + .map(|(f, _)| f.pts) + .filter(|&pts| pts >= floor && in_ns.is_none_or(|i| pts >= i)) + .fold(idr_pts, i64::min); + self.origin_lead_ns = idr_pts - origin; + let tracks = &self.tracks; + let before = self.buffered.len(); + self.buffered + .retain(|(f, _)| !is_av(tracks, f.track) || f.pts >= origin); + self.dropped_pre_origin = (before - self.buffered.len()) as u64; + } } /// Matroska container stream. @@ -196,6 +237,15 @@ struct MvcMerge { } impl MvcMerge { + // Muxer track of a frame that is neither the base nor the dependent view + // (`Some(None)` = unmapped, dropped); `None` when the frame needs `ingest`. + fn passthrough_track(&self, track: usize) -> Option> { + if track == self.base_stream_idx || track == self.dep_stream_idx { + return None; + } + Some(self.stream_to_track.get(track).copied().flatten()) + } + // Ingest one frame; returns `(frame, additional)` pairs ready for the muxer, // in emit order. Base frames buffer to pair with their dependent by PTS; the // dependent produces no frame of its own (folds into `BlockAdditional`). @@ -290,11 +340,12 @@ impl MvcMerge { /// plain block. fn emit_to_muxer( m: &mut MkvMuxer>, + track: usize, frame: &crate::pes::PesFrame, additional: Option<&[u8]>, ) -> io::Result<()> { m.write_frame_at_with_padding( - frame.track, + track, frame.pts, frame.keyframe, &frame.data, @@ -441,6 +492,8 @@ impl MkvStream { opening_capture_path: output_path.map(|p| p.to_path_buf()), buffered: Vec::new(), buffered_bytes: 0, + origin_lead_ns: 0, + dropped_pre_origin: 0, }))), }) } @@ -452,6 +505,18 @@ impl MkvStream { &mut self, coding: Option, video_picture_seen: bool, + ) -> io::Result<()> { + let result = self.build_muxer(coding, video_picture_seen); + if result.is_err() { + self.mode = Mode::Write(WriteMode::Failed); + } + result + } + + fn build_muxer( + &mut self, + coding: Option, + video_picture_seen: bool, ) -> io::Result<()> { let mut pending = match std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building)) { @@ -495,6 +560,7 @@ impl MkvStream { )?; // Seam correction from the playlist's marks where the title has them. muxer.set_clips(&self.disc_title.clips, self.disc_title.content_format); + muxer.set_origin_lead_ns(pending.origin_lead_ns, pending.dropped_pre_origin); if let Some(path) = &pending.opening_capture_path { muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len())); } @@ -518,16 +584,21 @@ impl MkvStream { Ok(()) } - // Emit one frame (track index already muxer-relative) with an optional MVC - // dependent-view `BlockAdditional`: the first video frame triggers muxer + // Emit one frame on muxer track `track` (overrides `frame.track`) with an optional + // MVC dependent-view `BlockAdditional`: the first video frame triggers muxer // construction (its coding sets FieldOrder); earlier frames buffer. - fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> { + fn emit( + &mut self, + track: usize, + frame: &crate::pes::PesFrame, + additional: Option<&[u8]>, + ) -> io::Result<()> { match &mut self.mode { Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()), Mode::Write(WriteMode::Active(m)) => { - return emit_to_muxer(m, frame, additional); + return emit_to_muxer(m, track, frame, additional); } - Mode::Write(WriteMode::Building) => return Ok(()), + Mode::Write(WriteMode::Building | WriteMode::Failed) => return Err(muxer_unusable()), Mode::Write(WriteMode::Pending(_)) => {} } // Pending: the first video frame (or the safety cap) triggers muxer @@ -536,7 +607,7 @@ impl MkvStream { let (activate_now, use_coding) = match &self.mode { Mode::Write(WriteMode::Pending(p)) => { let is_video = match p.video_track { - Some(vt) => frame.track == vt, + Some(vt) => track == vt, // No video track: nothing to wait for — build on frame one. None => true, }; @@ -551,9 +622,17 @@ impl MkvStream { // audio already buffered while its parser assembled the opening GOP. // Replaying the audio first made the muxer drop that entire prefix. if use_coding && frame.keyframe { + // Only Blu-ray marks share the PES clock (see `TimelineContinuity::with_clips`). + let in_ns = match self.disc_title.content_format { + crate::disc::ContentFormat::BdTs => self.disc_title.clips.first(), + crate::disc::ContentFormat::MpegPs => None, + } + .map(|c| c.in_time as i64 * 1_000_000_000 / 45_000); if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode { - p.buffered - .insert(0, (frame.clone(), additional.map(<[u8]>::to_vec))); + p.set_origin(frame.pts, in_ns); + let mut f = frame.clone(); + f.track = track; + p.buffered.insert(0, (f, additional.map(<[u8]>::to_vec))); } return self.activate(frame.coding, true); } @@ -562,7 +641,7 @@ impl MkvStream { // is passed (apply_coding_to_track then logs + leaves UNDETERMINED). self.activate(if use_coding { frame.coding } else { None }, use_coding)?; if let Mode::Write(WriteMode::Active(m)) = &mut self.mode { - return emit_to_muxer(m, frame, additional); + return emit_to_muxer(m, track, frame, additional); } Ok(()) } else { @@ -571,7 +650,9 @@ impl MkvStream { p.buffered_bytes = p .buffered_bytes .saturating_add(frame.data.len() + add.as_ref().map_or(0, |a| a.len())); - p.buffered.push((frame.clone(), add)); + let mut f = frame.clone(); + f.track = track; + p.buffered.push((f, add)); } Ok(()) } @@ -580,10 +661,10 @@ impl MkvStream { /// Open an MKV file for reading → PES frames. pub fn open(mut reader: impl Read + Send + 'static) -> io::Result { let (disc_title, codec_privates, ts_scale_ns, tracks) = parse_mkv_header(&mut reader)?; - Ok(Self { + let mut stream = Self { disc_title, mvc: None, - mode: Mode::Read(ReadState { + mode: Mode::Read(Box::new(ReadState { reader: Box::new(reader), cluster_ts_ticks: 0, ts_scale_ns, @@ -592,8 +673,139 @@ impl MkvStream { pending: std::collections::VecDeque::new(), additions_dropped: 0, additions_dropped_bytes: 0, - }), - }) + })), + }; + stream.resolve_pcm_depths()?; + Ok(stream) + } + + // Decide the depth of each PCM track without BitDepth from its first blocks. + // Read-ahead stops at PCM_PROBE_BYTES or PCM_PROBE_FRAMES of the track itself. + fn resolve_pcm_depths(&mut self) -> io::Result<()> { + let mut buffered: Vec = Vec::new(); + let mut bytes = 0usize; + loop { + let (open, lace) = match &self.mode { + Mode::Read(rs) => ( + rs.tracks.pcm_infer.iter().any(Option::is_some), + rs.pending.iter().map(|f| f.data.len()).sum::(), + ), + Mode::Write(_) => (false, 0), + }; + if !open { + break; + } + let end = if bytes.saturating_add(lace) >= PCM_PROBE_BYTES { + ProbeEnd::Capped + } else { + ProbeEnd::Reading + }; + let decided = self.try_infer_pcm(&buffered, end); + if !decided.is_empty() { + self.apply_pcm_decisions(&decided, &mut buffered); + continue; + } + match self.read_parsed()? { + Some(f) => { + bytes = bytes.saturating_add(f.data.len()); + buffered.push(f); + } + None => { + let decided = self.try_infer_pcm(&buffered, ProbeEnd::Eof); + self.apply_pcm_decisions(&decided, &mut buffered); + } + } + } + if let Mode::Read(rs) = &mut self.mode { + for f in buffered.into_iter().rev() { + rs.pending.push_front(f); + } + } + Ok(()) + } + + // `(track, Some(depth) | None=undecidable)` for tracks decidable from `frames`. + // Past the budget, a measured but ambiguous span takes ffmpeg's 16-bit default. + fn try_infer_pcm( + &self, + frames: &[crate::pes::PesFrame], + end: ProbeEnd, + ) -> Vec<(usize, Option)> { + let Mode::Read(rs) = &self.mode else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (idx, info) in rs.tracks.pcm_infer.iter().enumerate() { + let Some(info) = info else { continue }; + let mine: Vec<_> = frames.iter().filter(|f| f.track == idx).collect(); + let depth = match pcm_depth_fit(&mine, *info, rs.ts_scale_ns) { + PcmFit::One(w) => Some(w * 8), + PcmFit::Neither => None, + PcmFit::Both | PcmFit::Unmeasured + if end == ProbeEnd::Reading && mine.len() < PCM_PROBE_FRAMES => + { + continue; + } + PcmFit::Unmeasured => None, + PcmFit::Both => { + tracing::warn!( + target: "mux", + code = crate::error::E_MKV_SOURCE_INVALID, + track = idx, + "mkv read-back: PCM depth ambiguous, assuming 16-bit" + ); + Some(16) + } + }; + out.push((idx, depth)); + } + out + } + + // Record inferred depths, convert already-read frames, or demote the track. + fn apply_pcm_decisions( + &mut self, + decided: &[(usize, Option)], + buffered: &mut [crate::pes::PesFrame], + ) { + let Mode::Read(rs) = &mut self.mode else { + return; + }; + for &(idx, depth) in decided { + let Some(Some(info)) = rs.tracks.pcm_infer.get_mut(idx).map(Option::take) else { + continue; + }; + match depth.map(|d| PcmIn::for_track(info.le, d)) { + Some(Ok(layout)) => { + if let Some(slot) = rs.tracks.pcm.get_mut(idx) { + *slot = layout; + } + if let Some(layout) = layout { + let pending = rs.pending.iter_mut(); + for f in buffered + .iter_mut() + .chain(pending) + .filter(|f| f.track == idx) + { + f.data = layout.to_be24(&f.data); + } + } + } + _ => { + tracing::warn!( + target: "mux", + code = crate::error::E_MKV_SOURCE_INVALID, + track = idx, + "mkv read-back: PCM track has no BitDepth and none can be inferred" + ); + if let Some(crate::disc::Stream::Audio(a)) = + self.disc_title.streams.get_mut(idx) + { + a.codec = Codec::Unknown(0); + } + } + } + } } } @@ -653,8 +865,9 @@ fn apply_coding_to_track( } } -impl crate::pes::Stream for MkvStream { - fn read(&mut self) -> io::Result> { +impl MkvStream { + // One parsed frame (PCM samples already converted where the layout is known). + fn read_parsed(&mut self) -> io::Result> { let rs = match self.mode { Mode::Read(ref mut rs) => rs, Mode::Write(_) => return Err(crate::error::Error::StreamWriteOnly.into()), @@ -739,13 +952,16 @@ impl crate::pes::Stream for MkvStream { checked_size(cs, MAX_BLOCK_SIZE)?, )?); } - 0x75A2 => { - if cs == 0 || cs > 8 { + ebml::DISCARD_PADDING => { + // Zero length is a valid EBML signed integer of value 0. + if cs > 8 { return Err(crate::error::Error::MkvSourceInvalid.into()); } - let raw = read_uint_bounded(&mut rs.reader, cs)?; - let shift = 64 - cs * 8; - discard_padding_ns = ((raw << shift) as i64) >> shift; + if cs > 0 { + let raw = read_uint_bounded(&mut rs.reader, cs)?; + let shift = 64 - cs * 8; + discard_padding_ns = ((raw << shift) as i64) >> shift; + } } ebml::BLOCK_DURATION => { duration_ms = Some(read_uint_bounded(&mut rs.reader, cs)?); @@ -825,21 +1041,34 @@ impl crate::pes::Stream for MkvStream { } } } +} + +impl crate::pes::Stream for MkvStream { + fn read(&mut self) -> io::Result> { + self.read_parsed() + } fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { if matches!(self.mode, Mode::Read(_)) { return Err(crate::error::Error::StreamReadOnly.into()); } // Non-3D fast path: emit the frame directly, no clone, no buffering. - if self.mvc.is_none() { - return self.emit(frame, None); + let Some(mvc) = self.mvc.as_mut() else { + return self.emit(frame.track, frame, None); + }; + // 3D, but not a base/dependent video frame: remap and emit without a clone. + if let Some(mapped) = mvc.passthrough_track(frame.track) { + return match mapped { + Some(t) => self.emit(t, frame, None), + None => Ok(()), + }; } // Blu-ray 3D: fold the dependent view into the base as BlockAdditional // (paired by PTS) and yield 0+ frames; `ingest` returns owned pairs so the // `self.mvc` borrow is released before `emit`. - let emits = self.mvc.as_mut().unwrap().ingest(frame); + let emits = mvc.ingest(frame); for (f, additional) in emits { - self.emit(&f, additional.as_deref())?; + self.emit(f.track, &f, additional.as_deref())?; } Ok(()) } @@ -857,7 +1086,7 @@ impl crate::pes::Stream for MkvStream { ); } for (f, additional) in tail { - self.emit(&f, additional.as_deref())?; + self.emit(f.track, &f, additional.as_deref())?; } } // A title that produced no frames (or only buffered ones) is still @@ -868,12 +1097,23 @@ impl crate::pes::Stream for MkvStream { // absent, not a parser defect — `video_picture_seen=false`. self.activate(None, false)?; } - if let Mode::Write(WriteMode::Active(m)) = - std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building)) - { - m.finish()?; + match std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building)) { + Mode::Write(WriteMode::Active(m)) => { + if let Err(e) = m.finish() { + self.mode = Mode::Write(WriteMode::Failed); + return Err(e); + } + Ok(()) + } + Mode::Write(WriteMode::Failed) => { + self.mode = Mode::Write(WriteMode::Failed); + Err(muxer_unusable()) + } + other => { + self.mode = other; + Ok(()) + } } - Ok(()) } fn info(&self) -> &crate::disc::DiscTitle { @@ -892,24 +1132,30 @@ impl crate::pes::Stream for MkvStream { track: usize, timing: crate::pes::TrackTiming, ) -> io::Result<()> { - if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode { - let mapped = if let Some(m) = &self.mvc { - let Some(mapped) = m.stream_to_track.get(track).copied().flatten() else { - return Ok(()); - }; - mapped - } else { - track - }; - if let Some(t) = p.timings.get_mut(mapped) { - *t = timing; + let p = match &mut self.mode { + Mode::Write(WriteMode::Pending(p)) => p, + Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()), + // The TrackEntry is already written: the timing can no longer be encoded. + Mode::Write(_) => return Err(crate::error::Error::StreamHeaderWritten.into()), + }; + let range = || -> io::Error { + crate::error::Error::MuxTrackRange { + track, + tracks: self.disc_title.streams.len(), } - Ok(()) - } else { - Err(io::Error::other( - "track timing must be set before writing frames", - )) - } + .into() + }; + let mapped = match &self.mvc { + Some(m) => match m.stream_to_track.get(track) { + // The dependent view folds into the base track: nothing to set. + Some(None) => return Ok(()), + Some(Some(t)) => *t, + None => return Err(range()), + }, + None => track, + }; + *p.timings.get_mut(mapped).ok_or_else(range)? = timing; + Ok(()) } fn set_codec_private(&mut self, track: usize, data: &[u8]) -> io::Result { @@ -1066,14 +1312,14 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult { } remaining -= consumed; if cid == ebml::TRACK_ENTRY { - let (stream, tnum, cp, default_dur, timing) = parse_track(r, cs)?; + let (stream, tnum, cp, default_dur, timing, pcm, infer) = + parse_track(r, cs)?; if let Some(s) = stream { // Record the TrackNumber alongside the stream it maps // to, in the SAME order, so block routing never has to // guess that TrackNumbers are 1..=N. streams.push(s); - tracks.push(tnum, default_dur); - *tracks.timings.last_mut().unwrap() = timing; + tracks.push(tnum, default_dur, timing, pcm, infer); } if let Some(cp) = cp { codec_privates.push((tnum, cp)); @@ -1140,8 +1386,122 @@ type ParsedTrack = ( Option>, Option, crate::pes::TrackTiming, + Option, + Option, ); +// A PCM track that declares no BitDepth: the depth is inferred from the first +// blocks' byte counts over their duration (see `MkvStream::resolve_pcm_depths`). +#[derive(Clone, Copy, Debug)] +struct PcmInfer { + le: bool, + rate: f64, + channels: u8, +} + +// Frames of one PCM track a depth probe may buffer before settling on a default. +const PCM_PROBE_FRAMES: usize = 512; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ProbeEnd { + Reading, + Capped, + Eof, +} + +#[derive(Debug, PartialEq, Eq)] +enum PcmFit { + One(u64), + Both, + // Both fit only because no span could be measured: no evidence. + Unmeasured, + Neither, +} + +// Which of 2/3 bytes per sample fits every block's size and the bytes over the +// measured span, allowing two timestamp ticks of quantisation error. +fn pcm_depth_fit(frames: &[&crate::pes::PesFrame], info: PcmInfer, tick_ns: i64) -> PcmFit { + let ch = u64::from(info.channels.max(1)); + let mut fits = [2u64, 3].map(|w| { + frames + .iter() + .all(|f| (f.data.len() as u64).is_multiple_of(ch * w)) + }); + let err = tick_ns.max(1).saturating_mul(2); + let span = pcm_span(frames); + if let Some((bytes, span_ns)) = span { + let per_ns = info.rate * ch as f64 / 1e9; + for (fit, w) in fits.iter_mut().zip([2.0f64, 3.0]) { + let lo = span_ns.saturating_sub(err).max(0) as f64 * per_ns * w * 0.99; + let hi = span_ns.saturating_add(err) as f64 * per_ns * w * 1.01; + *fit &= (lo..=hi).contains(&(bytes as f64)); + } + } + match fits { + [true, false] => PcmFit::One(2), + [false, true] => PcmFit::One(3), + [true, true] if span.is_some_and(|(_, ns)| ns > err) => PcmFit::Both, + [true, true] => PcmFit::Unmeasured, + [false, false] => PcmFit::Neither, + } +} + +// `(bytes, ns)` of the longest run of frames whose end time is known: a later +// frame's timestamp, or the last frame's timestamp plus its duration. +fn pcm_span(frames: &[&crate::pes::PesFrame]) -> Option<(u64, i64)> { + let first = frames.first()?.pts; + let mut best = None; + let mut bytes = 0u64; + for (i, f) in frames.iter().enumerate() { + if i > 0 && f.pts > frames[i - 1].pts { + best = Some((bytes, f.pts.saturating_sub(first))); + } + bytes = bytes.saturating_add(f.data.len() as u64); + } + let last = frames.last()?; + if let Some(d) = last.duration_ns.and_then(|d| i64::try_from(d).ok()) { + best = Some((bytes, last.pts.saturating_add(d).saturating_sub(first))); + } + best.filter(|&(_, ns)| ns > 0) +} + +// Source layout of a PCM track whose samples are rewritten to the 24-bit +// big-endian form every LPCM consumer expects. `None` = already 24-bit BE. +#[derive(Clone, Copy, Debug, PartialEq)] +enum PcmIn { + Be16, + Le16, + Le24, +} + +impl PcmIn { + // Layout for a PCM codec ID + BitDepth: `Ok(None)` = already 24-bit BE, + // `Err(())` = not representable as LPCM. + fn for_track(le: bool, bit_depth: u64) -> Result, ()> { + match (le, bit_depth) { + (false, 16) => Ok(Some(PcmIn::Be16)), + (false, 24) => Ok(None), + (true, 16) => Ok(Some(PcmIn::Le16)), + (true, 24) => Ok(Some(PcmIn::Le24)), + _ => Err(()), + } + } + + // Rewrite samples to 24-bit big-endian; a trailing partial sample is dropped. + fn to_be24(self, data: &[u8]) -> Vec { + let width = if self == PcmIn::Le24 { 3 } else { 2 }; + let mut out = Vec::with_capacity(data.len() / width * 3); + for s in data.chunks_exact(width) { + match self { + PcmIn::Be16 => out.extend_from_slice(&[s[0], s[1], 0]), + PcmIn::Le16 => out.extend_from_slice(&[s[1], s[0], 0]), + PcmIn::Le24 => out.extend_from_slice(&[s[2], s[1], s[0]]), + } + } + out + } +} + /// Returns (stream, track_number, codec_private_bytes, default_duration_ns) fn parse_track(r: &mut impl Read, size: u64) -> io::Result { let (mut ttype, mut tnum) = (0u64, 0u16); @@ -1153,6 +1513,7 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result { let mut timing = crate::pes::TrackTiming::default(); let (mut codec_id, mut lang, mut name) = (String::new(), String::from("und"), String::new()); let (mut ph, mut sr, mut ch, mut forced) = (0u32, 0.0f64, 0u8, false); + let mut bit_depth = 0u64; let mut codec_priv: Option> = None; let mut remaining = size; @@ -1184,8 +1545,8 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result { let ns = read_uint_bounded(r, cs)?; default_dur = (ns > 0 && ns <= MAX_DEFAULT_DURATION_NS).then_some(ns); } - 0x56AA => timing.codec_delay_ns = read_uint_bounded(r, cs)?, - 0x56BB => timing.seek_preroll_ns = read_uint_bounded(r, cs)?, + ebml::CODEC_DELAY => timing.codec_delay_ns = read_uint_bounded(r, cs)?, + ebml::SEEK_PRE_ROLL => timing.seek_preroll_ns = read_uint_bounded(r, cs)?, ebml::CODEC_ID => codec_id = read_string_bounded(r, cs)?, ebml::CODEC_PRIVATE => { codec_priv = Some(ebml::read_binary_val( @@ -1237,6 +1598,7 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result { // 256 would truncate to 0 (invalid) on a bare cast; saturate to // u8::MAX so an absurd count degrades to "many", never to 0. ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8, + ebml::BIT_DEPTH => bit_depth = read_uint_bounded(r, as_)?, _ => { skip_bytes(r, as_)?; } @@ -1252,7 +1614,24 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result { // &str consts can't be `match` patterns, so compare via guards — this keeps // the single source of truth in `ebml::CODEC_*` shared with the muxer. let cid = codec_id.as_str(); - let codec = if cid == ebml::CODEC_HEVC { + let (mut pcm, mut infer) = (None, None); + let is_pcm = cid == ebml::CODEC_PCM_BE || cid == ebml::CODEC_PCM_LE; + let codec = if is_pcm && bit_depth == 0 { + infer = Some(PcmInfer { + le: cid == ebml::CODEC_PCM_LE, + rate: sr, + channels: ch, + }); + Codec::Lpcm + } else if is_pcm { + match PcmIn::for_track(cid == ebml::CODEC_PCM_LE, bit_depth) { + Ok(layout) => { + pcm = layout; + Codec::Lpcm + } + Err(()) => Codec::Unknown(0), + } + } else if cid == ebml::CODEC_HEVC { Codec::Hevc } else if cid == ebml::CODEC_H264 { Codec::H264 @@ -1268,8 +1647,16 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result { Codec::TrueHd } else if cid == ebml::CODEC_DTS { Codec::Dts - } else if cid == ebml::CODEC_PCM_BE { - Codec::Lpcm + } else if cid == ebml::CODEC_AAC { + Codec::Aac + } else if cid == ebml::CODEC_MP2 { + Codec::Mp2 + } else if cid == ebml::CODEC_MP3 { + Codec::Mp3 + } else if cid == ebml::CODEC_FLAC { + Codec::Flac + } else if cid == ebml::CODEC_OPUS { + Codec::Opus } else if cid == ebml::CODEC_PGS { Codec::Pgs } else if cid == ebml::CODEC_VOBSUB { @@ -1341,7 +1728,7 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result { })), _ => None, }; - Ok((stream, tnum, codec_priv, default_dur, timing)) + Ok((stream, tnum, codec_priv, default_dur, timing, pcm, infer)) } // Read-side map from Matroska TrackNumber to the index of the corresponding entry in @@ -1356,13 +1743,26 @@ struct TrackTable { /// carry an "underdetermined" timestamp per RFC 9559 §10.3.5. default_durations: Vec>, timings: Vec, + /// PCM tracks whose samples are rewritten to 24-bit big-endian on read. + pcm: Vec>, + /// PCM tracks whose depth is still to be inferred from their blocks. + pcm_infer: Vec>, } impl TrackTable { - fn push(&mut self, num: u16, default_duration_ns: Option) { + fn push( + &mut self, + num: u16, + default_duration_ns: Option, + timing: crate::pes::TrackTiming, + pcm: Option, + infer: Option, + ) { self.nums.push(num); - self.timings.push(Default::default()); + self.timings.push(timing); self.default_durations.push(default_duration_ns); + self.pcm.push(pcm); + self.pcm_infer.push(infer); } /// Stream index carrying blocks with this TrackNumber, or `None` when the @@ -1388,6 +1788,8 @@ impl TrackTable { nums: (1..=n as u16).collect(), default_durations: vec![None; n], timings: vec![Default::default(); n], + pcm: vec![None; n], + pcm_infer: vec![None; n], } } } @@ -1518,6 +1920,22 @@ fn parse_block( ts_scale_ns: i64, tracks: &TrackTable, duration_ns: Option, +) -> io::Result> { + let mut frames = parse_block_raw(block, cluster_ts_ticks, ts_scale_ns, tracks, duration_ns)?; + for f in &mut frames { + if let Some(Some(layout)) = tracks.pcm.get(f.track) { + f.data = layout.to_be24(&f.data); + } + } + Ok(frames) +} + +fn parse_block_raw( + block: &[u8], + cluster_ts_ticks: i64, + ts_scale_ns: i64, + tracks: &TrackTable, + duration_ns: Option, ) -> io::Result> { if block.len() < 4 { return Ok(Vec::new()); @@ -1890,11 +2308,8 @@ mod tests { ); } - // Blu-ray 3D case: base (left-eye) + dependent (right-eye) video. The - // dependent must NOT become its own track (folds into base as - // BlockAdditional) — a silently-disabled merge yields two H.264 tracks, not 3D. - #[test] - fn a_base_and_dependent_video_pair_builds_the_mvc_merge_and_skips_the_dependent_track() { + // Blu-ray 3D title: base (0), MVC dependent (1) and AC-3 audio (2). + fn mvc_title() -> crate::disc::DiscTitle { use crate::disc::{ AudioStream, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream, VideoStream, @@ -1917,10 +2332,10 @@ mod tests { measured_cicp: None, }) }; - let title = DiscTitle { + DiscTitle { streams: vec![ - view(false), // 0: base (left eye) - view(true), // 1: dependent (right eye) + view(false), + view(true), Stream::Audio(AudioStream { pid: 0x1100, codec: Codec::Ac3, @@ -1930,11 +2345,36 @@ mod tests { secondary: false, purpose: crate::disc::LabelPurpose::Normal, label: String::new(), - }), // 2: audio + }), ], ..DiscTitle::empty() - }; - let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title, None).unwrap(); + } + } + + // write()-level: an audio frame on a 3D title takes the pass-through fast + // path and lands on its remapped muxer track with its payload intact. + #[test] + fn mvc_title_audio_passes_through_to_its_remapped_track() { + let out = SharedOut::new(); + let mut s = MkvStream::create(Box::new(out.clone()), &mvc_title(), None).unwrap(); + s.write(&av_frame(0, 0, true, vec![0x11; 16])).unwrap(); + s.write(&av_frame(2, 10_000_000, true, vec![0xA5; 8])) + .unwrap(); + s.write(&av_frame(2, 42_000_000, true, vec![0xA6; 8])) + .unwrap(); + s.finish().unwrap(); + let back = drain(&mut MkvStream::open(Cursor::new(out.bytes())).unwrap()); + let audio: Vec<_> = back.iter().filter(|f| f.track == 1).collect(); + assert_eq!(audio.len(), 2); + assert_eq!(audio[0].data, vec![0xA5; 8]); + assert_eq!(audio[1].pts, 42_000_000); + } + + // The dependent must NOT become its own track (it folds into the base as + // BlockAdditional): a silently-disabled merge yields two H.264 tracks, not 3D. + #[test] + fn a_base_and_dependent_video_pair_builds_the_mvc_merge_and_skips_the_dependent_track() { + let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &mvc_title(), None).unwrap(); let mvc = s .mvc .as_ref() @@ -2589,6 +3029,8 @@ mod tests { nums: vec![65535], default_durations: vec![None], timings: vec![Default::default()], + pcm: vec![None], + pcm_infer: vec![None], } .index_of(65535), Some(0), @@ -3833,7 +4275,13 @@ mod tests { (2, ebml::CODEC_EAC3, Codec::Ac3Plus), (2, ebml::CODEC_TRUEHD, Codec::TrueHd), (2, ebml::CODEC_DTS, Codec::Dts), - (2, ebml::CODEC_PCM_BE, Codec::Lpcm), + // No BitDepth and no blocks to infer it from: not presented as LPCM. + (2, ebml::CODEC_PCM_BE, Codec::Unknown(0)), + (2, ebml::CODEC_AAC, Codec::Aac), + (2, ebml::CODEC_MP2, Codec::Mp2), + (2, ebml::CODEC_MP3, Codec::Mp3), + (2, ebml::CODEC_FLAC, Codec::Flac), + (2, ebml::CODEC_OPUS, Codec::Opus), (17, ebml::CODEC_PGS, Codec::Pgs), (17, ebml::CODEC_VOBSUB, Codec::DvdSub), // An ID this crate does not carry stays Unknown — never silently @@ -4936,7 +5384,6 @@ mod tests { writer.finish().unwrap(); let mut reader = MkvStream::open(Cursor::new(out.bytes())).unwrap(); assert_eq!(reader.track_timing(0), timing); - assert_eq!(reader.track_timing(1), Default::default()); let back = reader.read().unwrap().unwrap(); assert_eq!(back.discard_padding_ns, padding); assert_eq!(back.data, frame.data); @@ -4944,6 +5391,24 @@ mod tests { } } + // Timing set on one track lands on that track only; the others read default. + #[test] + fn track_timing_is_per_track() { + let out = SharedOut::new(); + let timing = crate::pes::TrackTiming { + codec_delay_ns: 6_500_000, + seek_preroll_ns: 80_000_000, + }; + let mut w = MkvStream::create(Box::new(out.clone()), &three_track_title(), None).unwrap(); + w.set_track_timing(1, timing).unwrap(); + w.write(&av_frame(0, 0, true, vec![1, 2, 3])).unwrap(); + w.finish().unwrap(); + let r = MkvStream::open(Cursor::new(out.bytes())).unwrap(); + assert_eq!(r.track_timing(1), timing); + assert_eq!(r.track_timing(0), Default::default()); + assert_eq!(r.track_timing(2), Default::default()); + } + #[test] fn laced_padding_applies_only_to_the_appropriate_edge_frame() { for padding in [-1_000_000i64, 1_000_000] { @@ -4954,7 +5419,7 @@ mod tests { let mut group = Vec::new(); // Fixed lace: two one-byte frames. ebml::write_binary(&mut group, ebml::BLOCK, &[0x81, 0, 0, 4, 1, 0xaa, 0xbb]).unwrap(); - ebml::write_int(&mut group, 0x75A2, padding).unwrap(); + ebml::write_int(&mut group, ebml::DISCARD_PADDING, padding).unwrap(); ebml::write_binary(&mut cluster, ebml::BLOCK_GROUP, &group).unwrap(); let bytes = mkv_with_tracks_and_cluster( &[TrackSpec::new(1, 2).with_default_duration(32_000_000)], @@ -4975,16 +5440,455 @@ mod tests { #[test] fn malformed_discard_padding_size_is_rejected() { - for size in [0, 9] { - let mut cluster = Vec::new(); - ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap(); - ebml::write_unknown_size(&mut cluster).unwrap(); - let mut group = Vec::new(); - ebml::write_binary(&mut group, ebml::BLOCK, &[0x81, 0, 0, 0, 0xaa]).unwrap(); - ebml::write_binary(&mut group, 0x75A2, &vec![0; size]).unwrap(); - ebml::write_binary(&mut cluster, ebml::BLOCK_GROUP, &group).unwrap(); - let bytes = mkv_with_tracks_and_cluster(&[TrackSpec::new(1, 2)], &cluster); - assert!(MkvStream::open(Cursor::new(bytes)).unwrap().read().is_err()); + let mut cluster = Vec::new(); + ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap(); + ebml::write_unknown_size(&mut cluster).unwrap(); + let mut group = Vec::new(); + ebml::write_binary(&mut group, ebml::BLOCK, &[0x81, 0, 0, 0, 0xaa]).unwrap(); + ebml::write_binary(&mut group, ebml::DISCARD_PADDING, &[0; 9]).unwrap(); + ebml::write_binary(&mut cluster, ebml::BLOCK_GROUP, &group).unwrap(); + let bytes = mkv_with_tracks_and_cluster(&[TrackSpec::new(1, 2)], &cluster); + assert!(MkvStream::open(Cursor::new(bytes)).unwrap().read().is_err()); + } + + fn av_frame(track: usize, pts: i64, keyframe: bool, data: Vec) -> crate::pes::PesFrame { + crate::pes::PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track, + pts, + keyframe, + data, + duration_ns: None, + } + } + + #[test] + fn a_zero_length_discard_padding_is_value_zero_not_malformed() { + let mut cluster = Vec::new(); + ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap(); + ebml::write_unknown_size(&mut cluster).unwrap(); + let mut group = Vec::new(); + ebml::write_binary(&mut group, ebml::BLOCK, &[0x81, 0, 0, 0, 0xaa]).unwrap(); + ebml::write_binary(&mut group, ebml::DISCARD_PADDING, &[]).unwrap(); + ebml::write_binary(&mut cluster, ebml::BLOCK_GROUP, &group).unwrap(); + let bytes = mkv_with_tracks_and_cluster(&[TrackSpec::new(1, 2)], &cluster); + let frames = drain(&mut MkvStream::open(Cursor::new(bytes)).unwrap()); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].discard_padding_ns, 0); + } + + fn pts_of(frames: &[crate::pes::PesFrame], track: usize) -> Vec { + frames + .iter() + .filter(|f| f.track == track) + .map(|f| f.pts) + .collect() + } + + // mkvmerge-style origin: the earliest audio/video frame, not the first IDR. + // Audio 40 ms before the IDR keeps its spacing; an earlier subtitle cue is + // kept (clamped to the origin), never dropped. + #[test] + fn frames_before_the_first_idr_are_kept_relative_to_the_earliest() { + let out = SharedOut::new(); + let mut s = MkvStream::create(Box::new(out.clone()), &three_track_title(), None).unwrap(); + s.write(&av_frame( + 2, + 100_000_000, + true, + vec![0, 8, 0, 4, 0xAA, 0xBB, 0, 4], + )) + .unwrap(); + s.write(&av_frame(1, 120_000_000, true, vec![0xA0; 8])) + .unwrap(); + s.write(&av_frame(0, 160_000_000, true, vec![0x11; 16])) + .unwrap(); + s.write(&av_frame(1, 192_000_000, true, vec![0xB0; 8])) + .unwrap(); + s.finish().unwrap(); + let back = drain(&mut MkvStream::open(Cursor::new(out.bytes())).unwrap()); + assert_eq!(pts_of(&back, 0), vec![40_000_000]); + assert_eq!(pts_of(&back, 1), vec![0, 72_000_000]); + assert_eq!(pts_of(&back, 2), vec![0], "the early cue is kept, clamped"); + } + + // DVD/HD-DVD clip marks run on another clock: the origin ignores them, and no + // kept frame lands before the origin (squashed to t=0). + #[test] + fn an_mpeg_ps_origin_ignores_clip_marks_and_squashes_nothing() { + let mut t = three_track_title(); + t.content_format = crate::disc::ContentFormat::MpegPs; + t.clips = vec![crate::disc::Clip { + clip_id: String::new(), + in_time: 36_000, + out_time: 450_000, + duration_secs: 9.2, + source_packets: 0, + feed_span: None, + }]; + let out = SharedOut::new(); + let mut s = MkvStream::create(Box::new(out.clone()), &t, None).unwrap(); + for (track, pts) in [(1, 500), (1, 900), (0, 1_000), (1, 1_032)] { + s.write(&av_frame(track, pts * 1_000_000, true, vec![0xA0; 8])) + .unwrap(); + } + s.finish().unwrap(); + let back = drain(&mut MkvStream::open(Cursor::new(out.bytes())).unwrap()); + assert_eq!(pts_of(&back, 1), vec![0, 400_000_000, 532_000_000]); + assert_eq!(pts_of(&back, 0), vec![500_000_000]); + } + + // Blu-ray with unusable marks (no seam plan): A/V before the clip IN is + // dropped, not squashed onto the origin. + #[test] + fn a_bd_frame_before_the_clip_in_is_dropped_without_a_seam_plan() { + let mut t = three_track_title(); + t.content_format = crate::disc::ContentFormat::BdTs; + t.clips = vec![crate::disc::Clip { + clip_id: String::new(), + in_time: 36_000, + out_time: 0, + duration_secs: 0.0, + source_packets: 0, + feed_span: None, + }]; + let out = SharedOut::new(); + let mut s = MkvStream::create(Box::new(out.clone()), &t, None).unwrap(); + for (track, pts) in [(1, 500), (1, 900), (0, 1_000), (1, 1_032)] { + s.write(&av_frame(track, pts * 1_000_000, true, vec![0xA0; 8])) + .unwrap(); + } + s.finish().unwrap(); + let back = drain(&mut MkvStream::open(Cursor::new(out.bytes())).unwrap()); + assert_eq!(pts_of(&back, 1), vec![0, 132_000_000]); + assert_eq!(pts_of(&back, 0), vec![100_000_000]); + } + + // Audio further before the IDR than a block can reach from its cluster is dropped. + #[test] + fn audio_far_before_the_first_idr_is_dropped_not_squashed() { + let out = SharedOut::new(); + let mut s = MkvStream::create(Box::new(out.clone()), &three_track_title(), None).unwrap(); + s.write(&av_frame(1, 0, true, vec![0xA0; 8])).unwrap(); + s.write(&av_frame(1, 4_000_000_000, true, vec![0xA1; 8])) + .unwrap(); + s.write(&av_frame(0, 5_000_000_000, true, vec![0x11; 16])) + .unwrap(); + s.finish().unwrap(); + let back = drain(&mut MkvStream::open(Cursor::new(out.bytes())).unwrap()); + assert_eq!(pts_of(&back, 1), vec![0]); + assert_eq!(pts_of(&back, 0), vec![1_000_000_000]); + } + + struct FailingWriter; + impl io::Write for FailingWriter { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::ErrorKind::StorageFull.into()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + impl io::Seek for FailingWriter { + fn seek(&mut self, _: io::SeekFrom) -> io::Result { + Ok(0) + } + } + + #[test] + fn a_failed_header_write_fails_every_later_write_and_finish() { + let mut s = MkvStream::create(Box::new(FailingWriter), &h264_title(), None).unwrap(); + let kf = av_frame(0, 0, true, vec![1, 2, 3]); + assert!(s.write(&kf).is_err(), "the header write failure surfaces"); + let err = s.write(&kf).unwrap_err(); + let code = format!("E{}", crate::error::E_STREAM_CLOSED); + assert!(err.to_string().starts_with(&code), "{err}"); + assert!(s.finish().is_err(), "a truncated MKV must not finish Ok"); + } + + #[test] + fn set_track_timing_rejects_bad_tracks_and_late_calls_with_codes() { + let out = SharedOut::new(); + let mut s = MkvStream::create(Box::new(out), &h264_title(), None).unwrap(); + let timing = crate::pes::TrackTiming { + codec_delay_ns: 1, + seek_preroll_ns: 2, + }; + let err = s.set_track_timing(5, timing).unwrap_err(); + let code = format!("E{}", crate::error::E_MUX_TRACK_RANGE); + assert!(err.to_string().starts_with(&code), "{err}"); + s.write(&av_frame(0, 0, true, vec![1])).unwrap(); + let err = s.set_track_timing(0, timing).unwrap_err(); + let code = format!("E{}", crate::error::E_STREAM_HEADER_WRITTEN); + assert!(err.to_string().starts_with(&code), "{err}"); + } + + // mkv -> mkv: an A_FLAC track must come back as FLAC with its STREAMINFO + // CodecPrivate, not as an A_AC3-labelled track. + #[test] + fn a_flac_track_survives_an_mkv_to_mkv_remux() { + use crate::disc::{AudioChannels, AudioStream, DiscTitle, SampleRate, Stream}; + let streaminfo = b"fLaC\x80\x00\x00\x22streaminfo".to_vec(); + let mut title = DiscTitle { + streams: vec![Stream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::Flac, + channels: AudioChannels::Stereo, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: crate::labels::LabelPurpose::Normal, + label: String::new(), + })], + ..DiscTitle::empty() + }; + title.codec_privates = vec![Some(streaminfo.clone())]; + let mut bytes = Vec::new(); + for _pass in 0..2 { + let out = SharedOut::new(); + let mut w = MkvStream::create(Box::new(out.clone()), &title, None).unwrap(); + w.write(&av_frame(0, 0, true, vec![0xFF, 0xF8, 0x69, 0x08])) + .unwrap(); + w.finish().unwrap(); + bytes = out.bytes(); + let r = MkvStream::open(Cursor::new(bytes.clone())).unwrap(); + title = r.info().clone(); + title.codec_privates = vec![r.codec_private(0)]; + } + let r = MkvStream::open(Cursor::new(bytes)).unwrap(); + assert_eq!(stream_codec(&r.info().streams[0]), Codec::Flac); + assert_eq!(r.codec_private(0), Some(streaminfo)); + } + + // One PCM audio track (`codec_id`, optional BitDepth) with one SimpleBlock. + fn pcm_mkv(codec_id: &str, bit_depth: Option, samples: &[u8]) -> Vec { + pcm_mkv_blocks(codec_id, bit_depth, &[(0, samples.to_vec())]) + } + + // Stereo 48 kHz PCM track with one SimpleBlock per `(timestamp_ms, samples)`. + fn pcm_mkv_blocks( + codec_id: &str, + bit_depth: Option, + blocks: &[(u64, Vec)], + ) -> Vec { + let mut entry = Vec::new(); + ebml::write_uint(&mut entry, ebml::TRACK_NUMBER, 1).unwrap(); + ebml::write_uint(&mut entry, ebml::TRACK_TYPE, 2).unwrap(); + ebml::write_string(&mut entry, ebml::CODEC_ID, codec_id).unwrap(); + let mut audio = Vec::new(); + ebml::write_uint(&mut audio, ebml::CHANNELS, 2).unwrap(); + ebml::write_id(&mut audio, ebml::SAMPLING_FREQUENCY).unwrap(); + ebml::write_size(&mut audio, 8).unwrap(); + audio.extend_from_slice(&48_000f64.to_be_bytes()); + if let Some(d) = bit_depth { + ebml::write_uint(&mut audio, ebml::BIT_DEPTH, d).unwrap(); + } + ebml::write_binary(&mut entry, ebml::AUDIO, &audio).unwrap(); + let mut tracks = Vec::new(); + ebml::write_binary(&mut tracks, ebml::TRACK_ENTRY, &entry).unwrap(); + let mut out = Vec::new(); + ebml::write_id(&mut out, ebml::EBML).unwrap(); + ebml::write_size(&mut out, 0).unwrap(); + ebml::write_id(&mut out, ebml::SEGMENT).unwrap(); + ebml::write_unknown_size(&mut out).unwrap(); + ebml::write_binary(&mut out, ebml::TRACKS, &tracks).unwrap(); + for (ms, samples) in blocks { + let mut block = vec![0x81, 0, 0, 0x80]; + block.extend_from_slice(samples); + out.extend(cluster_with_simple_block(*ms, &block)); + } + out + } + + // No BitDepth: the depth is inferred from bytes per block over its duration; + // an undecidable track is not presented as LPCM. + #[test] + fn pcm_without_bit_depth_infers_the_depth_from_block_sizes() { + // Blocks 2 ms apart: 96 stereo sample frames at 48 kHz. + let b24: Vec = (0..96 * 2 * 3).map(|i| i as u8).collect(); + let blocks: Vec<_> = (0..10u64).map(|i| (i * 2, b24.clone())).collect(); + let bytes = pcm_mkv_blocks(ebml::CODEC_PCM_BE, None, &blocks); + let mut s = MkvStream::open(Cursor::new(bytes)).unwrap(); + assert_eq!(stream_codec(&s.info().streams[0]), Codec::Lpcm); + let frames = drain(&mut s); + assert_eq!(frames.len(), 10); + assert_eq!(frames[0].data, b24, "24-bit big-endian passes through"); + + let b16 = [0x12u8, 0x34].repeat(96 * 2); + let blocks: Vec<_> = (0..10u64).map(|i| (i * 2, b16.clone())).collect(); + let bytes = pcm_mkv_blocks(ebml::CODEC_PCM_BE, None, &blocks); + let mut s = MkvStream::open(Cursor::new(bytes)).unwrap(); + assert_eq!(drain(&mut s)[0].data[..3], [0x12, 0x34, 0]); + + // Two blocks 2 ms apart are within timestamp error: no evidence, not LPCM. + let bytes = pcm_mkv_blocks(ebml::CODEC_PCM_BE, None, &[(0, b16.clone()), (2, b16)]); + let s = MkvStream::open(Cursor::new(bytes)).unwrap(); + assert_eq!(stream_codec(&s.info().streams[0]), Codec::Unknown(0)); + + let odd = vec![0u8; 250]; + let bytes = pcm_mkv_blocks(ebml::CODEC_PCM_BE, None, &[(0, odd.clone()), (2, odd)]); + let s = MkvStream::open(Cursor::new(bytes)).unwrap(); + assert_eq!(stream_codec(&s.info().streams[0]), Codec::Unknown(0)); + } + + // Header for video track 1 (HEVC) + stereo 48 kHz PCM track 2 without BitDepth. + fn video_then_pcm_header() -> Vec { + let mut video = Vec::new(); + ebml::write_uint(&mut video, ebml::TRACK_NUMBER, 1).unwrap(); + ebml::write_uint(&mut video, ebml::TRACK_TYPE, 1).unwrap(); + ebml::write_string(&mut video, ebml::CODEC_ID, ebml::CODEC_HEVC).unwrap(); + let mut pcm = Vec::new(); + ebml::write_uint(&mut pcm, ebml::TRACK_NUMBER, 2).unwrap(); + ebml::write_uint(&mut pcm, ebml::TRACK_TYPE, 2).unwrap(); + ebml::write_string(&mut pcm, ebml::CODEC_ID, ebml::CODEC_PCM_BE).unwrap(); + let mut audio = Vec::new(); + ebml::write_uint(&mut audio, ebml::CHANNELS, 2).unwrap(); + ebml::write_id(&mut audio, ebml::SAMPLING_FREQUENCY).unwrap(); + ebml::write_size(&mut audio, 8).unwrap(); + audio.extend_from_slice(&48_000f64.to_be_bytes()); + ebml::write_binary(&mut pcm, ebml::AUDIO, &audio).unwrap(); + let mut tracks = Vec::new(); + ebml::write_binary(&mut tracks, ebml::TRACK_ENTRY, &video).unwrap(); + ebml::write_binary(&mut tracks, ebml::TRACK_ENTRY, &pcm).unwrap(); + let mut out = Vec::new(); + ebml::write_id(&mut out, ebml::EBML).unwrap(); + ebml::write_size(&mut out, 0).unwrap(); + ebml::write_id(&mut out, ebml::SEGMENT).unwrap(); + ebml::write_unknown_size(&mut out).unwrap(); + ebml::write_binary(&mut out, ebml::TRACKS, &tracks).unwrap(); + out + } + + fn simple_block(track: u8, ms: u64, payload: &[u8]) -> Vec { + let mut block = vec![0x80 | track, 0, 0, 0x80]; + block.extend_from_slice(payload); + cluster_with_simple_block(ms, &block) + } + + fn pending_bytes(s: &MkvStream) -> usize { + match &s.mode { + Mode::Read(rs) => rs.pending.iter().map(|f| f.data.len()).sum(), + Mode::Write(_) => panic!("expected a read stream"), + } + } + + // 72-sample blocks with 1 ms timestamps: one block alone reads as 24-bit. + #[test] + fn short_16_bit_pcm_blocks_with_quantised_timestamps_infer_16_bit() { + let b16 = [0x12u8, 0x34].repeat(72 * 2); + let blocks: Vec<_> = (0..30u64).map(|i| (i * 3 / 2, b16.clone())).collect(); + let bytes = pcm_mkv_blocks(ebml::CODEC_PCM_BE, None, &blocks); + let mut s = MkvStream::open(Cursor::new(bytes)).unwrap(); + assert_eq!(stream_codec(&s.info().streams[0]), Codec::Lpcm); + let f = drain(&mut s); + assert_eq!(f[0].data.len(), 72 * 2 * 3, "widened from 16-bit"); + assert_eq!(f[0].data[..3], [0x12, 0x34, 0]); + } + + // A PCM track that starts after 600 video frames still resolves its depth. + #[test] + fn a_late_starting_pcm_track_still_resolves_its_depth() { + let mut bytes = video_then_pcm_header(); + for i in 0..600u64 { + bytes.extend(simple_block(1, i, &[0u8; 100])); + } + let b16 = [0x12u8, 0x34].repeat(96 * 2); + for i in 0..30u64 { + bytes.extend(simple_block(2, 600 + i * 2, &b16)); + } + let mut s = MkvStream::open(Cursor::new(bytes)).unwrap(); + assert_eq!(stream_codec(&s.info().streams[1]), Codec::Lpcm); + let pcm: Vec<_> = drain(&mut s).into_iter().filter(|f| f.track == 1).collect(); + assert_eq!(pcm.len(), 30); + assert_eq!(pcm[0].data[..3], [0x12, 0x34, 0]); + } + + // Big video blocks ahead of a PCM track: open() stops buffering at the byte cap. + #[test] + fn pcm_depth_probe_is_memory_bounded() { + struct Lazy { + head: Cursor>, + block: Vec, + left: usize, + at: usize, + } + impl Read for Lazy { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let n = self.head.read(buf)?; + if n > 0 { + return Ok(n); + } + if self.at == self.block.len() { + if self.left == 0 { + return Ok(0); + } + self.left -= 1; + self.at = 0; + } + let n = buf.len().min(self.block.len() - self.at); + buf[..n].copy_from_slice(&self.block[self.at..self.at + n]); + self.at += n; + Ok(n) + } + } + let block = simple_block(1, 0, &vec![0u8; 256 << 10]); + // No PCM frame, or one whose size fits 16 and 24 bit: no evidence, so + // the track is not presented as LPCM rather than guessed. + for pcm in [None, Some(vec![0u8; 96 * 2 * 3])] { + let mut head = video_then_pcm_header(); + if let Some(p) = &pcm { + head.extend(simple_block(2, 0, p)); + } + let r = Lazy { + head: Cursor::new(head), + at: block.len(), + block: block.clone(), + left: 1000, + }; + let s = MkvStream::open(r).unwrap(); + assert!(pending_bytes(&s) <= PCM_PROBE_BYTES + (256 << 10) + (1 << 10)); + assert_eq!(stream_codec(&s.info().streams[1]), Codec::Unknown(0)); + } + } + + // LPCM frames downstream are 24-bit big-endian: 16-bit and little-endian + // PCM sources are widened/reordered on read, unsupported layouts are not LPCM. + // (codec id, BitDepth, input samples, expected 24-bit BE samples or None = not LPCM) + type PcmCase<'a> = (&'a str, Option, &'a [u8], Option<&'a [u8]>); + + #[test] + fn pcm_tracks_are_read_as_24_bit_big_endian() { + let cases: &[PcmCase] = &[ + ( + ebml::CODEC_PCM_BE, + Some(16), + &[0x12, 0x34, 0xAB, 0xCD], + Some(&[0x12, 0x34, 0, 0xAB, 0xCD, 0]), + ), + (ebml::CODEC_PCM_BE, Some(24), &[1, 2, 3], Some(&[1, 2, 3])), + ( + ebml::CODEC_PCM_LE, + Some(16), + &[0x34, 0x12], + Some(&[0x12, 0x34, 0]), + ), + (ebml::CODEC_PCM_LE, Some(24), &[3, 2, 1], Some(&[1, 2, 3])), + (ebml::CODEC_PCM_BE, Some(32), &[1, 2, 3, 4], None), + ("A_PCM/FLOAT/IEEE", Some(32), &[1, 2, 3, 4], None), + ]; + for (cid, depth, input, want) in cases { + let mut s = MkvStream::open(Cursor::new(pcm_mkv(cid, *depth, input))).unwrap(); + let codec = stream_codec(&s.info().streams[0]); + let frames = drain(&mut s); + match want { + Some(w) => { + assert_eq!(codec, Codec::Lpcm, "{cid} {depth:?}"); + assert_eq!(frames[0].data, w.to_vec(), "{cid} {depth:?}"); + } + None => assert_eq!(codec, Codec::Unknown(0), "{cid} {depth:?}"), + } } } } diff --git a/src/mux/mp4/audio.rs b/src/mux/mp4/audio.rs index 7e0a221a..a20e5434 100644 --- a/src/mux/mp4/audio.rs +++ b/src/mux/mp4/audio.rs @@ -349,7 +349,7 @@ fn dts_channel_layout(amode: usize, lfe: bool) -> u16 { /// The `ddts` config box (ETSI TS 102 114 Annex; DTS-in-ISO registration). /// Describes the DTS core; whole access units (core + any extension) are passed /// through as samples, so a DTS-HD-aware decoder still finds the extension. -fn ddts_box(c: &DtsConfig) -> Vec { +fn ddts_box(c: &DtsConfig, max_rate: u32) -> Vec { // avg/max bitrate: derived from core frame size × frame rate (core RATE reads // "open/variable" for lossless, so unusable directly). Rate isn't integral // (e.g. 93.75 frames/s), so multiply before dividing, rounding to nearest. @@ -362,14 +362,18 @@ fn ddts_box(c: &DtsConfig) -> Vec { }; let mut out = Vec::new(); - out.extend_from_slice(&c.sample_rate.to_be_bytes()); // DTSSamplingFrequency + out.extend_from_slice(&max_rate.to_be_bytes()); // DTSSamplingFrequency out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth + // FrameDuration counts samples at DTSSamplingFrequency, not the core rate. + let at_max = (u64::from(c.frame_samples) * u64::from(max_rate)) + .checked_div(u64::from(c.sample_rate)) + .unwrap_or(u64::from(c.frame_samples)); // Bit-packed tail (56 bits): FrameDuration2 StreamConstruction5 CoreLFEPresent1 // CoreLayout6 CoreSize14 StereoDownmix1 RepresentationType3 ChannelLayout16 // MultiAssetFlag1 LBRDurationMod1 ReservedBoxPresent1 Reserved5 - let frame_duration = match c.frame_samples { + let frame_duration = match at_max { 0..=512 => 0, 513..=1024 => 1, 1025..=2048 => 2, @@ -404,10 +408,26 @@ fn ddts_box(c: &DtsConfig) -> Vec { bx(b"ddts", &out) } +// DTS sample-entry samplerate: the family base of the maximum rate (ETSI TS 102 114 +// E.2.2.2); the 4x core rates join their family per Table 5-6. +fn dts_entry_rate(max_rate: u32) -> u32 { + match max_rate { + 12_000 | 24_000 | 48_000 | 96_000 | 192_000 => 48_000, + 11_025 | 22_050 | 44_100 | 88_200 | 176_400 => 44_100, + 8_000 | 16_000 | 32_000 | 64_000 | 128_000 => 32_000, + other => other, + } +} + /// The MP4 fourcc + config box for an audio frame, or `None` if the codec has no /// MP4 mapping here. Together with [`audio_fits`] this is the fit oracle for /// audio: only what returns `Some` is muxable. -pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option> { +/// `stream_hz` is the title's rate for the track (0 if unknown). +pub(super) fn dolby_sample_entry( + codec: Codec, + first_frame: &[u8], + stream_hz: u32, +) -> Option> { match codec { Codec::Ac3 => { let c = parse_dolby(first_frame)?; @@ -439,11 +459,17 @@ pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option c.sample_rate && stream_hz.is_multiple_of(c.sample_rate) { + stream_hz + } else { + c.sample_rate + }; Some(audio_sample_entry( fourcc, c.channels, - c.sample_rate, - &ddts_box(&c), + dts_entry_rate(max_rate), + &ddts_box(&c, max_rate), )) } _ => None, @@ -549,7 +575,7 @@ mod tests { // A Codec::Ac3Plus track whose first syncframe is legacy AC-3 (bsid 8 < 11) // has no nominal data rate; an `ec-3` entry built from it would declare // 0 kbps, violating ETSI TS 102 366 F.6.1 — emit ac-3/dac3 (Annex F.4) instead. - let e = dolby_sample_entry(Codec::Ac3Plus, &ac3_frame_5_1()).expect("entry built"); + let e = dolby_sample_entry(Codec::Ac3Plus, &ac3_frame_5_1(), 0).expect("entry built"); assert_eq!( &e[4..8], b"ac-3", @@ -570,7 +596,7 @@ mod tests { // A real Annex-E syncframe still gets `ec-3` + `dec3`, and the data_rate // read back out of the emitted box is the frame's nominal rate, not 0. // 128 B / 1536 samples @ 48 kHz = 32 kbit/s. - let e = dolby_sample_entry(Codec::Ac3Plus, &eac3_frame_5_1()).expect("entry built"); + let e = dolby_sample_entry(Codec::Ac3Plus, &eac3_frame_5_1(), 0).expect("entry built"); assert_eq!(&e[4..8], b"ec-3"); let i = e .windows(4) @@ -642,7 +668,7 @@ mod tests { assert_eq!(c.core_size, 96); assert_eq!(c.frame_samples, 512); - let ddts = ddts_box(&c); + let ddts = ddts_box(&c, c.sample_rate); assert_eq!(&ddts[4..8], b"ddts"); // DTSSamplingFrequency (first field) = 48000. assert_eq!( @@ -650,10 +676,57 @@ mod tests { 48_000 ); // Sample entry uses dtsc (no extension in this synthetic frame). - let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap(); + let e = dolby_sample_entry(Codec::DtsHdMa, &f, 0).unwrap(); assert_eq!(&e[4..8], b"dtsc"); } + // ETSI TS 102 114 E.2.2: a 48 kHz core in a 96 kHz stream → entry 48000, ddts 96000. + #[test] + fn a_96k_dts_stream_writes_its_max_rate_in_ddts_and_the_family_base_in_the_entry() { + let f = vec![ + 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00, + ]; + let e = dolby_sample_entry(Codec::DtsHdMa, &f, 96_000).unwrap(); + assert_eq!(&e[32..34], &48_000u16.to_be_bytes(), "entry samplerate"); + let ddts = e.windows(4).position(|w| w == b"ddts").unwrap(); + assert_eq!(&e[ddts + 4..ddts + 8], &96_000u32.to_be_bytes()); + // FrameDuration counts samples at DTSSamplingFrequency: 512 core samples + // at 48 kHz are 1024 at 96 kHz → code 1. + let c = parse_dts(&f).unwrap(); + assert_eq!(ddts_tail(&ddts_box(&c, 96_000))[0] >> 6, 1); + assert_eq!(ddts_tail(&ddts_box(&c, 48_000))[0] >> 6, 0); + // A title rate that is not a multiple of the core rate is ignored. + let e = dolby_sample_entry(Codec::DtsHdMa, &f, 44_100).unwrap(); + let ddts = e.windows(4).position(|w| w == b"ddts").unwrap(); + assert_eq!(&e[ddts + 4..ddts + 8], &48_000u32.to_be_bytes()); + } + + // ETSI TS 102 114 E.2.2.2 families, with the 4x-interpolated core rates of + // Table 5-6 (8/11.025/12 kHz) in the same family as their 2x siblings. + #[test] + fn dts_entry_rate_maps_every_rate_to_its_family_base() { + for (rate, base) in [ + (12_000, 48_000), + (24_000, 48_000), + (48_000, 48_000), + (96_000, 48_000), + (192_000, 48_000), + (11_025, 44_100), + (22_050, 44_100), + (44_100, 44_100), + (88_200, 44_100), + (176_400, 44_100), + (8_000, 32_000), + (16_000, 32_000), + (32_000, 32_000), + (64_000, 32_000), + (128_000, 32_000), + (36_000, 36_000), + ] { + assert_eq!(dts_entry_rate(rate), base, "{rate}"); + } + } + #[test] fn dts_ext_sync_inside_core_is_not_a_false_positive() { // A 4-byte ext-sync pattern INSIDE the compressed core payload must NOT be @@ -665,7 +738,7 @@ mod tests { ]; let c = parse_dts(&f).expect("parses"); assert!(!c.has_extension, "ext-sync inside core is not an extension"); - let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap(); + let e = dolby_sample_entry(Codec::DtsHdMa, &f, 0).unwrap(); assert_eq!(&e[4..8], b"dtsc"); } @@ -679,7 +752,7 @@ mod tests { let c = parse_dts(&f).expect("parses"); assert_eq!(c.core_size, 9); assert!(c.has_extension, "EXSS sync at core end is a real extension"); - let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap(); + let e = dolby_sample_entry(Codec::DtsHdMa, &f, 0).unwrap(); assert_eq!(&e[4..8], b"dtsh"); } @@ -723,7 +796,7 @@ mod tests { has_extension: false, channel_layout: dts_channel_layout(9, true), }; - let b = ddts_box(&c); + let b = ddts_box(&c, c.sample_rate); // Decode CoreSize back out of the emitted box: 8-byte header + 13 bytes // (freq/bitrates/depth) precede the 56-bit tail, where CoreSize sits after // FrameDuration(2)+StreamConstruction(5)+CoreLFEPresent(1)+CoreLayout(6) = bits 14..28. @@ -752,7 +825,7 @@ mod tests { // Decode the flag from the emitted box: 8-byte header + 13 bytes precede the // 56-bit tail, where MultiAssetFlag is bit 48 — after FrameDuration+ // StreamConstruction+CoreLFEPresent+CoreLayout+CoreSize+StereoDownmix+RepType+ChannelLayout — MSB of byte 7. - let b = ddts_box(&c); + let b = ddts_box(&c, c.sample_rate); let tail = &b[8 + 13..]; assert_eq!(tail.len(), 7, "56-bit packed tail"); assert_eq!( @@ -1114,7 +1187,7 @@ mod tests { !c.has_extension, "the pattern ends at the core's last byte, inside it" ); - let e = dolby_sample_entry(Codec::Dts, &f).expect("entry built"); + let e = dolby_sample_entry(Codec::Dts, &f, 0).expect("entry built"); assert_eq!(&e[4..8], b"dtsc"); } @@ -1133,7 +1206,7 @@ mod tests { has_extension: false, channel_layout: dts_channel_layout(9, true), }; - let (max, avg) = ddts_bitrates(&ddts_box(&c)); + let (max, avg) = ddts_bitrates(&ddts_box(&c, c.sample_rate)); assert_eq!(max, 5513, "2822400/512 = 5512.5 → 5513"); assert_eq!(avg, 5513, "both fields carry the same computed rate"); } @@ -1152,7 +1225,7 @@ mod tests { has_extension: false, channel_layout: dts_channel_layout(9, true), }; - assert_eq!(ddts_bitrates(&ddts_box(&c)), (0, 0)); + assert_eq!(ddts_bitrates(&ddts_box(&c, c.sample_rate)), (0, 0)); } #[test] @@ -1171,7 +1244,7 @@ mod tests { has_extension: false, channel_layout: dts_channel_layout(9, true), }; - ddts_tail(&ddts_box(&c))[0] >> 6 + ddts_tail(&ddts_box(&c, c.sample_rate))[0] >> 6 }; assert_eq!(dur(512), 0, "512 samples"); assert_eq!(dur(1024), 1, "1024 samples"); diff --git a/src/mux/mp4/mod.rs b/src/mux/mp4/mod.rs index 61dda7c1..15e471ff 100644 --- a/src/mux/mp4/mod.rs +++ b/src/mux/mp4/mod.rs @@ -428,7 +428,11 @@ impl Stream for Mp4Sink { // here used to lose audio silently — finish() now reports that case loudly. if self.tracks[slot].media == Media::Audio && self.tracks[slot].audio_entry.is_none() - && let Some(entry) = audio::dolby_sample_entry(self.tracks[slot].codec, &frame.data) + && let Some(entry) = audio::dolby_sample_entry( + self.tracks[slot].codec, + &frame.data, + self.tracks[slot].audio_timescale, + ) { self.tracks[slot].audio_entry = Some(entry); } diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index a376ee9e..04c13b8f 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -135,6 +135,8 @@ impl Mp4Reader { config, channels, sample_rate: entry_sample_rate, + dts_max_rate, + dts_hd, }) = parse_stsd(stsd) else { tracing::warn!( @@ -165,13 +167,10 @@ impl Mp4Reader { // `as u8` (a crafted 256 would alias to 0/Mono). channels: AudioChannels::from_count(channels.min(u8::MAX as u16) as u8), language: language.clone().unwrap_or_else(|| "und".into()), - // Prefer the AudioSampleEntry's own samplerate; the mdhd media - // timescale is only a fallback when the entry omits it (0). - sample_rate: SampleRate::from_hz(if entry_sample_rate != 0 { - entry_sample_rate - } else { - timescale - }), + sample_rate: SampleRate::from_hz( + dts_max_rate + .unwrap_or_else(|| audio_rate(entry_sample_rate, timescale, dts_hd)), + ), secondary: false, purpose: LabelPurpose::Normal, label: String::new(), @@ -679,6 +678,10 @@ struct StsdInfo { /// field (high 16 bits). `0` when absent/video — the caller then falls /// back to the mdhd media timescale. sample_rate: u32, + /// DTS `ddts` DTSSamplingFrequency when present and non-zero. + dts_max_rate: Option, + /// `Some(dtsh|dtsl)` for a DTS entry; `None` otherwise. + dts_hd: Option, } /// stsd → codec + dimensions + codec_private + channel count (first entry). @@ -727,35 +730,84 @@ fn parse_stsd(b: &[u8]) -> Option { config, channels: 0, sample_rate: 0, + dts_max_rate: None, + dts_hd: None, }) } else { - // AudioSampleEntry header is 28 bytes, then child boxes. AAC (mp4a) carries - // its AudioSpecificConfig in an `esds` box — the MKV CodecPrivate for - // A_AAC. AC-3/DTS are self-describing in-band (None). - let channels = if body.len() >= 28 { be16(body, 16) } else { 2 }; - // Real audio sample rate: AudioSampleEntry 16.16 samplerate field at body - // offset 24 (ISO/IEC 14496-12 §12.2.3), integer rate = high 16 bits. The mdhd - // media timescale is only a fallback (USUALLY the rate, but need not be). - let sample_rate = if body.len() >= 28 { - be16(body, 24) as u32 + // AudioSampleEntry (ISO/IEC 14496-12 §12.2.3): 28 bytes then children. Under + // a v0 stsd, entry v1 is QuickTime (+16 bytes) and v2 has a float64 rate at + // 32, u32 channels at 40, children at 64. + let version = if b[0] == 0 && body.len() >= 28 { + be16(body, 8) } else { 0 }; - let config = if matches!(codec, Codec::Aac) && body.len() >= 28 { - find_box(&body[28..], b"esds").and_then(parse_esds_asc) + let (channels, sample_rate, children) = match version { + 2 if body.len() >= 64 => { + let rate = f64::from_bits(u64::from_be_bytes(body[32..40].try_into().ok()?)); + let rate = if rate.is_finite() && rate >= 1.0 && rate <= u32::MAX as f64 { + rate.round() as u32 + } else { + 0 + }; + let ch = be32(body, 40).min(u16::MAX as u32) as u16; + (ch, rate, 64) + } + // The 16.16 field's integer part; `audio_rate` falls back to mdhd. + 1 if body.len() >= 44 => (be16(body, 16), be16(body, 24) as u32, 44), + _ if body.len() >= 28 => (be16(body, 16), be16(body, 24) as u32, 28), + _ => (2, 0, body.len()), + }; + let config = if matches!(codec, Codec::Aac) { + find_box(&body[children..], b"esds").and_then(parse_esds_asc) } else { None }; + // ISO AudioSampleEntryV1 carries rates above 65535 in `srat`. + let sample_rate = find_box(&body[children..], b"srat") + .filter(|b| b.len() >= 8) + .map_or(sample_rate, |b| be32(b, 4)); + // ETSI TS 102 114 E.2.2.3: DTSSamplingFrequency is the stream's maximum rate. + // An unnameable rate stays Unknown; the entry holds only its family base. + let dts_max_rate = match codec { + Codec::Dts => find_box(&body[children..], b"ddts") + .filter(|b| b.len() >= 4) + .map(|b| be32(b, 0)) + .filter(|&hz| hz != 0), + _ => None, + }; Some(StsdInfo { codec, height: 0, config, channels, sample_rate, + dts_max_rate, + dts_hd: (codec == Codec::Dts).then_some(matches!(&fourcc, b"dtsh" | b"dtsl")), }) } } +// Sample rate from the entry rate and the mdhd timescale. The entry wins when +// it is known; a DTS-HD entry (`dts_hd`) holds the family base of a 2x/4x rate. +fn audio_rate(entry: u32, timescale: u32, dts_hd: Option) -> u32 { + let known = |hz| SampleRate::from_hz(hz) != SampleRate::Unknown; + if matches!(entry, 0 | 1 | 0xFFFF) { + return timescale; + } + let is = |k: u32| entry.checked_mul(k) == Some(timescale) && known(timescale); + let wider = match dts_hd { + Some(hd) => hd && (is(2) || is(4)), + None => { + !known(entry) + && timescale > 0xFFFF + && known(timescale) + && timescale.is_multiple_of(entry) + } + }; + if wider { timescale } else { entry } +} + /// Read an MPEG-4 expandable descriptor length (ISO/IEC 14496-1), advancing `pos`. /// Each byte contributes 7 bits, continued while the high bit is set (max 4 bytes). fn read_descriptor_len(b: &[u8], pos: &mut usize) -> usize { @@ -2251,13 +2303,12 @@ mod tests { /// (mdhd timescale = 90_000, AudioSampleEntry samplerate = 48_000); the /// entry value must win. Old code read the rate from the timescale, so a /// 90 kHz mdhd yielded SampleRate::Unknown, dropping the real 48 kHz. - #[test] - fn audio_sample_entry_samplerate_wins_over_mdhd_timescale() { - use std::io::Cursor; + // One-sample audio moov whose mdhd timescale and sample entry are given. + fn audio_moov(timescale: u32, fourcc: &[u8; 4], entry: &[u8]) -> Vec { let mdhd = { // v0: version+flags(4) creation(4) modification(4) timescale(4) duration(4). let mut p = vec![0u8; 24]; - p[12..16].copy_from_slice(&90_000u32.to_be_bytes()); // NOT the audio rate + p[12..16].copy_from_slice(×cale.to_be_bytes()); mp4_box(b"mdhd", &p) }; let hdlr = { @@ -2265,8 +2316,7 @@ mod tests { p[8..12].copy_from_slice(b"soun"); mp4_box(b"hdlr", &p) }; - // Full AudioSampleEntry: samplerate 48_000 in the 16.16 field at body 24. - let stsd = mp4_box(b"stsd", &stsd_with(b"ac-3", &audio_entry(6, 0, &[]))); + let stsd = mp4_box(b"stsd", &stsd_with(fourcc, entry)); // stsz: sample_size=10, count=1 (one 10-byte sample). let stsz = { let mut p = vec![0u8, 0, 0, 0]; @@ -2305,19 +2355,216 @@ mod tests { mdia.extend_from_slice(&hdlr); mdia.extend_from_slice(&minf); let trak = mp4_box(b"trak", &mp4_box(b"mdia", &mdia)); - let moov = mp4_box(b"moov", &trak); + mp4_box(b"moov", &trak) + } - let rd = Mp4Reader::from_reader(Cursor::new(moov), "sr".into()).unwrap(); + fn read_audio(moov: Vec) -> AudioStream { + let rd = Mp4Reader::from_reader(std::io::Cursor::new(moov), "sr".into()).unwrap(); match &rd.info().streams[0] { - DiscStream::Audio(a) => assert_eq!( - a.sample_rate, - SampleRate::S48, - "the AudioSampleEntry 48 kHz must win over the 90 kHz mdhd timescale" - ), + DiscStream::Audio(a) => a.clone(), other => panic!("expected an audio stream, got {other:?}"), } } + /// Regression: audio sample rate must come from the AudioSampleEntry 16.16 + /// samplerate field, NOT the mdhd media timescale, when they merely differ + /// (a 90 kHz mdhd is not an audio rate). + #[test] + fn audio_sample_entry_samplerate_wins_over_mdhd_timescale() { + let a = read_audio(audio_moov(90_000, b"ac-3", &audio_entry(6, 0, &[]))); + assert_eq!(a.sample_rate, SampleRate::S48); + } + + // The 16-bit entry field cannot hold 96 kHz: our own Mp4Sink saturates it to + // 65535, and a DTS-HD entry may carry the 48 kHz core rate. mdhd wins then. + #[test] + fn a_high_rate_mdhd_timescale_beats_an_unrepresentable_entry_rate() { + for entry_rate in [0xFFFFu32, 48_000] { + let mut e = audio_entry(6, 0, &[]); + e[24..28].copy_from_slice(&(entry_rate << 16).to_be_bytes()); + let a = read_audio(audio_moov(96_000, b"dtsh", &e)); + assert_eq!(a.sample_rate, SampleRate::S96, "entry {entry_rate}"); + } + } + + // Without ddts, only a DTS-HD entry takes a 2x/4x mdhd timescale; dtsc keeps its rate. + #[test] + fn a_dts_timescale_counts_only_as_2x_or_4x_on_dts_hd_entries() { + let entry = |hz: u32| { + let mut e = audio_entry(6, 0, &[]); + e[24..28].copy_from_slice(&(hz << 16).to_be_bytes()); + e + }; + for (fourcc, entry_hz, ts, want) in [ + (b"dtsc", 48_000, 96_000, SampleRate::S48), + (b"dtsh", 48_000, 192_000, SampleRate::S192), + (b"dtsl", 44_100, 88_200, SampleRate::S88_2), + (b"dtsh", 32_000, 96_000, SampleRate::from_hz(32_000)), + ] { + let a = read_audio(audio_moov(ts, fourcc, &entry(entry_hz))); + assert_eq!(a.sample_rate, want, "{fourcc:?} {entry_hz} {ts}"); + } + } + + // ETSI TS 102 114 E.2.2.3: ddts DTSSamplingFrequency is the stream's maximum rate. + #[test] + fn a_dts_ddts_max_rate_beats_the_family_entry_rate() { + let mut ddts = 96_000u32.to_be_bytes().to_vec(); + ddts.extend_from_slice(&[0u8; 15]); + let e = audio_entry(6, 0, &mp4_box(b"ddts", &ddts)); + let a = read_audio(audio_moov(90_000, b"dtsh", &e)); + assert_eq!(a.sample_rate, SampleRate::S96); + } + + // A ddts rate SampleRate cannot name reads as Unknown, never the entry's family base. + #[test] + fn a_dts_ddts_rate_outside_sample_rate_is_not_read_as_its_family_base() { + for hz in [12_000u32, 24_000, 32_000] { + let mut ddts = hz.to_be_bytes().to_vec(); + ddts.extend_from_slice(&[0u8; 15]); + // audio_entry's samplerate is 48000, the family base of 12/24 kHz. + let e = audio_entry(6, 0, &mp4_box(b"ddts", &ddts)); + let a = read_audio(audio_moov(48_000, b"dtsc", &e)); + assert_eq!(a.sample_rate, SampleRate::Unknown, "{hz}"); + } + } + + // A 96 kHz DTS-HD MA track written by Mp4Sink reads back as 96 kHz. + #[test] + fn a_96k_dts_hd_track_round_trips_its_rate() { + use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle, LabelPurpose}; + use crate::mux::mp4::Mp4Sink; + use crate::pes::{PesFrame, Stream as _}; + use std::io::Cursor; + + let mut t = DiscTitle::empty(); + t.streams = vec![ + DiscStream::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: Resolution::R1080p, + frame_rate: crate::disc::FrameRate::F23_976, + hdr: crate::disc::HdrFormat::Sdr, + color_space: crate::disc::ColorSpace::Unknown, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }), + DiscStream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::DtsHdMa, + channels: AudioChannels::Surround51, + language: "eng".into(), + sample_rate: SampleRate::S96, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + }), + ]; + t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC]), None]; + // 48 kHz DTS core (SFREQ=13), as on a 96 kHz DTS-HD MA disc track. + let core = vec![ + 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00, + ]; + let mut buf = Vec::new(); + { + let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap(); + for i in 0..2i64 { + sink.write(&PesFrame { + discard_padding_ns: 0, + track: 0, + pts: i * 41_708_333, + keyframe: i == 0, + data: vec![0x11; 64], + duration_ns: None, + source: None, + coding: None, + }) + .unwrap(); + sink.write(&PesFrame { + discard_padding_ns: 0, + track: 1, + pts: i * 5_333_333, + keyframe: true, + data: core.clone(), + duration_ns: None, + source: None, + coding: None, + }) + .unwrap(); + } + sink.finish().unwrap(); + } + let rd = Mp4Reader::from_reader(Cursor::new(buf), "dts96".into()).unwrap(); + match &rd.info().streams[1] { + DiscStream::Audio(a) => assert_eq!(a.sample_rate, SampleRate::S96), + other => panic!("expected audio, got {other:?}"), + } + } + + // A valid known entry rate is kept even when mdhd uses a higher timescale. + #[test] + fn a_known_entry_rate_beats_a_higher_timescale() { + let a = read_audio(audio_moov(96_000, b"ac-3", &audio_entry(2, 0, &[]))); + assert_eq!(a.sample_rate, SampleRate::S48); + } + + // ISO AudioSampleEntryV1 (stsd version 1): not a QuickTime layout; children + // follow the 28-byte part and `srat` carries a rate above 65535. + #[test] + fn an_iso_v1_sample_entry_reads_srat_and_keeps_its_children() { + let asc = [0x11u8, 0x90]; + let mut esds = vec![0u8, 0, 0, 0, 0x03, 22, 0, 1, 0, 0x04, 17, 0x40, 0x15]; + esds.extend_from_slice(&[0u8; 11]); + esds.extend_from_slice(&[0x05, 2]); + esds.extend_from_slice(&asc); + let mut srat = vec![0u8; 4]; + srat.extend_from_slice(&96_000u32.to_be_bytes()); + let mut e = audio_entry(2, 0, &[]); + e[8..10].copy_from_slice(&1u16.to_be_bytes()); + e[24..28].copy_from_slice(&(1u32 << 16).to_be_bytes()); + e.extend_from_slice(&mp4_box(b"esds", &esds)); + e.extend_from_slice(&mp4_box(b"srat", &srat)); + let mut stsd = stsd_with(b"mp4a", &e); + stsd[0] = 1; + let info = parse_stsd(&stsd).expect("mp4a parses"); + assert_eq!(info.config.as_deref(), Some(&asc[..])); + assert_eq!(info.sample_rate, 96_000); + } + + // QuickTime SoundDescription v2: the 16.16 field is fixed at 1.0 and the real + // rate is a float64 at body offset 32; channels at 40; children at 64. + #[test] + fn a_quicktime_v2_sound_description_reads_its_float_rate_and_channels() { + let mut e = vec![0u8; 64]; + e[8..10].copy_from_slice(&2u16.to_be_bytes()); + e[16..18].copy_from_slice(&3u16.to_be_bytes()); + e[24..28].copy_from_slice(&(1u32 << 16).to_be_bytes()); + e[32..40].copy_from_slice(&44_100f64.to_bits().to_be_bytes()); + e[40..44].copy_from_slice(&6u32.to_be_bytes()); + let a = read_audio(audio_moov(1_000, b"ac-3", &e)); + assert_eq!(a.sample_rate, SampleRate::S44_1); + assert_eq!(a.channels, AudioChannels::from_count(6)); + } + + // QuickTime SoundDescription v1 inserts 16 bytes before the child boxes, so an + // mp4a esds must be looked up at body offset 44, not 28. + #[test] + fn a_quicktime_v1_sound_description_finds_its_esds_after_the_v1_fields() { + let asc = [0x12u8, 0x10]; + let mut esds = vec![0u8, 0, 0, 0, 0x03, 22, 0, 1, 0, 0x04, 17, 0x40, 0x15]; + esds.extend_from_slice(&[0u8; 11]); + esds.extend_from_slice(&[0x05, 2]); + esds.extend_from_slice(&asc); + let mut e = audio_entry(2, 0, &[]); + e[8..10].copy_from_slice(&1u16.to_be_bytes()); + e.extend_from_slice(&[0u8; 16]); + e.extend_from_slice(&mp4_box(b"esds", &esds)); + let info = parse_stsd(&stsd_with(b"mp4a", &e)).expect("mp4a parses"); + assert_eq!(info.config.as_deref(), Some(&asc[..])); + } + // ============================================================ // Edit lists — ISO/IEC 14496-12 §8.6.5 (`edts`) / §8.6.6 (`elst`). // ============================================================ diff --git a/src/mux/network.rs b/src/mux/network.rs index 2a40feee..a6fe4368 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -8,12 +8,117 @@ use super::meta; use crate::disc::DiscTitle; -use std::io::{self, BufReader, BufWriter, Write}; +use crate::halt::{Halt, POLL_INTERVAL}; +use rustix::event::{PollFd, PollFlags, Timespec}; +use std::io::{self, BufReader, BufWriter, Read, Write}; use std::net::{IpAddr, TcpListener, TcpStream, ToSocketAddrs}; /// I/O buffer size for network reads/writes. const NET_BUF_SIZE: usize = 256 * 1024; +// Accept poll period while waiting for the sender (a halt is checked each tick). +const ACCEPT_POLL: std::time::Duration = std::time::Duration::from_millis(50); + +// A slow sender (bad-sector retries) may be silent for minutes, so there is no +// data-idle limit; a vanished peer is detected by TCP keepalive instead. +const KEEPALIVE_IDLE: std::time::Duration = std::time::Duration::from_secs(60); +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +const KEEPALIVE_RETRIES: u32 = 6; + +// Enable keepalive probes so a crashed or unplugged sender fails the read. +fn enable_keepalive(stream: &TcpStream) -> io::Result<()> { + let ka = socket2::TcpKeepalive::new().with_time(KEEPALIVE_IDLE); + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + let ka = ka + .with_interval(KEEPALIVE_INTERVAL) + .with_retries(KEEPALIVE_RETRIES); + socket2::SockRef::from(stream).set_tcp_keepalive(&ka) +} + +// Keepalive is best effort: a stack that rejects an option (e.g. TCP_KEEPCNT on +// old Windows) still receives, just without dead-peer detection. +fn arm_keepalive(stream: &TcpStream) { + if let Err(e) = enable_keepalive(stream) { + tracing::warn!( + target: "mux", + error = %e, + "network: TCP keepalive unavailable" + ); + } +} + +// Poll outcome: `Ok(true)` reads now (data, EOF or a socket error for `read` to +// surface), `Ok(false)` is a halt-check tick. Readiness, not SO_RCVTIMEO: on +// Windows a receive timeout can cancel a completing recv and lose its data. +fn poll_says_read(res: io::Result, revents: PollFlags) -> io::Result { + match res { + Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(false), + Err(e) => Err(e), + Ok(0) => Ok(false), + Ok(_) => Ok(!revents.is_empty()), + } +} + +// Receive side: waits for readability in `tick` slices so a halt is observed +// instead of blocking in `read` forever. +struct HaltRead { + stream: TcpStream, + halt: Option, + halted: bool, + tick: Timespec, +} + +impl HaltRead { + fn new(stream: TcpStream, halt: Option) -> Self { + Self::with_tick(stream, halt, POLL_INTERVAL) + } + + fn with_tick(stream: TcpStream, halt: Option, tick: std::time::Duration) -> Self { + let tick = Timespec { + tv_sec: tick.as_secs() as _, + tv_nsec: tick.subsec_nanos() as _, + }; + Self { + stream, + halt, + halted: false, + tick, + } + } + + // `e`, or `Error::Halted` when the read was aborted by the halt. + fn halted_or(&self, e: io::Error) -> io::Error { + if self.halted { + crate::error::Error::Halted.into() + } else { + e + } + } +} + +impl Read for HaltRead { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let Some(halt) = &self.halt else { + return self.stream.read(buf); + }; + loop { + if halt.is_cancelled() { + // Halted maps to ErrorKind::Interrupted, which std's read_exact + // retries forever; abort with another kind and remap in `halted_or`. + self.halted = true; + return Err(io::ErrorKind::ConnectionAborted.into()); + } + let mut fds = [PollFd::new(&self.stream, PollFlags::IN)]; + let res = rustix::event::poll(&mut fds, Some(&self.tick)).map_err(io::Error::from); + if poll_says_read(res, fds[0].revents())? { + return self.stream.read(buf); + } + } + } +} + // True if `ip` must never be a `network://` connect target (loopback, private, link-local, // unspecified, multicast). Re-checked here at connect time to close a DNS-rebinding TOCTOU. pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool { @@ -94,9 +199,12 @@ enum Mode { Write { writer: BufWriter, header_written: bool, + timings: Vec, + padded: bool, }, Read { - reader: BufReader, + reader: BufReader, + meta: meta::M2tsMeta, }, } @@ -135,6 +243,8 @@ impl NetworkStream { mode: Mode::Write { writer: BufWriter::with_capacity(NET_BUF_SIZE, stream), header_written: false, + timings: Vec::new(), + padded: false, }, }) } @@ -158,7 +268,13 @@ impl NetworkStream { /// `accept`, so the bound port is freed and any subsequent connection /// attempt to the same address is refused. pub fn listen(addr: &str) -> io::Result { - Self::accept_from(TcpListener::bind(addr)?) + Self::listen_with_halt(addr, None) + } + + /// [`listen`](Self::listen) that a [`Halt`] can interrupt, while waiting for + /// the sender to connect and while blocked on a stalled sender. + pub fn listen_with_halt(addr: &str, halt: Option) -> io::Result { + Self::accept_from_with_halt(TcpListener::bind(addr)?, halt) } /// Accept one connection from an already-bound listener and read from it. @@ -166,33 +282,69 @@ impl NetworkStream { /// `:0` bind) and hand the listener in, closing the bind/drop/re-bind race /// that `listen(addr)` would otherwise have. pub fn accept_from(listener: TcpListener) -> io::Result { - let (stream, _peer) = listener.accept()?; + Self::accept_from_with_halt(listener, None) + } + + /// [`accept_from`](Self::accept_from) that a [`Halt`] can interrupt. + pub fn accept_from_with_halt(listener: TcpListener, halt: Option) -> io::Result { + let Some(h) = halt else { + let (stream, _peer) = listener.accept()?; + stream.set_nodelay(true)?; + arm_keepalive(&stream); + return Self::read_from(stream, None); + }; + listener.set_nonblocking(true)?; + let halt = Some(h); + let stream = loop { + if halt.as_ref().is_some_and(Halt::is_cancelled) { + return Err(crate::error::Error::Halted.into()); + } + match listener.accept() { + Ok((stream, _peer)) => break stream, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(ACCEPT_POLL); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + }; + // Accepted sockets can inherit O_NONBLOCK (BSD/macOS); reads wait on readiness. + stream.set_nonblocking(false)?; stream.set_nodelay(true)?; - let mut reader = BufReader::with_capacity(NET_BUF_SIZE, stream); + arm_keepalive(&stream); + Self::read_from(stream, halt) + } + + // Wrap an accepted connection and read its FMKV header. + fn read_from(stream: TcpStream, halt: Option) -> io::Result { + let mut reader = BufReader::with_capacity(NET_BUF_SIZE, HaltRead::new(stream, halt)); // Read FMKV metadata header - let disc_title = meta::read_header(&mut reader)? - .ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })? - .to_title(); + let meta = meta::read_header(&mut reader) + .map_err(|e| reader.get_ref().halted_or(e))? + .ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })?; Ok(Self { - disc_title, - mode: Mode::Read { reader }, + disc_title: meta.to_title(), + mode: Mode::Read { reader, meta }, }) } } -// Write the FMKV header exactly once, before any frames. Always writes -// (even for a title with no streams) so read_header() finds the magic -// and doesn't fall into NoMetadata on a zero-frame stream. +// Write the FMKV header once, before any frames, even for a zero-frame stream +// (else read_header() reports NoMetadata). Sets `padded` when frames carry the +// DiscardPadding extension (header v2). fn ensure_header_written( writer: &mut BufWriter, header_written: &mut bool, disc_title: &DiscTitle, + timings: &[crate::pes::TrackTiming], + padded: &mut bool, ) -> io::Result<()> { if !*header_written { - let m = meta::M2tsMeta::from_title(disc_title); + let m = meta::M2tsMeta::from_title(disc_title).with_timings(timings); meta::write_header(writer, &m)?; + *padded = m.frame_padding; *header_written = true; } Ok(()) @@ -201,7 +353,10 @@ fn ensure_header_written( impl crate::pes::Stream for NetworkStream { fn read(&mut self) -> io::Result> { match &mut self.mode { - Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader), + Mode::Read { reader, meta } => { + crate::pes::PesFrame::deserialize_ext(reader, meta.frame_padding) + .map_err(|e| reader.get_ref().halted_or(e)) + } _ => Err(crate::error::Error::StreamWriteOnly.into()), } } @@ -210,9 +365,11 @@ impl crate::pes::Stream for NetworkStream { Mode::Write { writer, header_written, + timings, + padded, } => { - ensure_header_written(writer, header_written, &self.disc_title)?; - frame.serialize(writer) + ensure_header_written(writer, header_written, &self.disc_title, timings, padded)?; + frame.serialize_ext(writer, *padded) } _ => Err(crate::error::Error::StreamReadOnly.into()), } @@ -221,12 +378,14 @@ impl crate::pes::Stream for NetworkStream { if let Mode::Write { writer, header_written, + timings, + padded, } = &mut self.mode { // Always emit the FMKV header before shutdown, even for a zero-frame stream, // or the receiver's read_header() sees a clean EOF and rejects the stream // with NoMetadata. - ensure_header_written(writer, header_written, &self.disc_title)?; + ensure_header_written(writer, header_written, &self.disc_title, timings, padded)?; writer.flush()?; writer.get_ref().shutdown(std::net::Shutdown::Write)?; } @@ -235,6 +394,49 @@ impl crate::pes::Stream for NetworkStream { fn info(&self) -> &DiscTitle { &self.disc_title } + fn track_timing(&self, track: usize) -> crate::pes::TrackTiming { + match &self.mode { + Mode::Read { meta, .. } => meta.timing(track), + Mode::Write { .. } => Default::default(), + } + } + fn set_track_timing( + &mut self, + track: usize, + timing: crate::pes::TrackTiming, + ) -> io::Result<()> { + match &mut self.mode { + Mode::Write { + header_written: false, + timings, + .. + } => set_timing(timings, track, timing, self.disc_title.streams.len()), + // The header (which carries it) is already on the wire. + Mode::Write { .. } => Err(crate::error::Error::StreamHeaderWritten.into()), + Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()), + } + } + // The sender's codec privates travel in the FMKV header (as for stdio). + fn codec_private(&self, track: usize) -> Option> { + self.disc_title.codec_privates.get(track).cloned().flatten() + } +} + +// Record `timing` for stream `track` of a `tracks`-stream title. +pub(crate) fn set_timing( + timings: &mut Vec, + track: usize, + timing: crate::pes::TrackTiming, + tracks: usize, +) -> io::Result<()> { + if track >= tracks { + return Err(crate::error::Error::MuxTrackRange { track, tracks }.into()); + } + if timings.len() < tracks { + timings.resize(tracks, Default::default()); + } + timings[track] = timing; + Ok(()) } // NetworkStream is PES-only — no IOStream/Read/Write byte interface. @@ -729,4 +931,341 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidInput); handle.join().unwrap(); } + + // A Stop must interrupt a receiver still waiting for its sender to connect. + #[test] + fn a_halt_interrupts_a_pending_accept() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let halt = crate::halt::Halt::new(); + let h = halt.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = NetworkStream::accept_from_with_halt(listener, Some(h)).map(|_| ()); + let _ = tx.send(r); + }); + std::thread::sleep(std::time::Duration::from_millis(100)); + halt.cancel(); + let r = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("accept must observe the halt"); + assert!(crate::error::is_halt(&r.unwrap_err())); + } + + // ... and a receiver blocked on a sender that stalled mid-stream. + #[test] + fn a_halt_interrupts_a_stalled_read() { + use crate::pes; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let halt = crate::halt::Halt::new(); + let h = halt.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut ns = NetworkStream::accept_from_with_halt(listener, Some(h)).unwrap(); + let _ = tx.send(pes::Stream::read(&mut ns).map(|_| ())); + }); + let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false) + .unwrap() + .meta(&sample_title()); + if let Mode::Write { + writer: w, + header_written, + padded, + .. + } = &mut writer.mode + { + ensure_header_written(w, header_written, &sample_title(), &[], padded).unwrap(); + w.flush().unwrap(); + } + std::thread::sleep(std::time::Duration::from_millis(300)); + halt.cancel(); + let r = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("read must observe the halt"); + assert!(crate::error::is_halt(&r.unwrap_err())); + drop(writer); + } + + // A halt while the sender has connected but not finished its header. + #[test] + fn a_halt_interrupts_a_stalled_header_read() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let halt = crate::halt::Halt::new(); + let h = halt.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = NetworkStream::accept_from_with_halt(listener, Some(h)).map(|_| ()); + let _ = tx.send(r); + }); + let mut sender = TcpStream::connect(addr).unwrap(); + sender.write_all(b"FM").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(300)); + halt.cancel(); + let r = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("the header read must observe the halt"); + assert!(crate::error::is_halt(&r.unwrap_err())); + } + + // A receiver without a halt (`listen`/`accept_from`) arms keepalive too. + #[test] + fn a_receiver_without_a_halt_arms_keepalive() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let ns = NetworkStream::accept_from(listener).unwrap(); + match &ns.mode { + Mode::Read { reader, .. } => socket2::SockRef::from(&reader.get_ref().stream) + .keepalive() + .unwrap(), + Mode::Write { .. } => false, + } + }); + let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false) + .unwrap() + .meta(&sample_title()); + if let Mode::Write { + writer: w, + header_written, + padded, + .. + } = &mut writer.mode + { + ensure_header_written(w, header_written, &sample_title(), &[], padded).unwrap(); + w.flush().unwrap(); + } + assert!(handle.join().unwrap(), "keepalive must be armed"); + } + + // Windows may drop data when SO_RCVTIMEO cancels a recv, so a halt-aware + // receiver waits on readiness instead and never arms a read timeout. + #[test] + fn a_halt_aware_receiver_sets_no_read_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let halt = crate::halt::Halt::new(); + let ns = NetworkStream::accept_from_with_halt(listener, Some(halt)).unwrap(); + match &ns.mode { + Mode::Read { reader, .. } => reader.get_ref().stream.read_timeout().unwrap(), + Mode::Write { .. } => None, + } + }); + let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false) + .unwrap() + .meta(&sample_title()); + crate::pes::Stream::finish(&mut writer).unwrap(); + assert_eq!(handle.join().unwrap(), None); + } + + // Only a readiness report reads; a timeout or EINTR ticks; a poll failure surfaces. + #[test] + fn poll_says_read_only_when_the_socket_reports_readiness() { + use io::ErrorKind::{ConnectionReset, Interrupted, TimedOut, WouldBlock}; + let ok = |n: usize, f: PollFlags| poll_says_read(Ok(n), f).unwrap(); + assert!(!ok(0, PollFlags::empty()), "timeout is a tick"); + assert!(!ok(1, PollFlags::empty())); + for f in [ + PollFlags::IN, + PollFlags::HUP, + PollFlags::ERR, + PollFlags::NVAL, + ] { + assert!(ok(1, f), "{f:?} must hand over to read"); + } + assert!(!poll_says_read(Err(Interrupted.into()), PollFlags::empty()).unwrap()); + for kind in [WouldBlock, TimedOut, ConnectionReset] { + let e = poll_says_read(Err(kind.into()), PollFlags::empty()).unwrap_err(); + assert_eq!(e.kind(), kind); + } + } + + // A sender dribbling bytes with gaps many ticks long is read byte-identically. + #[test] + fn a_slow_sender_across_many_poll_ticks_arrives_byte_identical() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let data: Vec = (0..200_000u32).map(|i| (i * 7 + i / 251) as u8).collect(); + let sent = data.clone(); + let sender = std::thread::spawn(move || { + let mut s = TcpStream::connect(addr).unwrap(); + let mut off = 0; + for i in 0usize.. { + if off == sent.len() { + break; + } + let n = (1 + i * 37 % 4093).min(sent.len() - off); + s.write_all(&sent[off..off + n]).unwrap(); + off += n; + if i % 8 == 0 { + std::thread::sleep(std::time::Duration::from_millis(6)); + } + } + }); + let (stream, _) = listener.accept().unwrap(); + let tick = std::time::Duration::from_millis(1); + let mut r = HaltRead::with_tick(stream, Some(crate::halt::Halt::new()), tick); + let mut got = Vec::new(); + let mut small = [0u8; 1500]; + loop { + match r.read(&mut small).unwrap() { + 0 => break, + n => got.extend_from_slice(&small[..n]), + } + } + sender.join().unwrap(); + assert_eq!(got.len(), data.len()); + assert!( + got == data, + "stream bytes must survive the poll ticks unchanged" + ); + } + + // A halt-aware receiver arms keepalive (dead-peer detection) and sets no + // data-idle limit: a sender silent for a while is still read afterwards. + #[test] + fn a_halt_aware_receiver_uses_keepalive_not_an_idle_limit() { + use crate::pes; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let halt = crate::halt::Halt::new(); + let mut ns = NetworkStream::accept_from_with_halt(listener, Some(halt)).unwrap(); + let keepalive = match &ns.mode { + Mode::Read { reader, .. } => socket2::SockRef::from(&reader.get_ref().stream) + .keepalive() + .unwrap(), + Mode::Write { .. } => false, + }; + ( + keepalive, + pes::Stream::read(&mut ns).map(|f| f.map(|f| f.data)), + ) + }); + let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false) + .unwrap() + .meta(&sample_title()); + if let Mode::Write { + writer: w, + header_written, + padded, + .. + } = &mut writer.mode + { + ensure_header_written(w, header_written, &sample_title(), &[], padded).unwrap(); + w.flush().unwrap(); + } + std::thread::sleep(std::time::Duration::from_millis(700)); + let frame = pes::PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track: 0, + pts: 0, + keyframe: true, + data: vec![7; 4], + duration_ns: None, + }; + pes::Stream::write(&mut writer, &frame).unwrap(); + pes::Stream::finish(&mut writer).unwrap(); + let (keepalive, read) = handle.join().unwrap(); + assert!(keepalive, "keepalive must be armed"); + assert_eq!(read.unwrap(), Some(vec![7; 4])); + } + + // The FMKV header's codec privates (e.g. FLAC STREAMINFO, OpusHead, avcC) + // must be exposed on the receive side, or the MKV writer drops them. + #[test] + fn the_receiver_exposes_the_header_codec_privates() { + use crate::pes; + let mut title = sample_title(); + title.codec_privates = vec![Some(vec![1, 2, 3]), Some(b"OpusHead".to_vec())]; + let (addr, handle) = spawn_codec_private_reader(); + let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false) + .unwrap() + .meta(&title); + pes::Stream::finish(&mut writer).unwrap(); + let cps = handle.join().unwrap(); + assert_eq!(cps, title.codec_privates); + } + + fn spawn_codec_private_reader() -> ( + std::net::SocketAddr, + std::thread::JoinHandle>>>, + ) { + use crate::pes::Stream as _; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let ns = NetworkStream::accept_from(listener).unwrap(); + (0..2).map(|i| ns.codec_private(i)).collect() + }); + (addr, handle) + } + + // mkv(opus) -> network -> mkv: CodecDelay/SeekPreRoll and DiscardPadding + // survive the PES wire hop. + #[test] + fn opus_track_timing_and_padding_survive_network_to_mkv() { + use crate::pes::{self, Stream as _}; + let mut title = sample_title(); + title.streams.truncate(1); + title.streams[0] = Stream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::Opus, + channels: AudioChannels::Stereo, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: crate::disc::LabelPurpose::Normal, + label: String::new(), + }); + title.codec_privates = vec![Some(b"OpusHead\x01\x02".to_vec())]; + let timing = pes::TrackTiming { + codec_delay_ns: 6_500_000, + seek_preroll_ns: 80_000_000, + }; + let frame = pes::PesFrame { + discard_padding_ns: -2_500_000, + coding: None, + source: None, + track: 0, + pts: 0, + keyframe: true, + data: vec![0xFC, 1, 2, 3], + duration_ns: Some(20_000_000), + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let mut ns = NetworkStream::accept_from(listener).unwrap(); + let got = ns.track_timing(0); + let f = ns.read().unwrap().unwrap(); + (ns.info().clone(), got, f) + }); + let mut w = NetworkStream::connect_vetted(&addr.to_string(), false) + .unwrap() + .meta(&title); + w.set_track_timing(0, timing).unwrap(); + w.write(&frame).unwrap(); + w.finish().unwrap(); + let (rx_title, rx_timing, rx_frame) = handle.join().unwrap(); + assert_eq!(rx_timing, timing); + assert_eq!(rx_frame.discard_padding_ns, frame.discard_padding_ns); + assert_eq!(rx_frame.data, frame.data); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.mkv"); + let file = std::fs::File::create(&path).unwrap(); + let mut mkv = + crate::mux::mkvstream::MkvStream::create(Box::new(file), &rx_title, None).unwrap(); + mkv.set_track_timing(0, rx_timing).unwrap(); + mkv.write(&rx_frame).unwrap(); + mkv.finish().unwrap(); + let mut back = + crate::mux::mkvstream::MkvStream::open(std::fs::File::open(&path).unwrap()).unwrap(); + assert_eq!(back.track_timing(0), timing); + assert_eq!(back.read().unwrap().unwrap().discard_padding_ns, -2_500_000); + } } diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 01cbfc4d..72ff49d7 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -25,6 +25,8 @@ pub struct PipelinedPesStream { parsers: Vec<(u16, Box)>, pid_to_track: Vec<(u16, usize)>, + /// Declared BEFORE `demux_thread`: dropped first, so a worker blocked on a + /// full channel exits before `DemuxThread::drop` joins it. demux_rx: Receiver, /// Kept alive so dropping this stream joins the demux + producer /// workers deterministically. Never poked directly after spawn. @@ -412,6 +414,8 @@ impl Stream for PipelinedPesStream { .iter() .find(|(p, _)| *p == pid) .and_then(|(_, parser)| parser.codec_private()) + // FLAC/Opus ES carry no init data: keep what the source header supplied. + .or_else(|| self.title.codec_privates.get(track).cloned().flatten()) } // `lost_bytes` uses the trait default (0): the file-backed highway has no @@ -1030,6 +1034,24 @@ mod tests { ); } + /// A codec whose ES carries no init data (FLAC, Opus) keeps the CodecPrivate + /// the source header supplied (FMKV `m2ts://`) when its parser has none. + #[test] + fn codec_private_falls_back_to_the_source_title() { + let mut title = video_title(false); + title.codec_privates = vec![Some(b"fLaC".to_vec())]; + let parsers: Vec<(u16, Box)> = vec![( + 0x1011, + Box::new(CountingParser { + per_pes: 0, + flush_n: 0, + cp: None, + }), + )]; + let (stream, _tx) = make_stream(title, parsers, vec![(0x1011u16, 0usize)]); + assert_eq!(stream.codec_private(0).as_deref(), Some(&b"fLaC"[..])); + } + /// codec_private(track) returns None for a track index not present in /// pid_to_track — no panic, no wrong-track data. #[test] diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 3ed3fc3d..f3108e68 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -356,6 +356,15 @@ impl std::fmt::Debug for InputOptions { /// Open a PES input stream (produces PES frames). pub fn input(url: &str, opts: &InputOptions) -> io::Result> { + input_with_halt(url, opts, None) +} + +// `input` whose blocking network receive (`network://`) a halt can interrupt. +pub(crate) fn input_with_halt( + url: &str, + opts: &InputOptions, + halt: Option<&crate::halt::Halt>, +) -> io::Result> { let parsed = parse_url(url); match parsed { StreamUrl::Disc { .. } => { @@ -415,7 +424,10 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result { validate_network_addr(addr)?; - Ok(Box::new(NetworkStream::listen(addr)?)) + Ok(Box::new(NetworkStream::listen_with_halt( + addr, + halt.cloned(), + )?)) } StreamUrl::Stdio => Ok(Box::new(StdioStream::input())), StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()), diff --git a/src/mux/stdio.rs b/src/mux/stdio.rs index d73e261c..cb10dbbb 100644 --- a/src/mux/stdio.rs +++ b/src/mux/stdio.rs @@ -21,6 +21,11 @@ pub struct StdioStream { /// when no header was present — `headers_ready()` must gate on the /// metadata actually being available, not merely on having looked. meta_parsed: bool, + /// Write side: per-track decoder timing, sent in the header. Read side: the + /// header's timing. + timings: Vec, + /// Frames carry the DiscardPadding extension (FMKV header v2). + padded: bool, } impl StdioStream { @@ -33,6 +38,8 @@ impl StdioStream { header_written: false, header_read: false, meta_parsed: false, + timings: Vec::new(), + padded: false, } } @@ -45,6 +52,8 @@ impl StdioStream { header_written: false, header_read: false, meta_parsed: false, + timings: Vec::new(), + padded: false, } } @@ -54,8 +63,9 @@ impl StdioStream { if let Some(w) = &mut self.writer && !self.header_written { - let m = meta::M2tsMeta::from_title(&self.disc_title); + let m = meta::M2tsMeta::from_title(&self.disc_title).with_timings(&self.timings); meta::write_header(w, &m)?; + self.padded = m.frame_padding; self.header_written = true; } Ok(()) @@ -73,6 +83,10 @@ impl StdioStream { // garbage. Ok(None) (magic mismatch / clean EOF) stays non-error (default title). if let Some(m) = meta::read_header(r)? { self.disc_title = m.to_title(); + self.timings = (0..self.disc_title.streams.len()) + .map(|i| m.timing(i)) + .collect(); + self.padded = m.frame_padding; self.meta_parsed = true; } } @@ -84,7 +98,7 @@ impl crate::pes::Stream for StdioStream { fn read(&mut self) -> io::Result> { self.ensure_header_read()?; match &mut self.reader { - Some(r) => crate::pes::PesFrame::deserialize(r), + Some(r) => crate::pes::PesFrame::deserialize_ext(r, self.padded), None => Err(crate::error::Error::StreamWriteOnly.into()), } } @@ -94,7 +108,7 @@ impl crate::pes::Stream for StdioStream { } self.ensure_header_written()?; match &mut self.writer { - Some(w) => frame.serialize(w), + Some(w) => frame.serialize_ext(w, self.padded), None => Err(crate::error::Error::StreamReadOnly.into()), } } @@ -112,6 +126,26 @@ impl crate::pes::Stream for StdioStream { &self.disc_title } + // Read side: the header's timing, available once the first read parsed it. + fn track_timing(&self, track: usize) -> crate::pes::TrackTiming { + self.timings.get(track).copied().unwrap_or_default() + } + + fn set_track_timing( + &mut self, + track: usize, + timing: crate::pes::TrackTiming, + ) -> io::Result<()> { + if self.writer.is_none() { + return Err(crate::error::Error::StreamReadOnly.into()); + } + if self.header_written { + return Err(crate::error::Error::StreamHeaderWritten.into()); + } + let tracks = self.disc_title.streams.len(); + super::network::set_timing(&mut self.timings, track, timing, tracks) + } + fn codec_private(&self, track: usize) -> Option> { // Single source of truth: the title's own codec_privates. (The // previous `stored_codec_privates` field was a redundant clone diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 83fea5ac..1e85a21a 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -611,6 +611,37 @@ fn psi_payload_base(pkt: &[u8]) -> Option { } } +// MPEG audio layer (1-3) from the first frame header at the start of `pid`'s +// first PES in `data`; `None` when no such PES or header is in the scan window. +fn mpeg_audio_layer(data: &[u8], pid: u16) -> Option { + let mut offset = 0; + while offset + BD_SOURCE_PACKET_BYTES <= data.len() { + if !is_resync_point(data, offset) { + offset += 1; + continue; + } + let pkt = &data[offset..offset + BD_SOURCE_PACKET_BYTES]; + let pkt_pid = (((pkt[5] & 0x1F) as u16) << 8) | pkt[6] as u16; + if pkt_pid == pid && pkt[5] & 0x40 != 0 { + let pes = &pkt[psi_payload_base(pkt)?..]; + if pes.get(..3)? != [0, 0, 1] { + return None; + } + let es = pes.get(9 + *pes.get(8)? as usize..)?; + let (b0, b1) = (*es.first()?, *es.get(1)?); + if b0 != 0xFF || b1 & 0xE0 != 0xE0 { + return None; + } + return match (b1 >> 1) & 0x03 { + 0 => None, + l => Some(4 - l), + }; + } + offset += BD_SOURCE_PACKET_BYTES; + } + None +} + // Reassemble a single PSI section (PAT/PMT) for `target_pid`/`table_id` across TS-packet // boundaries. Returns the section bytes (from table_id) or None. fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option> { @@ -767,6 +798,8 @@ pub fn scan_streams(data: &[u8]) -> Option> { // PMTs may also carry ISO/IEC 13818-1 audio stream types that are // absent from Blu-ray's STN table. Keep that distinction local to TS. let codec = match stream_type { + // MPEG-1/2 audio covers Layers I-III; only the ES says which. + 0x03 | 0x04 if mpeg_audio_layer(data, es_pid) == Some(3) => Codec::Mp3, 0x03 | 0x04 => Codec::Mp2, 0x0f => Codec::Aac, 0x87 => Codec::Ac3Plus, @@ -1416,6 +1449,26 @@ mod tests { } } + // stream_type 0x03/0x04 covers Layers I-III; the layer comes from the ES. + #[test] + fn scan_streams_labels_mpeg_audio_by_the_es_layer() { + use crate::disc::{Codec, Stream}; + for (header, expected) in [([0xFF, 0xFB], Codec::Mp3), ([0xFF, 0xFD], Codec::Mp2)] { + let mut pes = vec![0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x80, 0x00, 0x00]; + pes.extend_from_slice(&header); + pes.extend_from_slice(&[0x90, 0x64]); + let mut data = pat_packet(0x100); + data.extend(pmt_packet(0x100, &[(0x1b, 0x1011), (0x03, 0x1100)])); + data.extend(es_packet_exact(0x1100, true, &pes)); + data.extend(pat_packet(0x100)); + let streams = scan_streams(&data).unwrap(); + assert!( + matches!(&streams[1], Stream::Audio(a) if a.codec == expected), + "{expected:?}" + ); + } + } + #[test] fn scan_streams_maps_lpcm_via_from_coding_type() { use crate::disc::{Codec, Stream}; diff --git a/src/mux/tsmux.rs b/src/mux/tsmux.rs index c01c3455..2ea1de49 100644 --- a/src/mux/tsmux.rs +++ b/src/mux/tsmux.rs @@ -27,6 +27,10 @@ fn is_video_pid(pid: u16) -> bool { VIDEO_PID_RANGE.contains(&pid) } +// Headroom below the first frame's PTS for frames presented before it but emitted +// after it. ISO/IEC 13818-1 §2.4.2.6 caps T-STD buffer delay at 1 s. +pub(crate) const ORIGIN_HEADROOM_NS: i64 = 1_000_000_000; + /// BD-TS muxer: PES frames in, 192-byte BD-TS packets out. /// /// Constructed over an output writer and a slice of per-track PIDs. The `track` index passed to @@ -45,8 +49,8 @@ pub struct TsMuxer { video_codec: Vec, /// Global PTS origin (nanoseconds), seeded by the FIRST frame of any kind /// (video or audio) so a single fixed origin rebases every frame and the - /// audio/video offset is preserved. A frame earlier than the origin (e.g. - /// audio reordered before the seeding frame) saturates to 0. + /// audio/video offset is preserved. It lies [`ORIGIN_HEADROOM_NS`] before + /// the seeding frame; only a frame earlier than that saturates to 0. base_pts_ns: Option, /// Count of PES frames actually emitted (a frame dropped as non-key /// before the first keyframe does NOT count). `finish()` returns @@ -153,10 +157,12 @@ impl TsMuxer { return Ok(()); } - // Seed the global PTS origin from the FIRST frame of ANY kind, so one fixed - // origin rebases every frame and the a/v offset is preserved. Video-ONLY seeding - // made pre-video audio fall back to `base = unwrap_or(pts_ns)` (own pts) → PTS 0, spacing lost. - let base = *self.base_pts_ns.get_or_insert(pts_ns); + // One origin for every track, seeded by the FIRST frame of any kind and set + // ORIGIN_HEADROOM_NS below it, so a frame emitted later but presented + // earlier (video behind its GOP) keeps its offset. + let base = *self + .base_pts_ns + .get_or_insert(pts_ns.saturating_sub(ORIGIN_HEADROOM_NS)); let pts_ns = pts_ns.saturating_sub(base); // NAL video (HEVC/H.264): convert length-prefixed NALUs to Annex B and prepend @@ -439,6 +445,9 @@ fn build_pes_header(pid: u16, pts_90k: Option, data_len: usize) -> Vec mod tests { use super::*; + // The origin headroom in 90 kHz ticks: the seeding frame's encoded PTS. + const H: u64 = ORIGIN_HEADROOM_NS as u64 * 9 / 100_000; + use crate::consts::BD_SOURCE_PACKET_BYTES; const VIDEO_PID: u16 = 0x1011; @@ -747,12 +756,12 @@ mod tests { #[test] fn av_offset_preserved_with_audio_before_first_video() { // Audio at t=0 before the first video keyframe at t=1s. The FIRST frame of any - // kind fixes the origin, so audio becomes origin (PTS 0), video lands 1s=90_000 + // kind fixes the origin, so audio lands at the headroom, video 1s=90_000 // ticks later. OLD video-only seeding made both fall back to own pts → collapse to 0, offset lost. let mut sink: Vec = Vec::new(); { let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]); - // Audio frame first, at PTS 0 — this seeds the origin. + // Audio frame first, at t=0 — this seeds the origin. mux.write_frame(1, 0, false, &[0x0B, 0x77, 0x00, 0x00]) .unwrap(); // Video keyframe at PTS 1s — 1s after the origin. @@ -763,14 +772,12 @@ mod tests { let packets = parse_bd_ts(&sink); let video_pts = first_pts_90k(&packets, VIDEO_PID); let audio_pts = first_pts_90k(&packets, AUDIO_PID); - // The leading audio frame is the origin ⇒ its relative PTS is 0. + // The leading audio frame seeds the origin one headroom below itself. + assert_eq!(audio_pts, H, "the first (audio) frame seeds the origin"); + // Video is 1s after the audio ⇒ the audio→video offset is preserved. assert_eq!( - audio_pts, 0, - "the first (audio) frame seeds the origin at 0" - ); - // Video is 1s after the origin ⇒ the audio→video offset is preserved. - assert_eq!( - video_pts, 90_000, + video_pts, + H + 90_000, "video 1s after the origin must stay 90_000 ticks ahead, not collapse to 0" ); } @@ -1010,13 +1017,13 @@ mod tests { #[test] fn pts_encoded_at_90khz_decodes_correctly() { // pts_ns → 90 kHz ticks = pts_ns * 9 / 100_000. 1 second (1e9 ns) - // = 90_000 ticks. The first (base) video frame rebases to 0, so use + // = 90_000 ticks. The first (base) video frame lands at the headroom, so use // a second frame at a known offset and check its encoded PTS. let mut sink: Vec = Vec::new(); { let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]); let idr = fake_hevc_nal(19, 50); - mux.write_frame(0, 0, true, &idr).unwrap(); // base = 0 + mux.write_frame(0, 0, true, &idr).unwrap(); // seeds the origin let p = fake_hevc_nal(1, 50); // +1 second relative to base. mux.write_frame(0, 1_000_000_000, false, &p).unwrap(); @@ -1035,7 +1042,7 @@ mod tests { | (((p[11] >> 1) as u64) << 15) | ((p[12] as u64) << 7) | ((p[13] >> 1) as u64); - assert_eq!(pts, 90_000, "1s offset encodes to 90000 ticks @ 90 kHz"); + assert_eq!(pts, H + 90_000, "1s offset encodes to 90000 ticks @ 90 kHz"); } #[test] @@ -1082,8 +1089,8 @@ mod tests { #[test] fn negative_relative_pts_saturates_to_zero() { // A frame earlier than the origin (negative relative PTS) must encode PTS 0, not - // an underflowed huge value. Video keyframe at t=2s seeds the origin; a later - // audio frame at t=0 is 2s BEFORE it → -2s, which must floor to 0. + // an underflowed huge value. Video keyframe at t=2s seeds the origin at t=1s; a + // later audio frame at t=0 is 1s BEFORE it, which must floor to 0. let mut sink: Vec = Vec::new(); { let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]); @@ -1120,9 +1127,13 @@ mod tests { let packets = parse_bd_ts(&sink); let pts = all_pts_90k(&packets, AUDIO_PID); assert_eq!(pts.len(), 2, "two audio frames → two PTS-bearing PES"); - assert_eq!(pts[0], 0, "the first audio frame is the origin → 0"); assert_eq!( - pts[1], 90_000, + pts[0], H, + "the first audio frame sits one headroom past the origin" + ); + assert_eq!( + pts[1], + H + 90_000, "the second frame (1s later) must keep its 1s spacing, not collapse to 0" ); } @@ -1341,10 +1352,30 @@ mod tests { | (((p[11] >> 1) as u64) << 15) | ((p[12] as u64) << 7) | ((p[13] >> 1) as u64); - assert_eq!(decoded, 0, "base video frame stays at relative PTS 0"); + assert_eq!(decoded, H, "base video frame stays at the headroom"); assert_eq!( - pts, big_pts_ticks, + pts, + H + big_pts_ticks, "the high bits (29..32) of a large PTS must round-trip through encoding" ); } + + // Audio emitted first but presented AFTER the first video frame (the video + // parser holds its opening GOP) must not clamp that video onto the audio's PTS. + #[test] + fn video_presented_before_the_seeding_audio_keeps_its_offset() { + let mut sink: Vec = Vec::new(); + { + let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]); + mux.write_frame(1, 100_000_000, false, &[0x0B, 0x77, 0x00, 0x00]) + .unwrap(); + let idr = fake_hevc_nal(19, 100); + mux.write_frame(0, 50_000_000, true, &idr).unwrap(); + mux.finish().unwrap(); + } + let packets = parse_bd_ts(&sink); + let video = first_pts_90k(&packets, VIDEO_PID); + let audio = first_pts_90k(&packets, AUDIO_PID); + assert_eq!(audio - video, 4_500, "50 ms apart, video first"); + } } diff --git a/src/pes.rs b/src/pes.rs index d8435f24..f713ad7f 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -57,7 +57,7 @@ pub struct PesFrame { /// preserved across network:// and stdio:// hops. pub duration_ns: Option, /// Matroska DiscardPadding in nanoseconds: positive trims the end, negative - /// trims the beginning. Local container remux metadata; not on the PES wire. + /// trims the beginning. On the PES wire only for FMKV header v2 streams. pub discard_padding_ns: i64, /// Byte-exact source provenance of this frame's first byte, stamped at the /// demux seam. `None` for synthetic sources / the `skip_parse` path and for @@ -81,6 +81,10 @@ impl PesFrame { /// /// `duration_ns` is encoded as `u64::MAX` when `None`, or the value when `Some`. pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> { + self.serialize_inner(w, false) + } + + fn serialize_inner(&self, w: &mut dyn std::io::Write, padded: bool) -> std::io::Result<()> { if self.track > 255 { return Err(crate::error::Error::PesTrackTooLarge { track: self.track }.into()); } @@ -98,9 +102,34 @@ impl PesFrame { w.write_all(&[if self.keyframe { 1 } else { 0 }])?; w.write_all(&duration_wire.to_le_bytes())?; w.write_all(&(self.data.len() as u32).to_le_bytes())?; + if padded { + w.write_all(&self.discard_padding_ns.to_le_bytes())?; + } w.write_all(&self.data) } + /// [`serialize`](Self::serialize) plus, when `padded` (FMKV header v2), an + /// 8-byte LE `discard_padding_ns` after the fixed header. + pub(crate) fn serialize_ext( + &self, + w: &mut dyn std::io::Write, + padded: bool, + ) -> std::io::Result<()> { + self.serialize_inner(w, padded) + } + + /// [`deserialize`](Self::deserialize) for a stream whose FMKV header says + /// frames carry the `discard_padding_ns` extension (`padded`). + pub(crate) fn deserialize_ext( + r: &mut dyn std::io::Read, + padded: bool, + ) -> std::io::Result> { + if !padded { + return Self::deserialize(r); + } + Self::deserialize_inner(r, true) + } + /// Deserialize from bytes. Returns None at a clean end of stream. /// /// A clean EOF is exactly zero bytes available before the next frame. @@ -108,6 +137,10 @@ impl PesFrame { /// error (`UnexpectedEof`), not silently treated as EOF — otherwise /// truncated `.pes` data would be accepted as a graceful end. pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result> { + Self::deserialize_inner(r, false) + } + + fn deserialize_inner(r: &mut dyn std::io::Read, padded: bool) -> std::io::Result> { // Probe one byte first to distinguish clean EOF from a truncated header. // Loop on EINTR so a recoverable interrupted read doesn't fail — symmetric // with read_exact's internal retry for the rest of the header and data. @@ -141,6 +174,12 @@ impl PesFrame { Some(duration_wire) }; let len = u32::from_le_bytes([header[18], header[19], header[20], header[21]]) as usize; + let mut discard_padding_ns = 0; + if padded { + let mut ext = [0u8; 8]; + r.read_exact(&mut ext)?; + discard_padding_ns = i64::from_le_bytes(ext); + } if len > MAX_FRAME_SIZE { return Err(crate::error::Error::PesFrameTooLarge { size: len }.into()); } @@ -157,7 +196,7 @@ impl PesFrame { filled += want; } Ok(Some(Self { - discard_padding_ns: 0, + discard_padding_ns, track, pts, keyframe, @@ -226,8 +265,9 @@ pub trait Stream: Send { TrackTiming::default() } - /// Set timing before the first frame is written. Sinks without this metadata - /// retain their existing behavior. + /// Set timing before the first frame is written. Carried by `mkv://` and the + /// FMKV `network://` / `stdio://` wire; `m2ts://` has no field for it and + /// `mp4://` carries no Opus track, so those keep the no-op default. fn set_track_timing(&mut self, _track: usize, _timing: TrackTiming) -> std::io::Result<()> { Ok(()) }