Make the drop count gate the verdict, not just the log

Round 3 counted frames the clip marks excluded and reported them at
finish. Counting is not bounding: the only other gate was a global
zero-frame check, and its error is additionally classified as a skippable
nav stub, so a title whose marks do not line up with its PES clock could
discard almost all of itself and still exit 0 — a two-hour feature
emitting seconds, which is the defect this change set already shipped
once. Dropping more than was kept is never a real join, so it now fails.

The demux sink had no zero-output guard at all, so a fully-dropped title
finished cleanly: a directory of zero-byte track files beside a populated
chapters document. It now refuses, keyed on frames having been OFFERED —
a chapters-only export, or a track class the title does not carry,
legitimately writes none, and two existing tests correctly said so.

A title set's placement group is the parsed number, so VTS_01_0.IFO and
VTS_1_0.IFO land on one key and the second silently overwrote the first's
constraint, placing a VOB where the IFO the reader uses does not point.
Refused rather than resolved by arrival order.
This commit is contained in:
Matthew Jackson
2026-08-05 18:25:47 -07:00
parent f4fb5c65e0
commit 0f61be00b7
4 changed files with 69 additions and 0 deletions
+23
View File
@@ -671,6 +671,11 @@ pub struct DemuxSink {
ref_first_pts_ns: Option<i64>,
timeline: TimelineContinuity,
finished: bool,
/// Frames actually placed on the timeline and written.
///
/// A sink that wrote nothing must not report success, and a sink that
/// dropped more than it kept is not looking at a real join.
frames_written: u64,
}
impl DemuxSink {
@@ -744,6 +749,7 @@ impl DemuxSink {
ref_first_pts_ns: None,
timeline: TimelineContinuity::with_clips(&title.clips, title.content_format),
finished: false,
frames_written: 0,
})
}
@@ -880,6 +886,7 @@ impl Stream for DemuxSink {
let Some(pts) = self.timeline.map(frame.pts, drives, frame.track, is_video) else {
return Ok(());
};
self.frames_written = self.frames_written.saturating_add(1);
if drives {
// Delay reference: recorded here, not in the track's `TrackOut`, so
// it survives the `audio://` / `sub://` kind filter dropping the
@@ -903,6 +910,22 @@ impl Stream for DemuxSink {
// write-only in one sink and reported in the other — an unexpected
// volume here is how a demux ends up quietly short.
let seam_dropped = self.timeline.dropped_total();
// Frames ARRIVED and every one was dropped. That is a fully-dropped
// title, which this sink used to finish cleanly: a directory of
// zero-byte track files beside a populated chapters document, at exit
// 0. Keyed on frames having been offered, because a sink that is never
// given any — a chapters-only export, or a track class the title does
// not carry — legitimately writes none.
if self.frames_written == 0 && seam_dropped > 0 {
return Err(crate::error::Error::SinkWroteNothing.into());
}
if seam_dropped > self.frames_written {
return Err(crate::error::Error::SeamPlanDroppedMost {
dropped: seam_dropped,
written: self.frames_written,
}
.into());
}
if seam_dropped > 0 {
tracing::info!(
target: "mux",
+16
View File
@@ -1718,6 +1718,22 @@ impl<W: Write + Seek> MkvMuxer<W> {
// counter above: an unexpected VOLUME here is how a title ends up
// quietly short while the run reports success.
let seam_dropped = self.continuity.dropped_total();
// Counting a drop is not the same as bounding it. A join legitimately
// discards the material a disc stores twice — tens of frames — but if a
// title's marks do not line up with its PES clock the plan can discard
// most of it, and the only other gate is a GLOBAL zero-frame check
// whose error is additionally classified as a skippable stub. Between
// them, a title emitting seconds of a two-hour feature exits 0. That is
// the defect this change set already shipped once.
//
// More dropped than kept is never a real join, so it fails.
if seam_dropped > self.frame_count {
return Err(crate::error::Error::SeamPlanDroppedMost {
dropped: seam_dropped,
written: self.frame_count,
}
.into());
}
if seam_dropped > 0 {
tracing::info!(
target: "mux",