Cryptomatte pick: keep a hover probe, built off the UI thread (#473)

effect.cryptomatteAt rendered the effect's input on every call to learn its pixel grid (up
to ~1 s per hover on a 3D precomp). The aux channels and that grid are now kept in a
CryptoProbe on the Session, keyed by the project revision, layer, effect, frame, scale and
render prefs (nested switches, draft shadows). On footage with nothing animated before the
Cryptomatte effect the frame is the source frame, so a still or a held frame keeps one
probe for every comp time. The effect's own Selection doesn't count: the probe never reads
it, so an animated Selection keeps the probe too.

Session::crypto_probe_ready says whether a query answers from the kept probe and changes
nothing. Session::crypto_probe_job builds it on another thread for the viewer; asking for a
job is starting it. Each started job has a generation: it stores its result only while it
is the newest, a newer job or an edit cancels it, and a panic in the build becomes the
probe's error. A cancelled build checks its generation between its stages (reading the IDs,
rendering the effect's input), so it stops early and lets go of what it read. `probeOnly`
makes cryptomatteAt / pickCryptomatte answer {pending} instead of building on the caller.

A pick re-keys the probe (it only edits the Selection), and undo, redo and history jumps
over picks keep it too (each pick is labelled "Pick Cryptomatte"). Any other edit, footage
changes, Open / New and Session::drop_crypto_probe (the eyedropper disarmed) let it go. The
slot's type is crate-private (the Session field stays public, doc(hidden), so other crates
can still build a Session); crypto_probe_stats and crypto_probe_jobs (jobs run, cancelled
ones included) are doc(hidden), for tests.
This commit is contained in:
Matej Kozlovský
2026-10-28 12:29:41 +01:00
parent fa9cf47175
commit 4407e82c62
7 changed files with 822 additions and 30 deletions
+2
View File
@@ -17,6 +17,8 @@ pub mod content_fill;
mod create;
mod edit;
mod effect;
pub use effect::CryptoProbeJob;
pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
pub mod essential;
pub mod expr_tools;
mod file;
+310 -25
View File
@@ -190,6 +190,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
struct CryptoHit {
at: (effectcraft_project::ItemId, LayerId, Tick),
probe: std::sync::Arc<CryptoProbe>,
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
layer: String,
hash: u32,
@@ -210,13 +211,135 @@ impl CryptoHit {
}
}
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
/// (the viewer's resolution).
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
/// What a pick at one frame needs besides the point: the aux channels the Cryptomatte effect
/// reads, its layer, and the effect's input buffer grid (scale, offset). Building it can render
/// (a precomp's ID pass, the effects before the Cryptomatte effect); a point lookup in it is a
/// few array reads. The session keeps the last one (see [`Session::crypto_probe_job`]).
pub struct CryptoProbe {
key: ProbeKey,
grid: std::result::Result<ProbeGrid, String>,
}
#[derive(Clone)]
struct ProbeGrid {
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
layer: String,
scale: f64,
offset: [f64; 2],
}
/// What a probe depends on: the project revision, the effect (and its index), the frame, the
/// scale and the render prefs ([`Session::render_opts`]: nested switches, draft shadows).
#[derive(Clone, Copy, PartialEq, Debug)]
struct ProbeKey {
revision: u64,
at: (effectcraft_project::ItemId, LayerId, Uid, usize),
frame: ProbeFrame,
scale: f64,
prefs: (bool, bool),
}
/// The frame a probe reads. On footage whose masks and effects up to the Cryptomatte effect
/// don't change over time, its source frame: a still has one, held or remapped frames share
/// theirs. Otherwise the comp time.
#[derive(Clone, Copy, PartialEq, Debug)]
enum ProbeFrame {
Source(i64),
Comp(Tick),
}
/// The session's kept probe, and the generation of the latest request: a build stores its
/// result only while no newer request or edit came since.
#[derive(Default)]
pub struct CryptoProbeSlot {
probe: Option<std::sync::Arc<CryptoProbe>>,
want: Option<ProbeKey>,
generation: u64,
/// Jobs run (each on its thread), and those that built (not cancelled before they began).
jobs: u64,
builds: u64,
}
impl CryptoProbeSlot {
/// Drop the kept probe and cancel builds in flight.
pub(crate) fn clear(&mut self) {
(self.probe, self.want) = (None, None);
self.generation += 1;
}
pub(crate) fn kept(&self) -> bool {
self.probe.is_some()
}
pub(crate) fn builds(&self) -> (u64, u64) {
(self.jobs, self.builds)
}
}
/// A probe built off the UI thread into the session's slot.
pub type CryptoProbeJob = Box<dyn FnOnce() + Send>;
const NO_CRYPTO: &str = "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)";
impl CryptoProbe {
/// `None` when `live` turns false between its stages (a newer request or an edit since):
/// a cancelled build stops early and lets go of what it read.
fn build(r: &effectcraft_render::Renderer, key: ProbeKey, time: Tick, live: &dyn Fn() -> bool) -> Option<CryptoProbe> {
let (cid, lid, _, pos) = key.at;
let build = || -> std::result::Result<Option<ProbeGrid>, String> {
let comp = r.project.comp(cid).ok_or("no composition")?;
let layer = comp.layer(lid).ok_or("no such layer")?;
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
if !live() {
return Ok(None);
}
let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?;
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale
// up, proxies have their own size, earlier effects can pad it), rendered and cached.
if !live() {
return Ok(None);
}
let b = r.layer_input(&ctx, layer, pos).ok_or("this layer has no pixels")?;
Ok(Some(ProbeGrid { aux, layer: layer_name, scale: b.scale, offset: b.offset }))
};
// On another thread no command guard runs: a panic becomes the probe's error.
let grid = crate::guard::guarded("Reading the Cryptomatte IDs", || Ok(build())).unwrap_or_else(|e| Err(e.to_string()));
Some(CryptoProbe { key, grid: grid.transpose()? })
}
}
/// Whether a property in `g` other than `skip` changes over time (keyframes or an expression).
fn moves(g: &PropGroup, skip: Option<Uid>) -> bool {
g.children.iter().any(|c| match c {
effectcraft_project::Node::Prop(p) => Some(p.uid) != skip && (p.is_animated() || p.has_expression()),
effectcraft_project::Node::Group(g) => moves(g, skip),
})
}
/// [`ProbeFrame`] of the effect at index `pos` of `layer`.
fn probe_frame(ctx: &effectcraft_render::EvalCtx, layer: &Layer, pos: usize) -> ProbeFrame {
let comp = ProbeFrame::Comp(ctx.time);
let effectcraft_project::LayerSource::Footage { item } = layer.source else { return comp };
let Some(effectcraft_project::ItemKind::Footage(f)) = ctx.project.item(item).map(|i| &i.kind) else { return comp };
let fx = layer.effects().into_iter().flat_map(|fx| fx.groups().take(pos + 1));
let time_fx = |g: &PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effect.starts_with("ec.time."));
let still = f.kind == effectcraft_project::FootageKind::Still;
let blend = !still && ctx.comp.enable_frame_blending && layer.switches.frame_blend != effectcraft_project::FrameBlend::Off;
// The Cryptomatte effect's own Selection isn't read by the probe: animated, it keeps it.
let own = |i: usize, g: &PropGroup| if i == pos { g.prop("selection").map(|p| p.uid) } else { None };
if blend || layer.masks().is_some_and(|m| moves(m, None)) || fx.enumerate().any(|(i, g)| moves(g, own(i, g)) || time_fx(g)) {
return comp;
}
ProbeFrame::Source(if still { 0 } else { effectcraft_render::frame_position(ctx.source_time(layer), f.frame_rate).0 })
}
/// The Cryptomatte effect `{layer?, effect?, scale?, time?}` names: its probe key, the comp
/// time, its layer, group and Selection property.
#[allow(clippy::type_complexity)]
fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick, &'a Layer, &'a PropGroup, &'a effectcraft_project::Property)> {
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let scale = f_p(p, "scale").unwrap_or(1.0);
if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
@@ -227,30 +350,175 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
let aux = r.layer_aux(&ctx, layer);
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
let time = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let frame = probe_frame(&ctx, layer, pos);
let o = s.render_opts(Default::default());
let prefs = (o.nested_switches, o.draft_shadows);
Ok((ProbeKey { revision: s.revision, at: (cid, lid, fx_uid, pos), frame, scale, prefs }, time, layer, g, prop))
}
/// The kept probe if it answers for the current revision, for [`keep_crypto_probe`] (taken
/// before an undo, redo or history jump).
pub(crate) fn kept_crypto_probe(s: &Session) -> Option<std::sync::Arc<CryptoProbe>> {
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key.revision == s.revision)
}
/// After an undo, redo or history jump from `before` (which dropped the probe): `kept`, the
/// probe from before, still answers when only Cryptomatte Selections changed, which the probe
/// doesn't read (undoing a pick, as the pick itself keeps it).
pub(crate) fn keep_crypto_probe(s: &Session, kept: Option<std::sync::Arc<CryptoProbe>>, before: &effectcraft_project::Project) {
let Some(p) = kept else { return };
if !only_crypto_selections_differ(before, &s.project) {
return;
}
if let Ok(mut slot) = s.crypto_probe.lock()
&& slot.probe.is_none()
{
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key: ProbeKey { revision: s.revision, ..p.key }, grid: p.grid.clone() }));
}
}
/// Whether projects `a` and `b` differ at most in the Selections of Cryptomatte effects.
fn only_crypto_selections_differ(a: &effectcraft_project::Project, b: &effectcraft_project::Project) -> bool {
let mut x = a.clone();
for (cid, it) in x.items.iter_mut() {
let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
let sels: Vec<(LayerId, Uid)> = c
.layers
.iter()
.flat_map(|l| {
l.effects()
.into_iter()
.flat_map(|fx| fx.groups())
.filter(|g| g.match_id == "ec.3d.cryptomatte")
.filter_map(move |g| g.get("selection").map(|p| (l.id, p.uid)))
})
.collect();
if sels.is_empty() {
continue;
}
let Some(bc) = b.comp(*cid) else { return false };
let c = std::sync::Arc::make_mut(c);
for (lid, uid) in sels {
let Some(src) = bc.layer(lid).and_then(|l| l.props.find(uid)) else { return false };
if let Some(dst) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) {
*dst = src.clone();
}
}
}
x.same_content(b)
}
/// The session's probe when it has `key`'s.
fn cached_probe(s: &Session, key: ProbeKey) -> Option<std::sync::Arc<CryptoProbe>> {
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key == key)
}
/// [`Session::crypto_probe_ready`].
pub(crate) fn crypto_probe_ready(s: &Session, p: &Value) -> Result<bool> {
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
Ok(cached_probe(s, key).is_some())
}
/// [`Session::crypto_probe_job`].
pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoProbeJob>> {
let (key, time, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
let generation = {
let mut slot = s.crypto_probe.lock().map_err(|_| EngineError::Other("the Cryptomatte probe is unavailable".into()))?;
if slot.probe.as_ref().is_some_and(|p| p.key == key) {
return Ok(None);
}
// A request for another probe cancels the one in flight.
if slot.want != Some(key) {
(slot.want, slot.generation) = (Some(key), slot.generation + 1);
}
slot.generation
};
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
// proxies have their own size, earlier effects can pad it), rendered and cached.
let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
let (project, footage, expr, cache, accel, slot) =
(s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone());
let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
// The slot while this is still the newest request.
fn current(slot: &std::sync::Mutex<CryptoProbeSlot>, generation: u64) -> Option<std::sync::MutexGuard<'_, CryptoProbeSlot>> {
slot.lock().ok().filter(|s| s.generation == generation)
}
Ok(Some(Box::new(move || {
match slot.lock() {
Ok(mut s) if s.generation == generation => (s.jobs, s.builds) = (s.jobs + 1, s.builds + 1),
Ok(mut s) => return s.jobs += 1,
Err(_) => return,
}
let mut r = effectcraft_render::Renderer::new(&project, footage.as_ref(), opts);
r.expr = expr.as_deref();
r.cache = Some(&cache);
r.accel = accel.as_deref();
// Checked between its stages too: a superseded build stops at the next one.
let Some(probe) = CryptoProbe::build(&r, key, time, &|| current(&slot, generation).is_some()) else { return };
let probe = std::sync::Arc::new(probe);
// Only the newest request's result, and never over a newer probe.
if let Some(mut s) = current(&slot, generation)
&& s.probe.as_ref().is_none_or(|p| p.key.revision <= key.revision)
{
s.probe = Some(probe);
}
})))
}
/// What a pick finds at a point.
enum Hit {
/// `probeOnly` and the probe isn't built yet.
Pending,
Nothing,
Id(CryptoHit),
}
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
/// (the viewer's resolution). Without a kept probe it builds one, unless `probeOnly`.
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
let (key, time, layer, g, prop) = probe_key(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let probe = match cached_probe(s, key) {
Some(probe) => probe,
None if b_p(p, "probeOnly").unwrap_or(false) => return Ok(Hit::Pending),
None => {
let r = s.renderer(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
if let Ok(mut slot) = s.crypto_probe.lock() {
slot.clear();
slot.builds += 1;
}
// (Never cancelled here: nothing else runs on this thread.)
let Some(probe) = CryptoProbe::build(&r, key, time, &|| true).map(std::sync::Arc::new) else { return Ok(Hit::Pending) };
if let Ok(mut slot) = s.crypto_probe.lock() {
slot.probe = Some(probe.clone());
}
probe
}
};
let b = probe.grid.as_ref().map_err(|e| bad(cmd, e.clone()))?;
let (bs, o) = (b.scale, b.offset);
// The centre of its pixel under the point, at the aux point the effect reads there.
let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
let (cid, lid, ..) = key.at;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
Ok(Hit::Id(CryptoHit { at: (cid, lid, time), probe: probe.clone(), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
}
/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
/// frame renders the effect's input to learn its pixel grid (cached afterwards).
/// frame renders the effect's input to learn its pixel grid (kept afterwards); with `probeOnly`
/// it answers `{pending: true}` instead.
fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
Ok(match crypto_hit(s, p, "effect.cryptomatteAt")? {
Hit::Pending => json!({"pending": true}),
Hit::Nothing => Value::Null,
Hit::Id(h) => h.reply(&h.selection),
})
}
/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
@@ -264,7 +532,11 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
"remove" => PickMode::Remove,
m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
};
let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
let h = match crypto_hit(s, p, cmd)? {
Hit::Id(h) => h,
Hit::Pending => return Ok(json!({"pending": true, "changed": false})),
Hit::Nothing => return Err(bad(cmd, "no Cryptomatte ID there (empty or outside the layer)")),
};
if h.driven {
return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
}
@@ -274,6 +546,19 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
if changed {
let (cid, lid, t) = h.at;
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
// The step says what made it (Edit ▸ Undo Pick Cryptomatte, the History panel), not
// "Change Property" like any other edit. (A changed Selection is always a new step.)
if let Some(step) = s.history.undo.last_mut() {
step.0 = "Pick Cryptomatte".into();
}
// Only the Selection changed, which the probe doesn't read: the next hover keeps it.
// (The edit dropped it; a probe built since is newer.)
if let Ok(mut slot) = s.crypto_probe.lock()
&& slot.probe.is_none()
{
let key = ProbeKey { revision: s.revision, ..h.probe.key };
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key, grid: h.probe.grid.clone() }));
}
}
let mut out = h.reply(&sel);
out["selection"] = json!(sel);
@@ -732,14 +1017,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Cryptomatte",
[],
None,
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?, probeOnly?} → {name, hash, layer, selection, changed, selected} | {pending} — edits the Cryptomatte effect's Selection with the ID under the point",
has_layers,
pick_crypto
),
crate::query!(
"effect.cryptomatteAt",
"Cryptomatte At",
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?, probeOnly?: bool (don't build the probe)} → {name, hash, layer, selected} | {pending} | null — the Cryptomatte ID under the point",
crypto_at
),
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
+2
View File
@@ -345,6 +345,7 @@ impl Session {
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
let edited = crate::commands::text_edit::active_editor_snapshot(self);
let probe = crate::commands::kept_crypto_probe(self);
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
self.keep_view_settings(&cur);
self.history.undo = undo;
@@ -353,6 +354,7 @@ impl Session {
self.history.merge_key = None;
self.sanitize_after_replacement(edited.as_ref());
self.bump();
crate::commands::keep_crypto_probe(self, probe, &cur);
true
}
}
+5
View File
@@ -162,6 +162,10 @@ pub struct Session {
pub journal: Vec<(String, Value)>,
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
pub layer_cache: Arc<LayerCache>,
/// The last Cryptomatte pick probe (see [`Session::crypto_probe_job`]). Public only so that
/// other crates can build a `Session { .. }`; its type is crate-private.
#[doc(hidden)]
pub crypto_probe: Arc<std::sync::Mutex<commands::CryptoProbeSlot>>,
/// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
/// Settings (Preferences).
@@ -310,6 +314,7 @@ impl Default for Session {
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
crypto_probe: Default::default(),
disk_cache: None,
prefs: prefs::Prefs::default(),
prefs_revision: 0,
+8 -2
View File
@@ -91,6 +91,8 @@ impl Session {
pub fn bump(&mut self) {
self.revision += 1;
// A Cryptomatte probe is keyed by the revision: an older one never answers again.
self.drop_crypto_probe();
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
if Arc::ptr_eq(&self.project, &self.saved_project) {
self.saved_revision = self.revision;
@@ -166,12 +168,14 @@ impl Session {
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let probe = commands::kept_crypto_probe(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_after_replacement(edited.as_ref());
self.bump();
commands::keep_crypto_probe(self, probe, &cur);
self.history.redo.push((label, cur));
true
}
@@ -182,12 +186,14 @@ impl Session {
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let probe = commands::kept_crypto_probe(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_after_replacement(edited.as_ref());
self.bump();
commands::keep_crypto_probe(self, probe, &cur);
self.history.undo.push((label, cur));
true
}
+44 -3
View File
@@ -1,4 +1,4 @@
use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick};
use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick, Value, commands};
impl Session {
/// Render a comp frame with the session's footage + expression hosts.
@@ -9,14 +9,54 @@ impl Session {
/// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and
/// accelerator, and the render prefs (nested switches, draft shadows) over `opts`.
pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> {
let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
let mut r = Renderer::new(&self.project, self.footage.as_ref(), self.render_opts(opts));
r.expr = self.expr.as_deref();
r.cache = Some(&self.layer_cache);
r.accel = self.accel.as_deref();
r
}
/// `opts` with the render prefs (nested switches, draft shadows), as [`Session::renderer`].
pub fn render_opts(&self, opts: RenderOpts) -> RenderOpts {
RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }
}
/// Whether `effect.cryptomatteAt` with params `p` answers from the kept probe (a few array
/// reads; `probeOnly` queries answer `pending` until then). Changes nothing: ask it every
/// frame, and start [`Session::crypto_probe_job`] only when a build will actually run.
pub fn crypto_probe_ready(&self, p: &Value) -> Result<bool> {
commands::crypto_probe_ready(self, p)
}
/// For the Cryptomatte eyedropper: `None` when `effect.cryptomatteAt` with params `p` answers
/// from the kept probe, else a job that builds that probe (it may render a precomp's ID pass)
/// off the UI thread, for the next query to use. Asking for a job supersedes a build in
/// flight for another probe (it then stores nothing): ask only to run it.
pub fn crypto_probe_job(&self, p: &Value) -> Result<Option<commands::CryptoProbeJob>> {
commands::crypto_probe_job(self, p)
}
/// Let go of the kept Cryptomatte probe (and its aux channels), and cancel a build in
/// flight: the eyedropper was disarmed. Edits, footage changes and Open / New do it too.
pub fn drop_crypto_probe(&self) {
if let Ok(mut slot) = self.crypto_probe.lock() {
slot.clear();
}
}
/// Whether a Cryptomatte probe is kept, and how many were built so far (for tests).
#[doc(hidden)]
pub fn crypto_probe_stats(&self) -> (bool, u64) {
self.crypto_probe.lock().map_or((false, 0), |s| (s.kept(), s.builds().1))
}
/// How many probe jobs ran so far, built or cancelled before they began (for tests: each
/// is a thread in the eyedropper).
#[doc(hidden)]
pub fn crypto_probe_jobs(&self) -> u64 {
self.crypto_probe.lock().map_or(0, |s| s.builds().0)
}
/// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled
/// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba).
pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec<u8>)> {
@@ -95,5 +135,6 @@ impl Session {
self.footage.forget(p);
}
self.layer_cache.clear();
self.drop_crypto_probe();
}
}
+451
View File
@@ -334,3 +334,454 @@ fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
}
}
/// `w` × `h` footage whose Cryptomatte has an ID per 37 × 29 px cell (`c<col>_<row>`, all in the
/// manifest), one aux pixel per layer pixel. Built once, as the media pool keeps decoded aux.
struct Cells(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
impl Cells {
fn name(x: usize, y: usize) -> String {
format!("c{}_{}", x / 37, y / 29)
}
}
impl FootageSource for Cells {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
Some(self.2.get_or_init(|| Arc::new(self.build())).clone())
}
}
impl Cells {
fn build(&self) -> AuxChannels {
let (w, h) = (self.0 as usize, self.1 as usize);
let ids = (0..w * h).map(|i| crypto_float(crypto_hash(&Cells::name(i % w, i / w)))).collect();
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
let names = (0..w.div_ceil(37)).flat_map(|x| (0..h.div_ceil(29)).map(move |y| Cells::name(x * 37, y * 29)));
aux.manifests = vec![("CryptoObject".into(), names.map(|n| (n.clone(), crypto_hash(&n))).collect())];
aux
}
}
struct CellsProbe(u32, u32);
impl crate::Importer for CellsProbe {
fn probe(&self, path: &str) -> Result<Footage, String> {
// `…_proxy` is a half-size proxy (rounded up); `…still…` a still image.
let (width, height) = if path.contains("proxy") { (self.0.div_ceil(2), self.1.div_ceil(2)) } else { (self.0, self.1) };
let (frame_rate, duration) = (FrameRate::from_f64(24.0), Tick::from_seconds_f64(2.0));
let kind = if path.contains("still") { FootageKind::Still } else { FootageKind::Video };
Ok(Footage { path: path.into(), kind, width, height, has_video: true, frame_rate, duration, ..Default::default() })
}
}
fn cells_session(w: u32, h: u32, blur: bool) -> Session {
cells_session_at(w, h, blur, "/renders/cells.exr")
}
fn cells_session_at(w: u32, h: u32, blur: bool, path: &str) -> Session {
let mut s = Session { footage: Arc::new(Cells(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
let item = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
if blur {
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap();
}
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
s
}
/// Hover latency of `effect.cryptomatteAt` (dev profile): the first call after the viewer's frame
/// and the next ones, on footage (1080p, 4K, 4K behind a blur) and a 1080p 3D precomp. Run with
/// `--ignored --nocapture`; it measures, it doesn't assert.
#[test]
#[ignore]
fn hover_latency() {
let mut cases: Vec<(&str, Session)> = vec![
("footage 1920x1080", cells_session(1920, 1080, false)),
("footage 3840x2160", cells_session(3840, 2160, false)),
("footage 3840x2160 + blur 40", cells_session(3840, 2160, true)),
];
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Outer", "width": 1920, "height": 1080, "frameRate": 24, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 1920, "height": 1080})).unwrap()["layer"].clone();
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 400, "height": 1080})).unwrap()["layer"].clone();
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
cases.push(("3D precomp 1920x1080", s));
for (name, mut s) in cases {
let cid = s.active_comp_id().unwrap();
let _ = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts::default());
let ms: Vec<String> = (0..6)
.map(|i| {
let t0 = std::time::Instant::now();
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 100.0 + 40.0 * i as f64, "y": 500.0})).unwrap();
assert!(!r.is_null(), "{name}");
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
})
.collect();
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
}
}
/// The hover's probe: built off the thread that asks, kept while only the Selection changes,
/// rebuilt when anything it reads changes, and a job finished for an older state is not used.
#[test]
fn hover_probe_is_built_by_a_job_kept_across_picks_and_dropped_on_other_edits() {
let mut s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
let job = s.crypto_probe_job(&q).unwrap().expect("nothing built yet");
std::thread::spawn(job).join().unwrap();
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "built");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
// Another scale or time is another probe.
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "scale": 0.5})).unwrap().is_some());
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.0})).unwrap().is_some());
// A pick edits only the Selection: the probe stays.
pick(&mut s, 0.5, "replace");
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "kept across the pick");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
// Other edits rebuild it, and the answer follows them.
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
let stale = s.crypto_probe_job(&q).unwrap().expect("time remap changed it");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "late");
// A job that finishes after the next edit doesn't answer for it.
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
stale();
assert!(s.crypto_probe_job(&q).unwrap().is_some(), "stale job result is not used");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
// Errors are kept too, and still errors.
s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let solid = json!({"effect": 1, "x": 1, "y": 1});
s.crypto_probe_job(&solid).unwrap().expect("solid")();
assert!(s.crypto_probe_job(&solid).unwrap().is_none());
assert!(s.execute("effect.cryptomatteAt", solid).unwrap_err().to_string().contains("no Cryptomatte channels"));
}
/// Picks on 1080p, 4K and odd-sized footage, at the viewer's resolutions and on a half-size proxy,
/// name the ID the rendered matte shows under the point (points next to cell edges).
#[test]
fn picks_match_the_render_at_1080p_4k_odd_sizes_and_on_proxies() {
for (w, h) in [(1920, 1080), (3840, 2160), (1919, 1079)] {
let mut s = cells_session(w, h, false);
let (wl, hl) = (f64::from(w), f64::from(h));
let xs = [36.6, 37.2, 0.4, wl / 2.0 + 0.3, wl - 0.6, wl - 37.4];
let ys = [28.7, 29.1, hl - 0.5, hl / 3.0];
let mut bad = mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys);
let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/cells_proxy.exr"})).unwrap();
bad.extend(mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys).into_iter().map(|b| format!("proxy {b}")));
assert!(bad.is_empty(), "{w}x{h}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
}
}
/// The kept probe, and the aux channels it holds, go when the eyedropper is disarmed, when its
/// layer or effect is deleted, on Open / New, and when its footage changes.
#[test]
fn hover_probe_is_released_on_disarm_delete_open_and_footage_changes() {
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 100.0});
let ends: [(&str, fn(&mut Session)); 5] = [
("disarm", |s| s.drop_crypto_probe()),
("layer delete", |s| {
let lid = s.active_comp().unwrap().layers[0].id.0;
s.execute_checked("edit.clear", json!({"layers": [lid]})).unwrap();
}),
("effect delete", |s| {
s.execute_checked("effect.remove", json!({"effect": "Cryptomatte"})).unwrap();
}),
("open / new", |s| s.replace_project(effectcraft_project::Project::default(), None)),
("footage change", |s| {
let f = s.project.items.values().find_map(|i| match &i.kind {
effectcraft_project::ItemKind::Footage(f) => Some(f.clone()),
_ => None,
});
s.forget_decoded(&f.unwrap());
}),
];
for (what, end) in ends {
let mut s = cells_session(640, 360, false);
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
let unpinned = Arc::strong_count(&aux);
assert!(!s.execute("effect.cryptomatteAt", q.clone()).unwrap().is_null());
assert!(s.crypto_probe_stats().0 && Arc::strong_count(&aux) > unpinned, "{what}: kept");
end(&mut s);
assert!(!s.crypto_probe_stats().0, "{what}: the probe is gone");
assert_eq!(Arc::strong_count(&aux), unpinned, "{what}: its aux channels are let go");
}
}
/// A probe is keyed by the frame it reads: on a still, or footage held by Time Remap, every comp
/// time shares one; each footage frame has its own, and so does each comp time once something
/// before the Cryptomatte effect is animated.
#[test]
fn hover_probe_is_keyed_by_the_source_frame() {
let builds = |s: &mut Session, times: &[f64]| -> Vec<u64> {
let b = s.crypto_probe_stats().1;
times
.iter()
.map(|t| {
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap();
assert_eq!(r["name"], "c1_1");
s.crypto_probe_stats().1 - b
})
.collect()
};
let f = 1.0 / 24.0;
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
assert_eq!(builds(&mut s, &[0.0, f, 0.5, 1.9]), [1, 1, 1, 1], "a still: one probe");
let mut s = cells_session(320, 180, false);
assert_eq!(builds(&mut s, &[0.0, f * 0.4, f, f * 1.5, 0.0]), [1, 1, 2, 2, 3], "footage: a probe per frame");
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
s.execute_checked("prop.toggleAnimation", json!({"path": "timeRemap", "value": false})).unwrap();
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
assert_eq!(builds(&mut s, &[0.0, f, 1.2]), [1, 1, 1], "held by Time Remap: one source frame");
let mut s = cells_session_at(320, 180, true, "/renders/still.exr");
assert_eq!(builds(&mut s, &[0.0, f]), [1, 1], "a still blur");
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 0.0, "value": 2.0})).unwrap();
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 1.0, "value": 6.0})).unwrap();
assert_eq!(builds(&mut s, &[0.0, f, f]), [1, 2, 2], "an animated blur before it: per comp time");
}
/// Probes of two Cryptomatte effects (on two footage layers) at the same revision and frame
/// don't answer for each other.
#[test]
fn hover_probes_are_per_layer_and_effect() {
/// `…late…` files have the IDs [`Crypto`] has from 1 s.
struct ByFile;
impl FootageSource for ByFile {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
Some(Arc::new(aux_at(if f.path.contains("late") { Tick::from_seconds_f64(1.0) } else { t })))
}
}
let mut s = Session { footage: Arc::new(ByFile), importer: Some(Arc::new(Probe)), ..Default::default() };
let items = s.execute("file.import", json!({"paths": ["/renders/crypto.exr", "/renders/late.exr"]})).unwrap()["items"].clone();
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
let layers: Vec<Value> = (0..2).map(|i| s.execute("layer.addItem", json!({"item": items[i]})).unwrap()["layer"].clone()).collect();
for l in &layers {
s.execute_checked("effect.apply", json!({"layers": [l], "effect": "Cryptomatte"})).unwrap();
}
let on = |l: &Value| json!({"layer": l, "effect": 1, "x": 0.5, "y": 1.5});
for (l, want) in [(&layers[0], "left"), (&layers[1], "late"), (&layers[0], "left")] {
assert_eq!(s.execute("effect.cryptomatteAt", on(l)).unwrap()["name"], want);
}
}
/// [`Crypto`]'s IDs, counting the reads, and holding them while `hold` is set (a build caught
/// in the middle).
#[derive(Default)]
struct Gate {
reads: std::sync::atomic::AtomicUsize,
hold: std::sync::atomic::AtomicBool,
entered: std::sync::atomic::AtomicBool,
/// Picture frames read (the effect's input render).
frames: std::sync::atomic::AtomicUsize,
}
impl FootageSource for Gate {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
self.frames.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
use std::sync::atomic::Ordering::SeqCst;
self.reads.fetch_add(1, SeqCst);
self.entered.store(true, SeqCst);
while self.hold.load(SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
Some(Arc::new(aux_at(t)))
}
}
/// Only the newest request's build is kept: a job that finishes late doesn't evict a newer probe,
/// a job superseded before it runs doesn't build, one superseded (or outdated by an edit) while
/// it builds stores nothing, and `probeOnly` never builds on the asking thread.
#[test]
fn hover_probe_builds_store_only_the_newest_and_never_build_for_probe_only() {
use std::sync::atomic::Ordering::SeqCst;
let gate = Arc::new(Gate::default());
let mut s = session();
s.footage = gate.clone();
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t});
let reads = || gate.reads.load(SeqCst);
// A late job for another frame doesn't evict the probe built since.
let job0 = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
s.execute("effect.cryptomatteAt", at(1.0)).unwrap();
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none());
job0();
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none(), "the late job evicted the newer probe");
// A request for another probe cancels the earlier job: it doesn't even build.
let (old, new) = (s.crypto_probe_job(&at(0.5)).unwrap().unwrap(), s.crypto_probe_job(&at(0.25)).unwrap().unwrap());
let before = reads();
old();
assert_eq!(reads(), before, "a superseded job doesn't build");
new();
assert!(reads() > before);
assert!(s.crypto_probe_job(&at(0.25)).unwrap().is_none(), "the newest request's probe is kept");
// The same request again doesn't cancel the job in flight.
let (a, b) = (s.crypto_probe_job(&at(1.5)).unwrap().unwrap(), s.crypto_probe_job(&at(1.5)).unwrap().unwrap());
a();
drop(b);
assert!(s.crypto_probe_job(&at(1.5)).unwrap().is_none());
// Superseded, or outdated by an edit, while it builds: it stores nothing.
for edit in [false, true] {
let job = s.crypto_probe_job(&at(0.75)).unwrap().unwrap();
gate.hold.store(true, SeqCst);
gate.entered.store(false, SeqCst);
let run = std::thread::spawn(job);
while !gate.entered.load(SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
if edit {
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
} else {
drop(s.crypto_probe_job(&at(0.8)).unwrap());
}
gate.hold.store(false, SeqCst);
run.join().unwrap();
let stored = if edit { s.crypto_probe_stats().0 } else { s.crypto_probe_job(&at(0.75)).unwrap().is_none() };
assert!(!stored, "edit {edit}: the outdated build was stored");
}
// `probeOnly`: pending without a probe, and nothing built.
let before = reads();
let mut q = at(0.0);
q["probeOnly"] = json!(true);
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap(), json!({"pending": true}));
q["mode"] = json!("add");
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["pending"], true);
assert_eq!(reads(), before, "nothing built for probeOnly");
s.crypto_probe_job(&q).unwrap().unwrap()();
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["selection"], "left");
}
/// A build cancelled while it reads the IDs (a newer request, disarming, an edit, another
/// project) stops before it renders the effect's input, so the next build isn't kept waiting.
#[test]
fn a_cancelled_build_stops_before_rendering_its_input() {
use std::sync::atomic::Ordering::SeqCst;
let cancels: [(&str, fn(&mut Session)); 4] = [
("newer request", |s| drop(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.5})).unwrap())),
("disarm", |s| s.drop_crypto_probe()),
("edit", |s| drop(s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap())),
("project switch", |s| s.replace_project(effectcraft_project::Project::default(), None)),
];
for (what, cancel) in [("not cancelled", None), ("newer request", Some(0)), ("disarm", Some(1)), ("edit", Some(2)), ("project switch", Some(3))] {
let gate = Arc::new(Gate::default());
let mut s = session();
s.footage = gate.clone();
let job = s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 0.5})).unwrap().unwrap();
gate.hold.store(true, SeqCst);
let run = std::thread::spawn(job);
while !gate.entered.load(SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
if let Some(k) = cancel {
cancels[k].1(&mut s);
}
gate.hold.store(false, SeqCst);
run.join().unwrap();
let rendered = gate.frames.load(SeqCst) > 0;
assert_eq!(rendered, cancel.is_none(), "{what}: the input was rendered: {rendered}");
}
}
/// The probe doesn't read the Cryptomatte effect's own Selection: keyframed, a still keeps one
/// probe for every comp time (an animated effect before it still makes one per time).
#[test]
fn an_animated_selection_keeps_one_probe_on_a_still() {
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
for (t, v) in [(0.0, "c1_1"), (1.0, "c2_2")] {
s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": v})).unwrap();
}
let b = s.crypto_probe_stats().1;
for t in [0.0, 1.0 / 24.0, 0.5, 1.5] {
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap()["name"], "c1_1");
}
assert_eq!(s.crypto_probe_stats().1 - b, 1, "one probe for the still");
}
/// A panic while a probe is built on another thread becomes the probe's error: the job returns,
/// the hover reports it, and it isn't retried for that frame.
#[test]
fn a_panicking_probe_build_becomes_an_error() {
struct Broken;
impl FootageSource for Broken {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
panic!("a broken EXR reader")
}
}
let mut s = Session { footage: Arc::new(Broken), importer: Some(Arc::new(Probe)), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
let job = s.crypto_probe_job(&q).unwrap().unwrap();
assert!(std::thread::spawn(job).join().is_ok(), "the job caught the panic");
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "the error is kept");
let e = s.execute("effect.cryptomatteAt", q).unwrap_err().to_string();
assert!(e.contains("Reading the Cryptomatte IDs failed") && e.contains("a broken EXR reader"), "{e}");
}
/// Asking whether the probe is built changes nothing: asked for other frames while a build runs
/// (the eyedropper's hover while a click waits), it doesn't cancel that build.
#[test]
fn asking_whether_a_probe_is_ready_cancels_no_build() {
let s = session();
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t, "probeOnly": true});
let job = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
for t in [1.0, 0.5, 1.0, 0.25] {
assert!(!s.crypto_probe_ready(&at(t)).unwrap());
}
job();
assert!(s.crypto_probe_ready(&at(0.0)).unwrap(), "the build was cancelled");
assert!(s.crypto_probe_job(&at(0.0)).unwrap().is_none());
}
/// The probe renders with the render prefs (nested switches, draft shadows): changing them asks
/// for a new one.
#[test]
fn a_render_prefs_change_asks_for_a_new_probe() {
let mut s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
s.crypto_probe_job(&q).unwrap().unwrap()();
assert!(s.crypto_probe_ready(&q).unwrap());
s.prefs.three_d.realtime_shadows = !s.prefs.three_d.realtime_shadows;
assert!(!s.crypto_probe_ready(&q).unwrap(), "draft shadows changed");
s.crypto_probe_job(&q).unwrap().unwrap()();
s.prefs.general.switches_affect_nested_comps = !s.prefs.general.switches_affect_nested_comps;
assert!(!s.crypto_probe_ready(&q).unwrap(), "nested switches changed");
}
/// A pick is an undo step named for it, and undoing, redoing or jumping over picks keeps the
/// probe (only the Selection changed); undoing any other edit doesn't.
#[test]
fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
let mut s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
s.crypto_probe_job(&q).unwrap().unwrap()();
let before = crate::history::state_id(&s.project);
pick(&mut s, 0.5, "replace");
assert_eq!(s.history.undo.last().unwrap().0, "Pick Cryptomatte");
pick(&mut s, 4.5, "add");
for (what, cmd) in [("undo", "edit.undo"), ("undo", "edit.undo"), ("redo", "edit.redo")] {
s.execute_checked(cmd, json!({})).unwrap();
assert!(s.crypto_probe_ready(&q).unwrap(), "{what} {}: probe dropped", selection(&s));
}
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
assert!(s.goto_history(&before) && selection(&s).is_empty());
assert!(s.crypto_probe_ready(&q).unwrap(), "history jump: probe dropped");
s.execute_checked("prop.set", json!({"path": "transform/opacity", "value": 50})).unwrap();
s.crypto_probe_job(&q).unwrap().unwrap()();
s.execute_checked("edit.undo", json!({})).unwrap();
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
}