From 0c695cff7740241a38621faa334a33c164f2e197 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:50:01 +0200 Subject: [PATCH] #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 --- crates/effects/src/object_matte.rs | 8 +- crates/engine/src/commands/roto_cmds.rs | 72 +++++++-- crates/engine/src/tests_roto.rs | 39 +++++ crates/ui-egui/src/icons.rs | 10 ++ crates/ui-egui/src/panels/mod.rs | 1 + .../ui-egui/src/panels/object_matte_tool.rs | 128 ++++++++++++++++ crates/ui-egui/src/panels/viewer.rs | 18 ++- crates/ui-egui/src/state.rs | 11 +- crates/ui-egui/tests/ui_object_matte.rs | 142 ++++++++++++++++++ 9 files changed, 409 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/panels/object_matte_tool.rs create mode 100644 crates/ui-egui/tests/ui_object_matte.rs diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 9ee33a90..a7db562a 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -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 { 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 { diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/commands/roto_cmds.rs index ff459d0b..f4f33d53 100644 --- a/crates/engine/src/commands/roto_cmds.rs +++ b/crates/engine/src/commands/roto_cmds.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "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 { 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 { 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) } diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index cdf58540..19eee110 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -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()); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52066f85..91dd0f85 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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. diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2f0ca804..da2e05d0 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/object_matte_tool.rs b/crates/ui-egui/src/panels/object_matte_tool.rs new file mode 100644 index 00000000..70c9df8e --- /dev/null +++ b/crates/ui-egui/src/panels/object_matte_tool.rs @@ -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, 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 { + let fx = layer.effects()?; + let om: Vec = 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) +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c..4ea3b8c3 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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::(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(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator + 'b { +pub(crate) fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator + 'b { selectable_layers(ectx.comp, ectx.time).filter(move |l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cce2c11..7c49c786 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, diff --git a/crates/ui-egui/tests/ui_object_matte.rs b/crates/ui-egui/tests/ui_object_matte.rs new file mode 100644 index 00000000..483d271e --- /dev/null +++ b/crates/ui-egui/tests/ui_object_matte.rs @@ -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 { + 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 { + 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))); +}