perf(media): tell the media pool which EXR footage effects read (#474)

The pool learned that an effect reads a footage item's channels only at the
first aux() call, so the first frame was decoded twice (picture, then in
full), and the hint was never cleared after the effect was removed.

The session now gives the footage source the whole set of files read by
enabled 3D Channel effects (FootageSource::set_aux_footage) with every
project change (Session::bump: edits, undo, redo, history jumps, rollbacks,
project open), sending it only when it changed. The pool keeps that set and,
as a safety net, what aux() learns; a new set drops what was learned.

Refs #474

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-11-03 03:21:40 +01:00
co-authored by Claude Opus 5.5
parent 00a15d847e
commit 5b31b96cde
14 changed files with 412 additions and 12 deletions
+175
View File
@@ -0,0 +1,175 @@
//! The footage files effects read the channels of (#474): the session gives the footage source
//! the whole set with every project change ([`Session::bump`]), so a multi-layer OpenEXR read by
//! EXtractoR or Cryptomatte decodes its picture and channels at once from the first frame, and
//! goes back to its picture alone once no effect reads it.
use std::sync::{Arc, Mutex};
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Project};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::Session;
use crate::render::{FootageSource, RenderOpts};
/// Records every set it is given, and the set it had when it was first asked for a frame.
#[derive(Default)]
struct Recorder {
sets: Mutex<Vec<Vec<String>>>,
at_first_frame: Mutex<Option<Option<Vec<String>>>>,
}
impl Recorder {
fn last(&self) -> Option<Vec<String>> {
self.sets.lock().unwrap().last().cloned()
}
fn calls(&self) -> usize {
self.sets.lock().unwrap().len()
}
}
impl FootageSource for Recorder {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
self.at_first_frame.lock().unwrap().get_or_insert_with(|| self.last());
Some(Arc::new(Image::new(f.width, f.height)))
}
fn set_aux_footage(&self, paths: &[String]) {
self.sets.lock().unwrap().push(paths.to_vec());
}
}
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(Footage {
path: path.into(),
kind: FootageKind::Still,
width: 8,
height: 4,
has_video: true,
frame_rate: FrameRate::from_f64(24.0),
duration: Tick::from_seconds_f64(2.0),
..Default::default()
})
}
}
fn session(rec: &Arc<Recorder>) -> Session {
let mut s = Session { footage: rec.clone(), importer: Some(Arc::new(Probe)), ..Default::default() };
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s
}
fn import(s: &mut Session, path: &str) -> serde_json::Value {
s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].clone()
}
/// Add a layer of `item` to the active comp; its layer id.
fn add_layer(s: &mut Session, item: &serde_json::Value) -> u64 {
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.state.selected_layers[0].0
}
fn paths(v: &[&str]) -> Option<Vec<String>> {
Some(v.iter().map(|p| p.to_string()).collect())
}
/// Adding, disabling, removing an effect and undoing or redoing each change the set; edits that
/// don't change it don't send it again.
#[test]
fn effect_edits_undo_and_redo_update_the_set() {
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);
assert_eq!(rec.last().unwrap_or_default(), Vec::<String>::new(), "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");
s.execute_checked("effect.apply", json!({"effect": "EXtractoR", "layers": [l]})).unwrap();
assert_eq!(rec.last(), paths(&["/renders/a.exr"]));
assert!(s.undo());
assert_eq!(rec.last(), paths(&[]), "undoing the add");
assert!(s.redo());
assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "redoing it");
let calls = rec.calls();
s.execute_checked("effect.toggle", json!({"layer": l, "effect": "Gaussian Blur", "value": false})).unwrap();
assert_eq!(rec.calls(), calls, "the set didn't change: it isn't sent again");
s.execute_checked("effect.toggle", json!({"layer": l, "effect": "EXtractoR", "value": false})).unwrap();
assert_eq!(rec.last(), paths(&[]), "a disabled effect reads nothing");
s.execute_checked("effect.toggle", json!({"layer": l, "effect": "EXtractoR", "value": true})).unwrap();
assert_eq!(rec.last(), paths(&["/renders/a.exr"]));
s.execute_checked("effect.remove", json!({"layer": l, "effect": "EXtractoR"})).unwrap();
assert_eq!(rec.last(), paths(&[]), "removed");
assert!(s.undo());
assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "undoing the removal");
assert!(s.redo());
assert_eq!(rec.last(), paths(&[]), "redoing it");
assert!(s.undo());
s.execute_checked("effect.duplicate", json!({"layer": l, "effect": "EXtractoR"})).unwrap();
s.execute_checked("effect.remove", json!({"layer": l, "effect": "EXtractoR"})).unwrap();
assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "the duplicate still reads it");
}
/// Opening a project gives the set before the first frame is rendered; opening another sends
/// it again even when it is empty.
#[test]
fn opening_a_project_gives_the_set_before_the_first_frame() {
let made = Arc::new(Recorder::default());
let mut s = session(&made);
let item = import(&mut s, "/renders/crypto.exr");
let l = add_layer(&mut s, &item);
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte", "layers": [l]})).unwrap();
let project: Project = (*s.project).clone();
let comp = s.state.active_comp.unwrap();
let rec = Arc::new(Recorder::default());
let mut s = Session { footage: rec.clone(), ..Default::default() };
s.replace_project(project, None);
assert_eq!(rec.last(), paths(&["/renders/crypto.exr"]));
s.render(comp, Tick::ZERO, RenderOpts::default());
assert_eq!(*rec.at_first_frame.lock().unwrap(), Some(paths(&["/renders/crypto.exr"])), "given before the first frame");
s.replace_project(Project::default(), None);
assert_eq!(rec.last(), paths(&[]));
let calls = rec.calls();
s.replace_project(Project::default(), None);
assert_eq!(rec.calls(), calls + 1, "each opened project gets its set");
}
/// Two footage items of one file: the file stays in while an effect reads either of them.
#[test]
fn two_items_of_one_file() {
let rec = Arc::new(Recorder::default());
let mut s = session(&rec);
let a = import(&mut s, "/renders/shared.exr");
let f = match &s.project.item(ItemId(a.as_u64().unwrap())).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
k => panic!("{k:?}"),
};
let b = s.edit("Add", None, |p, _| Ok(p.add_item("shared copy", crate::color::Label::None, None, ItemKind::Footage(f)))).unwrap();
let la = add_layer(&mut s, &a);
let lb = add_layer(&mut s, &json!(b.0));
s.execute_checked("effect.apply", json!({"effect": "EXtractoR", "layers": [la]})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "ID Matte", "layers": [lb]})).unwrap();
assert_eq!(rec.last(), paths(&["/renders/shared.exr"]), "once");
s.execute_checked("effect.remove", json!({"layer": la, "effect": "EXtractoR"})).unwrap();
assert_eq!(rec.last(), paths(&["/renders/shared.exr"]), "the other item still reads it");
s.execute_checked("effect.remove", json!({"layer": lb, "effect": "ID Matte"})).unwrap();
assert_eq!(rec.last(), paths(&[]));
}
/// Replacing a layer's source or an item's file moves the set to the new file.
#[test]
fn source_and_footage_replacement_follow() {
let rec = Arc::new(Recorder::default());
let mut s = session(&rec);
let a = import(&mut s, "/renders/a.exr");
let b = import(&mut s, "/renders/b.exr");
let l = add_layer(&mut s, &a);
s.execute_checked("effect.apply", json!({"effect": "Depth Matte", "layers": [l]})).unwrap();
assert_eq!(rec.last(), paths(&["/renders/a.exr"]));
s.execute_checked("layer.replaceSource", json!({"layers": [l], "item": b})).unwrap();
assert_eq!(rec.last(), paths(&["/renders/b.exr"]), "the layer's new source");
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");
}