diff --git a/Cargo.lock b/Cargo.lock index 118d8dc4f9..52a7edfc28 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,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-dv" +version = "0.13.0" +dependencies = [ + "filmcraft-testkit", +] + [[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..9631d90828 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,11 +1,13 @@ # 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. **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), @@ -168,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/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/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/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 new file mode 100644 index 0000000000..3c67d0c8b2 --- /dev/null +++ b/crates/dv/Cargo.toml @@ -0,0 +1,13 @@ +[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 + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/dv/README.md b/crates/dv/README.md new file mode 100644 index 0000000000..88a36b5224 --- /dev/null +++ b/crates/dv/README.md @@ -0,0 +1,39 @@ +# 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 +(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 +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 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 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 +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/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..8691aa5311 --- /dev/null +++ b/crates/dv/src/lib.rs @@ -0,0 +1,521 @@ +//! 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, + /// 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. +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, + ], +]; +// 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, + 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 = *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(()) + } + 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(()) +} + +/// 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)")); + } + 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], concealed: 0 }; + for sequence in 0..10 { + for segment in 0..27 { + 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")); + } + *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 { + 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()); + } + } + /// 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() + } + /// 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() + } + /// 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 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 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 (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, 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 luma.contains(&i) { 16 } else { want }, "luma {i}"); + } + 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 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] + 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 { + 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 { + 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..4fccb49552 --- /dev/null +++ b/crates/dv/tests/oracle.rs @@ -0,0 +1,215 @@ +//! 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; + +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] +#[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)))); + } +} + +#[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::().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(), 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[..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(), "{}", spec.name); + } + for n in [1, 79, 80, PACKET - 1] { + assert!(decode_frame(&frame[..n]).is_err(), "{}", spec.name); + } + } +} + +#[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/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..149b16e647 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 @@ -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 (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 | @@ -61,6 +61,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Date | Change | Summary | |---|---|---| +| 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 2bafd859f6..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 @@ -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, @@ -323,3 +323,4 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) | | 2026-10-12 | minor | G10: OpenEXR PXR24 import | | 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) | diff --git a/docs/testing.md b/docs/testing.md index 032c3155dd..abe637f60f 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. 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 | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 98902837a6..019317178d 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), @@ -443,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"),