#767: import MXF (OP1a / OP-Atom) footage

Problem: the Media Browser lists .mxf files, but File > Import's dialog
filtered them out and media::is_importable refused them, although the
pinned FilmCraft codecs behind crates/media already demux MXF (AVC,
MPEG-2 / XDCAM / D-10, VC-3 / DNxHD, ProRes, PCM audio) by content.

Fix: add "mxf" to VIDEO_EXTENSIONS and to the File > Import and Set
Proxy dialog filters. Tests check MPEG-2 4:2:2 long-GOP + 24-bit PCM and
DNxHD 36 MXF against ffmpeg frames (>35 dB, measured 64-67 dB), audio
level, and that the Import dialog offers MXF. AVI stays open: FilmCraft
has no AVI demuxer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 12:30:51 +02:00
parent 9a7d3e7699
commit f0bd916543
6 changed files with 120 additions and 8 deletions

View File

@ -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"];

View File

@ -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<PathBuf> {
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<PathBuf> {
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::<f32>() / 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 };

View File

@ -1213,7 +1213,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"keys.rpfCameraImport" => ("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<Res
return None;
}
const MEDIA: &[&str] = &[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", "ogg",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
"mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
"ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
];
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
// drag over the window then re-entered winit's event handler and aborted, #511) shows the

View File

@ -204,3 +204,24 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() {
assert_eq!(s.render(cid, Tick::ZERO, RenderOpts::default()).data, expected.data);
std::fs::remove_dir_all(dir).unwrap();
}
/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and
/// Avid), which the Media Browser lists and the importer reads.
#[test]
fn import_dialog_offers_mxf() {
use std::cell::RefCell;
use std::rc::Rc;
let mut app = EffectcraftApp::new(Session::default());
let offered = Rc::new(RefCell::new(Vec::<String>::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"));
}

View File

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

View File

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