From 14c4227292bce83046a937da6af94257b3d97ce6 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:52:48 -0700 Subject: [PATCH] mux: back-patch the MKV duration from the timeline when the source has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A title whose scan yields no duration (HD-DVD — its `.MAP` timemaps are not parsed, so DiscTitle.duration_secs is 0) produced an MKV with no Segment Duration element, so players/MediaInfo reported an unknown runtime. Reserve a DURATION placeholder when the source declares none, track the highest block timestamp written, and back-patch the placeholder at finish() with the real muxed runtime (also enabling the per-track BPS tags for these titles). Gated on duration_secs == 0, so BD/UHD/DVD — which carry a real mpls/IFO duration — write it up-front exactly as before, unchanged. --- src/mux/mkv.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 4397a30..acba74d 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -557,6 +557,14 @@ pub struct MkvMuxer { track_uids: Vec, /// Segment duration in seconds (from `Info`), for the BPS denominator. duration_secs: f64, + /// Byte offset of the DURATION element's 8-byte payload when it was written + /// as a patch-later placeholder — the source supplied no duration (e.g. + /// HD-DVD, whose `.MAP` timemaps are not parsed). `None` when a real duration + /// was written up-front. Back-patched at `finish()` from the muxed timeline. + duration_patch_pos: Option, + /// Highest block timestamp (TimestampScale ticks) written across all tracks — + /// the muxed runtime, used to back-patch the DURATION placeholder. + max_block_ticks: i64, /// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble /// is unreliable, so the channel count written in the track header is /// corrected from the AC-3 bitstream `acmod` of the first frame on the @@ -766,11 +774,21 @@ impl MkvMuxer { ebml::TIMESTAMP_SCALE, TIMESTAMP_SCALE_NS as u64, )?; - if duration_secs > 0.0 { + let duration_patch_pos = if duration_secs > 0.0 { // Duration is expressed in TimestampScale ticks (not ms). let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64; ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?; - } + None + } else { + // The source declared no duration (e.g. HD-DVD — its `.MAP` timemaps + // are not parsed). Reserve a DURATION placeholder now and back-patch + // it at finish() from the muxed timeline, so the file still declares a + // runtime instead of showing "unknown". The 8-byte float payload sits + // 3 bytes in (2-byte ID `0x4489` + 1-byte size `0x88`). + let pos = writer.stream_position()?; + ebml::write_float(&mut writer, ebml::DURATION, 0.0)?; + Some(pos + 3) + }; // Stamp the freemkv version so any muxed file is traceable to the build // that produced it (MediaInfo "Writing application"/"library"). ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?; @@ -1007,6 +1025,8 @@ impl MkvMuxer { track_bytes: vec![0u64; tracks.len()], track_uids, duration_secs, + duration_patch_pos, + max_block_ticks: 0, ac3_channel_fixups, opening_capture: None, }) @@ -1196,6 +1216,9 @@ impl MkvMuxer { // Committed to writing this frame — record its (monotonic) timestamp so // the next block on this track is forced strictly later. self.last_pts_ticks.insert(track_idx, pts_ticks); + // Track the highest block timestamp so a missing source duration can be + // back-patched from the real muxed runtime at finish(). + self.max_block_ticks = self.max_block_ticks.max(pts_ticks); let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16; match duration_ns { @@ -1268,6 +1291,13 @@ impl MkvMuxer { if self.frame_count == 0 { return Err(crate::error::Error::MkvInvalid.into()); } + // The source declared no duration up-front (DURATION was reserved as a + // placeholder). Derive the real runtime from the muxed timeline so the + // Segment declares it — and so the BPS tags below can be computed. + if self.duration_patch_pos.is_some() && self.max_block_ticks > 0 { + self.duration_secs = + self.max_block_ticks as f64 * TIMESTAMP_SCALE_NS as f64 / 1_000_000_000.0; + } // Close final cluster self.end_cluster()?; @@ -1322,6 +1352,17 @@ impl MkvMuxer { .seek(std::io::SeekFrom::Start(fixup.value_offset))?; self.writer.write_all(&offset.to_be_bytes())?; } + // Back-patch the DURATION placeholder (source supplied no duration) with + // the real runtime from the muxed timeline. The CUES-void and seek-to-end + // below re-seek absolutely, so no position restore is needed here. + if let Some(pos) = self.duration_patch_pos { + if self.max_block_ticks > 0 { + self.writer.seek(std::io::SeekFrom::Start(pos))?; + self.writer + .write_all(&(self.max_block_ticks as f64).to_be_bytes())?; + } + } + // Neutralise the unused CUES Seek entry. The entry is a fixed 21-byte // Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id + // SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID) @@ -2852,6 +2893,32 @@ mod tests { ); } + /// Scan for the first DURATION element (`0x4489`, 8-byte float payload) and + /// return its value in TimestampScale ticks. + fn find_duration_ticks(data: &[u8]) -> Option { + data.windows(3) + .position(|w| w == [0x44, 0x89, 0x88]) + .and_then(|i| data.get(i + 3..i + 11)) + .map(|b| f64::from_be_bytes(b.try_into().unwrap())) + } + + #[test] + fn duration_backpatched_from_timeline_when_source_gives_none() { + // `mux_to_bytes` muxes with `duration_secs = 0.0` (as an HD-DVD title + // does), so DURATION is reserved as a placeholder and must be + // back-patched from the muxed timeline at finish() — not left 0/absent. + let tracks = [make_video_track()]; + let frames = frames_for(5.0, 1.0); // ~5 s of 24 fps video + let (data, _) = mux_to_bytes(&tracks, &[], &frames); + let dur = find_duration_ticks(&data).expect("DURATION element present"); + let expect = 5.0 * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64; // ~50000 ticks + assert!(dur > 0.0, "duration back-patched from timeline, not 0"); + assert!( + (dur - expect).abs() < 60.0, + "duration ~ real runtime: got {dur} ticks, expected ~{expect}" + ); + } + #[test] fn seekhead_points_to_real_elements() { let tracks = [make_video_track(), make_audio_track()];