#783: Object Matte tool in the Composition viewer

Problem: Object Matte could only be applied through the objectMatte.apply
command; After Effects 26.2 picks the object directly in the viewer (click it,
or drag a box around it).

Fix: an Object Matte tool (with Roto Brush & Refine Edge in the Alt+W slot,
icon drawn in code). In the Composition viewer a click sets a point prompt and
a drag a box prompt (its centre the point) on the footage, solid or comp layer
under the pointer, converted to layer pixels through the layer's transform.
The layer's Object Matte (the selected one, else its last) is re-prompted;
Shift adds another. Everything goes through objectMatte.apply, which gained
`effect` to re-prompt an instance (point, box or no box, reference frame; the
reach kept unless given), so each prompt is one undo step; the effect is then
selected, so its point controls show in the viewer. Hover draws a crosshair
and the outline of the layer that would get the matte (a live matte preview
would need a segmentation per pointer move). Automation ids:
viewer.objectMatte.crosshair / .target / .box, tools.ObjectMatte.

Tests: ui-egui ui_object_matte (hover, click, drag box re-prompts the same
effect, Shift-click adds one, undo steps); engine
tests_roto::object_matte_reprompt_updates_the_instance. Checked with the
headless snapshot example (hover crosshair, box drag isolating a disk).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 13:50:01 +02:00
co-authored by Claude Opus 5.5
parent 4fc7b52351
commit 0c695cff77
9 changed files with 409 additions and 20 deletions
+6 -2
View File
@@ -113,6 +113,11 @@ pub fn reference_frame(params: &Params) -> i64 {
frames(params, "objectTracking/referenceFrame", -1e9, 1e9)
}
/// Frames Before / After (finite, clamped to `0..=MAX_REACH`).
pub fn reach(params: &Params) -> [i64; 2] {
[frames(params, "objectTracking/framesBefore", 0.0, MAX_REACH), frames(params, "objectTracking/framesAfter", 0.0, MAX_REACH)]
}
/// Whether the matte is carried from the reference frame (else every frame is segmented on its
/// own).
pub fn tracks(params: &Params) -> bool {
@@ -170,8 +175,7 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo
pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool) -> Option<RotoData> {
if tracks(params) {
let base = reference_frame(params);
let before = frames(params, "objectTracking/framesBefore", 0.0, MAX_REACH);
let after = frames(params, "objectTracking/framesAfter", 0.0, MAX_REACH);
let [before, after] = reach(params);
let strokes = prompt_strokes(params, layer_size, base, with_model)?;
Some(RotoData { base: Some(base), span: [base.saturating_sub(before), base.saturating_add(after)], strokes })
} else {
+58 -14
View File
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Matte",
[],
None,
"{layer?, 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)}",
"{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
),
@@ -455,6 +455,18 @@ fn object_matte_apply(s: &mut Session, p: &Value) -> Result<Value> {
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"))?;
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")))?;
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);
@@ -473,42 +485,74 @@ fn object_matte_apply(s: &mut Session, p: &Value) -> Result<Value> {
if frame < limits[0] || frame > limits[1] {
return Err(bad(cmd, format!("frame {frame} is outside the layer ({}–{})", limits[0], limits[1])));
}
let track = b_p(p, "track").unwrap_or(true);
let reach = |k: &str| f_p(p, k).filter(|v| v.is_finite()).unwrap_or(om::DEFAULT_REACH).round().clamp(0.0, om::MAX_REACH);
// 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 uid = s.edit("Object Matte", None, |proj, _| {
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 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) };
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, size);
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),
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("useBox", KV::Bool(true));
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])]));
}
set("objectTracking/trackObject", KV::Bool(track));
if let Some(t) = track {
set("objectTracking/trackObject", KV::Bool(t));
}
set("objectTracking/referenceFrame", KV::Scalar(frame as f64));
set("objectTracking/framesBefore", KV::Scalar(before));
set("objectTracking/framesAfter", KV::Scalar(after));
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;
fxg.children.push(g.into());
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];
Ok(json!({"layer": lid.0, "effect": uid, "frame": frame, "track": track, "span": [frame - before as i64, frame + after as i64]}))
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)
}
+39
View File
@@ -426,3 +426,42 @@ fn object_matte_from_a_click() {
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());
}
+10
View File
@@ -34,6 +34,8 @@ pub enum Icon {
Eraser,
RotoBrush,
RefineEdge,
/// Object Matte: a selection box's corners around a solid object, with a click point.
ObjectMatte,
/// Puppet Position Pin: a pushpin. The other pin tools add a badge at the lower right.
Puppet,
PuppetAdvanced,
@@ -314,6 +316,14 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.line(&[(8.0, 14.0), (9.5, 12.5)]);
pen.line(&[(11.0, 14.0), (12.5, 12.5)]);
}
ObjectMatte => {
// Corner brackets of a box around a filled object, and the click on it.
for (x, y, dx, dy) in [(2.0, 2.0, 1.0, 1.0), (14.0, 2.0, -1.0, 1.0), (14.0, 14.0, -1.0, -1.0), (2.0, 14.0, 1.0, -1.0)] {
pen.line(&[(x, y + 3.0 * dy), (x, y), (x + 3.0 * dx, y)]);
}
pen.dot(8.0, 8.0, 2.2);
pen.circle(8.0, 8.0, 4.4);
}
Puppet => pen.pushpin(8.0),
PuppetAdvanced => {
// Rotation ring with its square scale handle.
+1
View File
@@ -28,6 +28,7 @@ pub mod learn;
pub mod markers_ui;
pub mod media_panels;
pub mod misc;
pub mod object_matte_tool;
pub mod paint_panels;
pub mod panel_kit;
pub mod path_vr_panels;
@@ -0,0 +1,128 @@
//! The Object Matte tool in the Composition viewer (After Effects 26.2's Object Matte, from its
//! public description): click an object to isolate it, or drag a box around it. The layer under
//! the pointer gets an Object Matte effect (`objectMatte.apply`), or its Object Matte is
//! re-prompted (the selected one, else its last); Shift adds another. Every prompt is one undo
//! step, and the effect is selected so its point controls show in the viewer.
//!
//! While hovering, a crosshair and the outline of the layer that would get the matte preview the
//! click: segmenting on every pointer move would be far too slow for a hover preview (a model
//! pass or a graph cut per move), so the matte itself appears once the prompt is set.
use effectcraft_engine::geom::vec2 as gv2;
use effectcraft_engine::project::Layer;
use effectcraft_engine::render::EvalCtx;
use egui::{Color32, Pos2, Rect, Stroke, StrokeKind};
use serde_json::json;
use super::viewer::{ViewerMap, fx2c, layers_at};
use crate::EffectcraftApp;
/// Drags shorter than this (screen points) are clicks: a point prompt, no box.
pub const MIN_BOX: f32 = 4.0;
/// Whether Object Matte can work on `layer` (footage, solid or composition layers with pixels).
fn eligible(layer: &Layer) -> bool {
layer.has_video() && !layer.switches.adjustment && layer.effects().is_some()
}
/// The layer a prompt at comp point `cpt` goes to: a selected one under it, else the topmost.
pub fn target<'a>(app: &EffectcraftApp, ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> {
let hits: Vec<&Layer> = layers_at(ectx, cpt).filter(|l| eligible(l)).collect();
let selected = &app.session.state.selected_layers;
hits.iter().find(|l| selected.contains(&l.id)).or(hits.first()).copied()
}
/// The layer's effect-space rectangle (its pixels) on screen.
fn outline(ectx: &EvalCtx, map: &ViewerMap, layer: &Layer) -> [Pos2; 4] {
let [w, h] = ectx.effect_bounds(layer).0;
let m = fx2c(ectx, layer);
[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].map(|p| {
let c = m.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
}
/// Hover preview at `hp` (screen): the outline of the layer that would get the matte and a
/// crosshair where the object point would go.
pub fn hover(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, hp: Pos2) {
let accent = app.tokens.accent;
let cpt = map.to_comp(hp);
if let Some(layer) = target(app, ectx, cpt) {
let q = outline(ectx, map, layer);
let mut pts = q.to_vec();
pts.push(q[0]);
painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent)));
let r = Rect::from_points(&q);
app.auto.add("viewer.objectMatte.target", r, &layer.name);
}
let (gap, arm) = (4.0, 11.0);
for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] {
let d = egui::vec2(dx, dy);
for (w, c) in [(3.0, Color32::from_black_alpha(160)), (1.5, Color32::WHITE)] {
painter.line_segment([hp + d * gap, hp + d * arm], Stroke::new(w, c));
}
}
painter.circle_stroke(hp, 2.0, Stroke::new(1.5, accent));
app.auto.add("viewer.objectMatte.crosshair", Rect::from_center_size(hp, egui::vec2(2.0 * arm, 2.0 * arm)), "Object Matte point");
}
/// The box being dragged (screen `a`–`b`).
pub fn draw_box(app: &mut EffectcraftApp, painter: &egui::Painter, a: Pos2, b: Pos2) {
let r = Rect::from_two_pos(a, b);
let accent = app.tokens.accent;
painter.rect_stroke(r, 0.0, Stroke::new(2.5, Color32::from_black_alpha(140)), StrokeKind::Middle);
painter.rect_stroke(r, 0.0, Stroke::new(1.5, accent), StrokeKind::Middle);
painter.circle_filled(r.center(), 2.5, accent);
app.auto.add("viewer.objectMatte.box", r, "Object Matte box");
}
/// Set the prompt from a click at `a` (screen), or a box dragged from `a` to `b`: on the
/// target layer's Object Matte (re-prompted), or a new one with `add` or when it has none.
pub fn apply(app: &mut EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, a: Pos2, b: Option<Pos2>, add: bool) {
let boxed = b.filter(|b| (b.x - a.x).abs() >= MIN_BOX && (b.y - a.y).abs() >= MIN_BOX);
let center = boxed.map_or(a, |b| Rect::from_two_pos(a, b).center());
let Some(layer) = target(app, ectx, map.to_comp(center)) else {
app.ui.status = "Object Matte: click an object on a footage, solid or composition layer".into();
return;
};
let Some(inv) = fx2c(ectx, layer).inverse() else { return };
let to_layer = |p: Pos2| {
let c = map.to_comp(p);
let l = inv.apply(gv2(c[0], c[1]));
[l.x, l.y]
};
let point = to_layer(center);
let mut q = json!({"layer": layer.id.0, "point": point});
if let Some(b) = boxed {
// The box in layer pixels: the bounds of its corners (the layer may be turned).
let r = Rect::from_two_pos(a, b);
let c = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(to_layer);
let (x0, x1) = c.iter().map(|p| p[0]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)));
let (y0, y1) = c.iter().map(|p| p[1]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)));
q["box"] = json!([x0, y0, x1, y1]);
}
if !add && let Some(uid) = existing(app, layer) {
q["effect"] = json!(uid);
}
let lid = layer.id;
match app.session.execute("objectMatte.apply", q) {
Ok(r) => {
if let Some(uid) = r["effect"].as_u64() {
let _ = app.session.execute("prop.select", json!({"layer": lid.0, "prop": uid}));
}
}
Err(e) => app.ui.status = e.to_string(),
}
}
/// The Object Matte a prompt on `layer` re-prompts: the selected one, else the layer's last.
fn existing(app: &EffectcraftApp, layer: &Layer) -> Option<u64> {
let fx = layer.effects()?;
let om: Vec<u64> = fx.groups().filter(|g| is_object_matte(g)).map(|g| g.uid).collect();
let sel = app.session.state.selected_props.iter().rev().find(|(l, u)| *l == layer.id && om.contains(u)).map(|(_, u)| *u);
sel.or(om.last().copied())
}
fn is_object_matte(g: &effectcraft_engine::project::PropGroup) -> bool {
matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == effectcraft_engine::effects::object_matte::ID)
}
+17 -1
View File
@@ -164,6 +164,10 @@ enum Gesture {
PinMarquee {
start: Pos2,
},
/// Object Matte tool: a box around the object (screen point where the drag began).
ObjectMatteBox {
start: Pos2,
},
/// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds
/// since it began, effect space) and becomes keyframes on release (`puppet.recordPin`); the
/// current time runs along while recording.
@@ -1164,6 +1168,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
t if t.is_shape() => egui::CursorIcon::Crosshair,
_ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair,
Tool::ObjectMatte => {
// The tool draws its own crosshair (and the layer it would matte) while no box
// is being dragged.
if ui.data(|d| d.get_temp::<Gesture>(egui::Id::new("viewer-gesture"))).is_none() {
super::object_matte_tool::hover(app, &painter, &map, &ectx, hp);
}
egui::CursorIcon::None
}
t if t.puppet_kind().is_some() => match super::puppet_tool::hit(&pin_hits, hp) {
Some((_, super::puppet_tool::PinPart::Ring)) => egui::CursorIcon::Alias,
Some((_, super::puppet_tool::PinPart::Scale)) => egui::CursorIcon::ResizeNwSe,
@@ -1293,6 +1305,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// From where the button went down: by the time the pointer has moved far enough to
// count as a drag it has already left the press point, and the shape is drawn there.
t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }),
Tool::ObjectMatte => Some(Gesture::ObjectMatteBox { start: press }),
t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => {
// Off the pins: Alt or a press outside the art drags a marquee that selects pins.
(mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press })
@@ -1609,6 +1622,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
q["merge"] = json!(merge);
let _ = app.session.execute("track.setPoint", q);
}
Gesture::ObjectMatteBox { start } => super::object_matte_tool::draw_box(app, &painter, start, pos),
Gesture::Marquee { start } | Gesture::PinMarquee { start } => {
let r = Rect::from_two_pos(start, pos);
painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12));
@@ -1649,6 +1663,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, &ectx, &map, start, Some(pos), mods.shift),
Gesture::Create { tool, start } => {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods);
@@ -1752,6 +1767,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Clicking away from the free-transform box ends it.
ov::end_free_transform(&ctx);
}
Tool::ObjectMatte => super::object_matte_tool::apply(app, &ectx, &map, pos, None, mods.shift),
t if t.puppet_kind().is_some() => {
if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
// Click selects the pin; Shift-click adds or removes it.
@@ -2075,7 +2091,7 @@ pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator<Item =
}
/// Selectable layers under a comp point, front to back.
fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator<Item = &'a Layer> + 'b {
pub(crate) fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator<Item = &'a Layer> + 'b {
selectable_layers(ectx.comp, ectx.time).filter(move |l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q)))
}
+8 -3
View File
@@ -35,6 +35,8 @@ pub enum Tool {
Eraser,
RotoBrush,
RefineEdge,
/// Object Matte: click an object (or drag a box around it) in the Composition viewer.
ObjectMatte,
Puppet,
PuppetStarch,
PuppetBend,
@@ -59,10 +61,10 @@ impl Tool {
&[Tool::Brush],
&[Tool::Clone],
&[Tool::Eraser],
&[Tool::RotoBrush, Tool::RefineEdge],
&[Tool::RotoBrush, Tool::RefineEdge, Tool::ObjectMatte],
&[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap],
];
pub const ALL: [Tool; 30] = [
pub const ALL: [Tool; 31] = [
Tool::Selection,
Tool::Hand,
Tool::Zoom,
@@ -88,6 +90,7 @@ impl Tool {
Tool::Eraser,
Tool::RotoBrush,
Tool::RefineEdge,
Tool::ObjectMatte,
Tool::Puppet,
Tool::PuppetStarch,
Tool::PuppetBend,
@@ -121,6 +124,7 @@ impl Tool {
Tool::Eraser => "Eraser Tool",
Tool::RotoBrush => "Roto Brush Tool",
Tool::RefineEdge => "Refine Edge Tool",
Tool::ObjectMatte => "Object Matte Tool",
Tool::Puppet => "Puppet Position Pin Tool",
Tool::PuppetStarch => "Puppet Starch Pin Tool",
Tool::PuppetBend => "Puppet Bend Pin Tool",
@@ -142,7 +146,7 @@ impl Tool {
Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"),
Tool::Type | Tool::TypeVertical => Some("Cmd+T"),
Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"),
Tool::RotoBrush | Tool::RefineEdge => Some("Alt+W"),
Tool::RotoBrush | Tool::RefineEdge | Tool::ObjectMatte => Some("Alt+W"),
Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Some("Cmd+P"),
}
}
@@ -173,6 +177,7 @@ impl Tool {
Tool::Eraser => Icon::Eraser,
Tool::RotoBrush => Icon::RotoBrush,
Tool::RefineEdge => Icon::RefineEdge,
Tool::ObjectMatte => Icon::ObjectMatte,
Tool::Puppet => Icon::Puppet,
Tool::PuppetStarch => Icon::PuppetStarch,
Tool::PuppetBend => Icon::PuppetBend,
+142
View File
@@ -0,0 +1,142 @@
//! Headless checks for the Object Matte tool in the Composition viewer: the hover preview, a
//! click (point prompt), a drag (box prompt) re-prompting the same effect, Shift adding another,
//! one undo step each (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_engine::effects::flatten_params;
use effectcraft_engine::effects::object_matte as om;
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::{GroupKind, LayerId, PropGroup};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Matte", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Comp pixel → screen point.
fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 {
let c = rect(h, "viewer.comp");
let z = c.width() / 640.0;
c.min + vec2(p[0] * z, p[1] * z)
}
fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, pressed: bool, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers });
h.step();
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
press(h, p, true, modifiers);
press(h, p, false, modifiers);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) -> Option<Rect> {
h.input_mut().events.push(Event::PointerMoved(from));
press(h, from, true, Modifiers::NONE);
let mut drawn = None;
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
drawn = h.state().auto.find("viewer.objectMatte.box").map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])));
}
press(h, to, false, Modifiers::NONE);
h.run_steps(2);
drawn
}
fn box_layer(h: &Harness<'_, EffectcraftApp>) -> LayerId {
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id
}
/// The Box layer's Object Matte effects.
fn mattes(h: &Harness<'_, EffectcraftApp>) -> Vec<PropGroup> {
let comp = h.state().session.active_comp().unwrap();
let l = comp.layer(box_layer(h)).unwrap();
l.effects().unwrap().groups().filter(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == om::ID)).cloned().collect()
}
fn v2(g: &PropGroup, id: &str) -> [f64; 2] {
flatten_params(g, &mut |p| p.value.clone()).get(id).map(Value::as_vec2).unwrap()
}
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).abs() < 1.5 && (a[1] - b[1]).abs() < 1.5
}
#[test]
fn object_matte_tool_prompts_with_a_click_and_a_box() {
let mut h = harness();
assert_eq!(Tool::from_name("objectMatte"), Some(Tool::ObjectMatte));
assert!(Tool::SLOTS.iter().any(|s| s.contains(&Tool::RotoBrush) && s.contains(&Tool::ObjectMatte)));
h.state_mut().ui.tool = Tool::ObjectMatte;
h.run_steps(2);
// Hover: a crosshair at the pointer and the outline of the layer that would get the matte.
let center = screen(&h, [320.0, 180.0]);
h.input_mut().events.push(Event::PointerMoved(center));
h.run_steps(2);
assert!(rect(&h, "viewer.objectMatte.crosshair").contains(center));
let target = h.state().auto.find("viewer.objectMatte.target").expect("no target outline");
assert_eq!(target.label, "Box");
assert!(mattes(&h).is_empty(), "hovering must not apply anything");
let undo0 = h.state().session.history.undo.len();
// Click: a point prompt in layer pixels on the layer under the pointer, selected.
click(&mut h, center, Modifiers::NONE);
let m = mattes(&h);
assert_eq!(m.len(), 1);
assert!(near(v2(&m[0], "objectPoint"), [40.0, 40.0]), "{:?}", v2(&m[0], "objectPoint"));
let uid = m[0].uid;
let st = &h.state().session.state;
assert!(st.selected_props.contains(&(box_layer(&h), uid)), "the new effect is selected");
assert_eq!(h.state().session.history.undo.len(), undo0 + 1);
// Drag: a box (drawn while dragging) re-prompts the same effect; its centre is the point.
// (It starts clear of the selected effect's point controls, which take drags of their own.)
let (a, b) = (screen(&h, [286.0, 150.0]), screen(&h, [356.0, 214.0]));
let drawn = drag(&mut h, a, b).expect("no box drawn while dragging");
assert!(drawn.width() > 20.0);
let m = mattes(&h);
assert_eq!(m.len(), 1);
assert_eq!(m[0].uid, uid);
let ps = flatten_params(&m[0], &mut |p| p.value.clone());
assert_eq!(ps.get("useBox"), Some(&Value::Bool(true)));
assert!(near(v2(&m[0], "boxTopLeft"), [6.0, 10.0]), "{:?}", v2(&m[0], "boxTopLeft"));
assert!(near(v2(&m[0], "boxBottomRight"), [76.0, 74.0]), "{:?}", v2(&m[0], "boxBottomRight"));
assert!(near(v2(&m[0], "objectPoint"), [41.0, 42.0]), "{:?}", v2(&m[0], "objectPoint"));
assert_eq!(h.state().session.history.undo.len(), undo0 + 2);
// Shift-click adds a second Object Matte.
let p = screen(&h, [310.0, 170.0]);
click(&mut h, p, Modifiers::SHIFT);
let m = mattes(&h);
assert_eq!(m.len(), 2);
assert_eq!(m[1].name, "Object Matte 2");
assert!(near(v2(&m[1], "objectPoint"), [30.0, 30.0]));
// Undo takes the prompts back one at a time.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(mattes(&h).len(), 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(flatten_params(&mattes(&h)[0], &mut |p| p.value.clone()).get("useBox"), Some(&Value::Bool(false)));
}