oxedyne/fe2o3/fe2o3_graphics/tests/matroska_frames.rs
9.3 KiB, 24 runs
created by r1870400018:21780, 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 cluster reader hand back the same frames a player demuxes? |
| 2 | //! |
| 3 | //! [`matroska_corpus`](../matroska_corpus.rs) asks whether the *description* of |
| 4 | //! a film is right. This asks the harder question: whether the frames lifted out |
| 5 | //! of the clusters are the ones that are actually there. It matters more, |
| 6 | //! because the point of reading clusters at all is repackaging, and a |
| 7 | //! repackager that drops a frame, mistimes one, or glues a laced pair together |
| 8 | //! produces a file that plays -- badly, or for a while, or with the sound adrift |
| 9 | //! -- rather than one that fails. |
| 10 | //! |
| 11 | //! **The oracle is `ffprobe -show_packets`**, which demuxes the same file with a |
| 12 | //! wholly separate implementation and reports every packet's size, presentation |
| 13 | //! time and whether decoding may begin at it. Four things are compared per |
| 14 | //! frame, in order: the count, the size in bytes, the time, and the keyframe |
| 15 | //! flag. A reader that found the clusters but mis-split the blocks agrees on |
| 16 | //! none of them. |
| 17 | //! |
| 18 | //! Point `MKV_CORPUS` at a directory of films. `MKV_FRAMES` caps how many frames |
| 19 | //! of each are compared, because a two-hour film holds two hundred thousand and |
| 20 | //! the disease this catches shows itself in the first thousand. |
| 21 | //! |
| 22 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 23 | //! Anthropic Claude |
| 24 | |
| 25 | use oxedyne_fe2o3_core::prelude::*; |
| 26 | use oxedyne_fe2o3_graphics::matroska::{Clusters, Matroska, TrackKind}; |
| 27 | |
| 28 | use std::{ |
| 29 | env, |
| 30 | fs::{self, File}, |
| 31 | io::Read, |
| 32 | path::{Path, PathBuf}, |
| 33 | process::Command, |
| 34 | }; |
| 35 | |
| 36 | const HEAD: usize = 1024 * 1024; // read before the clusters, to find the tracks |
| 37 | |
| 38 | // The window the streaming reader is held to, in bytes. Deliberately small -- |
| 39 | // far smaller than a cluster, which is megabytes -- so that the test fails if |
| 40 | // the reader ever needs a whole cluster in hand. A window this size passing is |
| 41 | // the evidence that a four-gigabyte film can be repackaged without being held. |
| 42 | const WINDOW: usize = 256 * 1024; |
| 43 | |
| 44 | const FRAMES: usize = 4000; // frames of each film compared unless MKV_FRAMES says otherwise |
| 45 | const FILES: usize = 8; // films read unless MKV_FILES says otherwise |
| 46 | |
| 47 | /// One frame, as either implementation describes it. |
| 48 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 49 | struct Packet { |
| 50 | size: usize, |
| 51 | time: i64, |
| 52 | key: bool, |
| 53 | } |
| 54 | |
| 55 | #[test] |
| 56 | fn frames_agree_with_the_player() -> Outcome<()> { |
| 57 | let dir = match env::var("MKV_CORPUS") { |
| 58 | Ok(d) => PathBuf::from(d), |
| 59 | Err(_) => { |
| 60 | println!("MKV_CORPUS is not set, so no film was read. This test \ |
| 61 | compares real films against ffprobe and proves nothing without \ |
| 62 | them."); |
| 63 | return Ok(()); |
| 64 | }, |
| 65 | }; |
| 66 | if Command::new("ffprobe").arg("-version").output().is_err() { |
| 67 | println!("ffprobe is not installed, so there is no oracle to compare \ |
| 68 | against and this test proves nothing."); |
| 69 | return Ok(()); |
| 70 | } |
| 71 | |
| 72 | let want = num_from_env("MKV_FRAMES", FRAMES); |
| 73 | let most = num_from_env("MKV_FILES", FILES); |
| 74 | |
| 75 | let mut films = Vec::new(); |
| 76 | res!(gather(&dir, &mut films)); |
| 77 | films.sort(); |
| 78 | |
| 79 | let mut compared = 0usize; |
| 80 | let mut refused = 0usize; |
| 81 | let mut no_oracle = 0usize; |
| 82 | let mut frames = 0usize; |
| 83 | |
| 84 | for film in films.iter() { |
| 85 | if compared >= most { |
| 86 | break; |
| 87 | } |
| 88 | // Both streams, and the sound is not an afterthought: **lacing lives in |
| 89 | // audio**. A film's picture is one frame a block and exercises none of |
| 90 | // the three lacing schemes, so a reader checked on the picture alone has |
| 91 | // its lacing arithmetic wholly unproven against anything real. |
| 92 | let mut both = 0usize; |
| 93 | for (kind, pick) in [(TrackKind::Video, "v:0"), (TrackKind::Audio, "a:0")] { |
| 94 | let ours = match read_ours(film, want, kind) { |
| 95 | Ok(Some(v)) => v, |
| 96 | // A film with no such track, or one this reader will not open, |
| 97 | // is counted rather than passed over in silence. |
| 98 | Ok(None) => continue, |
| 99 | Err(e) => { |
| 100 | println!("{}: {} refused by the reader: {}", film.display(), pick, e); |
| 101 | refused += 1; |
| 102 | continue; |
| 103 | }, |
| 104 | }; |
| 105 | let theirs = match read_theirs(film, want, pick) { |
| 106 | Some(v) => v, |
| 107 | None => { no_oracle += 1; continue }, |
| 108 | }; |
| 109 | if theirs.is_empty() { |
| 110 | no_oracle += 1; |
| 111 | continue; |
| 112 | } |
| 113 | |
| 114 | if ours.len() != theirs.len() { |
| 115 | return Err(err!( |
| 116 | "{}: on {} the reader found {} frames and ffprobe found {}.", |
| 117 | film.display(), pick, ours.len(), theirs.len(); |
| 118 | Invalid, Mismatch)); |
| 119 | } |
| 120 | for i in 0..ours.len() { |
| 121 | if ours[i] != theirs[i] { |
| 122 | return Err(err!( |
| 123 | "{}: on {}, frame {} of {} differs. The reader says {:?} \ |
| 124 | and ffprobe says {:?}.", |
| 125 | film.display(), pick, i, ours.len(), ours[i], theirs[i]; |
| 126 | Invalid, Mismatch)); |
| 127 | } |
| 128 | } |
| 129 | frames += ours.len(); |
| 130 | both += 1; |
| 131 | println!("{}: {} {} frames agree", film.display(), ours.len(), pick); |
| 132 | } |
| 133 | if both == 0 { |
| 134 | refused += 1; |
| 135 | continue; |
| 136 | } |
| 137 | compared += 1; |
| 138 | } |
| 139 | |
| 140 | // Every film is accounted for, so that a run which quietly compared nothing |
| 141 | // cannot read as a pass. |
| 142 | println!( |
| 143 | "{} films found; {} compared ({} frames), {} refused, {} without an oracle", |
| 144 | films.len(), compared, frames, refused, no_oracle); |
| 145 | if !films.is_empty() && compared == 0 { |
| 146 | return Err(err!( |
| 147 | "{} films were found and not one was compared, so this run proves \ |
| 148 | nothing.", films.len(); |
| 149 | Invalid, Mismatch)); |
| 150 | } |
| 151 | Ok(()) |
| 152 | } |
| 153 | |
| 154 | /// Reads a film's frames through the streaming reader, holding only a window. |
| 155 | /// |
| 156 | /// `None` where the film carries no picture track to compare. |
| 157 | fn read_ours(path: &Path, want: usize, kind: TrackKind) -> Outcome<Option<Vec<Packet>>> { |
| 158 | let mut head = vec![0u8; HEAD]; |
| 159 | let mut file = res!(File::open(path)); |
| 160 | let n = res!(file.read(&mut head)); |
| 161 | head.truncate(n); |
| 162 | let mkv = res!(Matroska::read(&head)); |
| 163 | // The **first** track of the kind, in file order, because that is what |
| 164 | // ffprobe's `v:0` and `a:0` mean. Deliberately not `Matroska::video()`, |
| 165 | // which prefers a default-flagged stream and would compare two different |
| 166 | // streams on a film carrying an alternative take. |
| 167 | let track = match mkv.tracks().iter().find(|t| t.kind() == Some(kind)) { |
| 168 | Some(t) => { |
| 169 | if env::var("MKV_SAY_TRACKS").is_ok() { |
| 170 | println!(" track {} {:?} {} frame_ns={} rate={}", |
| 171 | t.number(), t.kind(), t.codec(), t.frame_nanos(), t.rate()); |
| 172 | } |
| 173 | t.number() |
| 174 | }, |
| 175 | None => return Ok(None), |
| 176 | }; |
| 177 | |
| 178 | let mut file = res!(File::open(path)); |
| 179 | let mut cl = Clusters::new(&mkv); |
| 180 | let mut buf: Vec<u8> = Vec::new(); |
| 181 | let mut out = Vec::new(); |
| 182 | let mut done = false; |
| 183 | |
| 184 | loop { |
| 185 | // Top the window up. Reading stops at the end of the file, which is not |
| 186 | // an error -- the reader is simply told nothing more is coming. |
| 187 | while buf.len() < WINDOW && !done { |
| 188 | let mut chunk = vec![0u8; WINDOW]; |
| 189 | let got = res!(file.read(&mut chunk)); |
| 190 | if got == 0 { |
| 191 | done = true; |
| 192 | break; |
| 193 | } |
| 194 | chunk.truncate(got); |
| 195 | buf.extend_from_slice(&chunk); |
| 196 | } |
| 197 | if buf.is_empty() { |
| 198 | break; |
| 199 | } |
| 200 | |
| 201 | let mut full = false; |
| 202 | let fed = res!(cl.feed(&buf, &mut |frame| { |
| 203 | if frame.track == track && out.len() < want { |
| 204 | out.push(Packet { |
| 205 | size: frame.data.len(), |
| 206 | time: frame.time, |
| 207 | key: frame.key, |
| 208 | }); |
| 209 | if out.len() >= want { |
| 210 | full = true; |
| 211 | } |
| 212 | } |
| 213 | Ok(()) |
| 214 | })); |
| 215 | buf.drain(..fed.used); |
| 216 | if full { |
| 217 | break; |
| 218 | } |
| 219 | // No progress and nothing more to read means the file ended inside an |
| 220 | // element; no progress with more to read means the window must grow, |
| 221 | // which a block larger than the window legitimately requires. |
| 222 | if fed.used == 0 { |
| 223 | if done && fed.want > buf.len() { |
| 224 | break; |
| 225 | } |
| 226 | if fed.want > buf.len() { |
| 227 | let mut chunk = vec![0u8; fed.want - buf.len()]; |
| 228 | let got = res!(file.read(&mut chunk)); |
| 229 | if got == 0 { |
| 230 | break; |
| 231 | } |
| 232 | chunk.truncate(got); |
| 233 | buf.extend_from_slice(&chunk); |
| 234 | } else if done { |
| 235 | break; |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | Ok(Some(out)) |
| 240 | } |
| 241 | |
| 242 | /// Reads the same film's packets with ffprobe. |
| 243 | fn read_theirs(path: &Path, want: usize, pick: &str) -> Option<Vec<Packet>> { |
| 244 | let out = match Command::new("ffprobe") |
| 245 | .args([ |
| 246 | "-v", "error", |
| 247 | "-select_streams", pick, |
| 248 | "-show_entries", "packet=pts,size,flags", |
| 249 | "-of", "csv=p=0", |
| 250 | ]) |
| 251 | .arg(path) |
| 252 | .output() |
| 253 | { |
| 254 | Ok(o) => o, |
| 255 | Err(_) => return None, |
| 256 | }; |
| 257 | if !out.status.success() { |
| 258 | return None; |
| 259 | } |
| 260 | let text = String::from_utf8_lossy(&out.stdout); |
| 261 | let mut packets = Vec::new(); |
| 262 | for line in text.lines() { |
| 263 | if packets.len() >= want { |
| 264 | break; |
| 265 | } |
| 266 | let mut parts = line.trim().split(','); |
| 267 | // A packet with no presentation time is one this comparison cannot |
| 268 | // speak about, and it is rare enough that giving up on the file is |
| 269 | // honest -- a partial list silently compared would be worse. |
| 270 | let time = match parts.next().map(|v| v.parse::<i64>()) { |
| 271 | Some(Ok(v)) => v, |
| 272 | _ => return None, |
| 273 | }; |
| 274 | let size = match parts.next().map(|v| v.parse::<usize>()) { |
| 275 | Some(Ok(v)) => v, |
| 276 | _ => return None, |
| 277 | }; |
| 278 | let flags = parts.next().unwrap_or(""); |
| 279 | packets.push(Packet { size, time, key: flags.starts_with('K') }); |
| 280 | } |
| 281 | Some(packets) |
| 282 | } |
| 283 | |
| 284 | fn gather(dir: &Path, out: &mut Vec<PathBuf>) -> Outcome<()> { |
| 285 | let entries = match fs::read_dir(dir) { |
| 286 | Ok(e) => e, |
| 287 | Err(_) => return Ok(()), |
| 288 | }; |
| 289 | for entry in entries.flatten() { |
| 290 | let path = entry.path(); |
| 291 | // A dangling link is a fact about the collection, not a fault here. |
| 292 | let meta = match fs::metadata(&path) { |
| 293 | Ok(m) => m, |
| 294 | Err(_) => continue, |
| 295 | }; |
| 296 | if meta.is_dir() { |
| 297 | res!(gather(&path, out)); |
| 298 | } else if meta.len() > 0 |
| 299 | && path.extension().map(|e| e.eq_ignore_ascii_case("mkv")).unwrap_or(false) |
| 300 | { |
| 301 | out.push(path); |
| 302 | } |
| 303 | } |
| 304 | Ok(()) |
| 305 | } |
| 306 | |
| 307 | fn num_from_env(key: &str, or: usize) -> usize { |
| 308 | match env::var(key) { |
| 309 | Ok(v) => v.parse::<usize>().unwrap_or(or), |
| 310 | Err(_) => or, |
| 311 | } |
| 312 | } |