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:
parent
771b614ad4
commit
3eae70b11e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue