Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 4 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- 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),
Expand Down Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions crates/codecs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 2 additions & 1 deletion crates/codecs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ impl From<CodecError> 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,
Expand Down
2 changes: 2 additions & 0 deletions crates/codecs/src/mp4.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down
49 changes: 49 additions & 0 deletions crates/codecs/src/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,55 @@ pub fn av1_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
/// 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<Vec<DecodedFrame>> {
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<u8>| 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<DecodedFrame> {
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<bool> {
Some(true)
}
fn is_disposable(&self, _sample: &[u8]) -> bool {
true
}
}

pub fn dv_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
matches!(&e.codec, CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"dvc ").then(|| Ok(Box::new(DvDecoder) as Box<dyn VideoDecoder>))
}

impl VideoDecoder for ProResDecoder {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
use std::sync::Arc;
Expand Down
73 changes: 73 additions & 0 deletions crates/codecs/tests/it/dv_oracle.rs
Original file line number Diff line number Diff line change
@@ -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<f32> = (0..want.len() / 2).flat_map(|i| [got.channels[0][i], got.channels[1][i]]).collect();
assert_eq!(interleaved, want, "PCM differs from ffmpeg");
}
1 change: 1 addition & 0 deletions crates/codecs/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions crates/dv/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
39 changes: 39 additions & 0 deletions crates/dv/README.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions crates/dv/examples/decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use std::{
fs::File,
io::{BufReader, BufWriter, Read, Write},
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}
Loading