Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_graphics/tests/matroska_corpus.rs

11.4 KiB, 20 runs

created by r1870400018:21770, 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 the Matroska header reader agree with a player about real films?
2//!
3//! The unit tests beside the reader build their own files, so they prove the
4//! reader agrees with the author's idea of the format and nothing more. This
5//! asks a different program about films nobody here wrote: point
6//! `MKV_CORPUS` at a directory and every `.mkv` under it is read twice, once by
7//! this crate and once by `ffprobe`, and the two answers are compared.
8//!
9//! Three things are compared, not one. The size and the running time are what
10//! [`avi_corpus`](../avi_corpus.rs) checks, and they would pass on a reader that
11//! found the picture and stopped. **The codec of every stream is checked too**,
12//! because the question a film library actually asks of this reader is whether a
13//! browser will play the file, and that is answered by the sound as often as by
14//! the picture.
15//!
16//! Only the front of each file is given to the reader, and how much of a front
17//! is needed is itself measured: a file that put its cover art before its
18//! tracks needs more than a scanner's usual sniffing buffer, and the number of
19//! files that do is the thing a scanner's buffer size should be chosen from.
20//!
21//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
22//! Anthropic Claude
23
24use oxedyne_fe2o3_core::prelude::*;
25use oxedyne_fe2o3_graphics::matroska::{Matroska, TrackKind};
26
27use std::{
28 collections::BTreeMap,
29 env,
30 fs::{self, File},
31 io::Read,
32 path::{Path, PathBuf},
33 process::Command,
34};
35
36const HEAD: usize = 64 * 1024; // the head a scanner would hold, and all the reader is first given
37
38// The larger head tried where the first found no tracks. A file may carry a
39// poster, a font for its subtitles, or a long seek index before the track list.
40// Reading a megabyte of a four-gigabyte film is still cheap; the point of
41// measuring is to find out how often it is needed.
42const DEEP: usize = 1024 * 1024;
43
44const SLACK_MS: i64 = 100; // how far a running time may differ from the player's and still agree
45
46/// What a Matroska codec identifier is called by the player.
47///
48/// The identifiers are fixed by the specification and the names are ffprobe's,
49/// so this table is the translation between two vocabularies and belongs with
50/// the oracle rather than in the library. Anything absent is not compared --
51/// the file is still checked for its size and running time.
52fn as_player_says(id: &str) -> Option<&'static str> {
53 let name = match id {
54 "V_MPEG4/ISO/AVC" => "h264",
55 "V_MPEGH/ISO/HEVC" => "hevc",
56 "V_AV1" => "av1",
57 "V_VP8" => "vp8",
58 "V_VP9" => "vp9",
59 "V_MPEG4/ISO/ASP" => "mpeg4",
60 "V_MPEG4/ISO/SP" => "mpeg4",
61 "V_MPEG4/ISO/AP" => "mpeg4",
62 "V_MPEG2" => "mpeg2video",
63 "V_MPEG1" => "mpeg1video",
64 "V_THEORA" => "theora",
65 "A_AAC" => "aac",
66 "A_AC3" => "ac3",
67 "A_EAC3" => "eac3",
68 "A_TRUEHD" => "truehd",
69 "A_DTS" => "dts",
70 "A_MPEG/L3" => "mp3",
71 "A_MPEG/L2" => "mp2",
72 "A_FLAC" => "flac",
73 "A_OPUS" => "opus",
74 "A_VORBIS" => "vorbis",
75 "S_TEXT/UTF8" => "subrip",
76 "S_TEXT/ASS" => "ass",
77 "S_TEXT/SSA" => "ssa",
78 "S_TEXT/WEBVTT" => "webvtt",
79 "S_VOBSUB" => "dvd_subtitle",
80 "S_HDMV/PGS" => "hdmv_pgs_subtitle",
81 // `V_MS/VFW/FOURCC` names its codec in a Windows bitmap header inside
82 // the private data, which is a second format and not this reader's job.
83 // `A_AAC/...` profiles and anything later fall through here too.
84 _ => return None,
85 };
86 Some(name)
87}
88
89fn films(dir: &Path, out: &mut Vec<PathBuf>) {
90 let entries = match fs::read_dir(dir) {
91 Ok(e) => e,
92 Err(_) => return,
93 };
94 let mut here = Vec::new();
95 for entry in entries.flatten() {
96 let path = entry.path();
97 if path.is_dir() {
98 films(&path, out);
99 } else if path.to_string_lossy().to_lowercase().ends_with(".mkv") {
100 here.push(path);
101 }
102 }
103 here.sort();
104 out.extend(here);
105}
106
107fn head_of(path: &Path, n: usize) -> Option<Vec<u8>> {
108 let mut f = match File::open(path) {
109 Ok(f) => f,
110 Err(_) => return None,
111 };
112 let mut buf = vec![0u8; n];
113 let mut got = 0usize;
114 while got < n {
115 match f.read(&mut buf[got..]) {
116 Ok(0) => break,
117 Ok(k) => got += k,
118 Err(_) => return None,
119 }
120 }
121 buf.truncate(got);
122 Some(buf)
123}
124
125/// What the player says: the picture's size and the running time, and every
126/// stream's codec in the order the file lists them.
127struct Said {
128 w: u32,
129 h: u32,
130 ms: i64,
131 codecs: Vec<String>,
132}
133
134fn oracle(path: &Path) -> Option<Said> {
135 let out = Command::new("ffprobe")
136 .args([
137 "-v", "error",
138 "-show_entries", "stream=codec_type,codec_name,width,height:format=duration",
139 "-of", "default=noprint_wrappers=1",
140 ])
141 .arg(path)
142 .output()
143 .ok()?;
144 if !out.status.success() {
145 return None;
146 }
147 let text = String::from_utf8_lossy(&out.stdout);
148 let mut said = Said { w: 0, h: 0, ms: 0, codecs: Vec::new() };
149 // ffprobe prints a block per stream and then the format block. The first
150 // video stream's size is the one wanted, and a later one must not replace it.
151 let mut have_size = false;
152 let (mut this_name, mut this_type) = (String::new(), String::new());
153 let (mut this_w, mut this_h) = (0u32, 0u32);
154 let flush = |name: &mut String,
155 kind: &mut String,
156 w: &mut u32,
157 h: &mut u32,
158 said: &mut Said,
159 have: &mut bool|
160 {
161 if name.is_empty() {
162 return;
163 }
164 if kind == "video" && !*have && *w > 0 {
165 said.w = *w;
166 said.h = *h;
167 *have = true;
168 }
169 said.codecs.push(name.clone());
170 name.clear();
171 kind.clear();
172 *w = 0;
173 *h = 0;
174 };
175 for line in text.lines() {
176 let (key, val) = match line.split_once('=') {
177 Some(kv) => kv,
178 None => continue,
179 };
180 match key.trim() {
181 "codec_name" => {
182 flush(&mut this_name, &mut this_type, &mut this_w, &mut this_h,
183 &mut said, &mut have_size);
184 this_name = val.trim().to_string();
185 },
186 "codec_type" => this_type = val.trim().to_string(),
187 "width" => this_w = val.trim().parse().unwrap_or(0),
188 "height" => this_h = val.trim().parse().unwrap_or(0),
189 "duration" => {
190 flush(&mut this_name, &mut this_type, &mut this_w, &mut this_h,
191 &mut said, &mut have_size);
192 let secs: f64 = val.trim().parse().ok()?;
193 said.ms = (secs * 1000.0).round() as i64;
194 },
195 _ => {},
196 }
197 }
198 flush(&mut this_name, &mut this_type, &mut this_w, &mut this_h,
199 &mut said, &mut have_size);
200 Some(said)
201}
202
203/// The codecs this reader found, in file order, as the player would name them.
204///
205/// A stream whose identifier is not in the table is `None`, which the comparison
206/// steps over on both sides rather than counting as a disagreement.
207fn mine_as_player_says(mkv: &Matroska) -> Vec<Option<&'static str>> {
208 mkv.tracks().iter().map(|t| {
209 // A track the file gave no type is not a stream a player lists either.
210 match t.kind() {
211 Some(TrackKind::Other(_)) | None => None,
212 _ => as_player_says(t.codec()),
213 }
214 }).collect()
215}
216
217#[test]
218fn the_header_agrees_with_ffprobe() -> Outcome<()> {
219 let dir = match env::var("MKV_CORPUS") {
220 Ok(d) if !d.is_empty() => PathBuf::from(d),
221 _ => {
222 println!("skipped: set MKV_CORPUS to a directory of films");
223 return Ok(());
224 },
225 };
226 let cap: usize = env::var("MKV_CORPUS_MAX").ok()
227 .and_then(|n| n.parse().ok())
228 .unwrap_or(2000);
229
230 let mut all = Vec::new();
231 films(&dir, &mut all);
232 println!("{} Matroska files under {}", all.len(), dir.display());
233
234 let (mut compared, mut agreed, mut refused, mut no_oracle) = (0usize, 0usize, 0usize, 0usize);
235 let (mut needed_deep, mut no_tracks, mut unreadable) = (0usize, 0usize, 0usize);
236 let mut worst_ms = 0i64;
237 let mut wrong = Vec::new();
238 let mut untranslated: BTreeMap<String, usize> = BTreeMap::new();
239 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
240
241 for path in all.iter().take(cap) {
242 // A file that will not open is counted rather than stepped over. A
243 // corpus check that quietly drops what it could not read reports having
244 // covered everything it was given, which is the one thing it must not do.
245 let head = match head_of(path, HEAD) {
246 Some(h) => h,
247 None => { unreadable += 1; continue },
248 };
249 let mut mine = match Matroska::read(&head) {
250 Ok(m) => m,
251 Err(_) => { refused += 1; continue },
252 };
253 // The measurement the sniffing buffer should be chosen from.
254 if mine.tracks().is_empty() {
255 needed_deep += 1;
256 match head_of(path, DEEP) {
257 Some(deep) => match Matroska::read(&deep) {
258 Ok(m) => mine = m,
259 Err(_) => { refused += 1; continue },
260 },
261 None => { unreadable += 1; continue },
262 }
263 if mine.tracks().is_empty() {
264 no_tracks += 1;
265 continue;
266 }
267 }
268 let said = match oracle(path) {
269 Some(o) => o,
270 None => { no_oracle += 1; continue },
271 };
272 compared += 1;
273
274 for t in mine.tracks() {
275 *seen.entry(t.codec().to_string()).or_insert(0) += 1;
276 if as_player_says(t.codec()).is_none() {
277 *untranslated.entry(t.codec().to_string()).or_insert(0) += 1;
278 }
279 }
280
281 let (mw, mh) = mine.size();
282 if (mw, mh) != (said.w, said.h) {
283 if wrong.len() < 8 {
284 wrong.push(fmt!("{}: {}x{} against {}x{}",
285 path.display(), mw, mh, said.w, said.h));
286 }
287 continue;
288 }
289
290 match mine.millis() {
291 Some(got) => {
292 let off = (got as i64 - said.ms).abs();
293 let slack = SLACK_MS.max(said.ms / 100);
294 if off > slack {
295 if wrong.len() < 8 {
296 wrong.push(fmt!("{}: {} ms against {} ms",
297 path.display(), got, said.ms));
298 }
299 continue;
300 }
301 worst_ms = worst_ms.max(off);
302 },
303 None => {
304 if wrong.len() < 8 {
305 wrong.push(fmt!("{}: no running time, player says {} ms",
306 path.display(), said.ms));
307 }
308 continue;
309 },
310 }
311
312 // The streams, in order, where both sides name the codec. A stream this
313 // reader translates but the player never listed is a disagreement; so is
314 // one whose name differs.
315 let names = mine_as_player_says(&mine);
316 let mut bad = None;
317 let mut at = 0usize;
318 for (i, name) in names.iter().enumerate() {
319 let name = match name {
320 Some(n) => *n,
321 None => { at += 1; continue },
322 };
323 match said.codecs.get(at) {
324 Some(theirs) if theirs == name => {},
325 Some(theirs) => {
326 bad = Some(fmt!("stream {} is {} and the player says {}",
327 i, name, theirs));
328 break;
329 },
330 None => {
331 bad = Some(fmt!("stream {} is {} and the player listed no such stream",
332 i, name));
333 break;
334 },
335 }
336 at += 1;
337 }
338 if let Some(why) = bad {
339 if wrong.len() < 8 {
340 wrong.push(fmt!("{}: {}", path.display(), why));
341 }
342 continue;
343 }
344
345 agreed += 1;
346 }
347
348 println!("{} compared, {} agreed on size, running time and every stream's codec",
349 compared, agreed);
350 println!("worst running-time difference: {} ms", worst_ms);
351 println!("{} needed more than {} KB of head, {} had no track list at all",
352 needed_deep, HEAD / 1024, no_tracks);
353 println!("{} refused by the reader, {} ffprobe would not read, {} would not open",
354 refused, no_oracle, unreadable);
355 req!(compared + refused + no_oracle + unreadable + no_tracks, all.len().min(cap),
356 "{} files were neither compared nor accounted for.",
357 all.len().min(cap) - (compared + refused + no_oracle + unreadable + no_tracks));
358 println!("codec identifiers found:");
359 for (id, n) in &seen {
360 println!(" {:>5} {}{}", n, id,
361 if as_player_says(id).is_none() { " (not translated)" } else { "" });
362 }
363 for line in &wrong {
364 println!(" {}", line);
365 }
366
367 if compared > 0 {
368 req!(agreed, compared,
369 "The header reader and ffprobe disagree about {} of {} films.",
370 compared - agreed, compared);
371 }
372 // A reader that found no track list in a megabyte of every file would pass
373 // every comparison above by never making one.
374 req!(no_tracks, 0,
375 "{} files gave up no track list even from {} KB of head.", no_tracks, DEEP / 1024);
376 Ok(())
377}