Oregami
Repositories/oxedyne/fe2o3

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
23use oxedyne_fe2o3_core::prelude::*;
24use oxedyne_fe2o3_graphics::{
25 matroska::{Clusters, Matroska, TrackKind},
26 mp4::{Codec, Sample, Track},
27};
28
29use std::{
30 env,
31 fs::{self, File},
32 io::Read,
33 path::{Path, PathBuf},
34 process::Command,
35};
36
37const HEAD: usize = 1024 * 1024; // read before the clusters, to find the tracks
38const WINDOW: usize = 256 * 1024; // the window the frame reader is held to
39const FRAMES: usize = 300; // repackaged, which decides how big the written film is
40
41#[test]
42fn 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.
173struct 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.
186fn 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(&times, &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.
306fn 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.
348fn 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.
380fn 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
408fn 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
430fn 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}