From 30a96f7d2a861dd2009d34ce88f2f972e008677b Mon Sep 17 00:00:00 2001 From: andymenard <198171234+andymenard@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:46:30 -0500 Subject: [PATCH 1/7] codecs: add native NTSC DV25 MOV decoding Implement a dependency-free decoder from SMPTE 314M-2005 and route MOV dvc samples through native video playback, preserving existing audio and seeking support. Add generated oracle and malformed-packet tests, a streaming decode example, and scope and architecture documentation. Assisted-by: OpenAI Codex --- Cargo.lock | 5 + Cargo.toml | 1 + README.md | 1 + ROADMAP.md | 2 + crates/codecs/Cargo.toml | 1 + crates/codecs/src/lib.rs | 3 +- crates/codecs/src/mp4.rs | 2 + crates/codecs/src/tests.rs | 48 +++++ crates/codecs/src/video.rs | 49 +++++ crates/dv/Cargo.toml | 10 + crates/dv/README.md | 27 +++ crates/dv/examples/decode.rs | 22 +++ crates/dv/src/lib.rs | 370 +++++++++++++++++++++++++++++++++++ crates/dv/src/vlc.rs | 99 ++++++++++ crates/dv/tests/oracle.rs | 88 +++++++++ docs/architecture.md | 1 + docs/codec-parity.md | 2 +- docs/gaps.md | 2 +- xtask/src/main.rs | 1 + 19 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 crates/dv/Cargo.toml create mode 100644 crates/dv/README.md create mode 100644 crates/dv/examples/decode.rs create mode 100644 crates/dv/src/lib.rs create mode 100644 crates/dv/src/vlc.rs create mode 100644 crates/dv/tests/oracle.rs diff --git a/Cargo.lock b/Cargo.lock index 118d8dc4f9..5480ee0ff5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1991,6 +1991,7 @@ dependencies = [ "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", + "filmcraft-dv", "filmcraft-frame", "filmcraft-h264", "filmcraft-hevc", @@ -2044,6 +2045,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-dv" +version = "0.13.0" + [[package]] name = "filmcraft-edit" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index e57cd803bc..796a398b44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ filmcraft-hevc = { path = "crates/hevc" } filmcraft-vp8 = { path = "crates/vp8" } filmcraft-vp9 = { path = "crates/vp9" } filmcraft-prores = { path = "crates/prores" } +filmcraft-dv = { path = "crates/dv" } filmcraft-dnx = { path = "crates/dnx" } filmcraft-apv = { path = "crates/apv" } filmcraft-dng = { path = "crates/dng" } diff --git a/README.md b/README.md index 95fb0a8c28..8f403d0fad 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 | | **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska | | **DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV | +| **DV25 / DVCPRO25 NTSC** | ✓ | | Native 525/60 4:1:1 MOV (`dvc `); PAL, DV50/HD and AVI DV not yet | | **MJPEG, PCM** | ✓ | ✓ | | | **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own | | **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks | diff --git a/ROADMAP.md b/ROADMAP.md index a93ae8ae09..ca2a01eb2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # FilmCraft Roadmap +- Codecs: native pure-Rust DV25/DVCPRO25 NTSC QuickTime decoding, preserving source media, field timing, clean aperture, PCM audio and timecode. PAL, DV50/HD and AVI DV remain unsupported. + - UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths. - UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained. diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 90b79e2e10..4ca9378884 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -29,6 +29,7 @@ filmcraft-hevc = { workspace = true } filmcraft-vp8 = { workspace = true } filmcraft-vp9 = { workspace = true } filmcraft-prores = { workspace = true } +filmcraft-dv = { workspace = true } filmcraft-dnx = { workspace = true } filmcraft-av1 = { workspace = true } filmcraft-apv = { workspace = true } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index f3ae47b72b..d0dc6d1050 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -77,7 +77,8 @@ impl From for filmcraft_media::MediaError { } /// Our own (pure-Rust) decoders, in the order they are tried. -const BUILTIN_FACTORIES: [VideoDecoderFactory; 11] = [ +const BUILTIN_FACTORIES: [VideoDecoderFactory; 12] = [ + video::dv_factory, video::h264_factory, video::hevc_factory, video::vp8_factory, diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 49bbf9e65d..c6fc49d429 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -617,6 +617,7 @@ fn codec_label(c: &CodecConfig) -> String { CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"raw " => "Uncompressed RGB".into(), CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"2vuy" => "Uncompressed YUV 4:2:2".into(), CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"v210" => "Uncompressed YUV 4:2:2 10-bit".into(), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"dvc " => "DV / DVCPRO NTSC".into(), CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")), CodecConfig::Apv(a) => match filmcraft_apv::Profile::from_idc(a.profile_idc) { Some(p) => p.name().into(), @@ -690,6 +691,7 @@ fn hevc_format(h: &filmcraft_isobmff::HevcConfig) -> (u32, u32) { fn pixfmt_label(c: &CodecConfig) -> String { match c { + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"dvc " => "YUV 4:1:1 8-bit".into(), CodecConfig::Avc(a) => avc_format(a).map(|(chroma, bits)| yuv_label(chroma, bits)).unwrap_or_default(), CodecConfig::Hevc(h) => { let (chroma, bits) = hevc_format(h); diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 9c8ea8c19d..fdb56e3a83 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -29,6 +29,54 @@ fn fixture(name: &str, args: &[&str]) -> Option> { Some(std::fs::read(out).ok()?.into()) } +#[test] +fn native_dv_mov_with_big_endian_pcm_and_random_seeking() { + if ffmpeg().is_none() { + eprintln!("SKIPPED: ffmpeg test oracle unavailable"); + return; + } + let bytes = fixture( + "native_dv_ntsc_pcm.mov", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=720x480:rate=30000/1001:duration=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=1", + "-c:v", + "dvvideo", + "-pix_fmt", + "yuv411p", + "-threads", + "1", + "-c:a", + "pcm_s16be", + "-ac", + "2", + "-shortest", + ], + ) + .unwrap(); + let src = crate::open_bytes("native_dv_ntsc_pcm.mov", bytes).unwrap(); + let video = src.info().video.as_ref().unwrap(); + assert_eq!(video.codec, "DV / DVCPRO NTSC"); + assert_eq!(video.pixel_format, "YUV 4:1:1 8-bit"); + assert_eq!(video.frame_rate, filmcraft_time::FrameRate::FPS_29_97); + let first = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap().to_rgba8(); + for frame in [29, 1, 18, 0, 15] { + let f = src.video_frame(FrameRequest::full(video.frame_rate.tick_of(frame))).unwrap(); + assert_eq!((f.width, f.height), (720, 480)); + if frame == 0 { + assert_eq!(f.to_rgba8(), first); + } + } + let audio = src.audio(0, 4800, 48000).unwrap(); + assert!(audio.peaks().iter().all(|p| *p > 0.05 && *p < 0.2)); +} + #[test] fn mjpeg_mov_with_pcm() { let Some(b) = fixture( diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 7a4a2ef555..2ab43302c6 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -755,6 +755,55 @@ pub fn av1_factory(e: &SampleEntry) -> Option>> { /// Our ProRes decoder (every frame is intra; slices decode in parallel). pub struct ProResDecoder; +/// Native DV25/DVCPRO25 decoder. Original files are decoded directly on demand. +pub struct DvDecoder; +impl VideoDecoder for DvDecoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + use std::sync::Arc; + let f = filmcraft_dv::decode_frame(sample).map_err(|e| CodecError::Decode(e.to_string()))?; + // The compositor supports 4:2:2 but not 4:1:1. Replication retains every original + // chroma sample without changing the source file or the frame/field timing. + let expand = |plane: Vec| plane.into_iter().flat_map(|v| [v, v]).collect(); + let frame = VideoFrame { + width: 720, + height: 480, + data: filmcraft_frame::PixelData::Yuv8 { + planes: [Arc::new(f.y), Arc::new(expand(f.cb)), Arc::new(expand(f.cr))], + chroma: filmcraft_frame::Chroma::C422, + alpha: None, + }, + color: filmcraft_color::ColorInfo { + matrix: filmcraft_color::Matrix::Bt601, + primaries: filmcraft_color::Primaries::Bt601_525, + ..filmcraft_color::ColorInfo::REC709 + }, + par: (10, 11), + pts: filmcraft_time::Tick::ZERO, + }; + Ok(vec![DecodedFrame { pts, frame, draft: false }]) + } + fn flush(&mut self) -> Vec { + Vec::new() + } + fn reset(&mut self) {} + fn name(&self) -> &str { + "FilmCraft DV25" + } + fn intra_only(&self) -> bool { + true + } + fn is_random_access(&self, _sample: &[u8]) -> Option { + Some(true) + } + fn is_disposable(&self, _sample: &[u8]) -> bool { + true + } +} + +pub fn dv_factory(e: &SampleEntry) -> Option>> { + matches!(&e.codec, CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"dvc ").then(|| Ok(Box::new(DvDecoder) as Box)) +} + impl VideoDecoder for ProResDecoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { use std::sync::Arc; diff --git a/crates/dv/Cargo.toml b/crates/dv/Cargo.toml new file mode 100644 index 0000000000..f6321269d7 --- /dev/null +++ b/crates/dv/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "filmcraft-dv" +description = "Clean-room SMPTE 314M DV25 / DVCPRO25 video decoder" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/dv/README.md b/crates/dv/README.md new file mode 100644 index 0000000000..920910c722 --- /dev/null +++ b/crates/dv/README.md @@ -0,0 +1,27 @@ +# Native DV25 / DVCPRO25 decoding + +Clean-room pure-Rust decoder for the 525/60, 25 Mb/s, 4:1:1 video used by +NTSC DV/DVCPRO QuickTime (`dvc `) footage. Implemented from **SMPTE 314M-2005** +(stabilized 2013), sections 4.7 and 5, tables 23–25 and figures 20, 22, 26–28, 30, 32. +Specification: https://pub.smpte.org/pub/st314/st0314-2005_stable2013.pdf + +Implements DIF indexing, three-pass AC bit allocation, VLC and escape codes, +inverse quantization/weighting, 8x8 and 2-4-8 inverse DCT, macroblock unshuffling, +and the clipped rightmost chroma blocks. Four abbreviated printed VLC words +are expanded to Table 24's lengths to obtain prefix-free codes, validated against +the external oracle. All frame sizes, coefficient runs and block locations are bounded. + +Returns original 720x480, 8-bit planar 4:1:1 pictures with the original field order. +FilmCraft's adapter represents the chroma in its existing 4:2:2 compositor by +replicating samples; the MOV source continues handling clean aperture, pixel aspect, +timecode, audio and seeking. No external decoder, transcoded file, or FFmpeg runtime +dependency is used. PCM audio uses FilmCraft's existing native decoder. + +Currently scoped to NTSC DV25/DVCPRO25 MOV. PAL DV, DV50/DVCPRO50, DVCPRO HD, +raw DV audio demultiplexing and AVI DV are not implemented. Unsupported or damaged +packets return a decoding error without changing the input or crashing. + +`cargo test -p filmcraft-dv` covers spatial coverage, malformed inputs, generated +progressive/interlaced patterns versus an external FFmpeg oracle (maximum sample +difference 3, mean square error below 0.5), and bit-flip/truncation mutation tests. +The standalone example decodes DIF test fixtures to planar YUV for oracle comparisons. diff --git a/crates/dv/examples/decode.rs b/crates/dv/examples/decode.rs new file mode 100644 index 0000000000..a7015e2adc --- /dev/null +++ b/crates/dv/examples/decode.rs @@ -0,0 +1,22 @@ +use std::{ + fs::File, + io::{BufReader, BufWriter, Read, Write}, +}; +fn main() -> Result<(), Box> { + let mut args = std::env::args_os().skip(1); + let mut input = BufReader::new(File::open(args.next().ok_or("input required")?)?); + let mut out = BufWriter::new(File::create_new(args.next().ok_or("output required")?)?); + let mut data = vec![0; 120000]; + loop { + if input.read(&mut data[..1])? == 0 { + break; + } + input.read_exact(&mut data[1..])?; + let f = filmcraft_dv::decode_frame(&data)?; + out.write_all(&f.y)?; + out.write_all(&f.cb)?; + out.write_all(&f.cr)?; + } + out.flush()?; + Ok(()) +} diff --git a/crates/dv/src/lib.rs b/crates/dv/src/lib.rs new file mode 100644 index 0000000000..8aa99b5f41 --- /dev/null +++ b/crates/dv/src/lib.rs @@ -0,0 +1,370 @@ +//! DV25 525/60 decoder, implemented from SMPTE 314M-2005 sections 4.7 and 5. +#![forbid(unsafe_code)] +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + +mod vlc; +use std::{f64::consts::PI, sync::OnceLock}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error(pub &'static str); +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} +impl std::error::Error for Error {} +type Result = std::result::Result; + +/// Original 720x480 picture, with 180x480 chroma planes (4:1:1), no scaling or deinterlacing. +pub struct Frame { + pub y: Vec, + pub cb: Vec, + pub cr: Vec, +} + +// Figure 27: one-based coefficient output order, expressed at raster positions. +const ORDER: [[u8; 64]; 2] = [ + [ + 1, 2, 6, 7, 15, 16, 28, 29, 3, 5, 8, 14, 17, 27, 30, 43, 4, 9, 13, 18, 26, 31, 42, 44, 10, 12, 19, 25, 32, 41, 45, 54, 11, 20, 24, 33, 40, 46, 53, 55, + 21, 23, 34, 39, 47, 52, 56, 61, 22, 35, 38, 48, 51, 57, 60, 62, 36, 37, 49, 50, 58, 59, 63, 64, + ], + [ + 1, 3, 7, 19, 21, 35, 37, 51, 5, 9, 17, 23, 33, 39, 49, 53, 11, 15, 25, 31, 41, 47, 55, 61, 13, 27, 29, 43, 45, 57, 59, 63, 2, 4, 8, 20, 22, 36, 38, 52, + 6, 10, 18, 24, 34, 40, 50, 54, 12, 16, 26, 32, 42, 48, 56, 62, 14, 28, 30, 44, 46, 58, 60, 64, + ], +]; +const AREA: [[usize; 64]; 2] = [ + [ + 0, 0, 0, 1, 1, 1, 2, 2, 0, 0, 1, 1, 1, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 3, 1, 1, 1, 2, 2, 2, 3, 3, 1, 1, 2, 2, 2, 3, 3, 3, 1, 2, 2, 2, 3, 3, 3, 3, 2, 2, + 2, 3, 3, 3, 3, 3, 2, 2, 3, 3, 3, 3, 3, 3, + ], + [ + 0, 0, 1, 1, 1, 2, 2, 3, 0, 1, 1, 2, 2, 2, 3, 3, 1, 1, 2, 2, 2, 3, 3, 3, 1, 2, 2, 2, 3, 3, 3, 3, 0, 0, 1, 1, 2, 2, 2, 3, 0, 1, 1, 2, 2, 2, 3, 3, 1, 1, + 2, 2, 2, 3, 3, 3, 1, 2, 2, 3, 3, 3, 3, 3, + ], +]; + +#[derive(Clone, Copy, Default)] +struct Token { + run: u8, + amp: i16, + len: usize, + eob: bool, +} +fn tokens() -> &'static [Token] { + static TABLE: OnceLock> = OnceLock::new(); + TABLE.get_or_init(|| { + let mut table = vec![Token::default(); 65536]; + let mut add = |code: usize, len: usize, run: u8, amp: i16, eob: bool| { + let start = code << (16 - len); + let end = (code + 1) << (16 - len); + for t in &mut table[start..end] { + *t = Token { run, amp, len, eob }; + } + }; + for &(run, amp, code, len, sign) in vlc::VLC { + if sign { + for s in 0..2 { + add((code as usize) * 2 + s, len as usize + 1, run, if s == 0 { amp as i16 } else { -(amp as i16) }, false); + } + } else { + add(code as usize, len as usize, run, amp as i16, false); + } + } + for run in 0..64 { + add((0b1111110 << 6) | run, 13, run as u8, 0, false); + } + for amp in 0..256 { + for s in 0..2 { + add((0b1111111 << 9) | (amp << 1) | s, 16, 0, if s == 0 { amp as i16 } else { -(amp as i16) }, false); + } + } + add(0b0110, 4, 0, 0, true); + table + }) +} + +struct Block { + bits: Vec, + cursor: usize, + coeff: [i16; 64], + scan: usize, + mode: usize, + class: usize, + q: usize, + done: bool, +} +impl Block { + fn new(data: &[u8], q: usize) -> Result { + if data.len() < 2 { + return Err(Error("truncated DV DCT block")); + } + let header = u16::from_be_bytes([data[0], data[1]]); + let dc = (header >> 7) as i16; + let dc = if dc & 256 != 0 { dc - 512 } else { dc }; + let mut coeff = [0; 64]; + coeff[0] = dc; + let bits = (12..data.len() * 8).map(|p| (data[p / 8] >> (7 - p % 8)) & 1).collect(); + Ok(Self { bits, cursor: 0, coeff, scan: 0, mode: ((header >> 6) & 1) as usize, class: ((header >> 4) & 3) as usize, q, done: false }) + } + fn decode(&mut self) -> Result<()> { + // Every complete token advances at least one bit; each block has 63 AC coefficients. + while !self.done && self.cursor < self.bits.len() { + let mut code = 0usize; + for k in 0..16 { + code = (code << 1) | self.bits.get(self.cursor + k).copied().unwrap_or(0) as usize; + } + let t = tokens()[code]; + if t.len == 0 { + if self.bits.len() - self.cursor < 16 { + break; + } + return Err(Error("invalid DV VLC")); + } + if self.bits.len() - self.cursor < t.len { + break; + } + self.cursor += t.len; + if t.eob { + self.done = true; + break; + } + self.scan += t.run as usize + 1; + if self.scan >= 64 { + return Err(Error("DV coefficient run exceeds block")); + } + let raster = ORDER[self.mode].iter().position(|&n| n as usize == self.scan + 1).ok_or(Error("invalid DV scan"))?; + self.coeff[raster] = t.amp; + } + Ok(()) + } + fn continue_from(&mut self, pool: &[u8], cursor: &mut usize) -> Result<()> { + if self.done { + return Ok(()); + } + let old_len = self.bits.len(); + let pending = old_len - self.cursor; + self.bits.extend_from_slice(pool.get(*cursor..).ok_or(Error("invalid DV bit pool"))?); + let before = self.cursor; + self.decode()?; + *cursor += self.cursor.saturating_sub(before).saturating_sub(pending); + if !self.done { + *cursor = pool.len(); + } + if self.done { + self.bits.truncate(self.cursor.max(old_len)); + } + Ok(()) + } +} + +struct Transform { + inverse: [[f64; 64]; 2], + cos8: [[f64; 8]; 8], + cos4: [[f64; 4]; 4], +} +fn transform() -> &'static Transform { + static T: OnceLock = OnceLock::new(); + T.get_or_init(|| { + let c = |n: usize| (n as f64 * PI / 16.0).cos(); + let w = [1.0, c(4) / (4.0 * c(7) * c(2)), c(4) / (2.0 * c(6)), 1.0 / (2.0 * c(5)), 7.0 / 8.0, c(4) / c(3), c(4) / c(2), c(4) / c(1)]; + let mut inverse = [[0.0; 64]; 2]; + for mode in 0..2 { + for v in 0..8 { + for h in 0..8 { + inverse[mode][v * 8 + h] = if v == 0 && h == 0 { 4.0 } else { 2.0 / (w[h] * w[if mode == 0 { v } else { 2 * (v % 4) }]) }; + } + } + } + let cos8 = std::array::from_fn(|x| { + std::array::from_fn(|h| ((2 * x + 1) as f64 * h as f64 * PI / 16.0).cos() * if h == 0 { 0.5 / std::f64::consts::SQRT_2 } else { 0.5 }) + }); + let cos4 = std::array::from_fn(|z| { + std::array::from_fn(|u| ((2 * z + 1) as f64 * u as f64 * PI / 8.0).cos() * if u == 0 { 0.5 / std::f64::consts::SQRT_2 } else { 0.5 }) + }); + Transform { inverse, cos8, cos4 } + }) +} +fn pixels(b: &Block) -> [u8; 64] { + let index = b.q as i32 - [0, 3, 6, 5][b.class]; + let steps = if index >= 9 { + [1, 1, 1, 1] + } else if index == 8 { + [1, 1, 1, 2] + } else if index >= 6 { + [1, 1, 2, 2] + } else if index >= 4 { + [1, 2, 2, 4] + } else if index >= 2 { + [2, 2, 4, 4] + } else if index >= 0 { + [2, 4, 4, 8] + } else if index >= -2 { + [4, 4, 8, 8] + } else if index >= -4 { + [4, 8, 8, 16] + } else { + [8, 8, 16, 16] + }; + let t = transform(); + let mut coeff = [0.0; 64]; + for i in 0..64 { + coeff[i] = b.coeff[i] as f64 * t.inverse[b.mode][i] * if i == 0 { 1.0 } else { (steps[AREA[b.mode][i]] * (if b.class == 3 { 2 } else { 1 })) as f64 }; + } + let mut tmp = [[0.0; 8]; 8]; + for v in 0..8 { + for x in 0..8 { + for h in 0..8 { + tmp[v][x] += coeff[v * 8 + h] * t.cos8[x][h]; + } + } + } + std::array::from_fn(|i| { + let (x, y) = (i % 8, i / 8); + let mut value = 0.0; + if b.mode == 0 { + for v in 0..8 { + value += tmp[v][x] * t.cos8[y][v]; + } + } else { + for u in 0..4 { + value += (tmp[u][x] + if y % 2 == 0 { tmp[u + 4][x] } else { -tmp[u + 4][x] }) * t.cos4[y / 2][u]; + } + } + (value + 128.0).round().clamp(0.0, 255.0) as u8 + }) +} + +// Figures 22 and 26: alternating 6-high serpentine superblocks; rightmost is clipped. +fn location(sequence: usize, segment: usize, mb: usize) -> (usize, usize) { + let j = [2, 1, 3, 0, 4][mb]; + let i = (sequence + [2, 6, 8, 0, 4][mb]) % 10; + let (col, row) = if j == 1 || j == 3 { + if segment < 3 { + (0, segment + 3) + } else { + let c = (segment + 3) / 6; + let r = (segment + 3) % 6; + (c, if c % 2 == 1 { 5 - r } else { r }) + } + } else { + let c = segment / 6; + let r = segment % 6; + (c, if c % 2 == 1 { 5 - r } else { r }) + }; + let x = [0, 128, 288, 416, 576][j] + col * 32; + let y = i * 48 + if x == 704 { row * 16 } else { row * 8 }; + (x, y) +} +fn put(plane: &mut [u8], stride: usize, x: usize, y: usize, data: &[u8; 64], right: bool) -> Result<()> { + for row in 0..8 { + for col in 0..8 { + let (dx, dy) = if right { (col % 4, row + if col >= 4 { 8 } else { 0 }) } else { (col, row) }; + let index = (y + dy) * stride + x + dx; + let target = plane.get_mut(index).ok_or(Error("DV block outside picture"))?; + *target = data[row * 8 + col]; + } + } + Ok(()) +} + +/// Decode one complete DV25 525/60 DIF frame. All bit pools and dimensions are bounded. +pub fn decode_frame(data: &[u8]) -> Result { + if data.len() != 120000 { + return Err(Error("unsupported DV frame size (requires 525/60 DV25)")); + } + if data[0] >> 5 != 0 || data[3] & 0x80 != 0 { + return Err(Error("invalid or unsupported DV DIF header")); + } + let mut frame = Frame { y: vec![16; 720 * 480], cb: vec![128; 180 * 480], cr: vec![128; 180 * 480] }; + for sequence in 0..10 { + for segment in 0..27 { + let mut blocks = Vec::with_capacity(30); + for mb in 0..5 { + let video = segment * 5 + mb; + let dif = sequence * 150 + 7 + video + video / 15; + let d = data.get(dif * 80..dif * 80 + 80).ok_or(Error("truncated DV DIF block"))?; + if d[0] >> 5 != 4 || d[1] >> 4 != sequence as u8 || d[2] as usize != video { + return Err(Error("invalid DV video DIF ID")); + } + if d[3] & 0xf == 15 && d[3] >> 4 == 15 { + return Err(Error("damaged DV macroblock")); + } + let mut offset = 4; + for b in 0..6 { + let n = if b < 4 { 14 } else { 10 }; + let mut block = Block::new(&d[offset..offset + n], (d[3] & 15) as usize)?; + block.decode()?; + blocks.push(block); + offset += n; + } + } + let mut video_pool = Vec::new(); + for mb in 0..5 { + let chunk = &mut blocks[mb * 6..mb * 6 + 6]; + let mut pool = Vec::new(); + for b in chunk.iter() { + if b.done { + pool.extend_from_slice(&b.bits[b.cursor..]); + } + } + let mut cursor = 0; + for b in chunk.iter_mut() { + b.continue_from(&pool, &mut cursor)?; + } + video_pool.extend_from_slice(&pool[cursor..]); + } + let mut cursor = 0; + for b in &mut blocks { + b.continue_from(&video_pool, &mut cursor)?; + if !b.done { + return Err(Error("DV AC data lacks end of block")); + } + } + for mb in 0..5 { + let (x, y) = location(sequence, segment, mb); + for b in 0..6 { + let p = pixels(&blocks[mb * 6 + b]); + if b < 4 { + let (dx, dy) = if x == 704 { ((b % 2) * 8, (b / 2) * 8) } else { (b * 8, 0) }; + put(&mut frame.y, 720, x + dx, y + dy, &p, false)?; + } else { + put(if b == 4 { &mut frame.cr } else { &mut frame.cb }, 180, x / 4, y, &p, x == 704)?; + } + } + } + } + } + Ok(frame) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn malformed_never_panics() { + for n in [0, 1, 80, 119999, 120000, 144000] { + let d = vec![0; n]; + assert!(std::panic::catch_unwind(|| decode_frame(&d)).is_ok()); + assert!(decode_frame(&d).is_err()); + } + } + #[test] + fn layout_covers_picture_once() { + let mut coverage = vec![0; 720 * 480]; + for s in 0..10 { + for k in 0..27 { + for m in 0..5 { + let (x, y) = location(s, k, m); + let (w, h) = if x == 704 { (16, 16) } else { (32, 8) }; + for dy in 0..h { + for dx in 0..w { + coverage[(y + dy) * 720 + x + dx] += 1; + } + } + } + } + } + assert!(coverage.iter().all(|&n| n == 1)); + } +} diff --git a/crates/dv/src/vlc.rs b/crates/dv/src/vlc.rs new file mode 100644 index 0000000000..0edd19fe0d --- /dev/null +++ b/crates/dv/src/vlc.rs @@ -0,0 +1,99 @@ +// SMPTE 314M-2005, Table 25. Transcribed from the published standard. +// Four printed codewords omit a zero/one: expanded to their Table 24 lengths +// to resolve the prefix ambiguities (runs 7/8, amp 1; run 1, amps 16/17). +pub const VLC: &[(u8, u8, u16, u8, bool)] = &[ + (0, 1, 0b00, 2, true), + (11, 1, 0b111100000, 9, true), + (7, 2, 0b111110110000, 12, true), + (0, 2, 0b010, 3, true), + (12, 1, 0b111100001, 9, true), + (8, 2, 0b111110110001, 12, true), + (13, 1, 0b111100010, 9, true), + (9, 2, 0b111110110010, 12, true), + (1, 1, 0b0111, 4, true), + (14, 1, 0b111100011, 9, true), + (10, 2, 0b111110110011, 12, true), + (0, 3, 0b1000, 4, true), + (5, 2, 0b111100100, 9, true), + (7, 3, 0b111110110100, 12, true), + (0, 4, 0b1001, 4, true), + (6, 2, 0b111100101, 9, true), + (8, 3, 0b111110110101, 12, true), + (2, 1, 0b10100, 5, true), + (3, 3, 0b111100110, 9, true), + (4, 5, 0b111110110110, 12, true), + (1, 2, 0b10101, 5, true), + (4, 3, 0b111100111, 9, true), + (3, 7, 0b111110110111, 12, true), + (0, 5, 0b10110, 5, true), + (2, 4, 0b111101000, 9, true), + (2, 7, 0b111110111000, 12, true), + (0, 6, 0b10111, 5, true), + (2, 5, 0b111101001, 9, true), + (2, 8, 0b111110111001, 12, true), + (3, 1, 0b110000, 6, true), + (1, 8, 0b111101010, 9, true), + (2, 9, 0b111110111010, 12, true), + (4, 1, 0b110001, 6, true), + (0, 18, 0b111101011, 9, true), + (2, 10, 0b111110111011, 12, true), + (0, 7, 0b110010, 6, true), + (0, 19, 0b111101100, 9, true), + (2, 11, 0b111110111100, 12, true), + (0, 8, 0b110011, 6, true), + (0, 20, 0b111101101, 9, true), + (1, 15, 0b111110111101, 12, true), + (5, 1, 0b1101000, 7, true), + (0, 21, 0b111101110, 9, true), + (1, 16, 0b111110111110, 12, true), + (6, 1, 0b1101001, 7, true), + (0, 22, 0b111101111, 9, true), + (1, 17, 0b111110111111, 12, true), + (2, 2, 0b1101010, 7, true), + (5, 3, 0b1111100000, 10, true), + (6, 0, 0b1111110000110, 13, false), + (1, 3, 0b1101011, 7, true), + (3, 4, 0b1111100001, 10, true), + (7, 0, 0b1111110000111, 13, false), + (1, 4, 0b1101100, 7, true), + (3, 5, 0b1111100010, 10, true), + (0, 9, 0b1101101, 7, true), + (2, 6, 0b1111100011, 10, true), + (0, 10, 0b1101110, 7, true), + (1, 9, 0b1111100100, 10, true), + (0, 11, 0b1101111, 7, true), + (1, 10, 0b1111100101, 10, true), + (61, 0, 0b1111110111101, 13, false), + (7, 1, 0b11100000, 8, true), + (1, 11, 0b1111100110, 10, true), + (0, 23, 0b111111100010111, 15, true), + (8, 1, 0b11100001, 8, true), + (0, 0, 0b11111001110, 11, false), + (0, 24, 0b111111100011000, 15, true), + (9, 1, 0b11100010, 8, true), + (1, 0, 0b11111001111, 11, false), + (10, 1, 0b11100011, 8, true), + (6, 3, 0b11111010000, 11, true), + (3, 2, 0b11100100, 8, true), + (4, 4, 0b11111010001, 11, true), + (4, 2, 0b11100101, 8, true), + (3, 6, 0b11111010010, 11, true), + (2, 3, 0b11100110, 8, true), + (1, 12, 0b11111010011, 11, true), + (0, 255, 0b111111111111111, 15, true), + (1, 5, 0b11100111, 8, true), + (1, 13, 0b11111010100, 11, true), + (1, 6, 0b11101000, 8, true), + (1, 14, 0b11111010101, 11, true), + (1, 7, 0b11101001, 8, true), + (2, 0, 0b111110101100, 12, false), + (0, 12, 0b11101010, 8, true), + (3, 0, 0b111110101101, 12, false), + (0, 13, 0b11101011, 8, true), + (4, 0, 0b111110101110, 12, false), + (0, 14, 0b11101100, 8, true), + (5, 0, 0b111110101111, 12, false), + (0, 15, 0b11101101, 8, true), + (0, 16, 0b11101110, 8, true), + (0, 17, 0b11101111, 8, true), +]; diff --git a/crates/dv/tests/oracle.rs b/crates/dv/tests/oracle.rs new file mode 100644 index 0000000000..c322927962 --- /dev/null +++ b/crates/dv/tests/oracle.rs @@ -0,0 +1,88 @@ +//! FFmpeg is an external test oracle only. The decoder has no dependencies or subprocesses. +use filmcraft_dv::decode_frame; +use std::{fs, process::Command}; + +#[test] +fn generated_patterns_match_reference_and_mutations_never_panic() { + if Command::new("ffmpeg").arg("-version").output().is_err() { + eprintln!("SKIPPED: ffmpeg test oracle unavailable"); + return; + } + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures").join(format!("dv-oracle-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + for (i, pattern) in [ + "testsrc2=size=720x480:rate=30000/1001", + "color=black:size=720x480:rate=30000/1001", + "color=white:size=720x480:rate=30000/1001", + "smptebars=size=720x480:rate=30000/1001", + "nullsrc=size=720x480:rate=30000/1001,geq=lum='128+100*sin(X*0.4)*sin(Y*0.7)':cb='128+60*sin(X*0.2)':cr='128+60*cos(Y*0.3)'", + "testsrc2=size=720x480:rate=60000/1001,tinterlace=mode=interleave_bottom", + ] + .iter() + .enumerate() + { + let dv = dir.join(format!("{i}.dv")); + let raw = dir.join(format!("{i}.yuv")); + let result = Command::new("ffmpeg") + .args([ + "-nostdin", + "-y", + "-v", + "error", + "-f", + "lavfi", + "-i", + pattern, + "-frames:v", + "6", + "-pix_fmt", + "yuv411p", + "-c:v", + "dvvideo", + "-threads", + "1", + "-an", + "-f", + "dv", + ]) + .arg(&dv) + .output() + .unwrap(); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + let result = Command::new("ffmpeg") + .args(["-nostdin", "-y", "-v", "error", "-i"]) + .arg(&dv) + .args(["-pix_fmt", "yuv411p", "-f", "rawvideo"]) + .arg(&raw) + .output() + .unwrap(); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + let input = fs::read(&dv).unwrap(); + let reference = fs::read(&raw).unwrap(); + let mut output = Vec::new(); + for packet in input.as_chunks::<120000>().0 { + let f = decode_frame(packet).unwrap(); + output.extend(f.y); + output.extend(f.cb); + output.extend(f.cr); + } + assert_eq!(output.len(), reference.len()); + let max = output.iter().zip(&reference).map(|(&a, &b)| a.abs_diff(b)).max().unwrap(); + let mse = output.iter().zip(&reference).map(|(&a, &b)| (f64::from(a) - f64::from(b)).powi(2)).sum::() / output.len() as f64; + assert!(max <= 3 && mse < 0.5, "pattern {i}: max={max}, mse={mse}"); + // Mutate arbitrary DIF IDs, headers, lengths and VLC payload bits of an actual frame. + let frame = &input[..120000]; + let mut state = 0x9387ac34u32; + for _ in 0..96 { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + let mut corrupted = frame.to_vec(); + let pos = state as usize % corrupted.len(); + corrupted[pos] ^= 1 << (state % 8); + assert!(std::panic::catch_unwind(|| decode_frame(&corrupted)).is_ok()); + } + for n in [1, 79, 80, 119999] { + assert!(decode_frame(&frame[..n]).is_err()); + } + } + fs::remove_dir_all(dir).unwrap(); +} diff --git a/docs/architecture.md b/docs/architecture.md index 3056a7e371..46150929a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,6 +49,7 @@ and `filmcraft-cli`. | `ogg` | L0 | Ogg demux (RFC 3533), Ogg Opus timing (RFC 7845 granules, pre-skip, end trimming), Vorbis headers | | `h264`, `h264enc` | L0 | H.264 decoder; H.264 encoder | | `hevc` | L0 | H.265 Main/Main 10 decoder | +| `dv` | L0 | Native DV25 / DVCPRO25 NTSC (SMPTE 314M-2005); 4:1:1, 8x8 and 2-4-8 DCT | | `prores` | L0 | ProRes decoder and encoder | | `dnx` | L0 | DNxHD / DNxHR (SMPTE ST 2019-1 VC-3) decoder and DNxHR encoder | | `apv` | L0 | Advanced Professional Video (IETF RFC 9924) decoder and encoder ([README](../crates/apv/README.md)) | diff --git a/docs/codec-parity.md b/docs/codec-parity.md index d3a79679d8..dba5ae8284 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -27,7 +27,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Motion JPEG | decode + encode | yes | yes (MOV) | — | 90% | — | | JPEG 2000 (MXF, DCP) | decode + encode (Kakadu) | none | none | — | 0% | 40–70 h | | JPEG XS | decode (`libjpegxs`) | none | none | — | 0% | 20–40 h | -| DV / DVCPRO / DV100 | decode + encode | none | none | — | 0% | 15–25 h | +| DV / DVCPRO / DV100 | decode + encode | native DV25/DVCPRO25 525/60 4:1:1 MOV; PAL, 50/100 Mb/s and AVI remain unsupported | none | generated progressive/interlaced oracle + mutation tests | partial | remaining profiles/encode not estimated | | MPEG-4 Part 2 | decode | none | none | — | 0% | 10–20 h | | WMV / VC-1 | decode, encode on Windows | none | none | — | 0% | 20–35 h | | CinemaDNG / DNG | decode | own decoder (#1065): uncompressed and lossless JPEG raw, Bayer and LinearRaw, DNG colour model to linear Rec. 709 floats; folders of `.dng` frames as one clip with the CinemaDNG frame rate and time code; **no** lossy / JPEG XL DNG, MXF-wrapped CinemaDNG, opcode lists or Raw settings UI | — | raw samples bit-exact (and identical to LibRaw's on our synthetic files), processing against hand-computed values | 60% | 10–20 h | diff --git a/docs/gaps.md b/docs/gaps.md index 2bafd859f6..b81a94d1c7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -190,7 +190,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Missing:** free-format MPEG audio, E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no - demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D + demuxer), WMV / ASF, PAL DV / DV50 / DV100 and AVI DV (NTSC DV25/DVCPRO25 MOV now decodes natively), MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, the rest of CinemaDNG (lossy / JPEG XL DNG, MXF-wrapped, opcode lists, Raw settings; DNG frames and folders import since #1065); stills: PSD, EXR beyond scan-line RLE / ZIP / PXR24 (PIZ, B44, DWA, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 98902837a6..f7796fb07a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -53,6 +53,7 @@ const LAYERS: &[(&str, u8)] = &[ ("vp8", 0), ("vp9", 0), ("prores", 0), + ("dv", 0), ("mjpeg", 0), ("dnx", 0), ("apv", 0), From e4adc691e16066863bd11a95066090925b4b5b6e Mon Sep 17 00:00:00 2001 From: andymenard <198171234+andymenard@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:42:12 -0500 Subject: [PATCH 2/7] dv: run the DV oracle tests through filmcraft-testkit and cover 2-4-8 Review follow-up for the DV25 decoder tests: - Find ffmpeg and generate fixtures through filmcraft-testkit, so FILMCRAFT_REQUIRE_ORACLES=1 turns a missing ffmpeg into a failure instead of a silent skip. The DV oracle fixtures are named specs cached under target/fixtures/dv/ and registered with `cargo xtask fixtures dv`; the mutation checks are their own test. - FFmpeg's DV encoder only codes luma with the 2-4-8 DCT when asked for +ildct and never codes chroma with it, so the interlaced pattern had no 2-4-8 blocks at all. Encode it with +ildct, add two patterns whose chroma blocks are switched to 2-4-8 before both decoders read them, assert that this coverage is present, and check the clipped rightmost chroma columns separately from the whole picture. - Move the DV MOV test to tests/it/dv_oracle.rs: each seek (out of order, then sequential) is compared against ffmpeg's frame, the 4:1:1 chroma must be replicated into 4:2:2, and the PCM must be sample-exact. The fixture has 29 frames; the old test requested a 30th. - Docs: the DV README states how the expanded VLC words and the 2-4-8 cases are covered; docs/testing.md lists the DV oracle criterion and its fixtures; codec-parity.md and gaps.md get revision-history rows for the DV entries the decoder commit changed. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/codecs/src/tests.rs | 48 ------ crates/codecs/tests/it/dv_oracle.rs | 73 +++++++++ crates/codecs/tests/it/main.rs | 1 + crates/dv/Cargo.toml | 3 + crates/dv/README.md | 14 +- crates/dv/tests/oracle.rs | 237 ++++++++++++++++++++-------- docs/codec-parity.md | 3 +- docs/gaps.md | 1 + docs/testing.md | 3 +- xtask/src/main.rs | 1 + 11 files changed, 266 insertions(+), 121 deletions(-) create mode 100644 crates/codecs/tests/it/dv_oracle.rs diff --git a/Cargo.lock b/Cargo.lock index 5480ee0ff5..52a7edfc28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2048,6 +2048,9 @@ dependencies = [ [[package]] name = "filmcraft-dv" version = "0.13.0" +dependencies = [ + "filmcraft-testkit", +] [[package]] name = "filmcraft-edit" diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index fdb56e3a83..9c8ea8c19d 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -29,54 +29,6 @@ fn fixture(name: &str, args: &[&str]) -> Option> { Some(std::fs::read(out).ok()?.into()) } -#[test] -fn native_dv_mov_with_big_endian_pcm_and_random_seeking() { - if ffmpeg().is_none() { - eprintln!("SKIPPED: ffmpeg test oracle unavailable"); - return; - } - let bytes = fixture( - "native_dv_ntsc_pcm.mov", - &[ - "-f", - "lavfi", - "-i", - "testsrc2=size=720x480:rate=30000/1001:duration=1", - "-f", - "lavfi", - "-i", - "sine=frequency=440:sample_rate=48000:duration=1", - "-c:v", - "dvvideo", - "-pix_fmt", - "yuv411p", - "-threads", - "1", - "-c:a", - "pcm_s16be", - "-ac", - "2", - "-shortest", - ], - ) - .unwrap(); - let src = crate::open_bytes("native_dv_ntsc_pcm.mov", bytes).unwrap(); - let video = src.info().video.as_ref().unwrap(); - assert_eq!(video.codec, "DV / DVCPRO NTSC"); - assert_eq!(video.pixel_format, "YUV 4:1:1 8-bit"); - assert_eq!(video.frame_rate, filmcraft_time::FrameRate::FPS_29_97); - let first = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap().to_rgba8(); - for frame in [29, 1, 18, 0, 15] { - let f = src.video_frame(FrameRequest::full(video.frame_rate.tick_of(frame))).unwrap(); - assert_eq!((f.width, f.height), (720, 480)); - if frame == 0 { - assert_eq!(f.to_rgba8(), first); - } - } - let audio = src.audio(0, 4800, 48000).unwrap(); - assert!(audio.peaks().iter().all(|p| *p > 0.05 && *p < 0.2)); -} - #[test] fn mjpeg_mov_with_pcm() { let Some(b) = fixture( diff --git a/crates/codecs/tests/it/dv_oracle.rs b/crates/codecs/tests/it/dv_oracle.rs new file mode 100644 index 0000000000..7097c9a23d --- /dev/null +++ b/crates/codecs/tests/it/dv_oracle.rs @@ -0,0 +1,73 @@ +//! NTSC DV25 QuickTime (`dvc `) with big-endian PCM against ffmpeg: frames requested out of order +//! and then sequentially are the right frames within the `filmcraft-dv` oracle tolerance, the +//! 4:1:1 chroma reaches the compositor replicated (not resampled) into 4:2:2, and the audio is +//! sample-exact. + +use crate::common; + +use common::*; +use filmcraft_media::FrameRequest; + +const W: usize = 720; +const H: usize = 480; + +#[test] +fn native_dv_mov_with_big_endian_pcm_and_random_seeking() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let name = "native_dv_ntsc_pcm.mov"; + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=size=720x480:rate=30000/1001:duration=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=1", + "-c:v", + "dvvideo", + "-pix_fmt", + "yuv411p", + "-threads", + "1", + "-c:a", + "pcm_s16be", + "-ac", + "2", + "-shortest", + ]; + let f = fixture(&ff, name, &args).unwrap_or_else(|| panic!("could not write {name}")); + let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let video = src.info().video.clone().unwrap(); + assert_eq!(video.codec, "DV / DVCPRO NTSC"); + assert_eq!(video.pixel_format, "YUV 4:1:1 8-bit"); + assert_eq!(video.frame_rate, filmcraft_time::FrameRate::FPS_29_97); + assert_eq!((video.width, video.height), (W as u32, H as u32)); + + let reference = ffmpeg_frames(&ff, &f, "yuv411p"); + let frame_len = W * H + 2 * (W / 4) * H; + // 29 frames: `-shortest` ends the 29.97 fps video with the one-second sine. + let frames = reference.len() / frame_len; + assert_eq!((frames, reference.len() % frame_len), (29, 0)); + // Back and forth, then sequential playback. testsrc2 changes every frame, so a seek that lands + // on a neighbouring frame is far outside the tolerance. + for k in [frames - 1, 1, 18, 0, 15].into_iter().chain(20..26) { + let frame = src.video_frame(FrameRequest::full(video.frame_rate.tick_of(k as i64))).unwrap(); + assert_eq!((frame.width, frame.height), (W as u32, H as u32)); + let ours = planes(&frame); + for c in &ours[1..] { + assert_eq!(c.len(), (W / 2) * H, "frame {k}: chroma is not 4:2:2"); + assert!(c.as_chunks::<2>().0.iter().all(|[a, b]| a == b), "frame {k}: chroma is not replicated"); + } + let ours = [ours[0].clone(), ours[1].iter().step_by(2).copied().collect(), ours[2].iter().step_by(2).copied().collect()]; + let want = raw_planes(&reference[k * frame_len..(k + 1) * frame_len], W, H, W / 4, H, 1); + let max = max_diff(&ours, &want); + assert!(max <= 3, "frame {k}: max difference {max} against ffmpeg"); + } + + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let got = src.audio(0, want.len() / 2, 48_000).unwrap(); + assert_eq!(got.channels.len(), 2); + let interleaved: Vec = (0..want.len() / 2).flat_map(|i| [got.channels[0][i], got.channels[1][i]]).collect(); + assert_eq!(interleaved, want, "PCM differs from ffmpeg"); +} diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs index 679dcaa6ba..318183b4b3 100644 --- a/crates/codecs/tests/it/main.rs +++ b/crates/codecs/tests/it/main.rs @@ -2,6 +2,7 @@ mod aac_channels_oracle; mod adts_file; mod common; mod draft_decoders; +mod dv_oracle; mod eac3_oracle; mod frame_color_oracle; mod h264_draft; diff --git a/crates/dv/Cargo.toml b/crates/dv/Cargo.toml index f6321269d7..3c67d0c8b2 100644 --- a/crates/dv/Cargo.toml +++ b/crates/dv/Cargo.toml @@ -8,3 +8,6 @@ rust-version.workspace = true [lints] workspace = true + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/dv/README.md b/crates/dv/README.md index 920910c722..81ef7e9f65 100644 --- a/crates/dv/README.md +++ b/crates/dv/README.md @@ -8,8 +8,10 @@ Specification: https://pub.smpte.org/pub/st314/st0314-2005_stable2013.pdf Implements DIF indexing, three-pass AC bit allocation, VLC and escape codes, inverse quantization/weighting, 8x8 and 2-4-8 inverse DCT, macroblock unshuffling, and the clipped rightmost chroma blocks. Four abbreviated printed VLC words -are expanded to Table 24's lengths to obtain prefix-free codes, validated against -the external oracle. All frame sizes, coefficient runs and block locations are bounded. +(run 7 and 8 with amplitude 1; run 1 with amplitudes 16 and 17) are expanded to +Table 24's lengths to obtain prefix-free codes. FFmpeg's encoder emits all four, and +they are frequent in camera footage, which decodes within the same tolerance of FFmpeg +(not committed). All frame sizes, coefficient runs and block locations are bounded. Returns original 720x480, 8-bit planar 4:1:1 pictures with the original field order. FilmCraft's adapter represents the chroma in its existing 4:2:2 compositor by @@ -23,5 +25,11 @@ packets return a decoding error without changing the input or crashing. `cargo test -p filmcraft-dv` covers spatial coverage, malformed inputs, generated progressive/interlaced patterns versus an external FFmpeg oracle (maximum sample -difference 3, mean square error below 0.5), and bit-flip/truncation mutation tests. +difference 3, mean square error below 0.5, over the whole picture and over the +clipped rightmost chroma columns), and bit-flip/truncation mutation tests. +FFmpeg's encoder never codes chroma with the 2-4-8 DCT, so two patterns set the +mode bit of every chroma block before both decoders read them; the interlaced +pattern is encoded with `+ildct` so luma 2-4-8 blocks are exercised too. The +fixtures are generated into `target/fixtures/dv/` (`cargo xtask fixtures dv`), and +`FILMCRAFT_REQUIRE_ORACLES=1` turns a missing ffmpeg into a failure. The standalone example decodes DIF test fixtures to planar YUV for oracle comparisons. diff --git a/crates/dv/tests/oracle.rs b/crates/dv/tests/oracle.rs index c322927962..df2f2a5ce1 100644 --- a/crates/dv/tests/oracle.rs +++ b/crates/dv/tests/oracle.rs @@ -1,88 +1,189 @@ -//! FFmpeg is an external test oracle only. The decoder has no dependencies or subprocesses. +//! Decode ffmpeg-made NTSC DV25 fixtures and compare with ffmpeg's own decode. FFmpeg is an +//! external test oracle and fixture generator only; the decoder has no dependencies or subprocesses. +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + use filmcraft_dv::decode_frame; -use std::{fs, process::Command}; +const PACKET: usize = 120000; +const FRAME: usize = 720 * 480 + 2 * 180 * 480; +const FRAMES: usize = 6; + +/// Offsets of the six DCT block headers in a video DIF block (four 14-byte Y, then Cr and Cb). +const BLOCKS: [usize; 6] = [4, 18, 32, 46, 60, 70]; + +struct Spec { + name: &'static str, + source: &'static str, + /// Extra dvvideo encoder flags. + flags: &'static [&'static str], + /// Switch every chroma block to the 2-4-8 DCT before decoding (see [`force_248_chroma`]). + field_chroma: bool, +} + +const CHROMA: &str = "nullsrc=size=720x480:rate=30000/1001,geq=lum='128+100*sin(X*0.4)*sin(Y*0.7)':cb='128+60*sin(X*0.2)':cr='128+60*cos(Y*0.3)'"; + +fn specs() -> Vec { + let s = |name, source, flags, field_chroma| Spec { name, source, flags, field_chroma }; + vec![ + s("testsrc2", "testsrc2=size=720x480:rate=30000/1001", &[], false), + s("black", "color=black:size=720x480:rate=30000/1001", &[], false), + s("white", "color=white:size=720x480:rate=30000/1001", &[], false), + s("smptebars", "smptebars=size=720x480:rate=30000/1001", &[], false), + s("chroma", CHROMA, &[], false), + // Without `+ildct` FFmpeg codes every block with the 8x8 DCT, interlaced or not. + s("interlaced_ildct", "testsrc2=size=720x480:rate=60000/1001,tinterlace=mode=interleave_bottom", &["-flags", "+ildct"], false), + s("testsrc2_248chroma", "testsrc2=size=720x480:rate=30000/1001", &[], true), + s("chroma_248chroma", CHROMA, &[], true), + ] +} + +fn ffmpeg() -> Option { + filmcraft_testkit::ffmpeg_or_skip("dv oracle") +} + +fn run(ff: &Path, args: &[&str], out: &Path) -> bool { + let st = Command::new(ff).args(["-nostdin", "-y", "-v", "error"]).args(args).arg(out).stdin(Stdio::null()).status(); + st.is_ok_and(|s| s.success()) +} + +/// Video DIF blocks (section type 4) of a DIF stream. +fn video_dif_blocks(dv: &mut [u8]) -> impl Iterator { + dv.as_chunks_mut::<80>().0.iter_mut().filter(|d| d[0] >> 5 == 4) +} + +/// Number of (luma, chroma) blocks coded with the 2-4-8 DCT (header mode bit set). +fn dct248_blocks(dv: &[u8]) -> (usize, usize) { + let mut dv = dv.to_vec(); + let mut count = (0, 0); + for d in video_dif_blocks(&mut dv) { + for (b, &o) in BLOCKS.iter().enumerate() { + if d[o + 1] & 0x40 != 0 { + if b < 4 { count.0 += 1 } else { count.1 += 1 } + } + } + } + count +} + +/// FFmpeg's encoder never codes chroma with the 2-4-8 DCT, but camera footage does. Setting the +/// mode bit keeps the stream valid and makes both decoders read the coefficients as 2-4-8. +fn force_248_chroma(dv: &mut [u8]) { + for d in video_dif_blocks(dv) { + for &o in &BLOCKS[4..] { + d[o + 1] |= 0x40; + } + } +} + +/// The spec's DIF stream (cached under `target/fixtures/dv/`). +fn make(ff: &Path, spec: &Spec) -> PathBuf { + let dir = filmcraft_testkit::fixtures_dir("dv"); + let out = dir.join(format!("{}.dv", spec.name)); + let made = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let frames = FRAMES.to_string(); + let mut args = vec!["-f", "lavfi", "-i", spec.source, "-frames:v", &frames, "-pix_fmt", "yuv411p", "-c:v", "dvvideo"]; + args.extend_from_slice(spec.flags); + args.extend_from_slice(&["-threads", "1", "-an", "-f", "dv"]); + if !run(ff, &args, tmp) { + return false; + } + if spec.field_chroma { + let mut dv = std::fs::read(tmp).unwrap(); + force_248_chroma(&mut dv); + std::fs::write(tmp, dv).unwrap(); + } + true + }); + made.unwrap_or_else(|| panic!("{}: could not generate the fixture", spec.name)) +} + +/// ffmpeg's decode of `dv` as planar 4:1:1 (cached next to it). +fn reference(ff: &Path, dv: &Path) -> Vec { + let out = dv.with_extension("yuv411p.yuv"); + let input = dv.to_str().unwrap(); + let made = filmcraft_testkit::fixtures::generate(&out, |tmp| run(ff, &["-i", input, "-pix_fmt", "yuv411p", "-f", "rawvideo"], tmp)); + std::fs::read(made.unwrap_or_else(|| panic!("{}: ffmpeg could not decode it", dv.display()))).unwrap() +} + +/// (maximum sample error, mean squared error) over `a` and `b`, or only their rightmost 4 chroma +/// columns (the clipped 4x16 chroma blocks of the 704..720 macroblocks). +fn error(a: &[u8], b: &[u8], right_chroma: bool) -> (u8, f64) { + let pairs: Vec<(u8, u8)> = if right_chroma { + (0..a.len() / FRAME) + .flat_map(|f| (0..2 * 480).map(move |row| f * FRAME + 720 * 480 + row * 180)) + .flat_map(|row| (176..180).map(move |x| row + x)) + .map(|i| (a[i], b[i])) + .collect() + } else { + a.iter().copied().zip(b.iter().copied()).collect() + }; + let max = pairs.iter().map(|&(a, b)| a.abs_diff(b)).max().unwrap(); + let mse = pairs.iter().map(|&(a, b)| (f64::from(a) - f64::from(b)).powi(2)).sum::() / pairs.len() as f64; + (max, mse) +} + +/// Generate every fixture (used by `cargo xtask fixtures dv`). #[test] -fn generated_patterns_match_reference_and_mutations_never_panic() { - if Command::new("ffmpeg").arg("-version").output().is_err() { - eprintln!("SKIPPED: ffmpeg test oracle unavailable"); - return; +#[ignore] +fn generate_fixtures() { + let Some(ff) = ffmpeg() else { return }; + for spec in specs() { + let dv = filmcraft_testkit::fixtures_dir("dv").join(format!("{}.dv", spec.name)); + let outputs = [dv.clone(), dv.with_extension("yuv411p.yuv")]; + filmcraft_testkit::fixtures::generate_and_report(spec.name, &outputs, || Some(reference(&ff, &make(&ff, &spec)))); } - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures").join(format!("dv-oracle-{}", std::process::id())); - fs::create_dir_all(&dir).unwrap(); - for (i, pattern) in [ - "testsrc2=size=720x480:rate=30000/1001", - "color=black:size=720x480:rate=30000/1001", - "color=white:size=720x480:rate=30000/1001", - "smptebars=size=720x480:rate=30000/1001", - "nullsrc=size=720x480:rate=30000/1001,geq=lum='128+100*sin(X*0.4)*sin(Y*0.7)':cb='128+60*sin(X*0.2)':cr='128+60*cos(Y*0.3)'", - "testsrc2=size=720x480:rate=60000/1001,tinterlace=mode=interleave_bottom", - ] - .iter() - .enumerate() - { - let dv = dir.join(format!("{i}.dv")); - let raw = dir.join(format!("{i}.yuv")); - let result = Command::new("ffmpeg") - .args([ - "-nostdin", - "-y", - "-v", - "error", - "-f", - "lavfi", - "-i", - pattern, - "-frames:v", - "6", - "-pix_fmt", - "yuv411p", - "-c:v", - "dvvideo", - "-threads", - "1", - "-an", - "-f", - "dv", - ]) - .arg(&dv) - .output() - .unwrap(); - assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); - let result = Command::new("ffmpeg") - .args(["-nostdin", "-y", "-v", "error", "-i"]) - .arg(&dv) - .args(["-pix_fmt", "yuv411p", "-f", "rawvideo"]) - .arg(&raw) - .output() - .unwrap(); - assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); - let input = fs::read(&dv).unwrap(); - let reference = fs::read(&raw).unwrap(); +} + +#[test] +fn decode_matches_ffmpeg() { + let Some(ff) = ffmpeg() else { return }; + for spec in specs() { + let dv = make(&ff, &spec); + let input = std::fs::read(&dv).unwrap(); + let reference = reference(&ff, &dv); + // Guard the coverage itself: an encoder change must not quietly drop the 2-4-8 cases. + let (luma_248, chroma_248) = dct248_blocks(&input); + if !spec.flags.is_empty() { + assert!(luma_248 > 0, "{}: no 2-4-8 luma blocks", spec.name); + } + if spec.field_chroma { + assert_eq!(chroma_248, 2 * 1350 * FRAMES, "{}", spec.name); + } let mut output = Vec::new(); - for packet in input.as_chunks::<120000>().0 { - let f = decode_frame(packet).unwrap(); + for packet in input.as_chunks::().0 { + let f = decode_frame(packet).unwrap_or_else(|e| panic!("{}: {e}", spec.name)); output.extend(f.y); output.extend(f.cb); output.extend(f.cr); } - assert_eq!(output.len(), reference.len()); - let max = output.iter().zip(&reference).map(|(&a, &b)| a.abs_diff(b)).max().unwrap(); - let mse = output.iter().zip(&reference).map(|(&a, &b)| (f64::from(a) - f64::from(b)).powi(2)).sum::() / output.len() as f64; - assert!(max <= 3 && mse < 0.5, "pattern {i}: max={max}, mse={mse}"); + assert_eq!(output.len(), FRAMES * FRAME, "{}", spec.name); + assert_eq!(output.len(), reference.len(), "{}", spec.name); + let (max, mse) = error(&output, &reference, false); + let (edge_max, edge_mse) = error(&output, &reference, true); + println!("{:<20} max {max} mse {mse:.4} | rightmost chroma max {edge_max} mse {edge_mse:.4} | 2-4-8 blocks: {luma_248} Y, {chroma_248} C", spec.name); + assert!(max <= 3 && mse < 0.5, "{}: max={max}, mse={mse}", spec.name); + assert!(edge_max <= 3 && edge_mse < 0.5, "{}, rightmost chroma: max={edge_max}, mse={edge_mse}", spec.name); + } +} + +#[test] +fn mutated_and_truncated_frames_never_panic() { + let Some(ff) = ffmpeg() else { return }; + for spec in specs() { + let input = std::fs::read(make(&ff, &spec)).unwrap(); // Mutate arbitrary DIF IDs, headers, lengths and VLC payload bits of an actual frame. - let frame = &input[..120000]; + let frame = &input[..PACKET]; let mut state = 0x9387ac34u32; for _ in 0..96 { state = state.wrapping_mul(1664525).wrapping_add(1013904223); let mut corrupted = frame.to_vec(); let pos = state as usize % corrupted.len(); corrupted[pos] ^= 1 << (state % 8); - assert!(std::panic::catch_unwind(|| decode_frame(&corrupted)).is_ok()); + assert!(std::panic::catch_unwind(|| decode_frame(&corrupted)).is_ok(), "{}", spec.name); } - for n in [1, 79, 80, 119999] { - assert!(decode_frame(&frame[..n]).is_err()); + for n in [1, 79, 80, PACKET - 1] { + assert!(decode_frame(&frame[..n]).is_err(), "{}", spec.name); } } - fs::remove_dir_all(dir).unwrap(); } diff --git a/docs/codec-parity.md b/docs/codec-parity.md index dba5ae8284..27132f83c0 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -27,7 +27,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Motion JPEG | decode + encode | yes | yes (MOV) | — | 90% | — | | JPEG 2000 (MXF, DCP) | decode + encode (Kakadu) | none | none | — | 0% | 40–70 h | | JPEG XS | decode (`libjpegxs`) | none | none | — | 0% | 20–40 h | -| DV / DVCPRO / DV100 | decode + encode | native DV25/DVCPRO25 525/60 4:1:1 MOV; PAL, 50/100 Mb/s and AVI remain unsupported | none | generated progressive/interlaced oracle + mutation tests | partial | remaining profiles/encode not estimated | +| DV / DVCPRO / DV100 | decode + encode | native DV25/DVCPRO25 525/60 4:1:1 MOV; PAL, 50/100 Mb/s and AVI remain unsupported | none | generated progressive/interlaced oracle (8×8 and 2-4-8 DCT, rightmost chroma) + MOV seek / PCM oracle + mutation tests | partial | remaining profiles/encode not estimated | | MPEG-4 Part 2 | decode | none | none | — | 0% | 10–20 h | | WMV / VC-1 | decode, encode on Windows | none | none | — | 0% | 20–35 h | | CinemaDNG / DNG | decode | own decoder (#1065): uncompressed and lossless JPEG raw, Bayer and LinearRaw, DNG colour model to linear Rec. 709 floats; folders of `.dng` frames as one clip with the CinemaDNG frame rate and time code; **no** lossy / JPEG XL DNG, MXF-wrapped CinemaDNG, opcode lists or Raw settings UI | — | raw samples bit-exact (and identical to LibRaw's on our synthetic files), processing against hand-computed values | 60% | 10–20 h | @@ -61,6 +61,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | DV25 / DVCPRO25 NTSC decode in MOV (#2092) | | 2026-10-22 | minor | ProRes alpha final-run syntax regression tests and native macOS HD decode validation | | 2026-10-11 | minor | CinemaDNG / DNG decode (#1065) | | 2026-10-10 | major | Created: codec-by-codec comparison with Premiere 26.5.2 from the codec crates' READMEs, the export format registry and the installed bundle's listings | diff --git a/docs/gaps.md b/docs/gaps.md index b81a94d1c7..630f4a313c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -322,4 +322,5 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | 2026-10-10 | major | Created from ROADMAP.md's "Where we are lacking" (2026-10-05); re-ranked against Premiere 26.5.2 and the 188 open issues; added `.prproj`, 10-bit / 4:2:2 camera decode, launch crashes and missing PR CI, localization, delivery formats | | 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) | | 2026-10-12 | minor | G10: OpenEXR PXR24 import | +| 2026-10-19 | minor | G10: NTSC DV25 / DVCPRO25 in MOV imports (#2092) | | 2026-10-24 | minor | G8: dragging the cut on a transition's timeline in Effect Controls moves the cut, and the transition moves with it (#577) | diff --git a/docs/testing.md b/docs/testing.md index 032c3155dd..1c046382db 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,7 +74,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). - `cargo xtask fixtures [crate…]` pre-generates the whole fixture matrix up front (useful before a parallel test run or on a fresh machine). It runs each crate's ignored `generate_fixtures` test — the same generators the oracle tests call — for `h264`, `hevc`, `isobmff`, `matroska`, `dnx`, - `prores`, `mpeg2v`, `ac3` and `codecs` (MXF, Ogg and MPEG TS / PS), and prints one `made` / `cached` / `skipped` line per fixture plus a summary. The + `dv`, `prores`, `mpeg2v`, `ac3` and `codecs` (MXF, Ogg and MPEG TS / PS), and prints one `made` / `cached` / `skipped` line per fixture plus a summary. The `aac`, `opus` and `h264enc` oracles generate small per-test signals on demand and are not part of the matrix. - Fixture sources are synthetic: `testsrc2`, `mandelbrot`, SMPTE bars, noise and fades, sine tones. @@ -110,6 +110,7 @@ anything is "fixed" to match) and add asserts for anything new. | `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) | +| `dv` decode | ffmpeg `dvvideo` NTSC DV25 fixtures (8×8 and `+ildct` 2-4-8 luma; two with every chroma block switched to 2-4-8), compared as `yuv411p`; the clipped rightmost chroma columns also on their own. `codecs` `dv_oracle`: MOV random access against ffmpeg's frames, PCM sample-exact | within **±3 LSB**, MSE < 0.5 (measured ±2; fixed-point vs floating-point weighting and IDCT) | | `dnx` encode | ffmpeg decodes with `-xerror` | no errors; agrees with our decoder within ±2; HQ luma PSNR ≥ 45 dB | | `aac` decode | ffmpeg's decode of the same stream | max abs error ~1e-7 (float) | | `aac` encode | ffmpeg decodes our stream | no errors, no clipping, CBR within ±5% of target; SNR reported | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f7796fb07a..019317178d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -444,6 +444,7 @@ const FIXTURE_GENERATORS: &[(&str, &str, &str)] = &[ ("matroska", "oracle", "generate_fixtures"), ("prores", "it", "oracle_decode::generate_fixtures"), ("dnx", "it", "oracle_decode::generate_fixtures"), + ("dv", "oracle", "generate_fixtures"), ("codecs", "it", "mxf_oracle::generate_fixtures"), ("codecs", "it", "mpeg_oracle::generate_fixtures"), ("mpeg2v", "it", "oracle::generate_fixtures"), From d5c7cf0c4450be1479d84a44b8a2976b3f123a87 Mon Sep 17 00:00:00 2001 From: andymenard <198171234+andymenard@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:45:46 -0500 Subject: [PATCH 3/7] dv: look up the scan order instead of searching it Block::decode searched ORDER (SMPTE 314M figure 27) for the raster position of every AC coefficient. SCAN is its inverse, built at compile time from ORDER, so the lookup is one checked index. Output is bit-identical (the 285-frame camera clip and every oracle fixture); the camera clip decodes in 5.6 ms per frame instead of 6.2. Co-Authored-By: Claude Opus 5.5 --- crates/dv/src/lib.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/dv/src/lib.rs b/crates/dv/src/lib.rs index 8aa99b5f41..a390a82479 100644 --- a/crates/dv/src/lib.rs +++ b/crates/dv/src/lib.rs @@ -33,6 +33,20 @@ const ORDER: [[u8; 64]; 2] = [ 6, 10, 18, 24, 34, 40, 50, 54, 12, 16, 26, 32, 42, 48, 56, 62, 14, 28, 30, 44, 46, 58, 60, 64, ], ]; +// Raster position of each zero-based scan index: the inverse of `ORDER`. +const SCAN: [[u8; 64]; 2] = { + let mut scan = [[0; 64]; 2]; + let mut mode = 0; + while mode < 2 { + let mut raster = 0; + while raster < 64 { + scan[mode][ORDER[mode][raster] as usize - 1] = raster as u8; + raster += 1; + } + mode += 1; + } + scan +}; const AREA: [[usize; 64]; 2] = [ [ 0, 0, 0, 1, 1, 1, 2, 2, 0, 0, 1, 1, 1, 2, 2, 2, 0, 1, 1, 1, 2, 2, 2, 3, 1, 1, 1, 2, 2, 2, 3, 3, 1, 1, 2, 2, 2, 3, 3, 3, 1, 2, 2, 2, 3, 3, 3, 3, 2, 2, @@ -133,7 +147,7 @@ impl Block { if self.scan >= 64 { return Err(Error("DV coefficient run exceeds block")); } - let raster = ORDER[self.mode].iter().position(|&n| n as usize == self.scan + 1).ok_or(Error("invalid DV scan"))?; + let raster = *SCAN.get(self.mode).and_then(|s| s.get(self.scan)).ok_or(Error("invalid DV scan"))? as usize; self.coeff[raster] = t.amp; } Ok(()) @@ -350,6 +364,14 @@ mod tests { } } #[test] + fn scan_inverts_order() { + for mode in 0..2 { + for raster in 0..64 { + assert_eq!(SCAN[mode][ORDER[mode][raster] as usize - 1] as usize, raster); + } + } + } + #[test] fn layout_covers_picture_once() { let mut coverage = vec![0; 720 * 480]; for s in 0..10 { From 6c326707da9de983308c30da5ffac392396a6a56 Mon Sep 17 00:00:00 2001 From: andymenard <198171234+andymenard@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:46:04 -0500 Subject: [PATCH 4/7] docs: date the DV entries in ROADMAP, codec-parity and gaps The DV25 decoder changes these docs; contributing.md asks for their "Last updated" lines and revision history to follow. Update the three headers, add the ROADMAP revision row, and date the codec-parity and gaps rows 2026-10-27, when the change was rebased onto main, so they are the newest entries. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 ++- docs/codec-parity.md | 4 ++-- docs/gaps.md | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ca2a01eb2a..9631d90828 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,7 @@ **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (DV25 / DVCPRO25 NTSC decode in MOV, #2092) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration), @@ -170,6 +170,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Codecs: native DV25 / DVCPRO25 NTSC decode in MOV (#2092) | | 2026-10-22 | minor | ProRes alpha: write the final singleton run code; 4444/XQ HD clips that failed native macOS decoding now decode, with syntax regression tests (EffectCraft #293) | | 2026-10-13 | minor | Language table: German covers the whole interface (~100%) | | 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) | diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 27132f83c0..448e38cb6d 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -1,6 +1,6 @@ # Codec parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (DV25 / DVCPRO25 NTSC decode in MOV, #2092) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 Video and audio codecs, decode and encode, with fidelity and how each is tested. Containers, project and interchange files are in [file-format-parity.md](file-format-parity.md); hardware @@ -61,7 +61,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | DV25 / DVCPRO25 NTSC decode in MOV (#2092) | +| 2026-10-27 | minor | DV25 / DVCPRO25 NTSC decode in MOV (#2092) | | 2026-10-22 | minor | ProRes alpha final-run syntax regression tests and native macOS HD decode validation | | 2026-10-11 | minor | CinemaDNG / DNG decode (#1065) | | 2026-10-10 | major | Created: codec-by-codec comparison with Premiere 26.5.2 from the codec crates' READMEs, the export format registry and the installed bundle's listings | diff --git a/docs/gaps.md b/docs/gaps.md index 630f4a313c..f4f9496444 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FilmCraft falls short of Premiere Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: ROADMAP's "Where we are lacking" re-measured against Premiere Pro 26.5.2 and the open issue list) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (DV25 / DVCPRO25 NTSC decode in MOV, #2092) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Every known shortfall, one entry each, ranked by what most stops an editor using FilmCraft for real work. This is the work list: agents should prefer it over adding checklist items. Numbers are in @@ -322,5 +322,5 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | 2026-10-10 | major | Created from ROADMAP.md's "Where we are lacking" (2026-10-05); re-ranked against Premiere 26.5.2 and the 188 open issues; added `.prproj`, 10-bit / 4:2:2 camera decode, launch crashes and missing PR CI, localization, delivery formats | | 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) | | 2026-10-12 | minor | G10: OpenEXR PXR24 import | -| 2026-10-19 | minor | G10: NTSC DV25 / DVCPRO25 in MOV imports (#2092) | | 2026-10-24 | minor | G8: dragging the cut on a transition's timeline in Effect Controls moves the cut, and the transition moves with it (#577) | +| 2026-10-27 | minor | G10: NTSC DV25 / DVCPRO25 in MOV imports (#2092) | From 8609540b2afc6114325913406f78673af6429081 Mon Sep 17 00:00:00 2001 From: andymenard <198171234+andymenard@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:08:47 -0500 Subject: [PATCH 5/7] dv: conceal damaged segments instead of failing the frame Damaged coded data (a macroblock with STA 0xF, an invalid VLC, an overlong coefficient run or a missing end of block) now conceals only its video segment, which keeps black luma and neutral chroma, and the rest of the frame decodes. The five macroblocks of a segment share its overflow bits, so the segment is the smallest unit that can be dropped safely. A wrong frame size, header or DIF ID is still a frame error. Frame::concealed counts the concealed segments. Co-Authored-By: Claude Opus 5.5 --- crates/dv/README.md | 7 +- crates/dv/src/lib.rs | 152 ++++++++++++++++++++++++++++++++----------- docs/codec-parity.md | 2 +- docs/testing.md | 2 +- 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/crates/dv/README.md b/crates/dv/README.md index 81ef7e9f65..ffbc7ba269 100644 --- a/crates/dv/README.md +++ b/crates/dv/README.md @@ -20,8 +20,11 @@ timecode, audio and seeking. No external decoder, transcoded file, or FFmpeg run dependency is used. PCM audio uses FilmCraft's existing native decoder. Currently scoped to NTSC DV25/DVCPRO25 MOV. PAL DV, DV50/DVCPRO50, DVCPRO HD, -raw DV audio demultiplexing and AVI DV are not implemented. Unsupported or damaged -packets return a decoding error without changing the input or crashing. +raw DV audio demultiplexing and AVI DV are not implemented. Unsupported packets and +frames with a wrong size, header or DIF IDs return a decoding error without changing the +input or crashing. Damaged coded data (a macroblock flagged as damaged, an invalid VLC, +an overlong coefficient run or a missing end of block) conceals only its video segment, +which keeps black luma and neutral chroma, and the rest of the frame is decoded. `cargo test -p filmcraft-dv` covers spatial coverage, malformed inputs, generated progressive/interlaced patterns versus an external FFmpeg oracle (maximum sample diff --git a/crates/dv/src/lib.rs b/crates/dv/src/lib.rs index a390a82479..60ba752a7e 100644 --- a/crates/dv/src/lib.rs +++ b/crates/dv/src/lib.rs @@ -20,6 +20,9 @@ pub struct Frame { pub y: Vec, pub cb: Vec, pub cr: Vec, + /// Video segments (five macroblocks each, 270 per frame) whose coded data was damaged. Their + /// pixels keep the initial black luma and neutral chroma. + pub concealed: usize, } // Figure 27: one-based coefficient output order, expressed at raster positions. @@ -282,7 +285,50 @@ fn put(plane: &mut [u8], stride: usize, x: usize, y: usize, data: &[u8; 64], rig Ok(()) } -/// Decode one complete DV25 525/60 DIF frame. All bit pools and dimensions are bounded. +/// The 30 decoded DCT blocks of one video segment (five macroblocks' DIF blocks, in order). +fn segment_blocks(difs: &[&[u8]; 5]) -> Result> { + let mut blocks = Vec::with_capacity(30); + for d in difs { + if d[3] & 0xf == 15 && d[3] >> 4 == 15 { + return Err(Error("damaged DV macroblock")); + } + let mut offset = 4; + for b in 0..6 { + let n = if b < 4 { 14 } else { 10 }; + let mut block = Block::new(&d[offset..offset + n], (d[3] & 15) as usize)?; + block.decode()?; + blocks.push(block); + offset += n; + } + } + let mut video_pool = Vec::new(); + for mb in 0..5 { + let chunk = &mut blocks[mb * 6..mb * 6 + 6]; + let mut pool = Vec::new(); + for b in chunk.iter() { + if b.done { + pool.extend_from_slice(&b.bits[b.cursor..]); + } + } + let mut cursor = 0; + for b in chunk.iter_mut() { + b.continue_from(&pool, &mut cursor)?; + } + video_pool.extend_from_slice(&pool[cursor..]); + } + let mut cursor = 0; + for b in &mut blocks { + b.continue_from(&video_pool, &mut cursor)?; + if !b.done { + return Err(Error("DV AC data lacks end of block")); + } + } + Ok(blocks) +} + +/// Decode one complete DV25 525/60 DIF frame. All bit pools and dimensions are bounded. A frame +/// with the wrong size, header or DIF IDs is an error; damaged coded data conceals only its +/// segment (see [`Frame::concealed`]). pub fn decode_frame(data: &[u8]) -> Result { if data.len() != 120000 { return Err(Error("unsupported DV frame size (requires 525/60 DV25)")); @@ -290,51 +336,25 @@ pub fn decode_frame(data: &[u8]) -> Result { if data[0] >> 5 != 0 || data[3] & 0x80 != 0 { return Err(Error("invalid or unsupported DV DIF header")); } - let mut frame = Frame { y: vec![16; 720 * 480], cb: vec![128; 180 * 480], cr: vec![128; 180 * 480] }; + let mut frame = Frame { y: vec![16; 720 * 480], cb: vec![128; 180 * 480], cr: vec![128; 180 * 480], concealed: 0 }; for sequence in 0..10 { for segment in 0..27 { - let mut blocks = Vec::with_capacity(30); - for mb in 0..5 { + let mut difs: [&[u8]; 5] = [&[]; 5]; + for (mb, slot) in difs.iter_mut().enumerate() { let video = segment * 5 + mb; let dif = sequence * 150 + 7 + video + video / 15; let d = data.get(dif * 80..dif * 80 + 80).ok_or(Error("truncated DV DIF block"))?; if d[0] >> 5 != 4 || d[1] >> 4 != sequence as u8 || d[2] as usize != video { return Err(Error("invalid DV video DIF ID")); } - if d[3] & 0xf == 15 && d[3] >> 4 == 15 { - return Err(Error("damaged DV macroblock")); - } - let mut offset = 4; - for b in 0..6 { - let n = if b < 4 { 14 } else { 10 }; - let mut block = Block::new(&d[offset..offset + n], (d[3] & 15) as usize)?; - block.decode()?; - blocks.push(block); - offset += n; - } - } - let mut video_pool = Vec::new(); - for mb in 0..5 { - let chunk = &mut blocks[mb * 6..mb * 6 + 6]; - let mut pool = Vec::new(); - for b in chunk.iter() { - if b.done { - pool.extend_from_slice(&b.bits[b.cursor..]); - } - } - let mut cursor = 0; - for b in chunk.iter_mut() { - b.continue_from(&pool, &mut cursor)?; - } - video_pool.extend_from_slice(&pool[cursor..]); - } - let mut cursor = 0; - for b in &mut blocks { - b.continue_from(&video_pool, &mut cursor)?; - if !b.done { - return Err(Error("DV AC data lacks end of block")); - } + *slot = d; } + // Damage inside a segment's coded data stays in that segment: its blocks share the + // segment's overflow bits, so the whole segment is concealed and decoding continues. + let Ok(blocks) = segment_blocks(&difs) else { + frame.concealed += 1; + continue; + }; for mb in 0..5 { let (x, y) = location(sequence, segment, mb); for b in 0..6 { @@ -363,6 +383,64 @@ mod tests { assert!(decode_frame(&d).is_err()); } } + /// A valid 525/60 frame whose every DCT block is a DC value followed by end of block. + fn flat_frame() -> Vec { + let mut data = vec![0; 120000]; + for sequence in 0..10 { + for video in 0..135 { + let dif = (sequence * 150 + 7 + video + video / 15) * 80; + data[dif] = 4 << 5; + data[dif + 1] = (sequence as u8) << 4; + data[dif + 2] = video as u8; + for offset in [4, 18, 32, 46, 60, 70] { + // DC 64, 8x8 DCT, class 0, then the 4-bit end-of-block code 0110. + data[dif + offset] = 64 >> 1; + data[dif + offset + 1] = 0b0110; + } + } + } + data + } + fn video_dif(sequence: usize, segment: usize, mb: usize) -> usize { + let video = segment * 5 + mb; + (sequence * 150 + 7 + video + video / 15) * 80 + } + /// Luma pixels of one segment's five macroblocks. + fn segment_luma(sequence: usize, segment: usize) -> Vec { + (0..5) + .flat_map(|mb| { + let (x, y) = location(sequence, segment, mb); + let (w, h) = if x == 704 { (16, 16) } else { (32, 8) }; + (0..h).flat_map(move |dy| (0..w).map(move |dx| (y + dy) * 720 + x + dx)) + }) + .collect() + } + #[test] + fn damaged_segments_are_concealed() { + let clean = decode_frame(&flat_frame()).unwrap(); + assert_eq!(clean.concealed, 0); + let mut data = flat_frame(); + // Sequence 2, segment 5: one macroblock's status says its data is damaged. + data[video_dif(2, 5, 3) + 3] = 0xff; + // Sequence 7, segment 20: a 13-bit run-63 escape overruns the first Y block. + let block = video_dif(7, 20, 0) + 4; + data[block + 1] = 0b1111; + data[block + 2] = 0b1101_1111; + data[block + 3] = 0b1000_0000; + let f = decode_frame(&data).unwrap(); + assert_eq!(f.concealed, 2); + let damaged: std::collections::HashSet = segment_luma(2, 5).into_iter().chain(segment_luma(7, 20)).collect(); + for (i, (&got, &want)) in f.y.iter().zip(&clean.y).enumerate() { + assert_eq!(got, if damaged.contains(&i) { 16 } else { want }, "luma {i}"); + } + assert_ne!(clean.y[segment_luma(2, 5)[0]], 16); + } + #[test] + fn bad_dif_ids_still_fail_the_frame() { + let mut data = flat_frame(); + data[video_dif(4, 9, 2) + 2] ^= 1; + assert!(decode_frame(&data).is_err()); + } #[test] fn scan_inverts_order() { for mode in 0..2 { diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 448e38cb6d..149b16e647 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -27,7 +27,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Motion JPEG | decode + encode | yes | yes (MOV) | — | 90% | — | | JPEG 2000 (MXF, DCP) | decode + encode (Kakadu) | none | none | — | 0% | 40–70 h | | JPEG XS | decode (`libjpegxs`) | none | none | — | 0% | 20–40 h | -| DV / DVCPRO / DV100 | decode + encode | native DV25/DVCPRO25 525/60 4:1:1 MOV; PAL, 50/100 Mb/s and AVI remain unsupported | none | generated progressive/interlaced oracle (8×8 and 2-4-8 DCT, rightmost chroma) + MOV seek / PCM oracle + mutation tests | partial | remaining profiles/encode not estimated | +| DV / DVCPRO / DV100 | decode + encode | native DV25/DVCPRO25 525/60 4:1:1 MOV; PAL, 50/100 Mb/s and AVI remain unsupported | none | generated progressive/interlaced oracle (8×8 and 2-4-8 DCT, rightmost chroma) + MOV seek / PCM oracle + mutation tests; damaged segments concealed, not the frame | partial | remaining profiles/encode not estimated | | MPEG-4 Part 2 | decode | none | none | — | 0% | 10–20 h | | WMV / VC-1 | decode, encode on Windows | none | none | — | 0% | 20–35 h | | CinemaDNG / DNG | decode | own decoder (#1065): uncompressed and lossless JPEG raw, Bayer and LinearRaw, DNG colour model to linear Rec. 709 floats; folders of `.dng` frames as one clip with the CinemaDNG frame rate and time code; **no** lossy / JPEG XL DNG, MXF-wrapped CinemaDNG, opcode lists or Raw settings UI | — | raw samples bit-exact (and identical to LibRaw's on our synthetic files), processing against hand-computed values | 60% | 10–20 h | diff --git a/docs/testing.md b/docs/testing.md index 1c046382db..cd8b815ffc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -110,7 +110,7 @@ anything is "fixed" to match) and add asserts for anything new. | `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) | -| `dv` decode | ffmpeg `dvvideo` NTSC DV25 fixtures (8×8 and `+ildct` 2-4-8 luma; two with every chroma block switched to 2-4-8), compared as `yuv411p`; the clipped rightmost chroma columns also on their own. `codecs` `dv_oracle`: MOV random access against ffmpeg's frames, PCM sample-exact | within **±3 LSB**, MSE < 0.5 (measured ±2; fixed-point vs floating-point weighting and IDCT) | +| `dv` decode | ffmpeg `dvvideo` NTSC DV25 fixtures (8×8 and `+ildct` 2-4-8 luma; two with every chroma block switched to 2-4-8), compared as `yuv411p`; the clipped rightmost chroma columns also on their own. `codecs` `dv_oracle`: MOV random access against ffmpeg's frames, PCM sample-exact. Unit tests: two damaged segments of a synthetic frame are concealed and every other pixel matches the clean decode; a bad DIF ID still fails the frame | within **±3 LSB**, MSE < 0.5 (measured ±2; fixed-point vs floating-point weighting and IDCT) | | `dnx` encode | ffmpeg decodes with `-xerror` | no errors; agrees with our decoder within ±2; HQ luma PSNR ≥ 45 dB | | `aac` decode | ffmpeg's decode of the same stream | max abs error ~1e-7 (float) | | `aac` encode | ffmpeg decodes our stream | no errors, no clipping, CBR within ±5% of target; SNR reported | From 38ae81613718d8edbe82829eccb4af551b7c4319 Mon Sep 17 00:00:00 2001 From: andymenard <198171234+andymenard@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:54:04 -0500 Subject: [PATCH 6/7] dv: check both chroma planes in the concealment test The test compared only luma. It now also checks that the concealed segments' Cb and Cr samples are neutral (128) and every other chroma sample matches the clean decode, as docs/testing.md describes. Co-Authored-By: Claude Opus 5.5 --- crates/dv/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/dv/src/lib.rs b/crates/dv/src/lib.rs index 60ba752a7e..196252c5b2 100644 --- a/crates/dv/src/lib.rs +++ b/crates/dv/src/lib.rs @@ -415,6 +415,16 @@ mod tests { }) .collect() } + /// Chroma samples (in the 180-wide planes) of one segment's five macroblocks. + fn segment_chroma(sequence: usize, segment: usize) -> Vec { + (0..5) + .flat_map(|mb| { + let (x, y) = location(sequence, segment, mb); + let (w, h) = if x == 704 { (4, 16) } else { (8, 8) }; + (0..h).flat_map(move |dy| (0..w).map(move |dx| (y + dy) * 180 + x / 4 + dx)) + }) + .collect() + } #[test] fn damaged_segments_are_concealed() { let clean = decode_frame(&flat_frame()).unwrap(); @@ -434,6 +444,13 @@ mod tests { assert_eq!(got, if damaged.contains(&i) { 16 } else { want }, "luma {i}"); } assert_ne!(clean.y[segment_luma(2, 5)[0]], 16); + let damaged: std::collections::HashSet = segment_chroma(2, 5).into_iter().chain(segment_chroma(7, 20)).collect(); + for (plane, got, want) in [("cb", &f.cb, &clean.cb), ("cr", &f.cr, &clean.cr)] { + for (i, (&got, &want)) in got.iter().zip(want).enumerate() { + assert_eq!(got, if damaged.contains(&i) { 128 } else { want }, "{plane} {i}"); + } + } + assert_ne!(clean.cb[segment_chroma(2, 5)[0]], 128); } #[test] fn bad_dif_ids_still_fail_the_frame() { From f1aa1c31b2be8facc96c4de1ce037852ed130fe1 Mon Sep 17 00:00:00 2001 From: andymenard <198171234+andymenard@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:59:36 -0500 Subject: [PATCH 7/7] dv: test every kind of segment damage, and payload flips on real frames The concealment test covered a damaged macroblock and an overlong run. It now also covers a segment with no end of block, and checks that each damaged segment is rejected for its own reason before it is concealed. An invalid VLC cannot occur: with its escapes the table covers every bit pattern (now a test), so the README no longer lists it, and corrupt AC bits surface as an overlong run or a missing end of block. On every oracle fixture, 96 bit flips in video DIF payloads must each decode to a frame with at most one concealed segment, and some flips must actually be concealed. Co-Authored-By: Claude Opus 5.5 --- crates/dv/README.md | 7 +++-- crates/dv/src/lib.rs | 60 ++++++++++++++++++++++++++++++--------- crates/dv/tests/oracle.rs | 26 +++++++++++++++++ docs/testing.md | 2 +- 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/crates/dv/README.md b/crates/dv/README.md index ffbc7ba269..88a36b5224 100644 --- a/crates/dv/README.md +++ b/crates/dv/README.md @@ -22,9 +22,10 @@ dependency is used. PCM audio uses FilmCraft's existing native decoder. Currently scoped to NTSC DV25/DVCPRO25 MOV. PAL DV, DV50/DVCPRO50, DVCPRO HD, raw DV audio demultiplexing and AVI DV are not implemented. Unsupported packets and frames with a wrong size, header or DIF IDs return a decoding error without changing the -input or crashing. Damaged coded data (a macroblock flagged as damaged, an invalid VLC, -an overlong coefficient run or a missing end of block) conceals only its video segment, -which keeps black luma and neutral chroma, and the rest of the frame is decoded. +input or crashing. Damaged coded data (a macroblock flagged as damaged, an overlong +coefficient run or a missing end of block) conceals only its video segment, which keeps +black luma and neutral chroma, and the rest of the frame is decoded. With its escapes the +VLC table covers every bit pattern, so corrupt AC bits surface as one of the latter two. `cargo test -p filmcraft-dv` covers spatial coverage, malformed inputs, generated progressive/interlaced patterns versus an external FFmpeg oracle (maximum sample diff --git a/crates/dv/src/lib.rs b/crates/dv/src/lib.rs index 196252c5b2..8691aa5311 100644 --- a/crates/dv/src/lib.rs +++ b/crates/dv/src/lib.rs @@ -425,31 +425,65 @@ mod tests { }) .collect() } + /// The five video DIF blocks of one segment. + fn segment_difs(data: &[u8], sequence: usize, segment: usize) -> [&[u8]; 5] { + std::array::from_fn(|mb| &data[video_dif(sequence, segment, mb)..video_dif(sequence, segment, mb) + 80]) + } + /// Overwrite a DCT block's AC bits (everything after its 12-bit header) with `bit(i)`. + fn set_ac_bits(data: &mut [u8], block: usize, len: usize, bit: impl Fn(usize) -> u8) { + for p in 12..len * 8 { + let mask = 0x80 >> (p % 8); + let byte = &mut data[block + p / 8]; + *byte = if bit(p - 12) & 1 == 1 { *byte | mask } else { *byte & !mask }; + } + } + #[test] + fn every_bit_pattern_is_a_code_word() { + // With its escapes the VLC table is complete, so damaged AC data shows up as an overlong + // run or a missing end of block, never as an invalid word; `Block::decode`'s + // "invalid DV VLC" check only guards the table. + assert!(tokens().iter().all(|t| t.len > 0)); + } #[test] - fn damaged_segments_are_concealed() { + fn each_kind_of_damage_conceals_only_its_segment() { let clean = decode_frame(&flat_frame()).unwrap(); assert_eq!(clean.concealed, 0); let mut data = flat_frame(); - // Sequence 2, segment 5: one macroblock's status says its data is damaged. + // Sequence 2, segment 5: one macroblock's STA and QNO are both 0xF (damaged). data[video_dif(2, 5, 3) + 3] = 0xff; - // Sequence 7, segment 20: a 13-bit run-63 escape overruns the first Y block. - let block = video_dif(7, 20, 0) + 4; - data[block + 1] = 0b1111; - data[block + 2] = 0b1101_1111; - data[block + 3] = 0b1000_0000; + // Sequence 7, segment 20: a 13-bit run-63 escape (1111110 111111) overruns the first Y block. + set_ac_bits(&mut data, video_dif(7, 20, 0) + 4, 14, |i| u8::from(i < 13 && i != 6)); + // Sequence 9, segment 26 (on the clipped right edge): every block is filled with the shortest + // run-0 code word, so the segment's bits run out with no end of block anywhere. + let (code, len) = (0..65536) + .map(|c| (c, tokens()[c])) + .filter(|(_, t)| !t.eob && t.len > 0 && t.run == 0) + .map(|(c, t)| (c >> (16 - t.len), t.len)) + .min_by_key(|&(_, l)| l) + .unwrap(); + for mb in 0..5 { + for (b, offset) in [4, 18, 32, 46, 60, 70].into_iter().enumerate() { + set_ac_bits(&mut data, video_dif(9, 26, mb) + offset, if b < 4 { 14 } else { 10 }, |i| (code >> (len - 1 - i % len)) as u8); + } + } + let cases = [(2, 5, "damaged DV macroblock"), (7, 20, "DV coefficient run exceeds block"), (9, 26, "DV AC data lacks end of block")]; + for (sequence, segment, why) in cases { + assert_eq!(segment_blocks(&segment_difs(&data, sequence, segment)).err(), Some(Error(why)), "segment {sequence}/{segment}"); + } let f = decode_frame(&data).unwrap(); - assert_eq!(f.concealed, 2); - let damaged: std::collections::HashSet = segment_luma(2, 5).into_iter().chain(segment_luma(7, 20)).collect(); + assert_eq!(f.concealed, cases.len()); + let luma: std::collections::HashSet = cases.iter().flat_map(|&(s, k, _)| segment_luma(s, k)).collect(); for (i, (&got, &want)) in f.y.iter().zip(&clean.y).enumerate() { - assert_eq!(got, if damaged.contains(&i) { 16 } else { want }, "luma {i}"); + assert_eq!(got, if luma.contains(&i) { 16 } else { want }, "luma {i}"); } - assert_ne!(clean.y[segment_luma(2, 5)[0]], 16); - let damaged: std::collections::HashSet = segment_chroma(2, 5).into_iter().chain(segment_chroma(7, 20)).collect(); + let chroma: std::collections::HashSet = cases.iter().flat_map(|&(s, k, _)| segment_chroma(s, k)).collect(); for (plane, got, want) in [("cb", &f.cb, &clean.cb), ("cr", &f.cr, &clean.cr)] { for (i, (&got, &want)) in got.iter().zip(want).enumerate() { - assert_eq!(got, if damaged.contains(&i) { 128 } else { want }, "{plane} {i}"); + assert_eq!(got, if chroma.contains(&i) { 128 } else { want }, "{plane} {i}"); } } + // The concealed values differ from what the clean frame has there. + assert_ne!(clean.y[segment_luma(2, 5)[0]], 16); assert_ne!(clean.cb[segment_chroma(2, 5)[0]], 128); } #[test] diff --git a/crates/dv/tests/oracle.rs b/crates/dv/tests/oracle.rs index df2f2a5ce1..4fccb49552 100644 --- a/crates/dv/tests/oracle.rs +++ b/crates/dv/tests/oracle.rs @@ -187,3 +187,29 @@ fn mutated_and_truncated_frames_never_panic() { } } } + +#[test] +fn payload_bit_flips_conceal_at_most_one_segment() { + let Some(ff) = ffmpeg() else { return }; + for spec in specs() { + let input = std::fs::read(make(&ff, &spec)).unwrap(); + let frame = &input[..PACKET]; + // Offsets of the video DIF blocks; everything after a block's 3-byte ID (STA/QNO, DCT + // headers and AC bits) is coded data that only its own segment depends on. + let video: Vec = (0..PACKET / 80).map(|i| i * 80).filter(|&o| frame[o] >> 5 == 4).collect(); + assert_eq!(video.len(), 1350, "{}", spec.name); + let mut state = 0x51ed270bu32; + let mut concealed = 0; + for _ in 0..96 { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + let mut corrupted = frame.to_vec(); + let pos = video[state as usize % video.len()] + 3 + (state >> 11) as usize % 77; + corrupted[pos] ^= 1 << (state % 8); + let decoded = decode_frame(&corrupted); + assert!(decoded.as_ref().is_ok_and(|f| f.concealed <= 1), "{}: flip at byte {pos}", spec.name); + concealed += decoded.map_or(0, |f| f.concealed); + } + // Not every flip breaks the bitstream (a changed coefficient is still valid), but some must. + assert!(concealed > 0, "{}: no flip exercised concealment", spec.name); + } +} diff --git a/docs/testing.md b/docs/testing.md index cd8b815ffc..abe637f60f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -110,7 +110,7 @@ anything is "fixed" to match) and add asserts for anything new. | `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) | -| `dv` decode | ffmpeg `dvvideo` NTSC DV25 fixtures (8×8 and `+ildct` 2-4-8 luma; two with every chroma block switched to 2-4-8), compared as `yuv411p`; the clipped rightmost chroma columns also on their own. `codecs` `dv_oracle`: MOV random access against ffmpeg's frames, PCM sample-exact. Unit tests: two damaged segments of a synthetic frame are concealed and every other pixel matches the clean decode; a bad DIF ID still fails the frame | within **±3 LSB**, MSE < 0.5 (measured ±2; fixed-point vs floating-point weighting and IDCT) | +| `dv` decode | ffmpeg `dvvideo` NTSC DV25 fixtures (8×8 and `+ildct` 2-4-8 luma; two with every chroma block switched to 2-4-8), compared as `yuv411p`; the clipped rightmost chroma columns also on their own. `codecs` `dv_oracle`: MOV random access against ffmpeg's frames, PCM sample-exact. Unit tests: in a synthetic frame, a segment with a damaged macroblock, one with an overlong coefficient run and one with no end of block are each rejected for that reason and concealed, and every other luma and chroma sample matches the clean decode; a bad DIF ID still fails the frame; the VLC table covers every bit pattern. On every fixture, bit flips in video DIF payloads always give a frame, with at most one segment concealed (and some flips concealing) | within **±3 LSB**, MSE < 0.5 (measured ±2; fixed-point vs floating-point weighting and IDCT) | | `dnx` encode | ffmpeg decodes with `-xerror` | no errors; agrees with our decoder within ±2; HQ luma PSNR ≥ 45 dB | | `aac` decode | ffmpeg's decode of the same stream | max abs error ~1e-7 (float) | | `aac` encode | ffmpeg decodes our stream | no errors, no clipping, CBR within ±5% of target; SNR reported |