h264enc: strict ffmpeg oracle that sees damaged slices (#74)

ffmpeg prints "corrupt decoded frame" as a warning, so the oracle's
`-v error -ec 0` decode let a stream with a whole slice missing pass
silently. Switching that decode to `-v warning` is not enough: with
`-ec 0`, ffmpeg 7.1/8.1 flags every picture that has more than one
slice, libx264's multi-slice streams included, while the pixels are
unchanged. That is the 119/120 in #74; the encoder's bitstream is not
at fault (VideoToolbox decodes the same streams bit-identically, and
ffmpeg with strict error detection reports nothing).

The oracle now decodes with ffmpeg's default concealment, `-v warning`
and `-err_detect +crccheck+bitstream+buffer+explode`, fails on any
output or a non-zero exit, and still compares every pixel with the
encoder's reconstruction. A negative control drops and truncates
slices (CAVLC and CABAC, 3 slices) and checks the oracle reports each
one; it fails with the old flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EthanSuquet 2026-10-06 16:02:53 -04:00
parent 771b614ad4
commit 3eae70b11e
5 changed files with 67 additions and 10 deletions

View File

@ -196,7 +196,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
- **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.

View File

@ -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):

View File

@ -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<u8>) {
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<std::ops::Range<usize>> {
let starts: Vec<usize> = (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.

View File

@ -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<Yuv> = (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() {

View File

@ -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) |