mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
# Conflicts: # crates/ui-egui/src/i18n/ui.rs # docs/gaps.md # docs/target-app-parity.md
657 lines
30 KiB
Rust
657 lines
30 KiB
Rust
//! 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
|
||
//! first). Every edit is one undo step.
|
||
|
||
use effectcraft_effects::roto::{self as fx, FROZEN, Stroke, StrokeKind};
|
||
use effectcraft_keyframe::Value as KV;
|
||
use effectcraft_project::build::Ids;
|
||
use effectcraft_project::{ItemId, Layer, LayerId, PropGroup, Uid};
|
||
use effectcraft_track::roto::{RotoData, iou, rle};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, layer_mut, layer_p, str_p};
|
||
use crate::roto::{self as rt, Direction, RotoOptions, RotoTask, VIEWS, is_roto, params_static};
|
||
use crate::{EngineError, Result, Session, cmd, query};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"roto.stroke",
|
||
"Roto Brush Stroke",
|
||
[],
|
||
None,
|
||
"{layer?, kind?: fg|bg|refine|refineErase, points: [[x, y], …] (layer pixels), frame? (layer frame; default current), radius? (layer px; default the tool's diameter / 2), effect?}",
|
||
has_comp,
|
||
stroke
|
||
),
|
||
cmd!(
|
||
"roto.propagate",
|
||
"Propagate Roto Brush",
|
||
[],
|
||
None,
|
||
"{layer?, effect?, direction?: forward|backward|both, to? (layer frame: extend the span), wait?: block until done}",
|
||
has_comp,
|
||
propagate
|
||
),
|
||
cmd!("roto.span", "Set Segmentation Span", [], None, "{layer?, effect?, start?, end? (layer frames)}", has_comp, span),
|
||
cmd!("roto.freeze", "Freeze", [], None, "{layer?, effect?, wait?}", has_comp, freeze),
|
||
cmd!("roto.unfreeze", "Unfreeze", [], None, "{layer?, effect?}", has_comp, unfreeze),
|
||
cmd!("roto.clearStrokes", "Remove Roto Brush Strokes", [], None, "{layer?, effect?, frame? (only this layer frame), kind?}", has_comp, clear_strokes),
|
||
cmd!("roto.cancel", "Stop Roto Brush", [], None, "{}", is_running, |s, _| Ok(json!({"stopped": s.stop_roto()}))),
|
||
cmd!(
|
||
"roto.options",
|
||
"Roto Brush Options",
|
||
[],
|
||
None,
|
||
"{diameter?, refineDiameter?, view?: alphaBoundary|alpha|alphaOverlay|none, overlayColor?: [r,g,b], overlayOpacity?, boundaryColor?: [r,g,b], autoPropagate?}",
|
||
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",
|
||
"{layer?, effect?, frame? (layer frame: also report its matte), matte?: include the matte (RLE, base64), compute?: compute the frame if needed, compareTo?: RLE matte to report the IoU against}",
|
||
status
|
||
),
|
||
]
|
||
}
|
||
|
||
fn is_running(s: &Session) -> std::result::Result<(), String> {
|
||
if s.is_roto_running() { Ok(()) } else { Err("no Roto Brush job is running".into()) }
|
||
}
|
||
|
||
/// 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") {
|
||
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
|
||
None => {
|
||
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
|
||
v.extend(s.state.selected_layers.iter().copied());
|
||
v.extend(comp.layers.iter().map(|l| l.id));
|
||
v
|
||
}
|
||
};
|
||
for lid in candidates {
|
||
let Some(fxg) = comp.layer(lid).and_then(Layer::effects) else { continue };
|
||
let g = match p.get("effect") {
|
||
Some(Value::Number(n)) => {
|
||
let n = n.as_u64().unwrap_or(0);
|
||
fxg.groups().find(|g| g.uid == n).or_else(|| fxg.groups().nth((n as usize).saturating_sub(1)))
|
||
}
|
||
Some(Value::String(name)) => fxg.groups().find(|g| &g.name == name),
|
||
_ => s
|
||
.state
|
||
.selected_props
|
||
.iter()
|
||
.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(g)) {
|
||
return Ok(Some((cid, lid, g.uid)));
|
||
}
|
||
if p.get("layer").is_some() {
|
||
break;
|
||
}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
fn group(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result<(&Layer, &PropGroup)> {
|
||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||
let g = l.props.find_group(uid).ok_or_else(|| EngineError::Other("the effect is gone".into()))?;
|
||
Ok((l, g))
|
||
}
|
||
|
||
fn data_of(g: &PropGroup) -> RotoData {
|
||
fx::data(¶ms_static(g)).as_ref().clone()
|
||
}
|
||
|
||
fn frozen_of(g: &PropGroup) -> bool {
|
||
matches!(g.get(FROZEN).map(|p| &p.value), Some(KV::Str(s)) if !s.is_empty())
|
||
}
|
||
|
||
/// Write an instance's data (one undo step).
|
||
fn write_data(s: &mut Session, label: &str, cid: ItemId, lid: LayerId, uid: Uid, d: &RotoData) -> Result<()> {
|
||
let j = d.to_json();
|
||
s.edit(label, 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(fx::STROKES) {
|
||
pr.value = KV::Str(j);
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// Current layer frame of `layer` (or `frame` from the params).
|
||
fn frame_p(s: &Session, p: &Value, cid: ItemId, layer: &Layer) -> i64 {
|
||
if let Some(f) = f_p(p, "frame") {
|
||
return f.round() as i64;
|
||
}
|
||
let comp = s.project.comp(cid);
|
||
let fps = comp.map(|c| c.frame_rate.as_f64()).unwrap_or(30.0);
|
||
fx::frame_of(layer.layer_time(s.time_of(cid)).seconds(), fps)
|
||
}
|
||
|
||
fn points_p(p: &Value, cmd: &str) -> Result<Vec<[f64; 2]>> {
|
||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer pixels)"))?;
|
||
let mut v = vec![];
|
||
for q in raw {
|
||
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?;
|
||
let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else {
|
||
return Err(bad(cmd, "each point is [x, y]"));
|
||
};
|
||
v.push([x, y]);
|
||
}
|
||
if v.is_empty() {
|
||
return Err(bad(cmd, "`points` is empty"));
|
||
}
|
||
Ok(v)
|
||
}
|
||
|
||
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "roto.stroke";
|
||
let (cid, lid) = match roto_p(s, p, cmd) {
|
||
Ok((c, l, _)) => (c, l),
|
||
Err(_) => layer_p(s, p, cmd)?,
|
||
};
|
||
let kind = match str_p(p, "kind") {
|
||
Some(k) => StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (fg | bg | refine | refineErase)")))?,
|
||
None => StrokeKind::Fg,
|
||
};
|
||
let points = points_p(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, "the Roto Brush works on footage, solid and composition layers"));
|
||
}
|
||
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])));
|
||
}
|
||
let o = &s.state.roto;
|
||
let def_r = if matches!(kind, StrokeKind::Refine | StrokeKind::RefineErase) { o.refine_diameter / 2.0 } else { o.diameter / 2.0 };
|
||
let radius = f_p(p, "radius").unwrap_or(def_r).clamp(0.5, 2500.0);
|
||
let size = rt::layer_size(&s.project, comp, layer);
|
||
// The instance: the requested / first one, else a new one.
|
||
let existing = roto_p(s, p, cmd).ok().filter(|(c, l, _)| *c == cid && *l == lid).map(|x| x.2);
|
||
if let Some(uid) = existing {
|
||
let (_, g) = group(s, cid, lid, uid)?;
|
||
if frozen_of(g) {
|
||
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to edit strokes"));
|
||
}
|
||
}
|
||
if existing.is_none() && !kind.is_segmentation() {
|
||
return Err(bad(cmd, "draw a foreground stroke with the Roto Brush tool first"));
|
||
}
|
||
let st = Stroke { kind, frame, radius, points };
|
||
let label = if kind.is_segmentation() { "Roto Brush Stroke" } else { "Refine Edge Stroke" };
|
||
let (uid, d) = 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 uid = match existing {
|
||
Some(u) => u,
|
||
None => {
|
||
let spec = effectcraft_effects::find(fx::ID).ok_or_else(|| EngineError::Other("Roto Brush effect missing".into()))?;
|
||
let n = fxg.groups().filter(|g| is_roto(g)).count();
|
||
let name = if n == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, n + 1) };
|
||
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, size);
|
||
let u = g.uid;
|
||
fxg.children.push(g.into());
|
||
u
|
||
}
|
||
};
|
||
let g = fxg.find_group_mut(uid).ok_or_else(|| bad(cmd, "effect gone"))?;
|
||
let mut d = data_of(g);
|
||
d.add(st, fx::DEFAULT_SPAN, limits);
|
||
if let Some(pr) = g.get_mut(fx::STROKES) {
|
||
pr.value = KV::Str(d.to_json());
|
||
}
|
||
proj.next_id = proj.next_id.max(next);
|
||
Ok((uid, d))
|
||
})?;
|
||
if !s.state.selected_props.iter().any(|(l, u)| *l == lid && *u == uid) {
|
||
s.state.selected_layers = vec![lid];
|
||
}
|
||
Ok(json!({
|
||
"layer": lid.0, "effect": uid, "frame": frame, "kind": kind, "radius": radius,
|
||
"base": d.base, "span": d.span, "strokes": d.strokes.len(),
|
||
}))
|
||
}
|
||
|
||
fn propagate(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "roto.propagate";
|
||
let (cid, lid, uid) = roto_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,
|
||
};
|
||
if let Some(to) = f_p(p, "to") {
|
||
let to = to.round() as i64;
|
||
let (l, g) = group(s, cid, lid, uid)?;
|
||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||
let lim = rt::frame_limits(comp, l);
|
||
let mut d = data_of(g);
|
||
let to = to.clamp(lim[0], lim[1]);
|
||
let before = d.span;
|
||
d.span[0] = d.span[0].min(to);
|
||
d.span[1] = d.span[1].max(to);
|
||
if d.span != before {
|
||
if frozen_of(g) {
|
||
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to change the span"));
|
||
}
|
||
write_data(s, "Extend Segmentation Span", cid, lid, uid, &d)?;
|
||
}
|
||
}
|
||
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"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn span(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "roto.span";
|
||
let (cid, lid, uid) = roto_p(s, p, cmd)?;
|
||
let (l, g) = group(s, cid, lid, uid)?;
|
||
if frozen_of(g) {
|
||
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to change the span"));
|
||
}
|
||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||
let [lo, hi] = rt::frame_limits(comp, l);
|
||
let mut d = data_of(g);
|
||
let base = d.base.ok_or_else(|| bad(cmd, "no strokes yet"))?;
|
||
let first = d.strokes.iter().map(|s| s.frame).min().unwrap_or(base);
|
||
let last = d.strokes.iter().map(|s| s.frame).max().unwrap_or(base);
|
||
// Trimming or shortening the comp can leave saved strokes outside the layer. No span
|
||
// inside its new bounds can contain those strokes; keep them for undo or a later untrim.
|
||
if first < lo || last > hi {
|
||
return Err(bad(cmd, "strokes outside the layer: restore the layer/comp timing or clear those strokes before changing the span"));
|
||
}
|
||
let [start, end] = &mut d.span;
|
||
if let Some(a) = f_p(p, "start") {
|
||
*start = (a.round() as i64).clamp(lo, first);
|
||
}
|
||
if let Some(b) = f_p(p, "end") {
|
||
*end = (b.round() as i64).clamp(last, hi);
|
||
}
|
||
write_data(s, "Segmentation Span", cid, lid, uid, &d)?;
|
||
Ok(json!({"layer": lid.0, "effect": uid, "span": d.span, "base": base}))
|
||
}
|
||
|
||
fn freeze(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "roto.freeze";
|
||
let (cid, lid, uid) = roto_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 unfreeze(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "roto.unfreeze";
|
||
let (cid, lid, uid) = roto_p(s, p, cmd)?;
|
||
let (_, g) = group(s, cid, lid, uid)?;
|
||
if !frozen_of(g) {
|
||
return Ok(json!({"layer": lid.0, "effect": uid, "frozen": false}));
|
||
}
|
||
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 clear_strokes(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "roto.clearStrokes";
|
||
let (cid, lid, uid) = roto_p(s, p, cmd)?;
|
||
let (_, g) = group(s, cid, lid, uid)?;
|
||
if frozen_of(g) {
|
||
return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to edit strokes"));
|
||
}
|
||
let mut d = data_of(g);
|
||
let frame = f_p(p, "frame").map(|f| f.round() as i64);
|
||
let kind = match str_p(p, "kind") {
|
||
Some(k) => Some(StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}`")))?),
|
||
None => None,
|
||
};
|
||
let n0 = d.strokes.len();
|
||
d.strokes.retain(|st| !(frame.is_none_or(|f| st.frame == f) && kind.is_none_or(|k| st.kind == k)));
|
||
if d.strokes.iter().all(|s| !s.kind.is_segmentation()) {
|
||
d.base = None;
|
||
} else if d.base.is_some_and(|b| !d.strokes.iter().any(|s| s.frame == b && s.kind.is_segmentation())) {
|
||
// The base moves to the earliest remaining stroked frame.
|
||
d.base = d.strokes.iter().filter(|s| s.kind.is_segmentation()).map(|s| s.frame).min();
|
||
}
|
||
let removed = n0 - d.strokes.len();
|
||
if removed > 0 {
|
||
write_data(s, "Remove Roto Brush Strokes", cid, lid, uid, &d)?;
|
||
}
|
||
Ok(json!({"layer": lid.0, "effect": uid, "removed": removed, "strokes": d.strokes.len(), "base": d.base}))
|
||
}
|
||
|
||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "roto.options";
|
||
let mut o: RotoOptions = s.state.roto.clone();
|
||
for (k, f) in [("diameter", &mut o.diameter as &mut f64), ("refineDiameter", &mut o.refine_diameter), ("overlayOpacity", &mut o.overlay_opacity)] {
|
||
if let Some(v) = f_p(p, k) {
|
||
*f = v;
|
||
}
|
||
}
|
||
o.diameter = o.diameter.clamp(1.0, 5000.0);
|
||
o.refine_diameter = o.refine_diameter.clamp(1.0, 5000.0);
|
||
o.overlay_opacity = o.overlay_opacity.clamp(0.0, 100.0);
|
||
if let Some(v) = str_p(p, "view") {
|
||
let v = VIEWS.iter().find(|x| x.eq_ignore_ascii_case(v)).ok_or_else(|| bad(cmd, format!("unknown view `{v}` ({})", VIEWS.join(" | "))))?;
|
||
o.view = v.to_string();
|
||
}
|
||
for (k, c) in [("overlayColor", &mut o.overlay_color), ("boundaryColor", &mut o.boundary_color)] {
|
||
if let Some(a) = p.get(k).and_then(Value::as_array) {
|
||
if a.len() < 3 {
|
||
return Err(bad(cmd, format!("`{k}` is [r, g, b] (0–1)")));
|
||
}
|
||
for (i, v) in a.iter().take(3).enumerate() {
|
||
c[i] = v.as_f64().unwrap_or(0.0).clamp(0.0, 1.0);
|
||
}
|
||
}
|
||
}
|
||
if let Some(b) = b_p(p, "autoPropagate") {
|
||
o.auto_propagate = b;
|
||
}
|
||
s.state.roto = o.clone();
|
||
Ok(serde_json::to_value(o).unwrap_or(Value::Null))
|
||
}
|
||
|
||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||
s.poll_roto(false);
|
||
let mut out =
|
||
json!({"running": s.is_roto_running(), "progress": s.roto_progress(), "banner": s.roto_progress().map(|p| p.banner()), "options": s.state.roto});
|
||
let Ok((cid, lid, uid)) = roto_p(s, p, "roto.status") else { return Ok(out) };
|
||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||
let (layer, g) = group(s, cid, lid, uid)?;
|
||
let (layer, g) = (layer.clone(), g.clone());
|
||
let d = data_of(&g);
|
||
out["layer"] = json!(lid.0);
|
||
out["effect"] = json!(uid);
|
||
out["base"] = json!(d.base);
|
||
out["span"] = json!(d.span);
|
||
out["strokes"] = json!(d.strokes.len());
|
||
out["strokeFrames"] = json!(d.stroke_frames());
|
||
out["frozen"] = json!(frozen_of(&g));
|
||
out["pending"] = json!(s.roto_pending.contains(&(cid, lid, uid)));
|
||
out["limits"] = json!(rt::frame_limits(&comp, &layer));
|
||
let frame = frame_p(s, p, cid, &layer);
|
||
out["frame"] = json!(frame);
|
||
let want_matte = b_p(p, "matte").unwrap_or(false);
|
||
let compute = b_p(p, "compute").unwrap_or(want_matte);
|
||
if (p.get("frame").is_some() || want_matte) && d.in_span(frame) {
|
||
let size = rt::layer_size(&s.project, &comp, &layer);
|
||
let fps = comp.frame_rate.as_f64();
|
||
let ps = params_static(&g);
|
||
let chain = fx::Chain::new(&ps, size, fps, 1.0);
|
||
let seg = match chain.keys.get(&frame).and_then(|k| fx::cached(*k, 1.0)) {
|
||
Some(sg) => Some(sg),
|
||
None if compute => {
|
||
let index = layer.effects().and_then(|f| f.groups().position(|x| x.uid == uid)).unwrap_or(0);
|
||
let mut src = rt::FrameSource::new(&s.project, s.footage.as_ref(), s.expr.as_deref(), &s.layer_cache, cid, &comp, &layer, index, size);
|
||
chain.ensure(frame, &mut |f| src.get(f), &|| false)
|
||
}
|
||
None => None,
|
||
};
|
||
if let Some(sg) = seg {
|
||
out["area"] = json!(sg.area());
|
||
out["centroid"] = json!(sg.centroid());
|
||
out["size"] = json!([sg.w, sg.h]);
|
||
out["refineArea"] = json!(sg.refine.iter().filter(|v| **v != 0).count());
|
||
if want_matte {
|
||
out["matte"] = json!(rle::encode(&sg.matte));
|
||
}
|
||
if let Some(gt) = p.get("compareTo").and_then(Value::as_str).and_then(rle::decode) {
|
||
out["iou"] = json!(iou(&sg.matte, >));
|
||
}
|
||
}
|
||
}
|
||
let computed = s.roto_computed(cid, lid, uid);
|
||
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)
|
||
}
|