test(media): review follow-ups for the aux footage set (#474)

- A watch-folder render gives the host session's set back after its own
  project replaced it on the shared footage source.
- Guard gpu fx_depth::IDS against reads_aux drifting apart.
- Count picture and channel decodes per thread instead of reading the
  shared channel cache, so parallel tests can't make the checks flaky.
- The engine test fails when no set was ever sent.
- Docs: Media Replacement items are learned at their first frame.

Refs #474

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-03 03:27:25 +01:00
1 parent 5b31b96cde
commit 0d2153e259
6 files changed
+67 -25

No files matched your search

+3
View File
@@ -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": []}),
+27 -2
View File
@@ -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::<String>::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::<String>::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);
}
+7
View File
@@ -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))]);
+28 -21
View File
@@ -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<Arc<[u8]>>) -> 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<u64> = 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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 |