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