Make six silent mux failures observable

All six confirmed against the code. The governing rule this cluster serves: a
lossy or degraded outcome is never silent, because a corrupt rip the user does
not know about is the worst failure available.

**A 3D MKV re-mux silently lost one eye.** The BlockGroup read path had arms for
BLOCK / BLOCK_DURATION / REFERENCE_BLOCK only, so BLOCK_ADDITIONS fell into the
skip arm — while the writer does emit BlockAdditions > BlockMore > BlockAdditional
for the MVC dependent view. Reconstruction was judged out of scope and the
reasoning is recorded: PesFrame has no side-payload field and the header parser
never reads BlockAdditionMapping, so there is no dependent-view track to route the
AU to. Instead the loss is now LOUD — counted in bytes and events, warned once,
and surfaced through MkvStream's errors()/lost_bytes(), which the driver already
samples into MuxOutcome. One detail in the finding was wrong and is corrected: the
re-mux does NOT still advertise the mvcC mapping, because the header parser
ignores that element, so the output is a plain 2D H.264 track.

**An all-titles rip silently skipped real titles.** The header-buffer-cap
overflow returned Error::MkvInvalid, and is_skippable_title_stub matches exactly
E_MKV_INVALID | E_CSS_KEY_MISSING — verified here — so a 512 MiB-of-frames title
was classified as an empty nav/menu PGC stub and dropped. It now has its own
E9051 / MuxHeaderBufferExceeded { bytes }, outside the skippable set.

**The public pre-mux report contradicted the file.** Mp4Sink::finish() drops an
audio track it cannot describe, which I chose last round over failing an export
whose video is fine — but mp4_fit_report still listed that stream as included, so
the application's plan and the actual output disagreed. Fixed at both levels:
Mp4SkipReason is now non_exhaustive with NoSamples and UndescribableAudio,
Mp4Sink::final_report() describes the FILE rather than the plan, and for the
boxed dyn Stream path a defaulted Stream::undelivered_streams() carries the
information out to MuxOutcome::undelivered_streams with a driver-side warn.

**MP4 track ids could collide.** ids were assigned before the retain that drops
sample-less tracks, while next_id came from the post-retain count, so [1,3]
yielded next_id 3. Now max(track_id) + 1, saturating.

**Stream selection silently skipped its codec_privates prune** when the lists were
not the same length — but codec_privates is consumed POSITIONALLY and trailing
extras are documented as benign, so the length-equality guard was itself the bug.
The prune now runs unconditionally by index.

**The m2ts_mux scaffolding armed params_written on both the absent and the
unparseable codec_private arms** — the same defect already fixed in tsmux.rs.
Split into params_attempted (a latch, since retrying identical bytes cannot help)
and params_emitted, with a warn on each failure arm and an accessor so the
eventual wiring and its test can observe it.

Each fix verified red by mutating back to the prior behaviour: errors() 0 vs 1,
E6008 vs E9051, final_report [0,1] vs [0], next_track_id 3 vs [1,3], and the
selection prune resolving index 1 to the wrong track's record.

API surface deliberately widened: MuxOutcome gains a public field and Mp4Sink
becomes public. Nothing in-repo breaks. Note a behaviour change on the mkv://
input path — a 3D re-mux now reports non-zero loss, so a consumer treating
errors > 0 as disc damage will trip on it. That is intended: the outcome IS
degraded.
This commit is contained in:
Matthew Jackson
2026-07-29 20:46:10 -07:00
parent 9ad68dd092
commit 3efa6211f3
9 changed files with 798 additions and 260 deletions
+19
View File
@@ -234,6 +234,21 @@ pub trait Stream: Send {
fn lost_bytes(&self) -> u64 {
0
}
/// Sink side: `info().streams` indices this sink PLANNED to carry (and
/// accepted frames for) but could not put in the finished container, valid
/// after [`finish`](Self::finish). Empty for every sink that writes
/// everything it accepted — which is all of them except `mp4://`, whose
/// `finish()` must drop an audio track no frame of which yielded a parseable
/// sample entry (an `stsd` cannot describe it).
///
/// This exists because such a drop otherwise contradicts the pre-mux plan the
/// crate publishes (`mp4_fit_report`), leaving the caller reporting a
/// successful export of a file missing a stream it was told would be there.
/// The driver folds this into `MuxOutcome::undelivered_streams`.
fn undelivered_streams(&self) -> Vec<usize> {
Vec::new()
}
}
/// Wraps any output stream and counts bytes written.
@@ -297,6 +312,10 @@ impl Stream for CountingStream {
self.inner.errors()
}
fn undelivered_streams(&self) -> Vec<usize> {
self.inner.undelivered_streams()
}
fn lost_bytes(&self) -> u64 {
self.inner.lost_bytes()
}