mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
Merge pull request #792 from vangel76/gap-object-matte
# Conflicts: # crates/ui-egui/src/i18n/ui.rs # docs/gaps.md # docs/target-app-parity.md
This commit is contained in:
@@ -199,7 +199,7 @@ fn capabilities(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The headline of `docs/target-app-parity.md` (kept in sync by hand at each re-measure).
|
||||
const PARITY: &str = "≈91% feature breadth and ≈45% ready for real work (estimated parity with After Effects 2026 26.5, stage alpha); all After Effects effects present (306 registered)";
|
||||
const PARITY: &str = "≈91% feature breadth and ≈45% ready for real work (estimated parity with After Effects 2026 26.5, stage alpha); all After Effects effects present (307 registered)";
|
||||
|
||||
fn state(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(serde_json::to_value(&s.state).unwrap_or_default())
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Roto Brush & Refine Edge commands: strokes (the Roto Brush and Refine Edge tools in the Layer
|
||||
//! panel), propagation, the segmentation span, Freeze / Unfreeze, tool options and status.
|
||||
//!
|
||||
//! `objectMatte.apply` adds an Object Matte effect (Effect ▸ Matte) from a point / box prompt on
|
||||
//! a frame; it segments with the same engine and model as Roto Brush.
|
||||
//!
|
||||
//! `roto.stroke` records a stroke into the layer's Roto Brush & Refine Edge effect (applying the
|
||||
//! effect on the first stroke, as After Effects does); the first foreground stroke sets the base
|
||||
//! frame and a span of 20 frames each side. Strokes on a frozen effect are refused (Unfreeze
|
||||
@@ -51,6 +54,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"objectMatte.apply",
|
||||
"Object Matte",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect? (uid: re-prompt this Object Matte instead of adding one), point: [x, y] (layer pixels: the object), box?: [x0, y0, x1, y1] (layer pixels, around the object), frame? (reference layer frame; default current), track?: follow the object from that frame (default true), before?, after? (frames to follow it; default 30, up to 600)}",
|
||||
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",
|
||||
@@ -67,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") {
|
||||
@@ -90,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)> {
|
||||
@@ -428,3 +467,190 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::<Vec<_>>());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Two finite numbers.
|
||||
fn xy_p(p: &Value, k: &str, n: usize, cmd: &str) -> Result<Option<Vec<f64>>> {
|
||||
let Some(v) = p.get(k).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let a = v.as_array().filter(|a| a.len() == n).ok_or_else(|| bad(cmd, format!("`{k}` is {n} numbers (layer pixels)")))?;
|
||||
let out: Vec<f64> = a.iter().filter_map(Value::as_f64).filter(|x| x.is_finite()).collect();
|
||||
if out.len() != n {
|
||||
return Err(bad(cmd, format!("`{k}` is {n} numbers (layer pixels)")));
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn object_matte_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use effectcraft_effects::object_matte as om;
|
||||
let cmd = "objectMatte.apply";
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
|
||||
if !layer.has_video() || layer.switches.adjustment || layer.effects().is_none() {
|
||||
return Err(bad(cmd, "Object Matte works on footage, solid and composition layers"));
|
||||
}
|
||||
// Re-prompt an existing instance (the Object Matte tool on a layer that has one).
|
||||
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"))?;
|
||||
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,
|
||||
};
|
||||
let size = rt::layer_size(&s.project, comp, layer);
|
||||
let point = xy_p(p, "point", 2, cmd)?.ok_or_else(|| bad(cmd, "missing `point` [x, y] (layer pixels)"))?;
|
||||
let inside = |x: f64, y: f64| (0.0..=size[0]).contains(&x) && (0.0..=size[1]).contains(&y);
|
||||
if !inside(point[0], point[1]) {
|
||||
return Err(bad(cmd, format!("`point` is outside the layer ({}×{})", size[0], size[1])));
|
||||
}
|
||||
let bx = xy_p(p, "box", 4, cmd)?;
|
||||
if let Some(b) = &bx {
|
||||
let (x0, x1, y0, y1) = (b[0].min(b[2]), b[0].max(b[2]), b[1].min(b[3]), b[1].max(b[3]));
|
||||
if !(point[0] > x0 && point[0] < x1 && point[1] > y0 && point[1] < y1) {
|
||||
return Err(bad(cmd, "`point` must be inside `box`"));
|
||||
}
|
||||
}
|
||||
let frame = frame_p(s, p, cid, layer);
|
||||
let limits = rt::frame_limits(comp, layer);
|
||||
if frame < limits[0] || frame > limits[1] {
|
||||
return Err(bad(cmd, format!("frame {frame} is outside the layer ({}–{})", limits[0], limits[1])));
|
||||
}
|
||||
// A new instance gets the defaults; a re-prompted one keeps what is not given.
|
||||
let track = b_p(p, "track").or(existing.is_none().then_some(true));
|
||||
let reach = |k: &str| {
|
||||
let v = f_p(p, k).filter(|v| v.is_finite());
|
||||
let v = if existing.is_none() { Some(v.unwrap_or(om::DEFAULT_REACH)) } else { v };
|
||||
v.map(|v| v.round().clamp(0.0, om::MAX_REACH))
|
||||
};
|
||||
let (before, after) = (reach("before"), reach("after"));
|
||||
let spec = effectcraft_effects::find(om::ID).ok_or_else(|| EngineError::Other("Object Matte effect missing".into()))?;
|
||||
let label = if existing.is_some() { "Object Matte Prompt" } else { "Object Matte" };
|
||||
let uid = s.edit(label, None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let fxg = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?;
|
||||
let mut fresh = None;
|
||||
let g = match existing {
|
||||
Some(u) => fxg.find_group_mut(u).ok_or_else(|| bad(cmd, "the Object Matte effect is gone"))?,
|
||||
None => {
|
||||
let n = fxg.groups().filter(|g| rt::is_object_matte(g)).count();
|
||||
let name = if n == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, n + 1) };
|
||||
fresh.insert(effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, size))
|
||||
}
|
||||
};
|
||||
let mut set = |path: &str, v: KV| {
|
||||
let (grp, leaf) = path.rsplit_once('/').map_or((None, path), |(a, b)| (Some(a), b));
|
||||
let target = match grp {
|
||||
Some(sub) => g.sub_mut(sub),
|
||||
None => Some(&mut *g),
|
||||
};
|
||||
// The prompt is static: a re-prompt replaces any keyframes.
|
||||
if let Some(pr) = target.and_then(|t| t.get_mut(leaf)) {
|
||||
pr.value = v;
|
||||
pr.keys.clear();
|
||||
}
|
||||
};
|
||||
set("objectPoint", KV::Vec2([point[0], point[1]]));
|
||||
set("useBox", KV::Bool(bx.is_some()));
|
||||
if let Some(b) = &bx {
|
||||
set("boxTopLeft", KV::Vec2([b[0].min(b[2]), b[1].min(b[3])]));
|
||||
set("boxBottomRight", KV::Vec2([b[0].max(b[2]), b[1].max(b[3])]));
|
||||
}
|
||||
if let Some(t) = track {
|
||||
set("objectTracking/trackObject", KV::Bool(t));
|
||||
}
|
||||
set("objectTracking/referenceFrame", KV::Scalar(frame as f64));
|
||||
if let Some(v) = before {
|
||||
set("objectTracking/framesBefore", KV::Scalar(v));
|
||||
}
|
||||
if let Some(v) = after {
|
||||
set("objectTracking/framesAfter", KV::Scalar(v));
|
||||
}
|
||||
let u = g.uid;
|
||||
if let Some(g) = fresh {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
proj.next_id = proj.next_id.max(next);
|
||||
Ok(u)
|
||||
})?;
|
||||
s.state.selected_layers = vec![lid];
|
||||
let (track, before, after) = object_matte_reach(s, cid, lid, uid);
|
||||
Ok(json!({"layer": lid.0, "effect": uid, "frame": frame, "track": track, "updated": existing.is_some(), "span": [frame - before, frame + after]}))
|
||||
}
|
||||
|
||||
/// Track Object and Frames Before / After of an Object Matte instance (static values).
|
||||
fn object_matte_reach(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> (bool, i64, i64) {
|
||||
use effectcraft_effects::object_matte as om;
|
||||
let Ok((_, g)) = group(s, cid, lid, uid) else { return (true, 0, 0) };
|
||||
let ps = params_static(g);
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
+148
-33
@@ -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 {
|
||||
@@ -147,6 +160,26 @@ pub(crate) fn is_roto(g: &PropGroup) -> bool {
|
||||
matches!(&g.kind, GroupKind::Effect { effect } if effect == fx::ID)
|
||||
}
|
||||
|
||||
/// An Object Matte instance (its Input Key is kept current like Roto Brush's; it segments on
|
||||
/// demand while rendering).
|
||||
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(),
|
||||
@@ -188,8 +221,9 @@ pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
|
||||
format!("{:016x}", fnv(&s))
|
||||
}
|
||||
|
||||
/// After an edit: refresh Input Keys; frozen mattes made from other frames are dropped.
|
||||
/// Returns the instances whose segmentation changed (to propagate again).
|
||||
/// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from
|
||||
/// 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![];
|
||||
@@ -197,13 +231,13 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI
|
||||
let ItemKind::Comp(c) = &it.kind else { continue };
|
||||
for l in &c.layers {
|
||||
let Some(fxg) = l.effects() else { continue };
|
||||
for g in fxg.groups().filter(|g| is_roto(g)) {
|
||||
for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) {
|
||||
let key = input_key(after, *cid, l, g.uid);
|
||||
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));
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
} else if old.is_none_or(|o| str_of(o, fx::STROKES) != str_of(g, fx::STROKES)) {
|
||||
} else if old.is_none_or(|o| guard_of(o) != guard_of(g)) {
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
}
|
||||
}
|
||||
@@ -219,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(¶ms_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(¶ms_static(g)).is_empty())
|
||||
});
|
||||
todo
|
||||
}
|
||||
@@ -274,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
|
||||
@@ -390,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);
|
||||
@@ -404,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];
|
||||
@@ -461,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()
|
||||
}
|
||||
})
|
||||
@@ -478,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)?;
|
||||
@@ -489,6 +547,27 @@ impl Session {
|
||||
Some(fx::Chain::new(¶ms, 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(¶ms, frame_limits(c, l));
|
||||
Some(om::frame_keys(¶ms, 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(¶ms_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;
|
||||
@@ -522,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(¶ms);
|
||||
if om::roto_data(¶ms, 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(¶ms, 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(),
|
||||
@@ -547,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 {
|
||||
@@ -559,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();
|
||||
@@ -574,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)
|
||||
@@ -591,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 });
|
||||
}
|
||||
@@ -654,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() {
|
||||
|
||||
@@ -132,6 +132,10 @@ impl Session {
|
||||
},
|
||||
};
|
||||
self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project))));
|
||||
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
|
||||
// disk cache off drops it).
|
||||
let store: std::sync::Arc<dyn effectcraft_effects::roto::SegStore> = dc.clone();
|
||||
effectcraft_effects::roto::set_seg_store(Some(std::sync::Arc::downgrade(&store)));
|
||||
self.disk_cache = Some(dc);
|
||||
}
|
||||
|
||||
|
||||
@@ -380,3 +380,264 @@ fn offloaded_propagation_streams_segmentations_back() {
|
||||
s.execute("roto.propagate", json!({"layer": clip.0, "wait": true})).unwrap();
|
||||
assert!(s.offloaded.is_empty());
|
||||
}
|
||||
|
||||
/// Object Matte: one click on the disk on frame 0 isolates it there and on the frames it is
|
||||
/// followed to; the Input Key follows the layer's frames; bad prompts are refused.
|
||||
#[test]
|
||||
fn object_matte_from_a_click() {
|
||||
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.mov".into();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("time.set", json!({"time": 0})).unwrap();
|
||||
let c = center(0);
|
||||
let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "after": 6})).unwrap();
|
||||
assert_eq!(r["frame"], 0);
|
||||
assert_eq!(r["span"], json!([-30, 6]));
|
||||
let uid = r["effect"].as_u64().unwrap();
|
||||
let key = |s: &Session| {
|
||||
let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(uid).unwrap().clone();
|
||||
crate::roto::params_static(&g).get(effectcraft_effects::roto::INPUT_KEY).cloned()
|
||||
};
|
||||
let k0 = key(&s);
|
||||
assert!(matches!(&k0, Some(effectcraft_keyframe::Value::Str(k)) if !k.is_empty()), "{k0:?}");
|
||||
let wrong = |s: &Session, f: u32| {
|
||||
let c = center(f);
|
||||
alpha_at(s, f)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, v)| {
|
||||
let d = (((*i as u32 % W) as f64 + 0.5 - c[0]).hypot((*i as u32 / W) as f64 + 0.5 - c[1])) - R;
|
||||
(d < -3.0 && **v < 0.5) || (d > 3.0 && **v > 0.5)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
let w0 = wrong(&s, 0);
|
||||
assert!(w0 < 40, "{w0} wrong pixels on the reference frame");
|
||||
let w4 = wrong(&s, 4);
|
||||
assert!(w4 < 60, "{w4} wrong pixels on frame 4");
|
||||
// Past Frames After: transparent.
|
||||
assert!(alpha_at(&s, 8).iter().all(|v| *v == 0.0));
|
||||
// Other frames under the effect: a new Input Key (the mattes are recomputed).
|
||||
s.edit("swap clip", None, |p, _| {
|
||||
for it in p.items.values_mut() {
|
||||
if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind {
|
||||
f.path = "object-matte-2.mov".into();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_ne!(key(&s), k0);
|
||||
// Bad prompts are refused.
|
||||
for bad in [
|
||||
json!({"layer": clip.0}),
|
||||
json!({"layer": clip.0, "point": [-10, 5]}),
|
||||
json!({"layer": clip.0, "point": [10, "x"]}),
|
||||
json!({"layer": clip.0, "point": c, "box": [0, 0, 10, 10]}),
|
||||
json!({"layer": clip.0, "point": c, "frame": 999}),
|
||||
] {
|
||||
assert!(s.execute_checked("objectMatte.apply", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
// A box and a static prompt are accepted; the second instance is numbered.
|
||||
let r = s
|
||||
.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "box": [c[0] + 50.0, c[1] + 50.0, c[0] - 50.0, c[1] - 50.0], "track": false}))
|
||||
.unwrap();
|
||||
let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(r["effect"].as_u64().unwrap()).unwrap().clone();
|
||||
assert_eq!(g.name, "Object Matte 2");
|
||||
}
|
||||
|
||||
/// `objectMatte.apply` with `effect` re-prompts that instance in one undo step: the new point and
|
||||
/// box, the box dropped when none is given, the reach kept unless given.
|
||||
#[test]
|
||||
fn object_matte_reprompt_updates_the_instance() {
|
||||
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);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let c = center(0);
|
||||
let r = s
|
||||
.execute_checked(
|
||||
"objectMatte.apply",
|
||||
json!({"layer": clip.0, "point": c, "box": [c[0] - 50.0, c[1] - 50.0, c[0] + 50.0, c[1] + 50.0], "after": 6, "frame": 0}),
|
||||
)
|
||||
.unwrap();
|
||||
let uid = r["effect"].as_u64().unwrap();
|
||||
let count = |s: &Session| s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().count();
|
||||
let ps = |s: &Session| crate::roto::params_static(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(uid).unwrap());
|
||||
let undo0 = s.history.undo.len();
|
||||
let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "effect": uid, "point": [c[0] + 4.0, c[1]], "frame": 2})).unwrap();
|
||||
assert_eq!((r["effect"].as_u64(), r["updated"].as_bool()), (Some(uid), Some(true)));
|
||||
assert_eq!(r["span"], json!([-28, 8]));
|
||||
assert_eq!(count(&s), 1);
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1);
|
||||
let p = ps(&s);
|
||||
assert_eq!(p.get("objectPoint"), Some(&effectcraft_keyframe::Value::Vec2([c[0] + 4.0, c[1]])));
|
||||
assert_eq!(p.get("useBox"), Some(&effectcraft_keyframe::Value::Bool(false)));
|
||||
assert_eq!(effectcraft_effects::object_matte::reference_frame(&p), 2);
|
||||
assert_eq!(effectcraft_effects::object_matte::reach(&p), [30, 6]);
|
||||
// Undo restores the first prompt.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(ps(&s).get("useBox"), Some(&effectcraft_keyframe::Value::Bool(true)));
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// Computed Object Matte segmentations go to the persistent disk cache: a second session on the
|
||||
/// same folder and project gets them back without segmenting (its frames cannot even be read
|
||||
/// here), and another prompt (other keys) finds nothing.
|
||||
#[test]
|
||||
fn object_matte_mattes_persist_in_the_disk_cache() {
|
||||
use effectcraft_effects::object_matte as om;
|
||||
use effectcraft_render::disk_cache::Kind;
|
||||
hold_roto_cache_test_lock();
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-om-disk-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let enable = |s: &mut Session| {
|
||||
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap();
|
||||
};
|
||||
let (mut s, clip, solid) = setup(|f| draw(center(f)));
|
||||
enable(&mut s);
|
||||
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-disk.mov".into();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let c = center(0);
|
||||
let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "before": 0, "after": 5, "frame": 0})).unwrap();
|
||||
let uid = r["effect"].as_u64().unwrap();
|
||||
s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid, "wait": true})).unwrap();
|
||||
let keys = s.roto_keys(cid, clip, uid).unwrap();
|
||||
assert_eq!(keys.len(), 6);
|
||||
let dc = s.disk_cache.clone().unwrap();
|
||||
dc.flush();
|
||||
assert!(dc.keys(Kind::Matte).len() >= 6, "{:?}", dc.stats());
|
||||
let before = alpha_at(&s, 3);
|
||||
let saved = s.project.clone();
|
||||
drop(dc);
|
||||
drop(s);
|
||||
effectcraft_effects::roto::forget(keys.iter().map(|(_, k)| *k));
|
||||
|
||||
// "Reopen": a new session on the same folder and project.
|
||||
let (mut s2, _, _) = setup(|f| draw(center(f)));
|
||||
enable(&mut s2);
|
||||
s2.project = saved;
|
||||
s2.bump();
|
||||
s2.open_comp(cid);
|
||||
assert_eq!(s2.roto_keys(cid, clip, uid).unwrap(), keys, "the same input key, so the same keys");
|
||||
let comp = s2.active_comp().unwrap().clone();
|
||||
let l = comp.layer(clip).unwrap();
|
||||
let ps = crate::roto::params_static(l.props.find_group(uid).unwrap());
|
||||
let size = crate::roto::layer_size(&s2.project, &comp, l);
|
||||
// No frames to segment from: whatever comes back was read from disk.
|
||||
for (f, _) in &keys {
|
||||
assert!(om::ensure(&ps, size, 25.0, *f, None, &mut |_| None, &|| false).is_some(), "frame {f} not reused from disk");
|
||||
}
|
||||
assert!(s2.disk_cache.as_ref().unwrap().stats().hits >= 6);
|
||||
let again = alpha_at(&s2, 3);
|
||||
assert_eq!(again, before, "the disk-cached matte renders the same");
|
||||
// Another prompt: other keys, nothing on disk.
|
||||
let mut moved = ps.clone();
|
||||
moved.values.insert("objectPoint".into(), effectcraft_keyframe::Value::Vec2([c[0] + 3.0, c[1]]));
|
||||
assert!(om::ensure(&moved, size, 25.0, 0, None, &mut |_| None, &|| false).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user