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))); +}