diff --git a/README.md b/README.md index 8028f585..19464a6f 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran

Export mode, set to write the trailer as H.264 MP4.

-- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes cleanly in ffmpeg with error concealment switched off. +- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on. - **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV. - **Background jobs** with progress and cancel, so you keep editing while it renders. - **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back. diff --git a/crates/h264enc/README.md b/crates/h264enc/README.md index 69622e85..27496214 100644 --- a/crates/h264enc/README.md +++ b/crates/h264enc/README.md @@ -55,10 +55,16 @@ for p in enc.flush() { /* ... */ } ## Verification `cargo test --release -p filmcraft-h264enc` (ffmpeg/ffprobe used only as external oracles; tests skip when -absent). Every stream is decoded with `ffmpeg -ec 0 -f rawvideo` and must report no errors, produce exactly the -number of frames encoded, and be **bit-identical to the encoder's reconstruction** — this covers Baseline/Main/High, -all presets, 0–3 B-frames, 1–4 slices, odd sizes, `testsrc2` and `mandelbrot` sources, and length-prefixed output -re-wrapped with the avcC parameter sets. B-frame display order is checked with ffprobe. +absent). Every stream is decoded with `ffmpeg -v warning -err_detect +crccheck+bitstream+buffer+explode -f rawvideo` +and must print nothing (not even a `corrupt decoded frame` warning), produce exactly the number of frames encoded, +and be **bit-identical to the encoder's reconstruction** — this covers Baseline/Main/High, all presets, 0–3 +B-frames, 1–4 slices and automatic slicing at 1080p, odd sizes, `testsrc2` and `mandelbrot` sources, and +length-prefixed output re-wrapped with the avcC parameter sets. B-frame display order is checked with ffprobe. A +negative control drops and truncates slices and checks that the oracle reports them. + +The oracle leaves ffmpeg's error concealment on. With `-ec 0`, ffmpeg 7.1 and 8.1 print `corrupt decoded frame` +for every picture that has more than one slice, libx264's multi-slice streams included, while the decoded pixels +are unchanged (#74). The bit-exact comparison is what rules out concealment. Results on the synthetic content (moving gradient, grain noise, text-like edges, sub-pixel panning): diff --git a/crates/h264enc/tests/common/mod.rs b/crates/h264enc/tests/common/mod.rs index 409175fe..c1b72e73 100644 --- a/crates/h264enc/tests/common/mod.rs +++ b/crates/h264enc/tests/common/mod.rs @@ -110,15 +110,36 @@ pub fn out_dir(name: &str) -> PathBuf { filmcraft_testkit::fixtures_dir(&format!("h264enc/{name}")) } -/// Decode an Annex-B file with ffmpeg; returns (stderr, raw yuv420p bytes). +/// Strict decode of an Annex-B file with ffmpeg; returns (diagnostics, raw yuv420p bytes). Any diagnostic +/// text is a failure for the caller. +/// +/// - `-v warning`: ffmpeg reports a picture with a damaged, missing or concealed slice as +/// `corrupt decoded frame`, which is a warning. At `-v error` a stream with a whole slice missing +/// decodes silently (#74). +/// - Error concealment stays at ffmpeg's default. With `-ec 0`, ffmpeg (7.1, 8.1) flags every picture +/// that has more than one slice as corrupt, libx264's multi-slice streams included, so that mode +/// can't tell a real defect from a clean multi-slice picture. Concealment can't hide a defect here: +/// a concealed picture is still reported, and the caller compares every decoded pixel with the +/// encoder's reconstruction. +/// - `-err_detect`: also report the non-conformances ffmpeg tolerates by default. pub fn ffmpeg_decode(file: &Path) -> (String, Vec) { let out = Command::new(ffmpeg().unwrap()) - .args(["-v", "error", "-ec", "0", "-i"]) + .args(["-nostdin", "-hide_banner", "-v", "warning", "-err_detect", "+crccheck+bitstream+buffer+explode", "-i"]) .arg(file) .args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]) .output() .expect("run ffmpeg"); - (String::from_utf8_lossy(&out.stderr).into_owned(), out.stdout) + let mut diag = String::from_utf8_lossy(&out.stderr).into_owned(); + if !out.status.success() { + diag.push_str(&format!("ffmpeg exited with {}", out.status)); + } + (diag, out.stdout) +} + +/// Annex-B NAL units of `stream` as byte ranges (start code included). +pub fn annexb_nals(stream: &[u8]) -> Vec> { + let starts: Vec = (0..stream.len().saturating_sub(3)).filter(|&i| stream[i..i + 3] == [0, 0, 1]).collect(); + starts.iter().enumerate().map(|(k, &s)| s..starts.get(k + 1).copied().unwrap_or(stream.len())).collect() } /// Raw frames produced by ffmpeg from a lavfi source. diff --git a/crates/h264enc/tests/ffmpeg_oracle.rs b/crates/h264enc/tests/ffmpeg_oracle.rs index 48bf807c..86446535 100644 --- a/crates/h264enc/tests/ffmpeg_oracle.rs +++ b/crates/h264enc/tests/ffmpeg_oracle.rs @@ -5,7 +5,7 @@ mod common; -use common::{Yuv, ffmpeg, ffmpeg_decode, ffmpeg_source, ffprobe, out_dir, psnr, synth}; +use common::{Yuv, annexb_nals, ffmpeg, ffmpeg_decode, ffmpeg_source, ffprobe, out_dir, psnr, synth}; use filmcraft_h264enc::*; use std::process::Command; @@ -101,6 +101,36 @@ fn bit_exact_profiles_presets() { } } +/// The oracle must see a damaged multi-slice picture (#74): a missing slice used to decode silently +/// at `-v error -ec 0`, and `-ec 0` at `-v warning` flags clean multi-slice pictures too. +#[test] +fn oracle_reports_damaged_slices() { + if ffmpeg().is_none() { + return; + } + let (w, h, n) = (176, 144, 6); + let frames: Vec = (0..n).map(|t| synth(w, h, t)).collect(); + for (name, profile) in [("cavlc", Profile::Baseline), ("cabac", Profile::High)] { + let run = encode(cfg(w, h, profile, Preset::Balanced, 0, RateControl::Qp(26), 3), &frames); + // the undamaged 3-slice stream decodes without a single message + verify(&format!("damage_{name}_clean"), &run, w, h, n); + let slices: Vec<_> = annexb_nals(&run.stream).into_iter().filter(|r| matches!(run.stream[r.start + 3] & 0x1f, 1 | 5)).collect(); + assert_eq!(slices.len(), 3 * n, "{name}: three slices per picture"); + for (k, slice) in [(1usize, &slices[4]), (3, &slices[10])] { + let mut dropped = run.stream.clone(); + dropped.drain(slice.clone()); + let mut truncated = run.stream.clone(); + truncated.drain(slice.start + slice.len() / 2..slice.end); + for (damage, stream) in [("dropped", dropped), ("truncated", truncated)] { + let path = out_dir("oracle").join(format!("damage_{name}_{damage}_{k}.h264")); + std::fs::write(&path, &stream).unwrap(); + let (err, _) = ffmpeg_decode(&path); + assert!(!err.trim().is_empty(), "{name}: the oracle missed a {damage} slice in picture {k}"); + } + } + } +} + #[test] fn bit_exact_lavfi_sources() { if ffmpeg().is_none() { diff --git a/docs/testing.md b/docs/testing.md index cf296272..4f52ed21 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,7 +74,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). |---|---|---| | `h264` | decode every fixture single-threaded and frame-threaded; compare with `ffmpeg -f rawvideo -pix_fmt yuv420p` | **bit-exact**, every frame | | `hevc` | same, `yuv420p` / `yuv420p10le` | **bit-exact** (8- and 10-bit) | -| `h264enc` | `ffmpeg -ec 0` decodes our stream | no errors, exact frame count, **bit-identical to the encoder's reconstruction**; B-frame order checked with ffprobe; rate-control targets | +| `h264enc` | `ffmpeg -v warning -err_detect +crccheck+bitstream+buffer+explode` decodes our stream | no output at all (a `corrupt decoded frame` warning fails; a negative control checks dropped and truncated slices are reported), exact frame count, **bit-identical to the encoder's reconstruction**; B-frame order checked with ffprobe; rate-control targets | | `prores` decode | ffmpeg `prores_ks` / `prores_aw` fixtures, compared at native depth | within **±1 LSB** (different integer IDCT); alpha bit-exact | | `prores` encode | ffmpeg decodes with `-xerror` | no errors; agrees with our decoder within ±1 | | `dnx` decode | ffmpeg VC-3 fixtures (13 DNxHD CIDs, DNxHR LB/SQ/HQ/HQX/444), compared at native depth | within **±2 LSB**, ≤ 100 samples per million beyond ±1 (ffmpeg's integer IDCT; we evaluate the exact IDCT) |