v0.11.5: MKV container fixes — timestamps, frame rate, HDR, chapters, disposition

This commit is contained in:
Matt Jackson
2026-04-18 16:29:21 +00:00
parent 7437af39c8
commit 80fcffd190
9 changed files with 162 additions and 8 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog # 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) ## 0.11.3 (2026-04-18)
### Unified versioning ### Unified versioning
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.11.3" version = "0.11.5"
edition = "2021" edition = "2021"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+2 -2
View File
@@ -159,12 +159,12 @@ impl Disc {
}) })
.collect(); .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 first_in_time = parsed.play_items.first().map(|pi| pi.in_time).unwrap_or(0);
let chapters: Vec<Chapter> = parsed let chapters: Vec<Chapter> = parsed
.marks .marks
.iter() .iter()
.filter(|m| m.mark_type == 1) .filter(|m| m.mark_type <= 1)
.enumerate() .enumerate()
.map(|(i, m)| { .map(|(i, m)| {
let time_secs = (m.timestamp as f64 - first_in_time as f64) / 45000.0; let time_secs = (m.timestamp as f64 - first_in_time as f64) / 45000.0;
+11 -1
View File
@@ -101,6 +101,16 @@ impl Disc {
streams.extend(audio_streams.iter().cloned()); streams.extend(audio_streams.iter().cloned());
streams.extend(subtitle_streams); streams.extend(subtitle_streams);
let chapters: Vec<Chapter> = dvd_title
.chapter_times
.iter()
.enumerate()
.map(|(i, &t)| Chapter {
time_secs: t,
name: format!("Chapter {}", i + 1),
})
.collect();
titles.push(DiscTitle { titles.push(DiscTitle {
playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number), playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number),
playlist_id: title_number, playlist_id: title_number,
@@ -108,7 +118,7 @@ impl Disc {
size_bytes, size_bytes,
clips: Vec::new(), clips: Vec::new(),
streams, streams,
chapters: Vec::new(), chapters,
extents, extents,
content_format: ContentFormat::MpegPs, content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(), codec_privates: Vec::new(),
+35
View File
@@ -47,6 +47,8 @@ pub struct DvdTitle {
pub duration_secs: f64, pub duration_secs: f64,
/// Cell sector ranges /// Cell sector ranges
pub cells: Vec<DvdCell>, pub cells: Vec<DvdCell>,
/// Chapter start times in seconds (derived from program map + cell times)
pub chapter_times: Vec<f64>,
/// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr]. /// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr].
pub palette: Option<Vec<[u8; 4]>>, pub palette: Option<Vec<[u8; 4]>>,
} }
@@ -541,6 +543,37 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
duration_secs 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] // 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 palette = if pgc_offset + 0xA4 + 64 <= data.len() {
let mut colors = Vec::with_capacity(16); let mut colors = Vec::with_capacity(16);
@@ -562,6 +595,7 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
chapters, chapters,
duration_secs, duration_secs,
cells, cells,
chapter_times,
palette, palette,
}) })
} }
@@ -651,6 +685,7 @@ mod tests {
chapters: 5, chapters: 5,
duration_secs: 3600.0, duration_secs: 3600.0,
cells: vec![cell.clone()], cells: vec![cell.clone()],
chapter_times: Vec::new(),
palette: None, palette: None,
}; };
assert_eq!(title.chapters, 5); assert_eq!(title.chapters, 5);
+1 -1
View File
@@ -256,7 +256,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
// Parse PlayListMark section // Parse PlayListMark section
let mut marks = Vec::new(); 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..]; let ms = &data[mark_start..];
if ms.len() >= 6 { if ms.len() >= 6 {
let num_marks = u16::from_be_bytes([ms[4], ms[5]]) as usize; let num_marks = u16::from_be_bytes([ms[4], ms[5]]) as usize;
+86 -3
View File
@@ -5,7 +5,7 @@
//! cues and seek head are finalized at the end. //! cues and seek head are finalized at the end.
use super::ebml; 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}; use std::io::{self, Seek, SeekFrom, Write};
/// MKV track definition (built from disc stream metadata). /// MKV track definition (built from disc stream metadata).
@@ -20,6 +20,14 @@ pub struct MkvTrack {
// Video-specific // Video-specific
pub pixel_width: u32, pub pixel_width: u32,
pub pixel_height: 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 // Audio-specific
pub sample_rate: f64, pub sample_rate: f64,
pub channels: u8, pub channels: u8,
@@ -36,16 +44,40 @@ impl MkvTrack {
_ => "V_MPEG2", _ => "V_MPEG2",
}; };
let (w, h) = v.resolution.pixels(); 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 { Self {
track_type: ebml::TRACK_TYPE_VIDEO, track_type: ebml::TRACK_TYPE_VIDEO,
codec_id, codec_id,
language: "und".into(), language: "und".into(),
name: v.label.clone(), name: v.label.clone(),
codec_private: None, // filled later by parser codec_private: None,
is_default: !v.secondary, is_default: !v.secondary,
is_forced: false, is_forced: false,
pixel_width: w, pixel_width: w,
pixel_height: h, 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, sample_rate: 0.0,
channels: 0, channels: 0,
bit_depth: 0, bit_depth: 0,
@@ -73,6 +105,13 @@ impl MkvTrack {
is_forced: false, is_forced: false,
pixel_width: 0, pixel_width: 0,
pixel_height: 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, sample_rate: sr,
channels: ch, channels: ch,
bit_depth: 0, bit_depth: 0,
@@ -94,6 +133,13 @@ impl MkvTrack {
is_forced: s.forced, is_forced: s.forced,
pixel_width: 0, pixel_width: 0,
pixel_height: 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, sample_rate: 0.0,
channels: 0, channels: 0,
bit_depth: 0, bit_depth: 0,
@@ -116,6 +162,7 @@ pub struct MkvMuxer<W: Write + Seek> {
cluster_pos: u64, cluster_pos: u64,
cluster_size_pos: u64, cluster_size_pos: u64,
cluster_ts_ms: i64, cluster_ts_ms: i64,
base_pts_ms: Option<i64>,
cues: Vec<CuePoint>, cues: Vec<CuePoint>,
frame_count: u64, frame_count: u64,
/// File positions of codecPrivate placeholders (track_idx → offset, max_size). /// File positions of codecPrivate placeholders (track_idx → offset, max_size).
@@ -216,11 +263,29 @@ impl<W: Write + Seek> MkvMuxer<W> {
codec_private_filled.push(true); 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 // Video-specific
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 { if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?; 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_WIDTH, track.pixel_width as u64)?;
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height 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)?; ebml::end_master(&mut writer, vid_pos)?;
} }
@@ -265,6 +330,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
cluster_pos: 0, cluster_pos: 0,
cluster_size_pos: 0, cluster_size_pos: 0,
cluster_ts_ms: 0, cluster_ts_ms: 0,
base_pts_ms: None,
cues: Vec::new(), cues: Vec::new(),
frame_count: 0, frame_count: 0,
codec_private_slots, codec_private_slots,
@@ -280,7 +346,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
keyframe: bool, keyframe: bool,
data: &[u8], data: &[u8],
) -> io::Result<()> { ) -> 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 // Start new cluster if needed
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS { if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
@@ -450,6 +518,13 @@ mod tests {
is_forced: false, is_forced: false,
pixel_width: 1920, pixel_width: 1920,
pixel_height: 1080, 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, sample_rate: 0.0,
channels: 0, channels: 0,
bit_depth: 0, bit_depth: 0,
@@ -467,6 +542,13 @@ mod tests {
is_forced: false, is_forced: false,
pixel_width: 0, pixel_width: 0,
pixel_height: 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, sample_rate: 48000.0,
channels: 6, channels: 6,
bit_depth: 0, bit_depth: 0,
@@ -760,3 +842,4 @@ mod tests {
); );
} }
} }
+10
View File
@@ -42,12 +42,22 @@ impl MkvStream {
/// Codec privates come from title.codec_privates (populated by input stream). /// Codec privates come from title.codec_privates (populated by input stream).
pub fn create(writer: Box<dyn WriteSeek>, title: &DiscTitle) -> io::Result<Self> { pub fn create(writer: Box<dyn WriteSeek>, title: &DiscTitle) -> io::Result<Self> {
let mut tracks = Vec::new(); 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() { for (idx, s) in title.streams.iter().enumerate() {
let mut track = match s { let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v), crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a), crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s), 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()) { if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone()); track.codec_private = Some(cp.clone());
} }
+5
View File
@@ -15,6 +15,7 @@ pub struct TsMuxer<W: Write> {
continuity: Vec<u8>, // per-PID continuity counter (0-15) continuity: Vec<u8>, // per-PID continuity counter (0-15)
codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets) codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets)
params_written: Vec<bool>, // per-track: have we written parameter sets? params_written: Vec<bool>, // per-track: have we written parameter sets?
base_pts_ns: Option<i64>,
} }
impl<W: Write> TsMuxer<W> { impl<W: Write> TsMuxer<W> {
@@ -26,6 +27,7 @@ impl<W: Write> TsMuxer<W> {
continuity: vec![0u8; n], continuity: vec![0u8; n],
codec_privates: vec![None; n], codec_privates: vec![None; n],
params_written: vec![false; n], params_written: vec![false; n],
base_pts_ns: None,
} }
} }
@@ -44,6 +46,9 @@ impl<W: Write> TsMuxer<W> {
if track >= self.pids.len() { if track >= self.pids.len() {
return Ok(()); // unknown track, skip return Ok(()); // unknown track, skip
} }
let base = *self.base_pts_ns.get_or_insert(pts_ns);
let pts_ns = pts_ns - base;
let pid = self.pids[track]; let pid = self.pids[track];
let is_video = (0x1011..=0x101F).contains(&pid); let is_video = (0x1011..=0x101F).contains(&pid);