diff --git a/crates/engine/src/cmd/watch_folder.rs b/crates/engine/src/cmd/watch_folder.rs index fd41071ed..6e480a9e6 100644 --- a/crates/engine/src/cmd/watch_folder.rs +++ b/crates/engine/src/cmd/watch_folder.rs @@ -88,6 +88,9 @@ fn render_project(host: &Session, path: &Path) -> Value { let failed = s.project.render_queue.iter().any(|i| matches!(i.status, RenderStatus::Failed(_))); Ok(json!({"state": if failed { "failed" } else { "done" }, "items": items})) })(); + // The project shared `host`'s footage source and gave it its own set of footage effects read + // the channels of (#474): give back the host's. + host.footage.set_aux_footage(host.aux_footage.as_deref().unwrap_or(&[])); let mut v = match result { Ok(v) => v, Err(e) => json!({"state": "failed", "error": e, "items": []}), diff --git a/crates/engine/src/tests/aux_footage.rs b/crates/engine/src/tests/aux_footage.rs index 5a74d3b77..01c3c1c96 100644 --- a/crates/engine/src/tests/aux_footage.rs +++ b/crates/engine/src/tests/aux_footage.rs @@ -83,9 +83,9 @@ fn effect_edits_undo_and_redo_update_the_set() { let mut s = session(&rec); let item = import(&mut s, "/renders/a.exr"); let l = add_layer(&mut s, &item); - assert_eq!(rec.last().unwrap_or_default(), Vec::::new(), "no effect reads it yet"); + assert_eq!(rec.last(), paths(&[]), "no effect reads it yet"); s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur", "layers": [l]})).unwrap(); - assert_eq!(rec.last().unwrap_or_default(), Vec::::new(), "a blur doesn't read channels"); + assert_eq!(rec.last(), paths(&[]), "a blur doesn't read channels"); s.execute_checked("effect.apply", json!({"effect": "EXtractoR", "layers": [l]})).unwrap(); assert_eq!(rec.last(), paths(&["/renders/a.exr"])); assert!(s.undo()); @@ -173,3 +173,28 @@ fn source_and_footage_replacement_follow() { s.execute_checked("file.replaceFootage", json!({"item": b, "path": "/renders/c.exr"})).unwrap(); assert_eq!(rec.last(), paths(&["/renders/c.exr"]), "the item's new file"); } + +/// A watch-folder render opens its project in a session sharing the host's footage source: the +/// host's set is given back afterwards. +#[test] +fn a_watch_folder_render_gives_the_host_set_back() { + let dir = std::env::temp_dir().join(format!("ec-aux-footage-watch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut other = Session::default(); + other.execute("comp.new", json!({"name": "Idle", "width": 8, "height": 8, "duration": 1})).unwrap(); + other.execute("file.saveAs", json!({"path": dir.join("idle.ecproj").to_string_lossy()})).unwrap(); + + let rec = Arc::new(Recorder::default()); + let mut s = session(&rec); + let item = import(&mut s, "/renders/a.exr"); + let l = add_layer(&mut s, &item); + s.execute_checked("effect.apply", json!({"effect": "EXtractoR", "layers": [l]})).unwrap(); + let calls = rec.calls(); + let r = s.execute("file.watchFolder", json!({"folder": dir.to_string_lossy()})).unwrap(); + assert_eq!(r["rendered"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(rec.calls() > calls + 1, "the watched project gave its own set"); + assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "the host's set is back"); + s.execute("file.watchFolder", json!({"stop": true})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/gpu/src/tests/fx_depth.rs b/crates/gpu/src/tests/fx_depth.rs index 8a6665655..ebc75919d 100644 --- a/crates/gpu/src/tests/fx_depth.rs +++ b/crates/gpu/src/tests/fx_depth.rs @@ -208,6 +208,13 @@ fn on() -> Value { Value::Bool(true) } +/// #474: every effect done here reads aux channels, so the session gives the media layer its +/// footage ([`effectcraft_effects::reads_aux`]). +#[test] +fn every_effect_here_reads_aux() { + assert!(crate::fx_depth::IDS.iter().all(|i| effectcraft_effects::reads_aux(i))); +} + #[test] fn channel_extract() { case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1200.0))]); diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 3b5ee6084..c3e7cfc3d 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -653,7 +653,7 @@ impl ChannelCache { decoded_here = true; self.decodes.fetch_add(1, Ordering::Relaxed); #[cfg(test)] - count_decode(); + count_decode(true); let _abandon = Abandon { cache: self, slot: &slot }; let bytes = read()?; // The planes it will allocate count against the budget while it decodes. @@ -742,21 +742,27 @@ pub(crate) fn cached(path: &str, read: impl FnOnce() -> Option>) -> Op #[cfg(test)] thread_local! { - /// Decodes of a still's pixels on this thread: the channel cache's and the `image` crate's - /// ([`count_decode`]). - static DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Decodes of a still's pixels on this thread ([`count_decode`]): the `image` crate's, then + /// the channel cache's. + static DECODES: std::cell::Cell<[u64; 2]> = const { std::cell::Cell::new([0; 2]) }; } -/// Count a decode of a file's pixels on this thread (tests). +/// Count a decode of a file's pixels on this thread (tests): by the channel cache (`channels`) +/// or by the `image` crate. #[cfg(test)] -pub(crate) fn count_decode() { - DECODES.with(|d| d.set(d.get() + 1)); +pub(crate) fn count_decode(channels: bool) { + DECODES.with(|d| { + let mut n = d.get(); + n[usize::from(channels)] += 1; + d.set(n); + }); } -/// The decodes [`count_decode`] counted on this thread since the last call. +/// The decodes [`count_decode`] counted on this thread since the last call: by the `image` crate +/// and by the channel cache. #[cfg(test)] -pub(crate) fn take_decodes() -> u64 { - DECODES.with(|d| d.replace(0)) +pub(crate) fn take_decodes() -> [u64; 2] { + DECODES.with(|d| d.replace([0; 2])) } #[cfg(test)] @@ -1088,11 +1094,11 @@ mod tests { pool.frame_at(&f[given], t).unwrap(); let aux = pool.aux(effectcraft_project::ItemId(1), &f[given], t).unwrap(); assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); - assert_eq!(take_decodes(), 1, "the picture and the channels share one decode"); + assert_eq!(take_decodes(), [0, 1], "the picture and the channels share one decode"); // Learned only at the first `aux()`: the picture was decoded on its own before. pool.frame_at(&f[learned], t).unwrap(); pool.aux(effectcraft_project::ItemId(2), &f[learned], t).unwrap(); - assert_eq!(take_decodes(), 2); + assert_eq!(take_decodes(), [1, 1]); } /// #474: once the project no longer names footage (its effect removed), later frames decode @@ -1109,21 +1115,22 @@ mod tests { f.kind = effectcraft_project::FootageKind::Sequence; f.sequence = paths.clone(); let at = |i: i64| f.frame_rate.tick_of(i); - let in_cache = |p: &str| cache().cached().iter().any(|c| c == p); + // (counted on this thread, so other tests sharing the channel cache don't change them) + let frame = |i: i64| { + take_decodes(); + pool.frame_at(&f, at(i)).unwrap(); + take_decodes() + }; pool.set_aux_footage(std::slice::from_ref(&f.path)); - pool.frame_at(&f, at(0)).unwrap(); - assert!(in_cache(&paths[0]), "given: the picture comes from the channel decode"); + assert_eq!(frame(0), [0, 1], "given: the picture comes from the channel decode"); pool.set_aux_footage(&[]); assert!(!cache().lock().held.iter().any(|(p, _)| *p == paths[0]), "no longer kept once no effect reads it"); - pool.frame_at(&f, at(1)).unwrap(); - assert!(!in_cache(&paths[1]), "no longer given: the picture alone"); + assert_eq!(frame(1), [1, 0], "no longer given: the picture alone"); // Learned by `aux()`, then a new set without it: picture only again. pool.aux(effectcraft_project::ItemId(1), &f, at(1)).unwrap(); - pool.frame_at(&f, at(2)).unwrap(); - assert!(in_cache(&paths[2]), "learned"); + assert_eq!(frame(2), [0, 1], "learned"); pool.set_aux_footage(&[]); - pool.frame_at(&f, at(3)).unwrap(); - assert!(!in_cache(&paths[3]), "a new set drops what was learned"); + assert_eq!(frame(3), [1, 0], "a new set drops what was learned"); } /// #474: the picture made of the cached planes of a file with exact R, G, B channels is the diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index e9157a5ca..b0b2f2539 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -885,7 +885,7 @@ impl Inner { return Ok(img); } #[cfg(test)] - crate::exr_channels::count_decode(); + crate::exr_channels::count_decode(false); match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) { Ok(img) => Ok(dynamic_to_image(&img, op)), // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f56a57319..ba8c1db7a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -41,7 +41,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | PNG | R/W | yes | yes (sequence) | good | | JPEG | R/W | yes | yes (sequence) | good | | TIFF | R/W | yes | yes (sequence) | good | -| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte from the first frame on, except files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 | +| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte from the first frame on, except Media Replacement items, which are learned at their first frame, and files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 | | Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec | | Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | | SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't |