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 | |
| 24 | use oxedyne_fe2o3_core::prelude::*; |
| 25 | use oxedyne_fe2o3_graphics::matroska::{Matroska, TrackKind}; |
| 26 | |
| 27 | use std::{ |
| 28 | collections::BTreeMap, |
| 29 | env, |
| 30 | fs::{self, File}, |
| 31 | io::Read, |
| 32 | path::{Path, PathBuf}, |
| 33 | process::Command, |
| 34 | }; |
| 35 | |
| 36 | const 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. |
| 42 | const DEEP: usize = 1024 * 1024; |
| 43 | |
| 44 | const 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. |
| 52 | fn 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 | |
| 89 | fn 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 | |
| 107 | fn 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. |
| 127 | struct Said { |
| 128 | w: u32, |
| 129 | h: u32, |
| 130 | ms: i64, |
| 131 | codecs: Vec<String>, |
| 132 | } |
| 133 | |
| 134 | fn 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. |
| 207 | fn 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] |
| 218 | fn 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 | } |