diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 000c1624..8e082c72 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -7,8 +7,8 @@ //! memory-budgeted LRU frame cache, one decoder per source, and the decoder kept positioned for //! sequential playback. Also audio ([`MediaPool::audio_samples`]) and thumbnails. //! -//! Video containers (MP4/MOV, Matroska/WebM) and codecs (H.264, HEVC, VP9, AV1, ProRes, DNxHD, -//! MJPEG; AAC, Opus, PCM, MP3/FLAC/Vorbis) come from FilmCraft's pure-Rust crates (git dependency, +//! Video containers (MP4/MOV, Matroska/WebM, MXF OP1a / OP-Atom) and codecs (H.264, HEVC, VP9, +//! AV1, ProRes, DNxHD, MPEG-2, MJPEG; AAC, Opus, PCM, MP3/FLAC/Vorbis) come from FilmCraft's pure-Rust crates (git dependency, //! pinned; `plan/adr/0001`). FilmCraft types never leave this crate. Stills use the `image` crate //! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR); Photoshop documents (merged image or one layer) come //! from `effectcraft-psd` and SVG from `effectcraft-svg` (rasterised at any scale). @@ -55,7 +55,7 @@ pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "ti /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; /// File extensions recognised as movies. -pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"]; +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"]; /// File extensions recognised as 3D models (Advanced 3D model layers). pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj"]; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index d26b1a1d..646d5049 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -379,6 +379,97 @@ fn vp9_webm_frames() { } } +/// MXF OP1a as broadcast cameras write it: long-GOP MPEG-2 4:2:2 (XDCAM-like, B-frames) with +/// 24-bit PCM audio. +fn mpeg2_mxf() -> Option { + ffmpeg_fixture( + "testsrc2_mpeg2_pcm.mxf", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=1280x720:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mpeg2video", + "-pix_fmt", + "yuv422p", + "-q:v", + "2", + "-g", + "12", + "-bf", + "2", + "-c:a", + "pcm_s24le", + "-ac", + "2", + ], + ) +} + +/// MXF OP1a with intra-only DNxHD 36 (Avid's offline codec). +fn dnxhd_mxf() -> Option { + ffmpeg_fixture( + "testsrc2_dnxhd.mxf", + &["-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=25:duration=1", "-c:v", "dnxhd", "-b:v", "36M", "-pix_fmt", "yuv422p"], + ) +} + +/// MXF is importable footage (After Effects imports it), whatever the extension's case. +#[test] +fn mxf_is_importable() { + assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf"))); + assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF"))); + assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet"); +} + +#[test] +fn mxf_mpeg2_with_pcm_audio() { + let Some(p) = mpeg2_mxf() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (1280, 720)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds()); + assert!(f.has_video && f.has_audio); + eprintln!("mxf mpeg-2: codec {:?}", f.codec); + let pool = MediaPool::new(); + // sequential, a backwards seek, a jump into a GOP (B-frames reordered), the last frame + for n in [0usize, 1, 2, 3, 30, 7, 49] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame(&p, n)); + eprintln!("mxf mpeg-2 frame {n}: {db:.2} dB"); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } + let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000); + assert_eq!(s.len(), 9600); + let rms = (s.iter().map(|v| v * v).sum::() / s.len() as f32).sqrt(); + // ffmpeg's sine source upmixed to stereo: −24 dBFS RMS + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +#[test] +fn mxf_dnxhd_frames() { + let Some(p) = dnxhd_mxf() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (1920, 1080)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!(!f.has_audio); + eprintln!("mxf dnxhd: codec {:?}", f.codec); + let pool = MediaPool::new(); + for n in [0usize, 12, 24, 3] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame(&p, n)); + eprintln!("mxf dnxhd frame {n}: {db:.2} dB"); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..999757be 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1213,7 +1213,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -1225,8 +1225,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option::new())); + let seen = offered.clone(); + app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| { + *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); + Vec::new() + })); + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); + assert!(result.is_null(), "cancelled: nothing imported"); + let offered = offered.borrow(); + assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); + assert!(offered.iter().any(|e| e == "mov")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 66f18b47..89c7103b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -64,7 +64,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | AV1 | R | yes | yes (MP4 `av01`, WebM) | beyond After Effects' export; no B-frames / CDEF | | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | -| MXF (OP1a / OP-Atom) | R/W | none (FilmCraft reads it; not wired) | none | Media Browser lists it | +| MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | | AVI | R | none | none | Media Browser lists it | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..a70c2d6e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest. | 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI; `media::is_importable` has none of them (MXF OP1a / OP-Atom imports since #767; FilmCraft has no AVI demuxer); #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |