mux: back-patch the MKV duration from the timeline when the source has none

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.
This commit is contained in:
Matthew Jackson
2026-07-09 12:52:48 -07:00
parent fc3e1dd003
commit 14c4227292
+69 -2
View File
@@ -557,6 +557,14 @@ pub struct MkvMuxer<W: Write + Seek> {
track_uids: Vec<u64>, track_uids: Vec<u64>,
/// Segment duration in seconds (from `Info`), for the BPS denominator. /// Segment duration in seconds (from `Info`), for the BPS denominator.
duration_secs: f64, 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<u64>,
/// 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 /// 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 /// 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 /// corrected from the AC-3 bitstream `acmod` of the first frame on the
@@ -766,11 +774,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::TIMESTAMP_SCALE, ebml::TIMESTAMP_SCALE,
TIMESTAMP_SCALE_NS as u64, 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). // Duration is expressed in TimestampScale ticks (not ms).
let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64; let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64;
ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?; 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 // Stamp the freemkv version so any muxed file is traceable to the build
// that produced it (MediaInfo "Writing application"/"library"). // that produced it (MediaInfo "Writing application"/"library").
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?; ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
@@ -1007,6 +1025,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
track_bytes: vec![0u64; tracks.len()], track_bytes: vec![0u64; tracks.len()],
track_uids, track_uids,
duration_secs, duration_secs,
duration_patch_pos,
max_block_ticks: 0,
ac3_channel_fixups, ac3_channel_fixups,
opening_capture: None, opening_capture: None,
}) })
@@ -1196,6 +1216,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Committed to writing this frame — record its (monotonic) timestamp so // Committed to writing this frame — record its (monotonic) timestamp so
// the next block on this track is forced strictly later. // the next block on this track is forced strictly later.
self.last_pts_ticks.insert(track_idx, pts_ticks); 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; let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
match duration_ns { match duration_ns {
@@ -1268,6 +1291,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
if self.frame_count == 0 { if self.frame_count == 0 {
return Err(crate::error::Error::MkvInvalid.into()); 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 // Close final cluster
self.end_cluster()?; self.end_cluster()?;
@@ -1322,6 +1352,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
.seek(std::io::SeekFrom::Start(fixup.value_offset))?; .seek(std::io::SeekFrom::Start(fixup.value_offset))?;
self.writer.write_all(&offset.to_be_bytes())?; 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 // 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 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) // 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<f64> {
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] #[test]
fn seekhead_points_to_real_elements() { fn seekhead_points_to_real_elements() {
let tracks = [make_video_track(), make_audio_track()]; let tracks = [make_video_track(), make_audio_track()];