mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
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:
1 parent
5b31b96cde
commit
0d2153e259
6 files changed
+67
-25
No files matched your search
@@ -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": []}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user