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
+42
View File
@@ -151,6 +151,11 @@ pub const E_NETWORK_ADDR_BLOCKED: u16 = 9022;
/// zero-frame mux (undecryptable input, fully-unreadable title, every
/// frame dropped before the first keyframe) cannot report success.
pub const E_MUX_EMPTY: u16 = 9023;
/// The mux driver buffered past its pre-headers cap without every video track's
/// `codec_private` resolving. Deliberately NOT [`E_MKV_INVALID`], which
/// [`is_skippable_title_stub`] treats as a skippable empty nav/menu stub — a
/// cap-overflow is a real title and must never be silently skipped.
pub const E_MUX_HEADER_BUFFER_EXCEEDED: u16 = 9051;
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
/// `mp4://` output but the title has no (primary) video track to carry.
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
@@ -442,6 +447,20 @@ pub enum Error {
/// every frame dropped before the first keyframe — fails loudly. The
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
MuxEmpty,
/// The mux driver's pre-headers frame buffer passed its cap before every
/// video track's `codec_private` resolved: the title keeps yielding real
/// frames but its codec init data never appears, so buffering further would
/// swap the box to death. Carries the buffered byte count.
///
/// DISTINCT from [`Error::MkvInvalid`] on purpose. `MkvInvalid` is what an
/// empty nav/menu PGC stub yields, and [`is_skippable_title_stub`] classifies
/// it as skippable — an all-titles rip drops that title and finishes the
/// rest. Hundreds of megabytes of real frames with unresolvable headers is
/// NOT a stub; reporting it as one silently dropped a main feature from a
/// rip that then exited successfully. This code is not skippable.
MuxHeaderBufferExceeded {
bytes: u64,
},
/// `mp4://` target title has no primary video track to mux.
Mp4NoVideoTrack,
/// `mp4://` source file is malformed/truncated — the MP4 demuxer failed.
@@ -629,6 +648,7 @@ impl Error {
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
Error::MuxEmpty => E_MUX_EMPTY,
Error::MuxHeaderBufferExceeded { .. } => E_MUX_HEADER_BUFFER_EXCEEDED,
Error::Mp4NoVideoTrack => E_MP4_NO_VIDEO_TRACK,
Error::Mp4Invalid => E_MP4_INVALID,
Error::Mp4MissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
@@ -797,6 +817,9 @@ impl std::fmt::Display for Error {
Error::SelectionPidUnknown { pid } => {
write!(f, "E{}: 0x{:04x}", self.code(), pid)
}
Error::MuxHeaderBufferExceeded { bytes } => {
write!(f, "E{}: {}", self.code(), bytes)
}
_ => write!(f, "E{}", self.code()),
}
}
@@ -869,6 +892,9 @@ impl From<Error> for std::io::Error {
// 9023 MuxEmpty: finish() reached with zero frames — the output
// would be a header-only container. Treat as invalid output.
E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
// 9051 MuxHeaderBufferExceeded: the source kept yielding frames but
// never its codec init data — the input is unusable as declared.
E_MUX_HEADER_BUFFER_EXCEEDED => std::io::ErrorKind::InvalidData,
// mp4:// demux errors: a malformed/truncated source file
// (E_MP4_INVALID), or a source whose tracks the mux can't use — no
// video track / missing codec-private config. All are invalid data.
@@ -1066,6 +1092,17 @@ mod tests {
// A plain io::Error with no E-code prefix is not skippable.
let plain = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
assert!(!is_skippable_title_stub(&plain));
// A header-buffer cap overflow is a REAL title whose codec init data
// never resolved — hundreds of MiB of frames, not an empty nav/menu
// stub. It used to be reported as `MkvInvalid`, which lands in the
// skippable set above, so an all-titles rip dropped a main feature and
// still exited successfully. It must have its own, non-skippable code.
let cap: std::io::Error = Error::MuxHeaderBufferExceeded {
bytes: 512 * 1024 * 1024 + 1,
}
.into();
assert!(!is_skippable_title_stub(&cap));
}
#[test]
@@ -1353,6 +1390,7 @@ mod tests {
E_STREAM_URL_MISSING_PORT,
E_NETWORK_ADDR_BLOCKED,
E_MUX_EMPTY,
E_MUX_HEADER_BUFFER_EXCEEDED,
E_MP4_NO_VIDEO_TRACK,
E_MP4_INVALID,
E_MP4_MISSING_CODEC_PRIVATE,
@@ -1443,6 +1481,10 @@ mod tests {
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
(Error::MuxEmpty, E_MUX_EMPTY),
(
Error::MuxHeaderBufferExceeded { bytes: 0 },
E_MUX_HEADER_BUFFER_EXCEEDED,
),
(Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
(Error::Mp4Invalid, E_MP4_INVALID),
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),