#783: Object Matte tracks in the background and freezes

Problem: Object Matte computed its frames only on demand while rendering, so
playing a shot stalled on every untracked frame, there was no progress or way
to stop, and nothing kept the mattes once computed.

Fix: Object Matte runs through Roto Brush's job machinery (one job slot,
cancellable with roto.cancel, a worker thread on the desktop, a Web Worker in
the browser, never the UI thread). objectMatte.propagate computes the frames
around the reference frame (clipped to the layer) outward, filling the same
content-keyed segmentation cache the renderer reads; the engine's sync now
queues an instance for background tracking when its frames or prompt change.
objectMatte.freeze stores the final mattes in a new hidden Frozen parameter
(Roto Brush's FrozenCache format) under a key of the input, prompt, span,
model and matte settings; renders use them without segmenting while the key
matches; a frozen prompt is refused until objectMatte.unfreeze. Freeze is one
undo step and is dropped if the prompt changed meanwhile.
objectMatte.status reports progress, frozen state and computed frames. Effect
Controls shows Freeze / Unfreeze and Track / Stop with a status line (progress
banner). The frontend now polls Roto Brush / Object Matte jobs on their own:
before, a finished job (and so a Freeze result) was only picked up while a
Warp Stabilizer job was active. Job progress is set before the worker starts.

Tests: engine tests_roto::object_matte_background_job_cancel_and_freeze
(queued by the edit, returns at once with progress, cancel keeps a partial
result, completion computes all 25 frames, frozen renders recompute nothing,
unfreeze recomputes, undo restores); effects object_matte job_frames and
frozen-matte tests (stored pattern used, key mismatch and damaged data
ignored); ui-egui ui_object_matte Effect Controls Freeze end to end.
docs/agents.md: Object Matte section; docs/effects.md regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 14:02:50 +02:00
co-authored by Claude Opus 5.5
parent 0c695cff77
commit 560767dfe7
11 changed files with 618 additions and 54 deletions
+98 -6
View File
@@ -63,6 +63,23 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
object_matte_apply
),
cmd!(
"objectMatte.propagate",
"Track Object Matte",
[],
None,
"{layer?, effect?, direction?: forward|backward|both, wait?: block until done} (a background job like Roto Brush propagation; roto.cancel stops it)",
has_comp,
object_matte_propagate
),
cmd!("objectMatte.freeze", "Freeze Object Matte", [], None, "{layer?, effect?, wait?}", has_comp, object_matte_freeze),
cmd!("objectMatte.unfreeze", "Unfreeze Object Matte", [], None, "{layer?, effect?}", has_comp, object_matte_unfreeze),
query!(
"objectMatte.status",
"Object Matte Status",
"{layer?, effect?}: the job's progress, whether the instance is frozen, its frames and which are computed",
object_matte_status
),
query!(
"roto.status",
"Roto Brush Status",
@@ -79,6 +96,16 @@ fn is_running(s: &Session) -> std::result::Result<(), String> {
/// The Roto Brush instance targeted by `p`: `layer` / `effect` (uid, name or 1-based index),
/// else a selected one, else the first on the selected (or any) layer.
pub(crate) fn roto_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
instance_lookup(s, p, cmd, is_roto)?.ok_or_else(|| bad(cmd, "no Roto Brush & Refine Edge effect found (paint a stroke with the Roto Brush tool first)"))
}
/// The Object Matte instance targeted by `p` (as [`roto_p`]).
fn object_matte_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
instance_lookup(s, p, cmd, rt::is_object_matte)?.ok_or_else(|| bad(cmd, "no Object Matte effect found (click an object with the Object Matte tool first)"))
}
/// The instance of a kind (`is`) targeted by `p`; `None` when there is none.
fn instance_lookup(s: &Session, p: &Value, cmd: &str, is: fn(&PropGroup) -> bool) -> Result<Option<(ItemId, LayerId, Uid)>> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let candidates: Vec<LayerId> = match p.get("layer") {
@@ -102,17 +129,17 @@ pub(crate) fn roto_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Layer
.state
.selected_props
.iter()
.find_map(|(l, u)| (*l == lid).then(|| fxg.groups().find(|g| g.uid == *u && is_roto(g))).flatten())
.or_else(|| fxg.groups().find(|g| is_roto(g))),
.find_map(|(l, u)| (*l == lid).then(|| fxg.groups().find(|g| g.uid == *u && is(g))).flatten())
.or_else(|| fxg.groups().find(|g| is(g))),
};
if let Some(g) = g.filter(|g| is_roto(g)) {
return Ok((cid, lid, g.uid));
if let Some(g) = g.filter(|g| is(g)) {
return Ok(Some((cid, lid, g.uid)));
}
if p.get("layer").is_some() {
break;
}
}
Err(bad(cmd, "no Roto Brush & Refine Edge effect found (paint a stroke with the Roto Brush tool first)"))
Ok(None)
}
fn group(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result<(&Layer, &PropGroup)> {
@@ -459,10 +486,13 @@ fn object_matte_apply(s: &mut Session, p: &Value) -> Result<Value> {
let existing = match p.get("effect").filter(|v| !v.is_null()) {
Some(v) => {
let uid = v.as_u64().ok_or_else(|| bad(cmd, "`effect` is an Object Matte effect's uid"))?;
layer
let g = layer
.effects()
.and_then(|f| f.groups().find(|g| g.uid == uid && rt::is_object_matte(g)))
.ok_or_else(|| bad(cmd, format!("no Object Matte effect {uid} on this layer")))?;
if frozen_of(g) {
return Err(bad(cmd, "the Object Matte is frozen: Unfreeze it to change the prompt"));
}
Some(uid)
}
None => None,
@@ -556,3 +586,65 @@ fn object_matte_reach(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> (bool
let [before, after] = om::reach(&ps);
(om::tracks(&ps), before, after)
}
fn object_matte_propagate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "objectMatte.propagate";
let (cid, lid, uid) = object_matte_p(s, p, cmd)?;
let dir = match str_p(p, "direction") {
Some(d) => Direction::from_name(d).ok_or_else(|| bad(cmd, format!("unknown direction `{d}` (forward | backward | both)")))?,
None => Direction::Both,
};
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_roto(cid, lid, uid, RotoTask::Propagate, dir, wait).map_err(|e| bad(cmd, e))?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_roto_running(), "progress": s.roto_progress()});
if wait {
out["status"] = object_matte_status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
fn object_matte_freeze(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "objectMatte.freeze";
let (cid, lid, uid) = object_matte_p(s, p, cmd)?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_roto(cid, lid, uid, RotoTask::Freeze, Direction::Both, wait).map_err(|e| bad(cmd, e))?;
let (_, g) = group(s, cid, lid, uid)?;
Ok(json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_roto_running(), "frozen": frozen_of(g), "progress": s.roto_progress()}))
}
fn object_matte_unfreeze(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "objectMatte.unfreeze";
let (cid, lid, uid) = object_matte_p(s, p, cmd)?;
let (_, g) = group(s, cid, lid, uid)?;
if frozen_of(g) {
s.edit("Unfreeze", None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| EngineError::Other("the effect is gone".into()))?;
if let Some(pr) = g.get_mut(FROZEN) {
pr.value = KV::Str(String::new());
}
Ok(())
})?;
}
Ok(json!({"layer": lid.0, "effect": uid, "frozen": false}))
}
fn object_matte_status(s: &mut Session, p: &Value) -> Result<Value> {
s.poll_roto(false);
let running = s.is_roto_running();
let mut out = json!({"running": running, "progress": s.roto_progress(), "banner": s.roto_progress().map(|p| p.banner())});
let Ok((cid, lid, uid)) = object_matte_p(s, p, "objectMatte.status") else { return Ok(out) };
let (_, g) = group(s, cid, lid, uid)?;
let frozen = frozen_of(g);
let (track, before, after) = object_matte_reach(s, cid, lid, uid);
let computed = s.roto_computed(cid, lid, uid);
out["layer"] = json!(lid.0);
out["effect"] = json!(uid);
out["frozen"] = json!(frozen);
out["track"] = json!(track);
out["reach"] = json!([before, after]);
out["mine"] = json!(running && s.roto_target().is_some_and(|(c, l, u, _)| (c, l, u) == (cid, lid, uid)));
out["pending"] = json!(s.roto_pending.contains(&(cid, lid, uid)));
out["frames"] = json!(computed.keys().collect::<Vec<_>>());
out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::<Vec<_>>());
Ok(out)
}
+2 -1
View File
@@ -561,7 +561,8 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po
/// Roto Brush propagation in a worker: the segmentations stream back (at most every 250 ms)
/// as they are computed, so stopping the job keeps what was propagated.
async fn propagate(s: &mut Session, comp: ItemId, layer: LayerId, effect: Uid, dir: crate::roto::Direction, post: Post) -> Result<(), String> {
let keys: Vec<u64> = s.roto_chain(comp, layer, effect).map(|c| c.keys.values().copied().collect()).ok_or("no Roto Brush strokes")?;
let keys: Vec<u64> =
s.roto_keys(comp, layer, effect).map(|k| k.into_iter().map(|(_, k)| k).collect()).ok_or("no Roto Brush strokes or Object Matte prompt")?;
let sent = Rc::new(RefCell::new(std::collections::HashSet::new()));
let (p, k2, s2) = (post.clone(), keys.clone(), sent.clone());
let mut last = None::<web_time::Instant>;
+141 -36
View File
@@ -10,12 +10,18 @@
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
//!
//! Object Matte instances run through the same jobs (`objectMatte.propagate` / `.freeze`): the
//! frames are [`om::job_frames`] around the reference frame, each segmented through
//! [`om::ensure`]; Freeze stores [`om::frame_matte`]'s mattes under [`om::frozen_key`]. An edit
//! to an instance's frames or prompt queues it for background tracking like a Roto Brush.
use crate::offload::JobKind;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::object_matte as om;
use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY};
use effectcraft_effects::{Params, flatten_params};
use effectcraft_keyframe::Value;
@@ -104,14 +110,18 @@ pub struct RotoProgress {
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
/// The job works on an Object Matte (else a Roto Brush & Refine Edge).
pub object_matte: bool,
}
impl RotoProgress {
pub fn banner(&self) -> String {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
match self.task {
Some(RotoTask::Freeze) => format!("Freezing Roto Brush & Refine Edge: {pc}%"),
_ => format!("Propagating Roto Brush: {pc}%"),
match (self.task, self.object_matte) {
(Some(RotoTask::Freeze), false) => format!("Freezing Roto Brush & Refine Edge: {pc}%"),
(Some(RotoTask::Freeze), true) => format!("Freezing Object Matte: {pc}%"),
(_, false) => format!("Propagating Roto Brush: {pc}%"),
(_, true) => format!("Tracking Object Matte: {pc}%"),
}
}
}
@@ -130,8 +140,11 @@ pub struct RotoJob {
pub layer: LayerId,
pub effect: Uid,
pub task: RotoTask,
/// The strokes the job was started with (a Freeze result is dropped when they changed).
/// What the job was started from: the strokes (Roto Brush) or the prompt (Object Matte); a
/// Freeze result is dropped when it changed.
strokes: String,
/// The job works on an Object Matte.
pub object_matte: bool,
}
impl RotoJob {
@@ -153,6 +166,20 @@ pub(crate) fn is_object_matte(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::object_matte::ID)
}
/// What an Object Matte's segmentation is made from besides its frames: the prompt and tracking
/// parameters (static values), for queueing it after edits and guarding a Freeze.
pub(crate) fn om_prompt(g: &PropGroup) -> String {
let ps = params_static(g);
let mut keys: Vec<&String> = ps.values.keys().filter(|k| ["objectPoint", "useBox", "box", "objectTracking/"].iter().any(|p| k.starts_with(p))).collect();
keys.sort();
keys.iter().map(|k| format!("{k}={:?};", ps.values.get(*k))).collect()
}
/// The job-relevant guard of an instance: its strokes (Roto Brush) or prompt (Object Matte).
fn guard_of(g: &PropGroup) -> String {
if is_object_matte(g) { om_prompt(g) } else { str_of(g, fx::STROKES) }
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.get(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
@@ -195,8 +222,8 @@ pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
}
/// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from
/// other frames are dropped. Returns the Roto Brush instances whose segmentation changed (to
/// propagate again).
/// other frames are dropped. Returns the instances whose segmentation changed (to propagate
/// again): new frames, strokes (Roto Brush) or prompt (Object Matte).
pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut set = vec![];
@@ -206,14 +233,11 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI
let Some(fxg) = l.effects() else { continue };
for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) {
let key = input_key(after, *cid, l, g.uid);
let roto = is_roto(g);
let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid));
if str_of(g, INPUT_KEY) != key {
set.push((*cid, l.id, g.uid, key));
if roto {
todo.push((*cid, l.id, g.uid));
}
} else if roto && old.is_none_or(|o| str_of(o, fx::STROKES) != str_of(g, fx::STROKES)) {
todo.push((*cid, l.id, g.uid));
} else if old.is_none_or(|o| guard_of(o) != guard_of(g)) {
todo.push((*cid, l.id, g.uid));
}
}
@@ -229,7 +253,11 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI
}
}
todo.retain(|(c, l, u)| {
after.comp(*c).and_then(|c| c.layer(*l)).and_then(|l| l.props.find_group(*u)).is_some_and(|g| !fx::data(&params_static(g)).is_empty())
after
.comp(*c)
.and_then(|c| c.layer(*l))
.and_then(|l| l.props.find_group(*u))
.is_some_and(|g| is_object_matte(g) || !fx::data(&params_static(g)).is_empty())
});
todo
}
@@ -284,6 +312,7 @@ struct Work {
fps: f64,
frames: Vec<i64>,
task: RotoTask,
object_matte: bool,
}
/// Renders the effect's input frames for a worker, keeping the last few. With a browser job
@@ -400,10 +429,13 @@ async fn run_work(w: Work, shared: &RotoShared) {
let mut s = lock(&shared.state);
s.task = Some(w.task);
s.total = w.frames.len() as u64;
s.object_matte = w.object_matte;
}
let cancel = || shared.cancel.load(Ordering::Relaxed);
let chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0);
let mut frozen = FrozenCache { key: fx::frozen_key(&w.params, w.size, w.fps), ..Default::default() };
let model = fx::installed_model();
let key = if w.object_matte { om::frozen_key(&w.params, w.size, w.fps, model.as_deref()) } else { fx::frozen_key(&w.params, w.size, w.fps) };
let mut frozen = FrozenCache { key, ..Default::default() };
for (k, f) in w.frames.iter().enumerate() {
if cancel() {
let mut s = lock(&shared.state);
@@ -414,9 +446,21 @@ async fn run_work(w: Work, shared: &RotoShared) {
// The frame and its neighbours (what a step reads), on the GPU.
src.prefetch(&[*f, f - 1, f + 1]).await;
let mut get = |g: i64| src.get(g);
let ok = match w.task {
RotoTask::Propagate => chain.ensure(*f, &mut get, &cancel).is_some(),
RotoTask::Freeze => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
let ok = match (w.task, w.object_matte) {
(RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(),
(RotoTask::Freeze, true) => {
let img = get(*f);
match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) {
Some((om::FrameMatte::Alpha(a), img)) => {
frozen.size = [img.width as usize, img.height as usize];
frozen.frames.insert(*f, fx::freeze_frame(&a, &[]));
true
}
_ => false,
}
}
(RotoTask::Propagate, false) => chain.ensure(*f, &mut get, &cancel).is_some(),
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
Some((a, b)) => {
if let Some(img) = src.get(*f) {
frozen.size = [img.width as usize, img.height as usize];
@@ -471,12 +515,15 @@ impl Session {
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded_roto().map(|(j, task)| {
let p = &j.progress;
let object_matte =
self.project.comp(j.comp).and_then(|c| c.layer(j.layer)).and_then(|l| l.props.find_group(j.uid)).is_some_and(is_object_matte);
RotoProgress {
task: Some(task),
done: p.done,
total: p.total,
elapsed: p.elapsed,
fps: crate::offload::rate(p.done, p.elapsed),
object_matte,
..Default::default()
}
})
@@ -488,7 +535,8 @@ impl Session {
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task)).or_else(|| self.offloaded_roto().map(|(j, task)| (j.comp, j.layer, j.uid, task)))
}
/// The segmentation chain a propagation of `effect` computes (its frames' cache keys).
/// The segmentation chain a propagation of Roto Brush `effect` computes (its frames' cache
/// keys).
pub fn roto_chain(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option<fx::Chain> {
let c = self.project.comp(comp)?;
let l = c.layer(layer)?;
@@ -499,6 +547,27 @@ impl Session {
Some(fx::Chain::new(&params, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0))
}
/// The cache keys of the segmentations a propagation of `effect` (Roto Brush or Object
/// Matte) computes, by layer frame.
pub fn roto_keys(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option<Vec<(i64, u64)>> {
if let Some(ch) = self.roto_chain(comp, layer, effect) {
return Some(ch.keys.into_iter().collect());
}
let c = self.project.comp(comp)?;
let l = c.layer(layer)?;
let g = l.effects()?.groups().find(|g| g.uid == effect && is_object_matte(g))?;
let params = self.om_params(comp, l, g)?;
let frames = om::job_frames(&params, frame_limits(c, l));
Some(om::frame_keys(&params, layer_size(&self.project, c, l), c.frame_rate.as_f64(), &frames, fx::installed_model()))
}
/// An Object Matte's parameters at its reference frame (what its jobs segment with).
fn om_params(&self, comp: ItemId, l: &Layer, g: &PropGroup) -> Option<Params> {
let c = self.project.comp(comp)?;
let t = comp_time_of(c, l, om::reference_frame(&params_static(g)));
Some(params_at(&self.project, comp, l, g, t, self.expr.as_deref()))
}
pub fn stop_roto(&mut self) -> bool {
if self.cancel_offloaded(JobKind::RotoFreeze) || self.cancel_offloaded(JobKind::RotoPropagate) {
return true;
@@ -532,17 +601,36 @@ impl Session {
let l = c.layer(layer).ok_or("no layer")?;
let fxg = l.effects().ok_or("the layer has no effects")?;
let index = fxg.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fxg.groups().nth(index).filter(|g| is_roto(g)).ok_or("not a Roto Brush & Refine Edge effect")?;
let g = fxg.groups().nth(index).filter(|g| is_roto(g) || is_object_matte(g)).ok_or("not a Roto Brush & Refine Edge or Object Matte effect")?;
let object_matte = is_object_matte(g);
let size = layer_size(&self.project, c, l);
let fps = c.frame_rate.as_f64();
let pstat = params_static(g);
let d = fx::data(&pstat);
if d.is_empty() {
return Err("draw a foreground stroke with the Roto Brush tool first".into());
}
let t = comp_time_of(c, l, d.base.unwrap_or(0));
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
let frames = job_frames(&d, if task == RotoTask::Freeze { Direction::Both } else { dir });
let (params, frames) = if object_matte {
let params = self.om_params(comp, l, g).ok_or("no composition")?;
let base = om::reference_frame(&params);
if om::roto_data(&params, size, base, fx::installed_model().is_some()).is_none() {
return Err("set the object first: click it with the Object Matte tool (its point is off the layer or outside its box)".into());
}
let all = om::job_frames(&params, frame_limits(c, l));
if all.is_empty() {
return Err(format!("the reference frame {base} is outside the layer"));
}
// Forward / backward from the reference frame, as for a Roto Brush.
let frames = all
.into_iter()
.filter(|f| task == RotoTask::Freeze || dir == Direction::Both || *f == base || (*f > base) == (dir == Direction::Forward))
.collect();
(params, frames)
} else {
let pstat = params_static(g);
let d = fx::data(&pstat);
if d.is_empty() {
return Err("draw a foreground stroke with the Roto Brush tool first".into());
}
let t = comp_time_of(c, l, d.base.unwrap_or(0));
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
(params, job_frames(&d, if task == RotoTask::Freeze { Direction::Both } else { dir }))
};
let n = frames.len();
let work = Work {
project: self.project.clone(),
@@ -557,8 +645,9 @@ impl Session {
fps,
frames,
task,
object_matte,
};
let strokes = str_of(g, fx::STROKES);
let strokes = guard_of(g);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(match task {
@@ -569,6 +658,13 @@ impl Session {
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(RotoShared::default());
{
// Known before the worker starts (the progress shows at once).
let mut st = lock(&shared.state);
st.task = Some(task);
st.total = n as u64;
st.object_matte = object_matte;
}
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
@@ -584,7 +680,7 @@ impl Session {
.map_err(|e| e.to_string())?,
)
};
self.roto_job = Some(RotoJob { shared, thread, comp, layer, effect, task, strokes });
self.roto_job = Some(RotoJob { shared, thread, comp, layer, effect, task, strokes, object_matte });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_roto_job();
Ok(n)
@@ -601,40 +697,43 @@ impl Session {
let _ = t.join();
}
let st = job.progress();
let name = if job.object_matte { "Object Matte" } else { "Roto Brush" };
if let Some(e) = &st.error {
self.events.push(Event::Toast { message: format!("Roto Brush: {e}"), error: true });
self.events.push(Event::Toast { message: format!("{name}: {e}"), error: true });
self.bump();
return true;
}
if st.cancelled {
self.events.push(Event::Toast { message: "Roto Brush: stopped".into(), error: false });
self.events.push(Event::Toast { message: format!("{name}: stopped"), error: false });
self.bump();
return true;
}
match job.task {
RotoTask::Propagate => {
self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", st.done, st.elapsed), error: false });
let verb = if job.object_matte { "tracked" } else { "propagated" };
self.events.push(Event::Toast { message: format!("{name}: {verb} {} frame(s) in {:.1} s", st.done, st.elapsed), error: false });
// Rendered frames depend on the cache: redraw.
self.bump();
}
RotoTask::Freeze => {
let Some(fc) = lock(&job.shared.frozen).take() else { return true };
let (cid, lid, uid, strokes) = (job.comp, job.layer, job.effect, job.strokes.clone());
let what = if job.object_matte { "prompt" } else { "strokes" };
let r = self.edit("Freeze", None, |p, _| {
let g = p
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the Roto Brush effect was removed".into()))?;
if str_of(g, fx::STROKES) != strokes {
return Err(crate::EngineError::Other("the strokes changed while freezing".into()));
.ok_or_else(|| crate::EngineError::Other(format!("the {name} effect was removed")))?;
if guard_of(g) != strokes {
return Err(crate::EngineError::Other(format!("the {what} changed while freezing")));
}
set_str(g, FROZEN, fc.to_json());
Ok(())
});
let msg = match r {
Ok(()) => format!("Roto Brush: froze {} frame(s) in {:.1} s", fc.frames.len(), st.elapsed),
Err(e) => format!("Roto Brush: {e}"),
Ok(()) => format!("{name}: froze {} frame(s) in {:.1} s", fc.frames.len(), st.elapsed),
Err(e) => format!("{name}: {e}"),
};
self.events.push(Event::Toast { message: msg, error: false });
}
@@ -664,6 +763,12 @@ impl Session {
let Some(c) = self.project.comp(comp) else { return out };
let Some(l) = c.layer(layer) else { return out };
let Some(g) = l.props.find_group(effect) else { return out };
if is_object_matte(g) {
for (f, k) in self.roto_keys(comp, layer, effect).unwrap_or_default() {
out.insert(f, fx::cached(k, 1.0).is_some());
}
return out;
}
let ps = params_static(g);
let ch = fx::Chain::new(&ps, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0);
for f in ch.keys.keys() {
+81
View File
@@ -465,3 +465,84 @@ fn object_matte_reprompt_updates_the_instance() {
// An unknown or non-Object Matte effect is refused.
assert!(s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "effect": 999_999, "point": c})).is_err());
}
/// Object Matte runs in the background like Roto Brush propagation: queued by the edit, a job
/// that returns at once and reports progress, stops when cancelled (keeping what it computed),
/// computes the tracked frames when left to run, and Freeze stores mattes that renders then use
/// without segmenting again.
#[test]
fn object_matte_background_job_cancel_and_freeze() {
hold_roto_cache_test_lock();
let (mut s, clip, solid) = setup(|f| draw(center(f)));
let cid = s.active_comp_id().unwrap();
s.edit("remove solid", None, |p, _| {
p.comp_mut(cid).unwrap().layers.retain(|l| l.id != solid);
for it in p.items.values_mut() {
if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind {
f.path = "object-matte-job.mov".into();
}
}
Ok(())
})
.unwrap();
let c = center(0);
let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "before": 0, "after": 24, "frame": 0})).unwrap();
let uid = r["effect"].as_u64().unwrap();
assert!(s.roto_pending.contains(&(cid, clip, uid)), "the new prompt is queued for tracking");
let wait_done = |s: &mut Session| {
let t = std::time::Instant::now();
while s.is_roto_running() {
assert!(t.elapsed().as_secs() < 120, "the job never finished");
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_roto(false);
}
s.poll_roto(false);
};
let toast = |s: &Session, m: &str| s.events.iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message.starts_with(m)));
let status = |s: &mut Session| s.execute("objectMatte.status", json!({"layer": clip.0, "effect": uid})).unwrap();
// Cancelled at once: it stops early, and says so.
let r = s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid})).unwrap();
assert_eq!(r["frames"], 25);
assert!(!s.roto_pending.contains(&(cid, clip, uid)));
let _ = s.execute("roto.cancel", json!({}));
wait_done(&mut s);
assert!(toast(&s, "Object Matte: stopped"), "{:?}", s.events);
let st = status(&mut s);
assert_eq!(st["frames"].as_array().unwrap().len(), 25, "{st}");
let early = st["computed"].as_array().unwrap().len();
assert!(early < 25, "{st}");
// Left to run: the call returns while the job works (progress visible), then every tracked
// frame is computed.
let r = s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid})).unwrap();
assert_eq!(r["running"], true, "{r}");
let pr = s.roto_progress().unwrap();
assert!(pr.object_matte && pr.banner().starts_with("Tracking Object Matte"), "{pr:?}");
// One job at a time.
assert!(s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid})).is_err());
wait_done(&mut s);
assert!(toast(&s, "Object Matte: tracked"), "{:?}", s.events);
assert_eq!(status(&mut s)["computed"].as_array().unwrap().len(), 25);
let before = alpha_at(&s, 4);
// Freeze, then drop the segmentations: renders use the frozen mattes and compute nothing.
let r = s.execute_checked("objectMatte.freeze", json!({"layer": clip.0, "effect": uid, "wait": true})).unwrap();
assert_eq!(r["frozen"], true, "{r}");
let keys: Vec<u64> = s.roto_keys(cid, clip, uid).unwrap().into_iter().map(|(_, k)| k).collect();
assert_eq!(keys.len(), 25);
effectcraft_effects::roto::forget(keys.iter().copied());
let frozen = alpha_at(&s, 4);
let diff = before.iter().zip(&frozen).filter(|(a, b)| (**a - **b).abs() > 0.02).count();
assert!(diff < 30, "{diff} pixels differ from the computed matte");
assert!(status(&mut s)["computed"].as_array().unwrap().is_empty(), "a frozen frame was segmented again");
assert_eq!(status(&mut s)["frozen"], true);
// A frozen prompt is refused; Unfreeze computes again.
assert!(s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "effect": uid, "point": c})).is_err());
s.execute_checked("objectMatte.unfreeze", json!({"layer": clip.0, "effect": uid})).unwrap();
let _ = alpha_at(&s, 4);
assert!(status(&mut s)["computed"].as_array().unwrap().iter().any(|f| f == 4));
// Undo brings the frozen mattes back.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(status(&mut s)["frozen"], true);
}