fix(mp4): refuse a video track with no resolved dimensions
Resolution::pixels() returned (0, 0) for Unknown, and the MP4 sink wrote it verbatim into tkhd (ISO/IEC 14496-12 8.3.2) and VisualSampleEntry (12.1.3). Both fields are MANDATORY there, so unlike Matroska — which omits the optional PixelWidth/PixelHeight elements — MP4 has nothing to leave out. The result was a structurally complete file that passes every container check, declares a 0x0 video track, cannot be rendered, and is written with no error anywhere. WHY IT WAS POSSIBLE, which is the part worth keeping: pixels() previously fabricated 1920x1080 for Unknown. That was wrong but playable, so this sink never needed a guard and the absence of one was invisible. Changing the sentinel to (0, 0) moved the defect instead of removing it — a zero PAIR still reads as a usable value, so the sink stored it and serialised it. The accessor's doc comment then ENUMERATED the callers it believed were safe: "the Matroska sink omits the optional elements, the VobSub writer omits its size: line, and no caller divides by either dimension." Two of those three are true. MP4 was not on the list because MP4 has no guard at all, and a prose list cannot enforce itself. mkv.rs's own comment even states the principle — "the check belongs in the one accessor rather than in each caller that remembered to write it" — and labels/mod.rs still carried its own duplicate Unknown test long after the accessor took that job over. So: pixels() now returns Option. Not because Option is tidier, but because every caller genuinely needs a DIFFERENT answer and the compiler is the only thing that reliably makes them choose one. Matroska and the metadata sinks take unwrap_or((0, 0)) with the reason stated at each site; the VobSub path degrades to a palette-only .idx; MP4 fails with E_MP4_UNKNOWN_RESOLUTION (9055). Six call sites, not the five my first grep showed — I piped it through `head` and acted on a truncated list. The compiler caught the sixth. That is the same mistake as trusting a lens that reported silence.
This commit is contained in:
@@ -137,7 +137,9 @@ fn stream_json(s: &DiscStream) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
match s {
|
||||
DiscStream::Video(v) => {
|
||||
let (w, h) = v.resolution.pixels();
|
||||
// Absent dimensions serialise as 0 here; the JSON consumer reads
|
||||
// this as informational metadata, not as a mux input.
|
||||
let (w, h) = v.resolution.pixels().unwrap_or((0, 0));
|
||||
let (fps_num, fps_den) = v.frame_rate.as_fraction();
|
||||
let mut o = json!({
|
||||
"kind": "video",
|
||||
|
||||
+3
-1
@@ -385,7 +385,9 @@ impl MkvTrack {
|
||||
// that used to sit here existed only because `pixels()` fabricated a
|
||||
// 1920x1080 default; it does not any more, so the check belongs in the
|
||||
// one accessor rather than in each caller that remembered to write it.
|
||||
let (w, h) = v.resolution.pixels();
|
||||
// None -> 0, and the writer already omits the optional
|
||||
// PixelWidth/PixelHeight elements on 0 (RFC 9559 5.1.4.1.28-29).
|
||||
let (w, h) = v.resolution.pixels().unwrap_or((0, 0));
|
||||
let (num, den) = v.frame_rate.as_fraction();
|
||||
let default_duration_ns = if num > 0 {
|
||||
(1_000_000_000u64 * den as u64) / num as u64
|
||||
|
||||
+49
-1
@@ -316,7 +316,15 @@ impl<W: Write + Seek> Mp4Sink<W> {
|
||||
.get(i)
|
||||
.and_then(|c| c.clone())
|
||||
.ok_or(crate::error::Error::Mp4MissingCodecPrivate)?;
|
||||
let (w, h) = v.resolution.pixels();
|
||||
// ISO/IEC 14496-12 makes width/height mandatory in tkhd
|
||||
// (8.3.2) and VisualSampleEntry (12.1.3) — unlike Matroska
|
||||
// there is no element to omit. Writing 0x0 yields a
|
||||
// structurally complete file no player can render, with no
|
||||
// error anywhere; refuse instead.
|
||||
let (w, h) = v
|
||||
.resolution
|
||||
.pixels()
|
||||
.ok_or(crate::error::Error::Mp4UnknownResolution)?;
|
||||
tracks.push(Track {
|
||||
media: Media::Video,
|
||||
track_id,
|
||||
@@ -1155,6 +1163,46 @@ mod tests {
|
||||
assert_eq!(r.included, vec![1], "only the AC-3 audio is carried");
|
||||
}
|
||||
|
||||
/// A video track whose resolution never resolved must FAIL the mux, not be
|
||||
/// written as a 0x0 track.
|
||||
///
|
||||
/// ISO/IEC 14496-12 makes width and height mandatory in both `tkhd` (8.3.2)
|
||||
/// and VisualSampleEntry (12.1.3), so unlike Matroska — which simply omits
|
||||
/// the optional PixelWidth/PixelHeight elements — MP4 has nothing to leave
|
||||
/// out. Writing zeros produces a structurally complete file that passes
|
||||
/// every container check and that no player can render, with no error
|
||||
/// anywhere: a wrong answer that looks like a successful rip.
|
||||
///
|
||||
/// `Resolution::pixels()` returns `Option` for this reason. It used to
|
||||
/// return a fabricated 1920x1080, then `(0, 0)`; the zero pair reads as a
|
||||
/// usable value, so this sink stored it and serialised it, and the guard
|
||||
/// that two of the three sinks have was never needed here and so was never
|
||||
/// written.
|
||||
#[test]
|
||||
fn a_video_track_with_no_resolved_resolution_is_an_error_not_a_zero_sized_track() {
|
||||
let DiscStream::Video(mut v) = hevc_video() else {
|
||||
unreachable!("hevc_video builds a video stream")
|
||||
};
|
||||
v.resolution = Resolution::Unknown;
|
||||
let t = title(
|
||||
vec![DiscStream::Video(v), audio(Codec::Ac3, "eng")],
|
||||
vec![Some(vec![0x01, 0x02, 0x03]), None],
|
||||
);
|
||||
|
||||
let err = match Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t) {
|
||||
Ok(_) => panic!("an unrenderable 0x0 track must not be written silently"),
|
||||
Err(e) => e,
|
||||
};
|
||||
// `From<Error> for io::Error` stringifies as "E<code>[: ...]", so the
|
||||
// code round-trips in the message.
|
||||
assert!(
|
||||
err.to_string()
|
||||
.starts_with(&format!("E{}", crate::error::E_MP4_UNKNOWN_RESOLUTION)),
|
||||
"the failure must name the missing dimensions, not a generic mux \
|
||||
error; got {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_video_track_is_an_error() {
|
||||
let t = title(vec![audio(Codec::Ac3, "eng")], vec![None]);
|
||||
|
||||
+2
-1
@@ -224,7 +224,8 @@ impl MapHeader {
|
||||
|
||||
let stream = match video {
|
||||
Some(v) => {
|
||||
let (width, height) = v.resolution.pixels();
|
||||
// Informational map; absent dimensions report as 0.
|
||||
let (width, height) = v.resolution.pixels().unwrap_or((0, 0));
|
||||
StreamInfo {
|
||||
codec: fvi_codec_id(v.codec),
|
||||
width,
|
||||
|
||||
Reference in New Issue
Block a user