diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f7a97..287e9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.11.5 (2026-04-18) + +### MKV container fixes — Jellyfin/player compatibility +- **Timestamp normalization** — MKV and M2TS output starts at 0.000s instead of raw disc PTS offset. Fixes playback failures in Jellyfin and other players. +- **DefaultDuration** — correct frame rate written to MKV track header. Fixes wrong avg_frame_rate (was 293/12, now 24000/1001). +- **HDR Colour metadata** — MatrixCoefficients, TransferCharacteristics, Primaries, Range written to MKV video track. Enables HDR tone mapping in players. +- **DisplayWidth/DisplayHeight** — aspect ratio fields in MKV video track. +- **Chapters (Blu-ray)** — accept mark_type 0 as chapter entry (was filtering to type 1 only, which no disc uses). +- **Chapters (DVD)** — extract chapter timestamps from PGC program map + cell durations. +- **Default disposition** — only first video and first audio track marked default. Fixes wrong auto-selection in players. + ## 0.11.3 (2026-04-18) ### Unified versioning diff --git a/Cargo.toml b/Cargo.toml index ff7d4ee..27ec618 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.11.3" +version = "0.11.5" edition = "2021" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 728ba57..bc65549 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -159,12 +159,12 @@ impl Disc { }) .collect(); - // Convert marks to chapters (filter mark_type == 1 = chapter entry) + // Convert marks to chapters (mark_type 0 or 1 = chapter entry, 2 = link) let first_in_time = parsed.play_items.first().map(|pi| pi.in_time).unwrap_or(0); let chapters: Vec = parsed .marks .iter() - .filter(|m| m.mark_type == 1) + .filter(|m| m.mark_type <= 1) .enumerate() .map(|(i, m)| { let time_secs = (m.timestamp as f64 - first_in_time as f64) / 45000.0; diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 45a9a97..104d80c 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -101,6 +101,16 @@ impl Disc { streams.extend(audio_streams.iter().cloned()); streams.extend(subtitle_streams); + let chapters: Vec = dvd_title + .chapter_times + .iter() + .enumerate() + .map(|(i, &t)| Chapter { + time_secs: t, + name: format!("Chapter {}", i + 1), + }) + .collect(); + titles.push(DiscTitle { playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number), playlist_id: title_number, @@ -108,7 +118,7 @@ impl Disc { size_bytes, clips: Vec::new(), streams, - chapters: Vec::new(), + chapters, extents, content_format: ContentFormat::MpegPs, codec_privates: Vec::new(), diff --git a/src/ifo.rs b/src/ifo.rs index 50c5117..888aff4 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -47,6 +47,8 @@ pub struct DvdTitle { pub duration_secs: f64, /// Cell sector ranges pub cells: Vec, + /// Chapter start times in seconds (derived from program map + cell times) + pub chapter_times: Vec, /// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr]. pub palette: Option>, } @@ -541,6 +543,37 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result duration_secs }; + // Extract chapter times from program map + cell durations + // PGC program map offset at 0xE6, maps program_number → first cell_number + let chapter_times = { + let pgm_map_offset = be_u16(data, pgc_offset + 0xE6).unwrap_or(0) as usize; + let nr_of_programs = byte_at(data, pgc_offset + 0x02).unwrap_or(0) as usize; + let mut times = Vec::new(); + if pgm_map_offset > 0 && nr_of_programs > 0 && cell_playback_offset > 0 { + let pgm_base = pgc_offset + pgm_map_offset; + // Collect cell durations + let mut cell_durations = Vec::with_capacity(num_cells); + let cell_base = pgc_offset + cell_playback_offset; + for i in 0..num_cells { + let co = cell_base + i * 24; + if co + 8 <= data.len() { + cell_durations.push(bcd_to_secs(&data[co + 4..co + 8])); + } else { + cell_durations.push(0.0); + } + } + // Program map: each byte is the first cell number (1-based) for that program + for p in 0..nr_of_programs { + if pgm_base + p >= data.len() { break; } + let first_cell = data[pgm_base + p] as usize; + // Chapter time = sum of cell durations before this program's first cell + let time: f64 = cell_durations[..first_cell.saturating_sub(1)].iter().sum(); + times.push(time); + } + } + times + }; + // Extract subtitle palette at PGC offset 0xA4: 16 colors × 4 bytes [padding, Y, Cb, Cr] let palette = if pgc_offset + 0xA4 + 64 <= data.len() { let mut colors = Vec::with_capacity(16); @@ -562,6 +595,7 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result chapters, duration_secs, cells, + chapter_times, palette, }) } @@ -651,6 +685,7 @@ mod tests { chapters: 5, duration_secs: 3600.0, cells: vec![cell.clone()], + chapter_times: Vec::new(), palette: None, }; assert_eq!(title.chapters, 5); diff --git a/src/mpls.rs b/src/mpls.rs index 6706544..84bf4ce 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -256,7 +256,7 @@ pub fn parse(data: &[u8]) -> Result { // Parse PlayListMark section let mut marks = Vec::new(); - if mark_start > 0 && mark_start + 4 < data.len() { + if mark_start > 0 && mark_start + 4 <= data.len() { let ms = &data[mark_start..]; if ms.len() >= 6 { let num_marks = u16::from_be_bytes([ms[4], ms[5]]) as usize; diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 1f6e53b..76dfc10 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -5,7 +5,7 @@ //! cues and seek head are finalized at the end. use super::ebml; -use crate::disc::{AudioStream, Chapter, Codec, SubtitleStream, VideoStream}; +use crate::disc::{AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream}; use std::io::{self, Seek, SeekFrom, Write}; /// MKV track definition (built from disc stream metadata). @@ -20,6 +20,14 @@ pub struct MkvTrack { // Video-specific pub pixel_width: u32, pub pixel_height: u32, + pub default_duration_ns: u64, // nanoseconds per frame (0 = unknown) + pub display_width: u32, // display aspect ratio width (0 = same as pixel) + pub display_height: u32, // display aspect ratio height (0 = same as pixel) + // HDR colour metadata + pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc) + pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ) + pub colour_primaries: u8, // Primaries (9=bt2020) + pub colour_range: u8, // Range (1=tv/limited) // Audio-specific pub sample_rate: f64, pub channels: u8, @@ -36,16 +44,40 @@ impl MkvTrack { _ => "V_MPEG2", }; let (w, h) = v.resolution.pixels(); + let (num, den) = v.frame_rate.as_fraction(); + let default_duration_ns = if num > 0 { + (1_000_000_000u64 * den as u64) / num as u64 + } else { + 0 + }; + let (matrix, transfer, primaries, range) = match v.color_space { + ColorSpace::Bt2020 => (9, 16, 9, 1), // bt2020nc, PQ, bt2020, limited + ColorSpace::Bt709 => (1, 1, 1, 1), // bt709 + ColorSpace::Unknown => (0, 0, 0, 0), + }; + // Override transfer for non-PQ HDR + let transfer = match v.hdr { + HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => 16, // PQ + HdrFormat::Hlg => 18, + _ => transfer, + }; Self { track_type: ebml::TRACK_TYPE_VIDEO, codec_id, language: "und".into(), name: v.label.clone(), - codec_private: None, // filled later by parser + codec_private: None, is_default: !v.secondary, is_forced: false, pixel_width: w, pixel_height: h, + default_duration_ns, + display_width: w, + display_height: h, + colour_matrix: matrix, + colour_transfer: transfer, + colour_primaries: primaries, + colour_range: range, sample_rate: 0.0, channels: 0, bit_depth: 0, @@ -73,6 +105,13 @@ impl MkvTrack { is_forced: false, pixel_width: 0, pixel_height: 0, + default_duration_ns: 0, + display_width: 0, + display_height: 0, + colour_matrix: 0, + colour_transfer: 0, + colour_primaries: 0, + colour_range: 0, sample_rate: sr, channels: ch, bit_depth: 0, @@ -94,6 +133,13 @@ impl MkvTrack { is_forced: s.forced, pixel_width: 0, pixel_height: 0, + default_duration_ns: 0, + display_width: 0, + display_height: 0, + colour_matrix: 0, + colour_transfer: 0, + colour_primaries: 0, + colour_range: 0, sample_rate: 0.0, channels: 0, bit_depth: 0, @@ -116,6 +162,7 @@ pub struct MkvMuxer { cluster_pos: u64, cluster_size_pos: u64, cluster_ts_ms: i64, + base_pts_ms: Option, cues: Vec, frame_count: u64, /// File positions of codecPrivate placeholders (track_idx → offset, max_size). @@ -216,11 +263,29 @@ impl MkvMuxer { codec_private_filled.push(true); } + // DefaultDuration — frame duration in nanoseconds + if track.default_duration_ns > 0 { + ebml::write_uint(&mut writer, ebml::DEFAULT_DURATION, track.default_duration_ns)?; + } + // Video-specific if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 { let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?; ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?; ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?; + if track.display_width > 0 && track.display_height > 0 { + ebml::write_uint(&mut writer, ebml::DISPLAY_WIDTH, track.display_width as u64)?; + ebml::write_uint(&mut writer, ebml::DISPLAY_HEIGHT, track.display_height as u64)?; + } + // Colour metadata (HDR) + if track.colour_matrix > 0 || track.colour_transfer > 0 { + let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?; + ebml::write_uint(&mut writer, ebml::MATRIX_COEFFICIENTS, track.colour_matrix as u64)?; + ebml::write_uint(&mut writer, ebml::TRANSFER_CHARACTERISTICS, track.colour_transfer as u64)?; + ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?; + ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?; + ebml::end_master(&mut writer, col_pos)?; + } ebml::end_master(&mut writer, vid_pos)?; } @@ -265,6 +330,7 @@ impl MkvMuxer { cluster_pos: 0, cluster_size_pos: 0, cluster_ts_ms: 0, + base_pts_ms: None, cues: Vec::new(), frame_count: 0, codec_private_slots, @@ -280,7 +346,9 @@ impl MkvMuxer { keyframe: bool, data: &[u8], ) -> io::Result<()> { - let pts_ms = pts_ns / 1_000_000; + let raw_ms = pts_ns / 1_000_000; + let base = *self.base_pts_ms.get_or_insert(raw_ms); + let pts_ms = raw_ms - base; // Start new cluster if needed if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS { @@ -450,6 +518,13 @@ mod tests { is_forced: false, pixel_width: 1920, pixel_height: 1080, + default_duration_ns: 41708333, + display_width: 1920, + display_height: 1080, + colour_matrix: 0, + colour_transfer: 0, + colour_primaries: 0, + colour_range: 0, sample_rate: 0.0, channels: 0, bit_depth: 0, @@ -467,6 +542,13 @@ mod tests { is_forced: false, pixel_width: 0, pixel_height: 0, + default_duration_ns: 0, + display_width: 0, + display_height: 0, + colour_matrix: 0, + colour_transfer: 0, + colour_primaries: 0, + colour_range: 0, sample_rate: 48000.0, channels: 6, bit_depth: 0, @@ -760,3 +842,4 @@ mod tests { ); } } + diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index aa01844..d5718eb 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -42,12 +42,22 @@ impl MkvStream { /// Codec privates come from title.codec_privates (populated by input stream). pub fn create(writer: Box, title: &DiscTitle) -> io::Result { let mut tracks = Vec::new(); + let mut has_default_video = false; + let mut has_default_audio = false; for (idx, s) in title.streams.iter().enumerate() { let mut track = match s { crate::disc::Stream::Video(v) => MkvTrack::video(v), crate::disc::Stream::Audio(a) => MkvTrack::audio(a), crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s), }; + // Only first video and first audio are default + if track.is_default { + match track.track_type { + 1 if !has_default_video => has_default_video = true, + 2 if !has_default_audio => has_default_audio = true, + _ => track.is_default = false, + } + } if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) { track.codec_private = Some(cp.clone()); } diff --git a/src/mux/tsmux.rs b/src/mux/tsmux.rs index 2811699..5be2418 100644 --- a/src/mux/tsmux.rs +++ b/src/mux/tsmux.rs @@ -15,6 +15,7 @@ pub struct TsMuxer { continuity: Vec, // per-PID continuity counter (0-15) codec_privates: Vec>>, // per-track codec_private (for video parameter sets) params_written: Vec, // per-track: have we written parameter sets? + base_pts_ns: Option, } impl TsMuxer { @@ -26,6 +27,7 @@ impl TsMuxer { continuity: vec![0u8; n], codec_privates: vec![None; n], params_written: vec![false; n], + base_pts_ns: None, } } @@ -44,6 +46,9 @@ impl TsMuxer { if track >= self.pids.len() { return Ok(()); // unknown track, skip } + let base = *self.base_pts_ns.get_or_insert(pts_ns); + let pts_ns = pts_ns - base; + let pid = self.pids[track]; let is_video = (0x1011..=0x101F).contains(&pid);