oxedyne/fe2o3/fe2o3_graphics/tests/mp4_remux.rs
13.8 KiB, 57 runs
created by r1870400018:21783, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Does a film repackaged out of Matroska into MP4 come out playable? |
| 2 | //! |
| 3 | //! This is the end-to-end claim the whole repackaging idea rests on: that a |
| 4 | //! browser refuses an MKV because of the *container* and not because of what is |
| 5 | //! inside it, so moving the same coded bytes into MP4 -- decoding nothing, |
| 6 | //! re-encoding nothing -- makes the film play. Everything else is detail. |
| 7 | //! |
| 8 | //! So the test writes one, and then asks a **different program** whether what |
| 9 | //! came out is a film: `ffprobe` must agree on the codec, the picture size and |
| 10 | //! the number of frames, and `ffmpeg` must decode every frame of it without |
| 11 | //! complaint. A container written wrongly typically still opens -- readers are |
| 12 | //! forgiving -- and then yields half the frames, or the right count with the |
| 13 | //! sample sizes shifted by one, which is why the frame count alone is not |
| 14 | //! enough and the decode is what settles it. |
| 15 | //! |
| 16 | //! Point `MKV_CORPUS` at a directory of films. Output goes under |
| 17 | //! `~/.cache/ochre-remux-probe`, **never `/tmp`**, which is a tmpfs here and is |
| 18 | //! charged to the memory budget of whoever writes to it. |
| 19 | //! |
| 20 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 21 | //! Anthropic Claude |
| 22 | |
| 23 | use oxedyne_fe2o3_core::prelude::*; |
| 24 | use oxedyne_fe2o3_graphics::{ |
| 25 | matroska::{Clusters, Matroska, TrackKind}, |
| 26 | mp4::{Codec, Sample, Track}, |
| 27 | }; |
| 28 | |
| 29 | use std::{ |
| 30 | env, |
| 31 | fs::{self, File}, |
| 32 | io::Read, |
| 33 | path::{Path, PathBuf}, |
| 34 | process::Command, |
| 35 | }; |
| 36 | |
| 37 | const HEAD: usize = 1024 * 1024; // read before the clusters, to find the tracks |
| 38 | const WINDOW: usize = 256 * 1024; // the window the frame reader is held to |
| 39 | const FRAMES: usize = 300; // repackaged, which decides how big the written film is |
| 40 | |
| 41 | #[test] |
| 42 | fn a_repackaged_film_plays() -> Outcome<()> { |
| 43 | let dir = match env::var("MKV_CORPUS") { |
| 44 | Ok(d) => PathBuf::from(d), |
| 45 | Err(_) => { |
| 46 | println!("MKV_CORPUS is not set, so nothing was repackaged and this \ |
| 47 | test proves nothing."); |
| 48 | return Ok(()); |
| 49 | }, |
| 50 | }; |
| 51 | if Command::new("ffprobe").arg("-version").output().is_err() { |
| 52 | println!("ffprobe is not installed, so there is no oracle and this test \ |
| 53 | proves nothing."); |
| 54 | return Ok(()); |
| 55 | } |
| 56 | |
| 57 | let out = match env::var("HOME") { |
| 58 | Ok(h) => PathBuf::from(h).join(".cache/ochre-remux-probe"), |
| 59 | Err(_) => return Err(err!("HOME is not set, so there is nowhere to \ |
| 60 | write the film."; Invalid, Init)), |
| 61 | }; |
| 62 | res!(fs::create_dir_all(&out)); |
| 63 | |
| 64 | let mut films = Vec::new(); |
| 65 | res!(gather(&dir, &mut films)); |
| 66 | films.sort(); |
| 67 | |
| 68 | let want = num_from_env("MKV_FRAMES", FRAMES); |
| 69 | let mut done = 0usize; |
| 70 | let mut skipped = 0usize; |
| 71 | |
| 72 | for film in films.iter() { |
| 73 | if done >= num_from_env("MKV_FILES", 3) { |
| 74 | break; |
| 75 | } |
| 76 | let made = match repackage(film, want) { |
| 77 | Ok(Some(v)) => v, |
| 78 | // A film coded in something the writer does not carry yet -- HEVC, |
| 79 | // AV1 -- is skipped by name rather than failed, because the writer |
| 80 | // growing a variant is the work and not a defect in this one. |
| 81 | Ok(None) => { skipped += 1; continue }, |
| 82 | Err(e) => { |
| 83 | println!("{}: could not be repackaged: {}", film.display(), e); |
| 84 | skipped += 1; |
| 85 | continue; |
| 86 | }, |
| 87 | }; |
| 88 | let path = out.join(format!("{}.mp4", done)); |
| 89 | res!(fs::write(&path, &made.bytes)); |
| 90 | |
| 91 | // 1. Does another program agree it is a film of the right shape? |
| 92 | let said = match probe(&path) { |
| 93 | Some(v) => v, |
| 94 | None => return Err(err!( |
| 95 | "{}: ffprobe would not read the film written from it.", |
| 96 | film.display(); Invalid, Mismatch)), |
| 97 | }; |
| 98 | if said.0 != made.coding { |
| 99 | return Err(err!( |
| 100 | "{}: the written film says it holds {} rather than {}.", |
| 101 | film.display(), said.0, made.coding; Invalid, Mismatch)); |
| 102 | } |
| 103 | if said.1 != made.w as u32 || said.2 != made.h as u32 { |
| 104 | return Err(err!( |
| 105 | "{}: the written film is {} by {} and the source is {} by {}.", |
| 106 | film.display(), said.1, said.2, made.w, made.h; |
| 107 | Invalid, Mismatch)); |
| 108 | } |
| 109 | if said.3 != made.count { |
| 110 | return Err(err!( |
| 111 | "{}: {} frames were written and ffprobe counts {}.", |
| 112 | film.display(), made.count, said.3; Invalid, Mismatch)); |
| 113 | } |
| 114 | |
| 115 | // 2. Does it actually decode? A container written wrongly still opens. |
| 116 | let decoded = res!(decode_count(&path)); |
| 117 | if decoded != made.count { |
| 118 | return Err(err!( |
| 119 | "{}: {} frames were written and only {} decode.", |
| 120 | film.display(), made.count, decoded; Invalid, Mismatch)); |
| 121 | } |
| 122 | |
| 123 | // 3. Are the pictures shown when the source said they were? |
| 124 | // |
| 125 | // The decisive check, and the one the first two cannot make. A film |
| 126 | // whose composition offsets are wrong still opens, still counts right |
| 127 | // and **still decodes every frame** -- it simply plays them in the wrong |
| 128 | // order, which is the fault this whole table exists to prevent. So the |
| 129 | // written film's presentation times are read back and held against the |
| 130 | // source's. They may differ by ONE constant, because delaying the whole |
| 131 | // track is how a negative offset is avoided; they may not differ by two. |
| 132 | let back = match times_of(&path) { |
| 133 | Some(v) => v, |
| 134 | None => return Err(err!( |
| 135 | "{}: the written film's presentation times could not be read.", |
| 136 | film.display(); Invalid, Mismatch)), |
| 137 | }; |
| 138 | if back.len() != made.times.len() { |
| 139 | return Err(err!( |
| 140 | "{}: {} presentation times were written and {} read back.", |
| 141 | film.display(), made.times.len(), back.len(); Invalid, Mismatch)); |
| 142 | } |
| 143 | let shift = back[0] - made.times[0]; |
| 144 | for i in 0..back.len() { |
| 145 | if back[i] - made.times[i] != shift { |
| 146 | return Err(err!( |
| 147 | "{}: frame {} is shown at {} and the source shows it at {}, \ |
| 148 | a difference of {} against the film's {}. The pictures are \ |
| 149 | in the wrong order.", |
| 150 | film.display(), i, back[i], made.times[i], |
| 151 | back[i] - made.times[i], shift; |
| 152 | Invalid, Mismatch)); |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | println!("{}: {} {} frames repackaged, {} by {}, all decode, shown in \ |
| 157 | order (whole film delayed {} ticks)", |
| 158 | film.display(), made.count, made.coding, made.w, made.h, shift); |
| 159 | done += 1; |
| 160 | } |
| 161 | |
| 162 | println!("{} films found; {} repackaged and played, {} skipped", |
| 163 | films.len(), done, skipped); |
| 164 | if !films.is_empty() && done == 0 { |
| 165 | return Err(err!( |
| 166 | "{} films were found and not one was repackaged, so this run proves \ |
| 167 | nothing.", films.len(); Invalid, Mismatch)); |
| 168 | } |
| 169 | Ok(()) |
| 170 | } |
| 171 | |
| 172 | /// A film written out, and what it should hold. |
| 173 | struct Made { |
| 174 | bytes: Vec<u8>, |
| 175 | w: u16, |
| 176 | h: u16, |
| 177 | count: usize, |
| 178 | times: Vec<i64>, // the presentation times the source stated, in decode order |
| 179 | coding: &'static str, // what a player should call the coding of what was written |
| 180 | } |
| 181 | |
| 182 | /// Reads a film's picture frames and writes them into an MP4, decoding nothing. |
| 183 | /// |
| 184 | /// `None` where the film's picture is coded in something the writer has no |
| 185 | /// variant for. |
| 186 | fn repackage(path: &Path, want: usize) -> Outcome<Option<Made>> { |
| 187 | let mut head = vec![0u8; HEAD]; |
| 188 | let mut file = res!(File::open(path)); |
| 189 | let n = res!(file.read(&mut head)); |
| 190 | head.truncate(n); |
| 191 | let mkv = res!(Matroska::read(&head)); |
| 192 | |
| 193 | let track = match mkv.tracks().iter().find(|t| t.kind() == Some(TrackKind::Video)) { |
| 194 | Some(t) => t, |
| 195 | None => return Ok(None), |
| 196 | }; |
| 197 | // Both length-prefixed picture codings move across the same way: the record |
| 198 | // the source states is the record the sample entry wants, byte for byte. |
| 199 | let coding = match track.codec() { |
| 200 | "V_MPEG4/ISO/AVC" => "h264", |
| 201 | "V_MPEGH/ISO/HEVC" => "hevc", |
| 202 | _ => return Ok(None), |
| 203 | }; |
| 204 | let (w, h) = track.size(); |
| 205 | if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { |
| 206 | return Ok(None); |
| 207 | } |
| 208 | // The configuration record moves across verbatim -- this is the whole trick. |
| 209 | // It is the `avcC` an MP4 sample entry wants and the `CodecPrivate` a |
| 210 | // Matroska track entry carries, and they are the same bytes. |
| 211 | let codec = if coding == "hevc" { |
| 212 | Codec::Hevc(track.private().to_vec()) |
| 213 | } else { |
| 214 | Codec::Avc(track.private().to_vec()) |
| 215 | }; |
| 216 | |
| 217 | // Milliseconds, matching the timestamps the frames come stamped in, so no |
| 218 | // rescaling is needed and no rounding is introduced. |
| 219 | let scale = 1000u32; |
| 220 | let step = if track.frame_nanos() > 0 { |
| 221 | (track.frame_nanos() / 1_000_000).max(1) as u32 |
| 222 | } else { |
| 223 | 40 |
| 224 | }; |
| 225 | let mut out = res!(Track::new(w as u16, h as u16, scale, codec)); |
| 226 | |
| 227 | let number = track.number(); |
| 228 | let mut file = res!(File::open(path)); |
| 229 | let mut cl = Clusters::new(&mkv); |
| 230 | let mut buf: Vec<u8> = Vec::new(); |
| 231 | let mut frames: Vec<(Vec<u8>, bool, i64)> = Vec::new(); |
| 232 | let mut eof = false; |
| 233 | |
| 234 | while frames.len() < want { |
| 235 | while buf.len() < WINDOW && !eof { |
| 236 | let mut chunk = vec![0u8; WINDOW]; |
| 237 | let got = res!(file.read(&mut chunk)); |
| 238 | if got == 0 { |
| 239 | eof = true; |
| 240 | break; |
| 241 | } |
| 242 | chunk.truncate(got); |
| 243 | buf.extend_from_slice(&chunk); |
| 244 | } |
| 245 | if buf.is_empty() { |
| 246 | break; |
| 247 | } |
| 248 | let fed = res!(cl.feed(&buf, &mut |frame| { |
| 249 | if frame.track == number && frames.len() < want { |
| 250 | frames.push((frame.data.to_vec(), frame.key, frame.time)); |
| 251 | } |
| 252 | Ok(()) |
| 253 | })); |
| 254 | buf.drain(..fed.used); |
| 255 | if fed.used == 0 { |
| 256 | if eof { |
| 257 | break; |
| 258 | } |
| 259 | if fed.want > buf.len() { |
| 260 | let mut chunk = vec![0u8; fed.want - buf.len()]; |
| 261 | let got = res!(file.read(&mut chunk)); |
| 262 | if got == 0 { |
| 263 | break; |
| 264 | } |
| 265 | chunk.truncate(got); |
| 266 | buf.extend_from_slice(&chunk); |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // A film must begin at a sync sample; anything before the first one cannot |
| 272 | // be decoded by a reader starting here and is dropped rather than written. |
| 273 | let first = match frames.iter().position(|(_, k, _)| *k) { |
| 274 | Some(i) => i, |
| 275 | None => return Ok(None), |
| 276 | }; |
| 277 | let kept: Vec<(Vec<u8>, bool, i64)> = frames.drain(..).skip(first).collect(); |
| 278 | |
| 279 | // The frames arrive in decode order carrying the times they are SHOWN, and |
| 280 | // MP4 wants the times they are decoded plus the difference. Every duration |
| 281 | // here is the same, so the decoding times are `i * step` and the offsets are |
| 282 | // what is left -- which for a film with B-pictures is not nought. |
| 283 | // Rebased on the first kept frame, because decoding starts at nought here and |
| 284 | // `composition_offsets` does not rebase: handed the absolute times of a film |
| 285 | // that starts an hour in, it would give every sample an offset of an hour. |
| 286 | // These films begin at nought, so it changes nothing today and stops the test |
| 287 | // passing for a reason that would not hold on a film that did not. |
| 288 | let t0 = kept.first().map(|(_, _, t)| *t).unwrap_or(0); |
| 289 | let times: Vec<i64> = kept.iter().map(|(_, _, t)| *t - t0).collect(); |
| 290 | let durs: Vec<u32> = vec![step; kept.len()]; |
| 291 | let offs = res!(oxedyne_fe2o3_graphics::mp4::composition_offsets(×, &durs)); |
| 292 | |
| 293 | let mut count = 0usize; |
| 294 | for (i, (data, key, _)) in kept.into_iter().enumerate() { |
| 295 | let s = if key { Sample::key(data, step) } else { Sample::delta(data, step) }; |
| 296 | res!(out.push(s.shown_after(offs[i]))); |
| 297 | count += 1; |
| 298 | } |
| 299 | if count == 0 { |
| 300 | return Ok(None); |
| 301 | } |
| 302 | Ok(Some(Made { bytes: res!(out.finish()), w: w as u16, h: h as u16, count, times, coding })) |
| 303 | } |
| 304 | |
| 305 | /// What ffprobe says the written film holds: codec, width, height, frames. |
| 306 | fn probe(path: &Path) -> Option<(String, u32, u32, usize)> { |
| 307 | let out = match Command::new("ffprobe") |
| 308 | .args([ |
| 309 | "-v", "error", |
| 310 | "-select_streams", "v:0", |
| 311 | "-count_packets", |
| 312 | "-show_entries", "stream=codec_name,width,height,nb_read_packets", |
| 313 | "-of", "csv=p=0", |
| 314 | ]) |
| 315 | .arg(path) |
| 316 | .output() |
| 317 | { |
| 318 | Ok(o) => o, |
| 319 | Err(_) => return None, |
| 320 | }; |
| 321 | if !out.status.success() { |
| 322 | return None; |
| 323 | } |
| 324 | let text = String::from_utf8_lossy(&out.stdout); |
| 325 | let line = text.lines().next().unwrap_or(""); |
| 326 | let mut parts = line.trim().split(','); |
| 327 | let codec = parts.next().unwrap_or("").to_string(); |
| 328 | let w = match parts.next().map(|v| v.parse::<u32>()) { |
| 329 | Some(Ok(v)) => v, |
| 330 | _ => return None, |
| 331 | }; |
| 332 | let h = match parts.next().map(|v| v.parse::<u32>()) { |
| 333 | Some(Ok(v)) => v, |
| 334 | _ => return None, |
| 335 | }; |
| 336 | let n = match parts.next().map(|v| v.parse::<usize>()) { |
| 337 | Some(Ok(v)) => v, |
| 338 | _ => return None, |
| 339 | }; |
| 340 | Some((codec, w, h, n)) |
| 341 | } |
| 342 | |
| 343 | /// How many frames ffmpeg can actually decode out of the written film. |
| 344 | /// |
| 345 | /// The stronger half of the check: a container whose sample table is wrong |
| 346 | /// still opens and still reports a plausible packet count, and only a decode |
| 347 | /// says whether the bytes handed to the decoder were the frames. |
| 348 | fn decode_count(path: &Path) -> Outcome<usize> { |
| 349 | let out = res!(Command::new("ffmpeg") |
| 350 | .args(["-v", "error", "-i"]) |
| 351 | .arg(path) |
| 352 | .args(["-f", "null", "-"]) |
| 353 | .output()); |
| 354 | let errs = String::from_utf8_lossy(&out.stderr); |
| 355 | if !errs.trim().is_empty() { |
| 356 | return Err(err!( |
| 357 | "ffmpeg complained while decoding the written film: {}", |
| 358 | errs.trim(); Invalid, Mismatch)); |
| 359 | } |
| 360 | // Counted by asking for the frames rather than by parsing a progress line. |
| 361 | let out = res!(Command::new("ffprobe") |
| 362 | .args([ |
| 363 | "-v", "error", |
| 364 | "-select_streams", "v:0", |
| 365 | "-count_frames", |
| 366 | "-show_entries", "stream=nb_read_frames", |
| 367 | "-of", "csv=p=0", |
| 368 | ]) |
| 369 | .arg(path) |
| 370 | .output()); |
| 371 | let text = String::from_utf8_lossy(&out.stdout); |
| 372 | match text.trim().parse::<usize>() { |
| 373 | Ok(v) => Ok(v), |
| 374 | Err(_) => Err(err!( |
| 375 | "ffprobe counted no frames in the written film."; Invalid, Mismatch)), |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | /// The presentation times of a written film's pictures, in decode order. |
| 380 | fn times_of(path: &Path) -> Option<Vec<i64>> { |
| 381 | let out = match Command::new("ffprobe") |
| 382 | .args([ |
| 383 | "-v", "error", |
| 384 | "-select_streams", "v:0", |
| 385 | "-show_entries", "packet=pts", |
| 386 | "-of", "csv=p=0", |
| 387 | ]) |
| 388 | .arg(path) |
| 389 | .output() |
| 390 | { |
| 391 | Ok(o) => o, |
| 392 | Err(_) => return None, |
| 393 | }; |
| 394 | if !out.status.success() { |
| 395 | return None; |
| 396 | } |
| 397 | let text = String::from_utf8_lossy(&out.stdout); |
| 398 | let mut times = Vec::new(); |
| 399 | for line in text.lines() { |
| 400 | match line.trim().parse::<i64>() { |
| 401 | Ok(v) => times.push(v), |
| 402 | Err(_) => return None, |
| 403 | } |
| 404 | } |
| 405 | Some(times) |
| 406 | } |
| 407 | |
| 408 | fn gather(dir: &Path, out: &mut Vec<PathBuf>) -> Outcome<()> { |
| 409 | let entries = match fs::read_dir(dir) { |
| 410 | Ok(e) => e, |
| 411 | Err(_) => return Ok(()), |
| 412 | }; |
| 413 | for entry in entries.flatten() { |
| 414 | let path = entry.path(); |
| 415 | let meta = match fs::metadata(&path) { |
| 416 | Ok(m) => m, |
| 417 | Err(_) => continue, |
| 418 | }; |
| 419 | if meta.is_dir() { |
| 420 | res!(gather(&path, out)); |
| 421 | } else if meta.len() > 0 |
| 422 | && path.extension().map(|e| e.eq_ignore_ascii_case("mkv")).unwrap_or(false) |
| 423 | { |
| 424 | out.push(path); |
| 425 | } |
| 426 | } |
| 427 | Ok(()) |
| 428 | } |
| 429 | |
| 430 | fn num_from_env(key: &str, or: usize) -> usize { |
| 431 | match env::var(key) { |
| 432 | Ok(v) => v.parse::<usize>().unwrap_or(or), |
| 433 | Err(_) => or, |
| 434 | } |
| 435 | } |