From bed49c4959787c41e4fd5986db9683f2debe8af5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 1/9] Media: import AVI (#1701) One list of movie extensions, in effectcraft-project, with AVI added: FilmCraft reads AVI 1.0 / OpenDML. Tests against ffmpeg fixtures: Motion JPEG with PCM audio, an unsupported codec (imports and says why it has no picture), a file that isn't an AVI (an import error), and a damaged one (a truncated recording imports as far as it goes; a garbled packet loses its own frame only). --- crates/media/src/lib.rs | 3 +- crates/media/tests/footage.rs | 135 ++++++++++++++++++++++++++++++++-- crates/project/src/lib.rs | 5 ++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..1b40fcfac 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -63,8 +63,7 @@ pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// 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", "mxf"]; +pub use effectcraft_project::VIDEO_EXTENSIONS; /// File extensions recognised as 3D models (Advanced 3D model layers). pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..8dd4db672 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -168,9 +168,14 @@ fn tone_wav() -> Option { /// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix). fn reference_frame(src: &Path, n: usize) -> Image { + reference_frame_in(src, n, "bt709") +} + +/// [`reference_frame`] of a movie whose Y'CbCr is in `matrix` (`bt601` for Motion JPEG). +fn reference_frame_in(src: &Path, n: usize, matrix: &str) -> Image { let stem = src.file_stem().expect("stem").to_string_lossy().to_string(); let name = format!("ref_{stem}_{n}.png"); - let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); + let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix={matrix}:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg"); load_png(&p) } @@ -472,12 +477,12 @@ fn dnxhd_mxf() -> Option { ) } -/// MXF is importable footage (After Effects imports it), whatever the extension's case. +/// MXF and AVI are importable footage (After Effects imports them), 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"); +fn mxf_and_avi_are_importable() { + for name in ["A001C003.mxf", "A001C003.MXF", "capture.avi", "CAPTURE.AVI"] { + assert!(effectcraft_media::is_importable(&Path::new("/media").join(name)), "{name}"); + } } #[test] @@ -523,6 +528,124 @@ fn mxf_dnxhd_frames() { } } +fn mjpeg_avi() -> Option { + ffmpeg_fixture( + "testsrc2_mjpeg_pcm.avi", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mjpeg", + "-q:v", + "2", + "-pix_fmt", + "yuvj420p", + "-c:a", + "pcm_s16le", + "-ac", + "2", + ], + ) +} + +#[test] +fn avi_mjpeg_with_pcm_audio() { + let Some(p) = mjpeg_avi() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (640, 360)); + 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); + assert_eq!(f.codec, "Motion JPEG"); + let pool = MediaPool::new(); + for n in [0usize, 1, 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_in(&p, n, "bt601")); + 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(); + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +/// An AVI whose video FilmCraft can't decode imports, and says why it shows no picture (as an +/// unsupported stream in any other container does), rather than failing as a broken file. +#[test] +fn avi_with_an_unsupported_codec_says_so() { + let Some(p) = ffmpeg_fixture("testsrc2_xvid.avi", &["-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=1", "-c:v", "mpeg4", "-vtag", "XVID"]) + else { + return; + }; + let f = probe(&p).expect("a valid AVI imports"); + assert_eq!((f.kind, f.width, f.height), (FootageKind::Video, 320, 180)); + let pool = MediaPool::new(); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // FilmCraft decodes MPEG-4 Part 2 now: nothing to report + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("MPEG-4 Part 2") && why.contains("isn't supported"), "{why}"); +} + +/// A file that only looks like an AVI is an import error that says so. +#[test] +fn an_invalid_avi_is_an_import_error() { + let e = probe_bytes("broken.avi", Arc::from(&b"RIFF\x10\0\0\0AVI LIST\0\0\0\0garbage"[..])).expect_err("not an AVI"); + assert!(e.to_string().contains("invalid AVI"), "{e}"); +} + +/// A damaged AVI imports: a truncated recording as far as it goes, and a packet that doesn't +/// decode loses its own frame only, with a reason that says the file is damaged rather than +/// unsupported. +#[test] +fn a_damaged_avi_loses_only_what_is_damaged() { + let Some(p) = mjpeg_avi() else { return }; + let bytes = std::fs::read(&p).expect("read"); + let full = probe(&p).expect("probe"); + let pool = MediaPool::new(); + + let cut: Arc<[u8]> = bytes[..bytes.len() / 3].into(); + let f = probe_bytes("cut.avi", cut.clone()).expect("a truncated AVI imports"); + assert!(f.duration > Tick::ZERO && f.duration < full.duration, "{:?} of {:?}", f.duration, full.duration); + pool.add_bytes("cut.avi", cut); + let last = f.frame_rate.frame_at(f.duration - Tick(1)); + assert!(pool.frame(ItemId(1), &f, t_of(f.frame_rate, last)).is_some(), "its last frame"); + + // Garble the JPEG data of the video chunk of frame 10. + let mut bad = bytes.clone(); + let mut at = bad.windows(4).position(|w| w == b"movi").expect("movi") + 4; + let mut video = 0; + while let Some(head) = bad.get(at..at + 8) { + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + if &head[2..4] == b"dc" { + if video == 10 { + bad[at + 8 + 200..at + 8 + size.min(3000)].iter_mut().enumerate().for_each(|(i, b)| *b = if i % 7 == 0 { 0xFF } else { 0 }); + break; + } + video += 1; + } + at += 8 + size + size % 2; + } + assert_eq!(video, 10, "found frame 10's chunk"); + let f = probe_bytes("bad.avi", bad.clone().into()).expect("a damaged AVI imports"); + pool.add_bytes("bad.avi", bad.into()); + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, 10)).is_none(), "the damaged frame"); + for n in [9, 11, 30] { + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, n)).is_some(), "frame {n}"); + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("can't be decoded") && !why.contains("isn't supported"), "{why}"); +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..70a85a718 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1089,6 +1089,11 @@ impl FieldOrder { } } +/// File extensions of the movie containers footage is imported from (FilmCraft reads them). The +/// import and proxy file dialogs, the Media Browser and `effectcraft_media::is_importable` all go +/// by this list. +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf", "avi"]; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AlphaMode { #[default] From e26b4af5c07fe8dd29139c71fe5e0a4848d26a43 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 2/9] Media Browser: list movies by the importer's list (#1701) --- crates/engine/src/media_browser.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index b394ad937..775e3db98 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) { entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); } -const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; @@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; pub fn kind_of(path: &str) -> Option<&'static str> { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); let e = ext.as_str(); - if VIDEO.contains(&e) { + // (An animated GIF plays as a movie.) + if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) { Some("video") } else if IMAGE.contains(&e) { Some("image") @@ -420,6 +420,15 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Every movie type the importer reads lists as video, as an animated GIF does. + #[test] + fn movies_list_as_video() { + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) { + assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}"); + } + assert_eq!(kind_of("/clips/capture.avi"), Some("video")); + } + #[test] fn virtual_tree_lists_like_a_folder() { let mut t = VirtualTree::default(); From 93a9295125e5487c0b9e99cb904cd07bb340901b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 3/9] File dialogs: offer every movie type the importer reads (#1701) Import and Set Proxy build their filters from the shared movie list, so they offer AVI; the web build's file picker gets the same list. --- crates/ui-egui/src/menus.rs | 19 ++++++++++++++----- crates/ui-egui/tests/ui_menus.rs | 20 ++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26a7b66f6..eda7ccf7e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1337,6 +1337,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("paths", Ask::Import), @@ -1366,7 +1368,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", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -1377,19 +1379,22 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option>(); // 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 // dialog without blocking; the command continues when the answer arrives. if let Some(spawn) = app.hooks.file_dialog_async.as_ref() { let request = match ask { Ask::Folder => crate::FileDialog::Folder, - Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, Ask::Save(default) => crate::FileDialog::save(default), }; let tx = app.dialog_answers.0.clone(); @@ -1409,7 +1414,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(MEDIA)) + picked_value(key, f(&footage(MEDIA))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -1419,6 +1424,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { + let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; + f(&footage(more)).first().map(|s| json!(s)) + } Ask::Save(default) => { let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; picked.map(|s| json!(s)) diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index ca4c8cabc..ce6b97800 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -205,10 +205,10 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { 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. +/// #767, #1701: File ▸ Import ▸ File… and the proxy dialog offer every movie type the importer +/// reads (MXF from broadcast cameras and Avid, AVI, …), the list the Media Browser goes by too. #[test] -fn import_dialog_offers_mxf() { +fn import_and_proxy_dialogs_offer_every_movie_type() { use std::cell::RefCell; use std::rc::Rc; @@ -219,11 +219,15 @@ fn import_dialog_offers_mxf() { *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")); + for (command, still) in [("file.import", "psd"), ("file.setProxy", "png")] { + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), command, json!({})).unwrap(); + assert!(result.is_null(), "{command} cancelled: nothing done"); + let offered = offered.borrow(); + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain([&still]) { + assert!(offered.iter().any(|e| e == ext), "{command} offers {ext}: {offered:?}"); + } + assert!(offered.iter().any(|e| e == "avi"), "{command}: {offered:?}"); + } } /// #1059: opening the demo from the start screen and immediately pressing Spacebar previews From 84d0f567656a0ba1b68da5ccbf804c64d2cdc74f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 4/9] Docs: AVI import (#1701) --- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9e2249e9b..f6e8913c4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | 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 | 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 | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | diff --git a/docs/gaps.md b/docs/gaps.md index a20e779cd..bda5eba76 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) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 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 (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; 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 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | 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 AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 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: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eac744b6f..74111b27d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -114,7 +114,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | |---|---:|---:|---:|---:|---| -| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; TGA/HDR/DPX/MXF/AVI import since #745, #756, #767, #1701 (no DV or MPEG-4 Part 2 AVI yet); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | | Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | From c7715a3f2d43d79e5d419c29aba736444443dd4d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 5/9] Footage: a proxy in use that can't be decoded says why (#1701) The picture comes from an item's proxy while Use Proxy is on, but the Project panel, the Viewer, project.summary and footage.info asked only about the item's own file: a proxy that couldn't be decoded (an AVI with an unsupported codec, say) left the layer empty with no reason given. They now ask FootageSource::item_problem, which gives the proxy's reason, said to be the proxy's, for footage and compositions alike. --- crates/engine/src/commands/footage_panel.rs | 5 +-- crates/engine/src/commands/query.rs | 8 ++-- crates/engine/src/tests_panels.rs | 44 +++++++++++++++++++ crates/render/src/lib.rs | 12 +++++ crates/ui-egui/src/panels/project.rs | 9 ++-- crates/ui-egui/src/panels/viewer.rs | 11 ++--- crates/ui-egui/tests/ui_unreadable_footage.rs | 39 +++++++++++----- 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index 1bd0d2236..7336d0c08 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -207,10 +207,7 @@ fn info(s: &mut Session, _: &Value) -> Result { let item = s.project.item(v.item); let name = item.map(|i| i.name.clone()).unwrap_or_default(); let frame = fr.frame_at(v.time); - let problem = match item.map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => s.footage.problem(f), - _ => None, - }; + let problem = item.and_then(|i| s.footage.item_problem(i)); Ok( json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}), ) diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 2ea8048fa..ed5eeffd1 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } - // Footage that shows no picture although its file is there (a codec or profile the - // decoders lack): why, so an agent can tell the user. - if let ItemKind::Footage(f) = &i.kind - && let Some(why) = s.footage.problem(f) - { + // Footage (or the proxy in use) that shows no picture although its file is there (a + // codec or profile the decoders lack): why, so an agent can tell the user. + if let Some(why) = s.footage.item_problem(i) { o["problem"] = json!(why); } o diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs index b442d7c3a..622088745 100644 --- a/crates/engine/src/tests_panels.rs +++ b/crates/engine/src/tests_panels.rs @@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() { s.execute("footage.open", json!({"item": item.0})).unwrap(); assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]); } + +/// A proxy in use that can't be decoded leaves its item without a picture although the item's +/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the +/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off. +#[test] +fn a_proxy_in_use_that_cannot_be_decoded_reports_why() { + struct BrokenProxies; + impl FootageSource for BrokenProxies { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, f: &Footage) -> Option { + f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec)) + } + } + let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() }; + let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still)); + let proxy = Footage { + kind: FootageKind::Video, + path: "plate.avi".into(), + codec: "MPEG-4 Part 2".into(), + width: 32, + height: 18, + has_video: true, + ..Default::default() + }; + let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder"); + let problem = |s: &mut Session, id: u64| { + let sum = s.execute("project.summary", json!({})).unwrap(); + sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned() + }; + for id in [item.0, comp] { + let p = std::sync::Arc::make_mut(&mut s.project); + p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true })); + assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}"); + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false; + assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine"); + } + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true; + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cfefd02db..b5aada454 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -130,6 +130,18 @@ pub trait FootageSource: Send + Sync { fn problem(&self, _footage: &Footage) -> Option { None } + /// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy + /// instead (the reason then says it is the proxy's), else the footage item's own. A + /// composition has one only through its proxy. + fn item_problem(&self, item: &effectcraft_project::Item) -> Option { + match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) { + Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")), + None => match &item.kind { + ItemKind::Footage(f) => self.problem(f), + _ => None, + }, + } + } } /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 76309a5d0..ab5936765 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -678,12 +678,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("file.useProxy".into(), json!({"item": id.0}))); } } - // Footage that can't be decoded (no decoder for its codec / profile): a warning marker - // at the end of the name cell, the reason on hover. - let problem = match &it.kind { - ItemKind::Footage(f) => app.session.footage.problem(f), - _ => None, - }; + // Footage (or the proxy in use) that can't be decoded (no decoder for its codec / + // profile): a warning marker at the end of the name cell, the reason on hover. + let problem = app.session.footage.item_problem(it); let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); if let Some(why) = &problem { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 51bab7fda..a50c30e53 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1155,13 +1155,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.warpBanner", r, &text); } - // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer - // instead of leaving a black picture. + // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder + // for its codec / profile): say why on the layer instead of leaving a black picture. let mut notices = 0; for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; - let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; - let Some(why) = app.session.footage.problem(f) else { continue }; + let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { + continue; + }; + let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; if notices >= 3 { break; } diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index 9b48b9531..b1b12d6d2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -1,10 +1,11 @@ -//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a -//! notice on the layer in the Composition viewer, instead of a black picture with no reason. +//! Footage (or the proxy in use for it) whose codec the decoders lack says so: a warning marker in +//! the Project panel and a notice on the layer in the Composition viewer, instead of a black +//! picture with no reason. use std::sync::Arc; use effectcraft_engine::Session; -use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, Proxy}; use effectcraft_engine::render::FootageSource; use effectcraft_render::Image; use effectcraft_time::Tick; @@ -14,19 +15,19 @@ use serde_json::json; const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; -/// A source that decodes nothing and, for `broken` movies, says why. -struct Stub(bool); +/// A source that decodes nothing and says why for the files `broken` picks. +struct Stub(fn(&Footage) -> bool); impl FootageSource for Stub { fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } - fn problem(&self, _: &Footage) -> Option { - self.0.then(|| WHY.to_string()) + fn problem(&self, f: &Footage) -> Option { + (self.0)(f).then(|| WHY.to_string()) } } -fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { +fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'static, EffectcraftApp>, ItemId) { let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); let footage = Footage { @@ -38,7 +39,11 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { duration: Tick::from_seconds_f64(2.0), ..Default::default() }; - let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + let p = std::sync::Arc::make_mut(&mut s.project); + let clip = p.add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + if let (Some(item), Some(footage)) = (p.items.get_mut(&clip), proxy) { + item.proxy = Some(Box::new(Proxy { footage, enabled: true })); + } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); @@ -48,7 +53,7 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { #[test] fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { - let (h, clip) = harness(true); + let (h, clip) = harness(|_| true, None); let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); assert!(marker.label.contains("4:2:2"), "{marker:?}"); let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); @@ -57,7 +62,19 @@ fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { #[test] fn readable_footage_has_no_notice() { - let (h, clip) = harness(false); + let (h, clip) = harness(|_| false, None); assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); } + +/// The clip's own file is fine, but the proxy in use for it can't be decoded: the item and the +/// layer say why, and that it is the proxy. +#[test] +fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { + let proxy = Footage { kind: FootageKind::Video, path: "clip_proxy.avi".into(), width: 32, height: 18, has_video: true, ..Default::default() }; + let (h, clip) = harness(|f| f.path.ends_with(".avi"), Some(proxy)); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.starts_with("Proxy: ") && marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); +} From 0a57bb9fc21a909adb822c017f22a0fd497a840d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 6/9] Docs: the AVI variants that don't import yet (#1701) --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f6e8913c4..8ca81f715 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | 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 | 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 | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | From f52ffb63ceb762638fb866a6074e8faae324b9c2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 12:59:24 -0400 Subject: [PATCH 7/9] Viewer: no proxy notice on a collapsed precomp, which draws its own layers (#1701) --- crates/ui-egui/src/panels/viewer.rs | 28 +++++++++++-------- crates/ui-egui/tests/ui_unreadable_footage.rs | 26 ++++++++++++++++- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 795ac0650..bec2440d6 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -8,8 +8,8 @@ use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; -use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; +use effectcraft_engine::render::{EvalCtx, Renderer}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -1194,16 +1194,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder - // for its codec / profile): say why on the layer instead of leaving a black picture. - let mut notices = 0; - for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { - continue; - }; - let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; - if notices >= 3 { - break; - } + // for its codec / profile): say why on the layer instead of leaving a black picture. A + // collapsed precomp draws its own layers, never the comp's proxy. + let problems: Vec<(&Layer, String)> = { + let r = Renderer::new(&snap_project, &*app.session.footage, Default::default()); + comp.layers + .iter() + .filter(|l| l.is_active_at(time) && l.switches.video && r.collapsed(&ectx, l).is_none()) + .filter_map(|layer| { + let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; + Some((layer, snap_project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) + }) + .take(3) + .collect() + }; + for (notices, (layer, why)) in problems.into_iter().enumerate() { let (m, _) = l2c(&ectx, layer); let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); @@ -1215,7 +1220,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); - notices += 1; } // Free transform box of a path (double-click a path with the Selection tool). diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index b1b12d6d2..63e3a75c2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -45,10 +45,14 @@ fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'st item.proxy = Some(Box::new(Proxy { footage, enabled: true })); } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + (open(s), clip) +} + +fn open(mut s: Session) -> Harness<'static, EffectcraftApp> { s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); - (h, clip) + h } #[test] @@ -78,3 +82,23 @@ fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); } + +/// A precomp whose proxy can't be decoded: the layer says so while it renders through the +/// proxy, but not with Collapse Transformations on, which draws the precomp's own layers. +#[test] +fn a_collapsed_precomp_has_no_notice_for_its_proxy() { + for collapse in [false, true] { + let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let p = Arc::make_mut(&mut s.project); + let inner = p.items.values_mut().find(|i| i.name == "Inner").unwrap(); + let proxy = Footage { kind: FootageKind::Video, path: "inner_proxy.avi".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + inner.proxy = Some(Box::new(Proxy { footage: proxy, enabled: true })); + let inner = inner.id; + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); + let h = open(s); + assert_eq!(h.state().auto.query("viewer.footageProblem.").is_empty(), collapse, "collapse {collapse}"); + } +} From 0378816923b0377d6ce3def7dc83006450556fa0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 27 Oct 2026 19:54:42 -0400 Subject: [PATCH 8/9] Export: pin the H.264 movie bytes the FilmCraft encoder this branch uses writes (#1701) --- crates/export/tests/it/movie_bytes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 5f1f8eb6b..c761f274c 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -94,7 +94,7 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec { #[test] fn h264_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); - assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4"); + assert_eq!((data.len(), fnv1a(&data)), (104_322, 11_051_847_956_767_423_392), "H.264 MP4"); } #[test] From d91b14db54848d3447b11bb5e36c29afdd904b03 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 29 Oct 2026 19:30:31 -0400 Subject: [PATCH 9/9] Viewer: a precomp's undecodable proxy has its notice with Collapse on, now that a proxy keeps the flattened path (#1701) --- crates/ui-egui/src/panels/viewer.rs | 8 +++----- crates/ui-egui/tests/it/ui_unreadable_footage.rs | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index a255bd321..d084dd006 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -8,8 +8,8 @@ use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; +use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; -use effectcraft_engine::render::{EvalCtx, Renderer}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -721,13 +721,11 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { } /// Up to three layers active at `time` whose footage, or the proxy in use for footage or a -/// precomp, can't be decoded, each with the reason. A collapsed precomp draws its own layers, -/// never the comp's proxy. +/// precomp, can't be decoded, each with the reason. fn footage_problems<'c>(app: &EffectcraftApp, ectx: &EvalCtx, comp: &'c Comp, time: Tick) -> Vec<(&'c Layer, String)> { - let r = Renderer::new(ectx.project, &*app.session.footage, Default::default()); comp.layers .iter() - .filter(|l| l.is_active_at(time) && l.switches.video && r.collapsed(ectx, l).is_none()) + .filter(|l| l.is_active_at(time) && l.switches.video) .filter_map(|layer| { let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; Some((layer, ectx.project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) diff --git a/crates/ui-egui/tests/it/ui_unreadable_footage.rs b/crates/ui-egui/tests/it/ui_unreadable_footage.rs index 63e3a75c2..a62884f43 100644 --- a/crates/ui-egui/tests/it/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/it/ui_unreadable_footage.rs @@ -83,10 +83,10 @@ fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); } -/// A precomp whose proxy can't be decoded: the layer says so while it renders through the -/// proxy, but not with Collapse Transformations on, which draws the precomp's own layers. +/// A precomp whose proxy can't be decoded: the layer says so with Collapse Transformations off +/// or on, since a proxy in use keeps the precomp on the flattened path that draws the proxy. #[test] -fn a_collapsed_precomp_has_no_notice_for_its_proxy() { +fn a_precomp_with_an_undecodable_proxy_has_a_notice_collapsed_or_not() { for collapse in [false, true] { let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); @@ -99,6 +99,6 @@ fn a_collapsed_precomp_has_no_notice_for_its_proxy() { s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); let h = open(s); - assert_eq!(h.state().auto.query("viewer.footageProblem.").is_empty(), collapse, "collapse {collapse}"); + assert!(!h.state().auto.query("viewer.footageProblem.").is_empty(), "collapse {collapse}"); } }