From e57e6a51209fb22ab7b635862e95cb27c299c55a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:52:20 -0400 Subject: [PATCH] M12.3: tracker panel + apply Tracker panel (Window > Tracker): Track Camera / Warp Stabilizer (disabled), Track Motion / Stabilize Motion, Motion Source, Current Track, Track Type, Position/Rotation/Scale, Motion Target with Edit Target..., Options... (Motion Tracker Options dialog), Analyze (1 frame back, back, forward, 1 frame forward; stop while running) with progress, Reset and Apply (Apply Dimensions dialog). Track point widgets in the viewer: feature and search regions with corner handles, attach point crosshair and track path, all draggable through track.setPoint (one undo step per drag). Automation ids for every widget; headless UI test. Tracker: featureless regions no longer drift (ties keep the prediction, only confident matches predict motion). docs/agents.md: tracking example. Co-Authored-By: Claude Opus 5.5 --- crates/track/src/lib.rs | 23 +- crates/track/src/tests.rs | 14 + crates/ui-egui/src/lib.rs | 10 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/panels/dialogs.rs | 6 + crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/tracker.rs | 722 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/viewer.rs | 38 +- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/tests/ui_tracker.rs | 117 +++++ docs/agents.md | 23 + 11 files changed, 961 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/panels/tracker.rs create mode 100644 crates/ui-egui/tests/ui_tracker.rs diff --git a/crates/track/src/lib.rs b/crates/track/src/lib.rs index 9fe4c48e..d106b209 100644 --- a/crates/track/src/lib.rs +++ b/crates/track/src/lib.rs @@ -372,7 +372,11 @@ impl PointState { }) }) .collect(); - cands.sort_by(|a, b| b.0.total_cmp(&a.0)); + // Best score first; ties (flat or repetitive regions) go to the candidate nearest the + // prediction so featureless areas don't drift. + let cp = crop.to_level(predicted, top); + let d2 = |c: &[f64; 2]| (c[0] - cp[0]).powi(2) + (c[1] - cp[1]).powi(2); + cands.sort_by(|a, b| if (a.0 - b.0).abs() < 1e-9 { d2(&a.1).total_cmp(&d2(&b.1)) } else { b.0.total_cmp(&a.0) }); let mut seeds: Vec<[f64; 2]> = vec![]; for (_, c) in &cands { if seeds.iter().all(|q| (q[0] - c[0]).abs() > 1.5 || (q[1] - c[1]).abs() > 1.5) { @@ -390,12 +394,16 @@ impl PointState { for k in (0..top).rev() { c = [c[0] * 2.0, c[1] * 2.0]; let (pl, tp) = (&crop.levels[k], &self.templates[k]); - let mut lb = (f64::MIN, c); + // Start from the projected match; neighbours must beat it (no drift on ties). + let mut lb = (ncc(pl, tp, c, shape), c); for j in -2..=2 { for i in -2..=2 { + if i == 0 && j == 0 { + continue; + } let q = [c[0] + i as f64, c[1] + j as f64]; let v = ncc(pl, tp, q, shape); - if v > lb.0 { + if v > lb.0 + 1e-9 { lb = (v, q); } } @@ -405,7 +413,7 @@ impl PointState { if top == 0 { score = ncc(&crop.levels[0], &self.templates[0], c, shape); } - if score > best.0 { + if score > best.0 + 1e-9 { best = (score, crop.level_to_image(c, 0)); } } @@ -428,6 +436,10 @@ impl PointState { } } } + // A featureless template can't be matched: stay put. + if self.templates[0].ss < 1e-6 { + (c, a) = (crop.to_level(predicted, 0), shape); + } let mut conf = ncc(&crop.levels[0], &self.templates[0], c, a); // Keep the coarse match if refinement made it worse. let coarse = ncc(&crop.levels[0], &self.templates[0], c0, shape); @@ -451,7 +463,8 @@ impl PointState { ConfidenceAction::Adapt => status = Status::Adapted, } } - self.velocity = [found[0] - prev[0], found[1] - prev[1]]; + // Only confident matches predict the next search position. + self.velocity = if low { [0.0; 2] } else { [found[0] - prev[0], found[1] - prev[1]] }; self.center = found; self.shape = a; if opts.adapt_every_frame || status == Status::Adapted { diff --git a/crates/track/src/tests.rs b/crates/track/src/tests.rs index 3d583a43..1e91e2e6 100644 --- a/crates/track/src/tests.rs +++ b/crates/track/src/tests.rs @@ -240,3 +240,17 @@ fn performance_1080p_single_point() { assert!(dist(last.center, [c0[0] + 2.3 * 5.0, c0[1] + 1.1 * 5.0]) < 0.25, "{:?}", last.center); assert!(per < 0.1, "{per} s per frame"); } + +#[test] +fn featureless_regions_stay_put() { + let flat = Image::filled(200, 160, [0.3, 0.3, 0.3, 1.0]); + for action in ConfidenceAction::ALL { + let opts = TrackOptions { action, ..Default::default() }; + let mut tr = Tracker::new(opts, &[spec([100.0, 80.0], 21.0, 61.0)], &Frame::new(&flat)); + for _ in 0..10 { + let r = tr.step(&Frame::new(&flat))[0]; + assert!(dist(r.center, [100.0, 80.0]) < 1e-9, "{action:?}: drifted to {:?}", r.center); + assert!(r.confidence < 1.0); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 01a2d36d..f55d920f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -57,6 +57,12 @@ pub enum Dialog { KeyVelocity, KeyInterpolation, TimeStretch, + /// Tracker ▸ Options… + TrackOptions, + /// Tracker ▸ Edit Target… + TrackTarget, + /// Tracker ▸ Apply (Transform / Stabilize): Apply Dimensions. + TrackApply, } /// Host hooks provided by the native app (file pickers etc.). @@ -536,6 +542,10 @@ impl EffectcraftApp { self.session.poll_render(); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } + if self.session.track_job.is_some() { + self.session.poll_track(); + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } self.handle_events(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a81ac54a..a938ab4a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -446,6 +446,14 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.show_panel(PanelKind::Flowchart); Value::Null } + "track.editTargetDialog" => { + crate::panels::tracker::open_target(app)?; + Value::Null + } + "track.optionsDialog" => { + crate::panels::tracker::open_options(app)?; + Value::Null + } "layer.openLayer" => { app.show_panel(PanelKind::Layer); Value::Null diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a92c3db3..3e8ae984 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -31,6 +31,9 @@ pub struct DialogState { pub velocity: super::key_dialogs::VelocityDraft, pub interp: super::key_dialogs::InterpDraft, pub stretch: super::key_dialogs::StretchDraft, + pub track_options: super::tracker::OptionsDraft, + pub track_target: super::tracker::TargetDraft, + pub track_apply: super::tracker::ApplyDraft, } pub fn open_new_comp(app: &mut EffectcraftApp) { @@ -108,6 +111,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t), Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t), Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t), + Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t), + Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t), + Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t), } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b5160127..d85feff2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -15,6 +15,7 @@ pub mod properties; pub mod render_queue; pub mod text_panels; pub mod timeline; +pub mod tracker; pub mod viewer; pub mod waveform; @@ -69,6 +70,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Markers => misc::markers(app, ui, rect), PanelKind::Wiggler => misc::wiggler(app, ui, rect), PanelKind::RenderQueue => render_queue::show(app, ui, rect), + PanelKind::Tracker => tracker::show(app, ui, rect), other => misc::placeholder(app, ui, rect, other), } } diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs new file mode 100644 index 00000000..ad84665b --- /dev/null +++ b/crates/ui-egui/src/panels/tracker.rs @@ -0,0 +1,722 @@ +//! Tracker panel (Window ▸ Tracker), its Motion Tracker Options / Motion Target / Apply Options +//! dialogs, and the track point widgets drawn in the viewer (feature region, search region, +//! attach point crosshair, track path) with their drag gestures. +//! +//! Everything goes through `track.*` commands, so agents can do the same. + +use effectcraft_engine::geom::{Mat3, vec2 as gv2}; +use effectcraft_engine::project::tracking::{LowConfidence, TrackChannel, TrackKind, TrackerOptions}; +use effectcraft_engine::project::{Layer, LayerId, Uid}; +use effectcraft_engine::time::Tick; +use effectcraft_engine::tracking::point_values; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use super::viewer::ViewerMap; +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp, widgets}; + +const ROW: f32 = 26.0; + +/// Motion Tracker Options dialog state. +#[derive(Clone, Debug, Default)] +pub struct OptionsDraft { + pub name: String, + pub opts: TrackerOptions, + pub blur_on: bool, +} + +/// Motion Target dialog state. +#[derive(Clone, Debug, Default)] +pub struct TargetDraft { + pub target: Option, +} + +/// Apply Options dialog state (0 = X and Y, 1 = X only, 2 = Y only). +#[derive(Clone, Debug, Default)] +pub struct ApplyDraft { + pub dims: usize, +} + +/// The current track: (tracked layer, tracker uid), resolved like the engine does. +fn current(app: &EffectcraftApp) -> Option<(LayerId, Uid)> { + let comp = app.session.active_comp()?; + if let Some((l, t)) = app.session.state.current_track + && comp.layer(l).and_then(|l| l.tracker(t)).is_some() + { + return Some((l, t)); + } + let src = motion_source(app)?; + let l = comp.layer(src)?; + l.trackers().next().map(|(g, _)| (l.id, g.uid)) +} + +fn source_id() -> egui::Id { + egui::Id::new("tracker-motion-source") +} + +/// Motion Source: the current track's layer, the chosen source, or the selected layer. +fn motion_source(app: &EffectcraftApp) -> Option { + let comp = app.session.active_comp()?; + if let Some((l, _)) = app.session.state.current_track + && comp.layer(l).is_some() + { + return Some(l); + } + let chosen: Option = app.ui.tracker_source; + if let Some(l) = chosen.map(LayerId).filter(|l| comp.layer(*l).is_some()) { + return Some(l); + } + app.session.state.selected_layers.first().copied().filter(|l| comp.layer(*l).is_some_and(trackable)) +} + +fn trackable(l: &Layer) -> bool { + use effectcraft_engine::project::LayerSource; + l.has_video() && !matches!(l.source, LayerSource::Text | LayerSource::Shape) +} + +fn label(p: &egui::Painter, pos: Pos2, s: &str, t: &Tokens) { + p.text(pos, Align2::LEFT_CENTER, s, Tokens::ui(12.0), t.text_dim); +} + +/// A button that can be disabled (drawn dim and inert). +fn button(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, text: &str, enabled: bool, id: &str) -> bool { + let t = app.tokens; + app.auto.add(id, r, text); + if !enabled { + ui.painter().rect_filled(r, r.height() / 2.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)); + ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), t.text_faint); + return false; + } + widgets::text_button(ui, r, text, false, &t, egui::Id::new(id)).clicked() +} + +fn run(app: &mut EffectcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value) { + if let Err(e) = crate::menus::invoke(app, ctx, cmd, p) { + app.ui.status = e; + } +} + +/// Analyze button glyphs: ◀| ◀ ▶ |▶ (and ■ while analysing in that direction). +fn analyze_glyph(p: &egui::Painter, r: Rect, forward: bool, frame: bool, stop: bool, col: Color32) { + let c = r.center(); + if stop { + p.rect_filled(Rect::from_center_size(c, vec2(9.0, 9.0)), 1.0, col); + return; + } + let s = if forward { 1.0 } else { -1.0 }; + let tri = vec![pos2(c.x - 4.0 * s, c.y - 5.0), pos2(c.x + 4.0 * s, c.y), pos2(c.x - 4.0 * s, c.y + 5.0)]; + p.add(egui::Shape::convex_polygon(tri, col, Stroke::NONE)); + if frame { + let x = c.x + 5.5 * s; + p.line_segment([pos2(x, c.y - 5.0), pos2(x, c.y + 5.0)], Stroke::new(1.6, col)); + } +} + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + if app.session.track_job.is_some() { + app.session.poll_track(); + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + let p = ui.painter().with_clip_rect(rect); + let x0 = rect.min.x + 10.0; + let w = (rect.width() - 20.0).max(120.0); + let half = (w - 6.0) / 2.0; + let mut y = rect.min.y + 10.0; + let has_comp = app.session.active_comp().is_some(); + let source = motion_source(app); + let cur = current(app); + let can_track = has_comp && source.is_some(); + + // Track Camera / Warp Stabilizer (not available) and Track Motion / Stabilize Motion. + let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0)); + let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0)); + button(app, ui, r1, "Track Camera", false, "tracker.trackCamera"); + button(app, ui, r2, "Warp Stabilizer", false, "tracker.warpStabilizer"); + y += 28.0; + let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0)); + let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0)); + let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({})); + if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") { + run(app, &ctx, "track.motion", src_p.clone()); + } + if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") { + run(app, &ctx, "track.stabilize", src_p.clone()); + } + y += 34.0; + let Some(comp) = app.session.active_comp().cloned() else { + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Open a composition to track motion.", Tokens::ui(12.0), t.text_faint); + return; + }; + let field_x = x0 + 100.0; + let field_w = (w - 100.0).max(80.0); + + // Motion Source. + label(&p, pos2(x0, y + 10.0), "Motion Source:", &t); + let srcs: Vec<(LayerId, String)> = + comp.layers.iter().enumerate().filter(|(_, l)| trackable(l)).map(|(i, l)| (l.id, format!("{}. {}", i + 1, l.name))).collect(); + let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0)); + let name = source.and_then(|s| srcs.iter().find(|(l, _)| *l == s)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into()); + let did = egui::Id::new("tracker-source-dd"); + if widgets::dropdown(ui, dd, &name, &t, did).clicked() { + widgets::open_popup(ui, did); + } + app.auto.add("tracker.motionSource", dd, "Motion Source"); + let mut opts: Vec = vec!["None".into()]; + opts.extend(srcs.iter().map(|(_, n)| n.clone())); + let cur_i = source.and_then(|s| srcs.iter().position(|(l, _)| *l == s)).map(|i| i + 1).or(Some(0)); + if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &opts, cur_i) { + let chosen = i.checked_sub(1).and_then(|i| srcs.get(i)).map(|(l, _)| *l); + app.ui.tracker_source = chosen.map(|l| l.0); + ctx.data_mut(|d| d.remove::(source_id())); + match chosen.and_then(|l| comp.layer(l)).and_then(|l| l.trackers().next().map(|(g, _)| (l.id, g.uid))) { + Some((l, g)) => run(app, &ctx, "track.select", json!({"layer": l.0, "tracker": g})), + None => app.session.state.current_track = None, + } + } + y += ROW; + + // Current Track. + label(&p, pos2(x0, y + 10.0), "Current Track:", &t); + let trackers: Vec<(Uid, String)> = + source.and_then(|s| comp.layer(s)).map(|l| l.trackers().map(|(g, _)| (g.uid, g.name.clone())).collect()).unwrap_or_default(); + let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0)); + let cname = cur.and_then(|(_, u)| trackers.iter().find(|(g, _)| *g == u)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into()); + let did = egui::Id::new("tracker-current-dd"); + if widgets::dropdown(ui, dd, &cname, &t, did).clicked() && !trackers.is_empty() { + widgets::open_popup(ui, did); + } + app.auto.add("tracker.currentTrack", dd, "Current Track"); + let names: Vec = trackers.iter().map(|(_, n)| n.clone()).collect(); + let ci = cur.and_then(|(_, u)| trackers.iter().position(|(g, _)| *g == u)); + if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &names, ci) + && let (Some(src), Some((uid, _))) = (source, trackers.get(i)) + { + run(app, &ctx, "track.select", json!({"layer": src.0, "tracker": uid})); + } + y += ROW; + + let settings = cur.and_then(|(l, u)| comp.layer(l).and_then(|l| l.tracker(u)).map(|(_, s)| s.clone())); + let enabled = settings.is_some(); + let addr = cur.map(|(l, u)| json!({"layer": l.0, "tracker": u})).unwrap_or_else(|| json!({})); + let with = |extra: serde_json::Value| { + let mut v = addr.clone(); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + for (k, x) in e { + o.insert(k.clone(), x.clone()); + } + } + v + }; + + // Track Type. + label(&p, pos2(x0, y + 10.0), "Track Type:", &t); + let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0)); + let kind = settings.as_ref().map(|s| s.kind).unwrap_or_default(); + let did = egui::Id::new("tracker-type-dd"); + if widgets::dropdown(ui, dd, if enabled { kind.label() } else { "" }, &t, did).clicked() && enabled { + widgets::open_popup(ui, did); + } + app.auto.add("tracker.trackType", dd, "Track Type"); + let kinds: Vec = TrackKind::ALL.iter().map(|k| k.label().to_string()).collect(); + if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &kinds, TrackKind::ALL.iter().position(|k| *k == kind)) { + run(app, &ctx, "track.setType", with(json!({"kind": TrackKind::ALL[i].id()}))); + } + y += ROW; + + // Position / Rotation / Scale. + let transformish = matches!(kind, TrackKind::Transform | TrackKind::Stabilize) && enabled; + let mut xx = x0; + for (key, text, on) in [ + ("position", "Position", settings.as_ref().is_some_and(|s| s.position)), + ("rotation", "Rotation", settings.as_ref().is_some_and(|s| s.rotation)), + ("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)), + ] { + let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0)); + let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key))); + app.auto.add(&format!("tracker.{key}"), cr, text); + p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint }); + if resp.clicked() && transformish { + run(app, &ctx, "track.setType", with(json!({ key: !on }))); + } + xx += 84.0; + } + y += ROW; + + // Motion Target. + let target_name = settings + .as_ref() + .and_then(|s| s.target) + .and_then(|l| comp.layer(l).map(|l| format!("{}. {}", comp.layers.iter().position(|x| x.id == l.id).unwrap_or(0) + 1, l.name))); + label(&p, pos2(x0, y + 10.0), "Motion Target:", &t); + let tr = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0)); + p.text( + pos2(tr.min.x, tr.center().y), + Align2::LEFT_CENTER, + target_name.unwrap_or_else(|| if enabled && kind != TrackKind::Raw { "None".into() } else { String::new() }), + Tokens::ui(12.0), + t.text, + ); + app.auto.add("tracker.motionTarget", tr, "Motion Target"); + y += ROW; + let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0)); + let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0)); + if button(app, ui, r1, "Edit Target...", enabled && kind != TrackKind::Raw && kind != TrackKind::Stabilize, "tracker.editTarget") { + run(app, &ctx, "track.editTargetDialog", json!({})); + } + if button(app, ui, r2, "Options...", enabled, "tracker.options") { + run(app, &ctx, "track.optionsDialog", json!({})); + } + y += 32.0; + + // Analyze. + label(&p, pos2(x0, y + 11.0), "Analyze:", &t); + let tracking = app.session.is_tracking(); + let running_dir = app.session.track_job.as_ref().map(|j| j.direction); + let mut bx = field_x; + for (dir, fwd, frame, id, tip) in [ + (effectcraft_engine::tracking::Direction::FrameBackward, false, true, "frameBackward", "Analyze 1 frame backward"), + (effectcraft_engine::tracking::Direction::Backward, false, false, "backward", "Analyze backward"), + (effectcraft_engine::tracking::Direction::Forward, true, false, "forward", "Analyze forward"), + (effectcraft_engine::tracking::Direction::FrameForward, true, true, "frameForward", "Analyze 1 frame forward"), + ] { + let r = Rect::from_min_size(pos2(bx, y), vec2(30.0, 22.0)); + let resp = ui.interact(r, egui::Id::new(("tracker-analyze", id)), Sense::click()).on_hover_text(tip); + let stop = tracking && running_dir == Some(dir) && !frame; + let on = enabled && (!tracking || stop); + p.rect_filled(r, 4.0, if resp.hovered() && on { t.hover } else { t.field_bg }); + p.rect_stroke(r, 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + analyze_glyph(&p, r, fwd, frame, stop, if on { t.text } else { t.text_faint }); + app.auto.add(&format!("tracker.analyze.{id}"), r, tip); + if resp.clicked() && on { + if stop { + run(app, &ctx, "track.stop", json!({})); + } else { + run(app, &ctx, "track.analyze", with(json!({ "direction": id }))); + } + } + bx += 34.0; + } + y += 32.0; + + // Reset / Apply. + let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0)); + let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0)); + if button(app, ui, r1, "Reset", enabled && !tracking, "tracker.reset") { + run(app, &ctx, "track.reset", addr.clone()); + } + if button(app, ui, r2, "Apply", enabled && !tracking && kind != TrackKind::Raw, "tracker.apply") { + if matches!(kind, TrackKind::Transform | TrackKind::Stabilize) { + app.dialog_state.track_apply = ApplyDraft::default(); + app.dialog = Some(Dialog::TrackApply); + } else { + run(app, &ctx, "track.apply", addr.clone()); + } + } + y += 32.0; + + // Progress. + if let Some(pr) = app.session.track_progress() { + let bar = Rect::from_min_size(pos2(x0, y), vec2(w, 6.0)); + p.rect_filled(bar, 3.0, t.field_bg); + let f = if pr.total > 0 { pr.done as f32 / pr.total as f32 } else { 0.0 }; + p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f.clamp(0.0, 1.0), 6.0)), 3.0, t.accent); + app.auto.add("tracker.progress", bar, "Analysis progress"); + p.text( + pos2(x0, y + 18.0), + Align2::LEFT_CENTER, + format!("Analyzing {} / {} frames ({:.0} fps)", pr.done, pr.total, pr.fps), + Tokens::ui(11.0), + t.text_dim, + ); + } +} + +// ---------------------------------------------------------------- dialogs + +pub fn open_options(app: &mut EffectcraftApp) -> Result<(), String> { + let (l, u) = current(app).ok_or("no current track")?; + let comp = app.session.active_comp().ok_or("no composition")?; + let (g, s) = comp.layer(l).and_then(|l| l.tracker(u)).ok_or("no tracker")?; + app.dialog_state.track_options = OptionsDraft { name: g.name.clone(), opts: s.options.clone(), blur_on: s.options.blur > 0.0 }; + if app.dialog_state.track_options.opts.blur <= 0.0 { + app.dialog_state.track_options.opts.blur = 2.0; + } + app.dialog = Some(Dialog::TrackOptions); + Ok(()) +} + +pub fn open_target(app: &mut EffectcraftApp) -> Result<(), String> { + let (l, u) = current(app).ok_or("no current track")?; + let comp = app.session.active_comp().ok_or("no composition")?; + let (_, s) = comp.layer(l).and_then(|l| l.tracker(u)).ok_or("no tracker")?; + app.dialog_state.track_target = TargetDraft { target: s.target.map(|l| l.0) }; + app.dialog = Some(Dialog::TrackTarget); + Ok(()) +} + +fn ok_cancel(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str) -> (bool, bool) { + let (mut ok, mut cancel) = (false, false); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)); + app.auto.add(&format!("{prefix}.ok"), r.rect, "OK"); + ok = r.clicked(); + let r = ui.button("Cancel"); + app.auto.add(&format!("{prefix}.cancel"), r.rect, "Cancel"); + cancel = r.clicked(); + }); + (ok, cancel) +} + +pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.track_options.clone(); + let (mut ok, mut cancel) = (false, false); + super::dialogs::modal(ctx, "Motion Tracker Options", vec2(460.0, 470.0), t, |ui| { + ui.horizontal(|ui| { + ui.label("Track Name:"); + let r = ui.text_edit_singleline(&mut d.name); + app.auto.add("dialog.trackOptions.name", r.rect, "Track Name"); + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("Channel").font(Tokens::medium(12.5))); + ui.horizontal(|ui| { + for (c, l) in [(TrackChannel::Rgb, "RGB"), (TrackChannel::Luminance, "Luminance"), (TrackChannel::Saturation, "Saturation")] { + let r = ui.radio_value(&mut d.opts.channel, c, l); + app.auto.add(&format!("dialog.trackOptions.channel.{}", l.to_ascii_lowercase()), r.rect, l); + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("Process Before Match").font(Tokens::medium(12.5))); + ui.horizontal(|ui| { + let r = ui.checkbox(&mut d.blur_on, "Blur"); + app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); + let r = ui.add_enabled(d.blur_on, egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")); + app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); + let r = ui.checkbox(&mut d.opts.enhance, "Enhance"); + app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); + }); + ui.add_space(8.0); + let r = ui.checkbox(&mut d.opts.subpixel, "Subpixel Positioning"); + app.auto.add("dialog.trackOptions.subpixel", r.rect, "Subpixel Positioning"); + let r = ui.checkbox(&mut d.opts.adapt_every_frame, "Adapt Feature On Every Frame"); + app.auto.add("dialog.trackOptions.adaptEveryFrame", r.rect, "Adapt Feature On Every Frame"); + let r = ui.checkbox(&mut d.opts.track_shape, "Follow Feature Rotation and Scale"); + app.auto.add("dialog.trackOptions.trackShape", r.rect, "Follow Feature Rotation and Scale"); + ui.add_space(8.0); + ui.horizontal(|ui| { + let actions = [ + (LowConfidence::Continue, "Continue Tracking"), + (LowConfidence::Stop, "Stop Tracking"), + (LowConfidence::Extrapolate, "Extrapolate Motion"), + (LowConfidence::Adapt, "Adapt Feature"), + ]; + let cur = actions.iter().find(|(a, _)| *a == d.opts.action).map(|(_, l)| *l).unwrap_or("Adapt Feature"); + let r = egui::ComboBox::from_id_salt("track-action") + .selected_text(cur) + .show_ui(ui, |ui| { + for (a, l) in actions { + ui.selectable_value(&mut d.opts.action, a, l); + } + }) + .response; + app.auto.add("dialog.trackOptions.action", r.rect, "If Confidence is Below action"); + ui.label("If Confidence is Below"); + let r = ui.add(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %")); + app.auto.add("dialog.trackOptions.threshold", r.rect, "Confidence threshold"); + }); + (ok, cancel) = ok_cancel(ui, app, t, "dialog.trackOptions"); + }); + app.dialog_state.track_options = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let o = &d.opts; + let action = match o.action { + LowConfidence::Continue => "continue", + LowConfidence::Stop => "stop", + LowConfidence::Extrapolate => "extrapolate", + LowConfidence::Adapt => "adapt", + }; + let channel = match o.channel { + TrackChannel::Rgb => "rgb", + TrackChannel::Luminance => "luminance", + TrackChannel::Saturation => "saturation", + }; + let p = json!({ + "name": d.name, "channel": channel, "blur": if d.blur_on { o.blur } else { 0.0 }, "enhance": o.enhance, + "subpixel": o.subpixel, "adaptEveryFrame": o.adapt_every_frame, "threshold": o.threshold, "action": action, "trackShape": o.track_shape, + }); + if let Err(e) = app.session.execute("track.options", p) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +pub fn target_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.track_target.clone(); + let (mut ok, mut cancel) = (false, false); + let layers: Vec<(u64, String)> = + app.session.active_comp().map(|c| c.layers.iter().enumerate().map(|(i, l)| (l.id.0, format!("{}. {}", i + 1, l.name))).collect()).unwrap_or_default(); + super::dialogs::modal(ctx, "Motion Target", vec2(400.0, 200.0), t, |ui| { + ui.label("Apply Motion To:"); + ui.horizontal(|ui| { + ui.label("Layer:"); + let cur = d.target.and_then(|id| layers.iter().find(|(l, _)| *l == id)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into()); + let r = egui::ComboBox::from_id_salt("track-target") + .selected_text(cur) + .width(240.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.target, None, "None"); + for (id, n) in &layers { + ui.selectable_value(&mut d.target, Some(*id), n); + } + }) + .response; + app.auto.add("dialog.trackTarget.layer", r.rect, "Layer"); + }); + (ok, cancel) = ok_cancel(ui, app, t, "dialog.trackTarget"); + }); + app.dialog_state.track_target = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + if let Err(e) = app.session.execute("track.setTarget", json!({"target": d.target})) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.track_apply.clone(); + let (mut ok, mut cancel) = (false, false); + super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { + ui.horizontal(|ui| { + ui.label("Apply Dimensions:"); + let labels = ["X and Y", "X only", "Y only"]; + let r = egui::ComboBox::from_id_salt("track-apply-dims") + .selected_text(labels[d.dims.min(2)]) + .show_ui(ui, |ui| { + for (i, l) in labels.iter().enumerate() { + ui.selectable_value(&mut d.dims, i, *l); + } + }) + .response; + app.auto.add("dialog.trackApply.dimensions", r.rect, "Apply Dimensions"); + }); + (ok, cancel) = ok_cancel(ui, app, t, "dialog.trackApply"); + }); + app.dialog_state.track_apply = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let dims = ["xy", "x", "y"][d.dims.min(2)]; + if let Err(e) = app.session.execute("track.apply", json!({"dimensions": dims})) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +// ---------------------------------------------------------------- viewer widgets + +/// Which part of a track point a drag grabbed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Part { + /// Inside the feature region: move the whole point. + Point, + /// Inside the search region: move only the search region. + Search, + /// A feature region corner (0..4: UL, UR, LR, LL). + FeatureCorner(usize), + SearchCorner(usize), + Attach, +} + +/// A track point widget hit area (screen space). +#[derive(Clone, Copy, Debug)] +pub struct Hit { + pub point: usize, + pub part: Part, + pub rect: Rect, + /// Screen quad for inside tests (feature / search region). + pub quad: Option<[Pos2; 4]>, +} + +/// A track point drag in progress. +#[derive(Clone, Debug)] +pub struct Drag { + pub point: usize, + pub part: Part, + /// Comp → tracked-layer matrix and the layer-space press point. + pub inv: Mat3, + pub start: [f64; 2], + pub center: [f64; 2], + pub feature: [f64; 2], + pub search_offset: [f64; 2], + pub search: [f64; 2], + pub attach: [f64; 2], +} + +const TRACK_COL: Color32 = Color32::from_rgb(0xf0, 0xc8, 0x3c); + +fn quad_contains(q: &[Pos2; 4], p: Pos2) -> bool { + let mut sign = 0.0f32; + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + let c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + if c.abs() < 1e-6 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true +} + +/// Draw the current track's points (layer → comp matrix `m`) and return their hit areas, from the +/// most to the least specific. +pub fn draw_overlay(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, m: &Mat3, layer: &Layer, tracker: Uid, comp_time: Tick) -> Vec { + let Some((g, _)) = layer.tracker(tracker) else { return vec![] }; + let lt = layer.layer_time(comp_time); + let scr = |p: [f64; 2]| { + let c = m.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }; + let mut hits = vec![]; + let mut inside = vec![]; + for (i, tp) in g.track_points().enumerate() { + let v = point_values(tp, lt); + let c = v.spec.center; + let attach = tp + .get("attachPoint") + .filter(|p| !p.keys.is_empty()) + .map(|p| p.value_at(lt).as_vec2()) + .unwrap_or([c[0] + v.attach_offset[0], c[1] + v.attach_offset[1]]); + let rect_q = |cx: [f64; 2], s: [f64; 2]| { + let (hx, hy) = (s[0] / 2.0, s[1] / 2.0); + [scr([cx[0] - hx, cx[1] - hy]), scr([cx[0] + hx, cx[1] - hy]), scr([cx[0] + hx, cx[1] + hy]), scr([cx[0] - hx, cx[1] + hy])] + }; + // Track path (feature centre keys). + if let Some(fc) = tp.get("featureCenter") + && fc.keys.len() > 1 + { + let pts: Vec = fc.keys.iter().map(|k| scr(k.value.as_vec2())).collect(); + painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, TRACK_COL.gamma_multiply(0.6)))); + for q in &pts { + painter.circle_filled(*q, 1.6, TRACK_COL); + } + } + let fq = rect_q(c, v.spec.feature_size); + let sc = [c[0] + v.spec.search_offset[0], c[1] + v.spec.search_offset[1]]; + let sq = rect_q(sc, v.spec.search_size); + painter.add(egui::Shape::closed_line(sq.to_vec(), Stroke::new(1.0, TRACK_COL.gamma_multiply(0.7)))); + painter.add(egui::Shape::closed_line(fq.to_vec(), Stroke::new(1.0, TRACK_COL))); + painter.circle_filled(scr(c), 2.0, TRACK_COL); + let a = scr(attach); + for d in [vec2(7.0, 0.0), vec2(0.0, 7.0)] { + painter.line_segment([a - d, a + d], Stroke::new(1.4, Color32::BLACK)); + painter.line_segment([a - d, a + d], Stroke::new(1.0, TRACK_COL)); + } + painter.text(sq[0] + vec2(0.0, -3.0), Align2::LEFT_BOTTOM, &tp.name, Tokens::ui(10.5), TRACK_COL); + // Corner handles only when the region is big enough on screen to see inside it. + let fbig = fq[0].distance(fq[2]) > 28.0; + let sbig = sq[0].distance(sq[2]) > 28.0; + let n = i + 1; + let hr = Rect::from_center_size(a, vec2(12.0, 12.0)); + hits.push(Hit { point: n, part: Part::Attach, rect: hr, quad: None }); + app.auto.add(&format!("viewer.track.{n}.attach"), hr, "Attach Point"); + for (k, q) in fq.iter().enumerate() { + let r = Rect::from_center_size(*q, vec2(5.0, 5.0)); + if fbig { + painter.rect_filled(r, 0.0, TRACK_COL); + } + hits.push(Hit { point: n, part: Part::FeatureCorner(k), rect: r.expand(2.0), quad: None }); + app.auto.add(&format!("viewer.track.{n}.feature.{k}"), r, "Feature region corner"); + } + for (k, q) in sq.iter().enumerate() { + let r = Rect::from_center_size(*q, vec2(5.0, 5.0)); + if sbig { + painter.rect_stroke(r, 0.0, Stroke::new(1.0, TRACK_COL), StrokeKind::Middle); + } + hits.push(Hit { point: n, part: Part::SearchCorner(k), rect: r.expand(2.0), quad: None }); + app.auto.add(&format!("viewer.track.{n}.search.{k}"), r, "Search region corner"); + } + let fr = Rect::from_points(&fq); + let sr = Rect::from_points(&sq); + app.auto.add(&format!("viewer.track.{n}.feature"), fr, "Feature region"); + app.auto.add(&format!("viewer.track.{n}.search"), sr, "Search region"); + inside.push(Hit { point: n, part: Part::Point, rect: fr, quad: Some(fq) }); + inside.push(Hit { point: n, part: Part::Search, rect: sr, quad: Some(sq) }); + } + // Regions after handles; feature regions before search regions. + inside.sort_by_key(|h| h.part == Part::Search); + hits.extend(inside); + hits +} + +/// The hit under `pos`. +pub fn hit_at(hits: &[Hit], pos: Pos2) -> Option { + hits.iter() + .find(|h| match h.quad { + Some(q) => quad_contains(&q, pos), + None => h.rect.contains(pos), + }) + .copied() +} + +/// Start dragging `hit` (press at comp point `cpt`). +pub fn begin_drag(layer: &Layer, tracker: Uid, comp_time: Tick, l2c: &Mat3, hit: Hit, cpt: [f64; 2]) -> Option { + let (g, _) = layer.tracker(tracker)?; + let tp = g.track_points().nth(hit.point - 1)?; + let v = point_values(tp, layer.layer_time(comp_time)); + let inv = l2c.inverse()?; + let s = inv.apply(gv2(cpt[0], cpt[1])); + Some(Drag { + point: hit.point, + part: hit.part, + inv, + start: [s.x, s.y], + center: v.spec.center, + feature: v.spec.feature_size, + search_offset: v.spec.search_offset, + search: v.spec.search_size, + attach: v.attach_offset, + }) +} + +/// The `track.setPoint` parameters for the drag at comp point `cpt`. +pub fn drag_params(d: &Drag, cpt: [f64; 2]) -> serde_json::Value { + let q = d.inv.apply(gv2(cpt[0], cpt[1])); + let lp = [q.x, q.y]; + let dl = [lp[0] - d.start[0], lp[1] - d.start[1]]; + match d.part { + Part::Point => json!({"point": d.point, "center": [d.center[0] + dl[0], d.center[1] + dl[1]]}), + Part::Search => json!({"point": d.point, "searchOffset": [d.search_offset[0] + dl[0], d.search_offset[1] + dl[1]]}), + Part::Attach => json!({"point": d.point, "attachOffset": [d.attach[0] + dl[0], d.attach[1] + dl[1]]}), + Part::FeatureCorner(_) => { + let size = [(2.0 * (lp[0] - d.center[0]).abs()).max(4.0), (2.0 * (lp[1] - d.center[1]).abs()).max(4.0)]; + json!({"point": d.point, "featureSize": size}) + } + Part::SearchCorner(_) => { + let sc = [d.center[0] + d.search_offset[0], d.center[1] + d.search_offset[1]]; + let size = [(2.0 * (lp[0] - sc[0]).abs()).max(4.0), (2.0 * (lp[1] - sc[1]).abs()).max(4.0)]; + json!({"point": d.point, "searchSize": size}) + } + } +} + +/// The tracked layer and tracker the viewer shows widgets for. +pub fn viewer_track(app: &EffectcraftApp) -> Option<(LayerId, Uid)> { + current(app) +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index caf0f9a0..8ae33980 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -119,6 +119,12 @@ enum Gesture { inv: Mat3, press: Pos2, }, + /// Dragging a track point's feature/search region, a corner or its attach point. + Track { + layer: LayerId, + tracker: u64, + drag: super::tracker::Drag, + }, } /// Pen tool path in progress: (layer, mask uid). @@ -502,6 +508,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + // Track points of the current track (Tracker panel) on its layer, while that layer is selected. + let mut track_hits = vec![]; + let track_cur = super::tracker::viewer_track(app); + if app.ui.viewer.show_layer_controls + && let Some((tl, tu)) = track_cur + && selected.contains(&tl) + && let Some(layer) = comp.layer(tl) + && layer.is_active_at(time) + { + let (m, _) = l2c(&ectx, layer); + track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time); + } + // Cameras and lights (wireframes) and the 3D view name. if comp.has_3d() { draw_rigs(app, &painter, &map, &ectx, &selected); @@ -530,7 +549,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tool::Dolly => egui::CursorIcon::ResizeVertical, t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair, _ => { - if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) { + if super::tracker::hit_at(&track_hits, hp).is_some() { + egui::CursorIcon::Move + } else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::Default @@ -610,6 +631,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }), t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), Tool::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }), + Tool::Selection if super::tracker::hit_at(&track_hits, press).is_some() => { + let hit = super::tracker::hit_at(&track_hits, press); + track_cur.zip(hit).and_then(|((tl, tu), hit)| { + let layer = comp.layer(tl)?; + let (m, _) = l2c(&ectx, layer); + super::tracker::begin_drag(layer, tu, time, &m, hit, map.to_comp(press)).map(|drag| Gesture::Track { layer: tl, tracker: tu, drag }) + }) + } Tool::Selection if vertex_at(press, true).is_some() => vertex_at(press, true).map(|h| Gesture::Tangent { layer: h.layer, mask: h.mask, @@ -793,6 +822,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .execute("mask.setVertex", json!({"layer": layer.0, "mask": mask, "index": index, "out": t, "in": [-t[0], -t[1]], "merge": merge})); } } + Gesture::Track { layer, tracker, drag } => { + let mut q = super::tracker::drag_params(&drag, cpt); + q["layer"] = json!(layer.0); + q["tracker"] = json!(tracker); + q["merge"] = json!(merge); + let _ = app.session.execute("track.setPoint", q); + } Gesture::Marquee { start } => { let r = Rect::from_two_pos(start, pos); painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9c14fbef..eb225dbf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -363,6 +363,9 @@ pub struct UiState { pub start_screen: bool, /// Composition ▸ Preview ▸ Cache Frames When Idle. pub cache_when_idle: bool, + /// Tracker panel ▸ Motion Source chosen without a tracker yet (layer id). + #[serde(default)] + pub tracker_source: Option, } impl Default for UiState { @@ -396,6 +399,7 @@ impl Default for UiState { preview_audio: true, start_screen: false, cache_when_idle: false, + tracker_source: None, } } } diff --git a/crates/ui-egui/tests/ui_tracker.rs b/crates/ui-egui/tests/ui_tracker.rs new file mode 100644 index 00000000..de8311e2 --- /dev/null +++ b/crates/ui-egui/tests/ui_tracker.rs @@ -0,0 +1,117 @@ +//! Headless checks for the Tracker panel and the viewer's track point widgets (egui_kittest, UI +//! logic only: no GPU needed). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui_kittest::Harness; +use serde_json::json; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Track", "width": 640, "height": 360, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Target", "width": 80, "height": 80})).unwrap(); + s.execute("layer.select", json!({"layers": ["Plate"]})).unwrap(); + EffectcraftApp::new(s) +} + +fn ids(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect() +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) +} + +#[test] +fn tracker_panel_and_track_point_widgets() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().show_panel(PanelKind::Tracker); + h.run_steps(3); + let all = ids(&h); + for want in [ + "tracker.trackCamera", + "tracker.warpStabilizer", + "tracker.trackMotion", + "tracker.stabilizeMotion", + "tracker.motionSource", + "tracker.currentTrack", + "tracker.trackType", + "tracker.position", + "tracker.rotation", + "tracker.scale", + "tracker.motionTarget", + "tracker.editTarget", + "tracker.options", + "tracker.analyze.frameBackward", + "tracker.analyze.backward", + "tracker.analyze.forward", + "tracker.analyze.frameForward", + "tracker.reset", + "tracker.apply", + ] { + assert!(all.iter().any(|i| i == want), "missing {want}"); + } + // Track Motion from the panel. + let r = rect(&h, "tracker.trackMotion"); + h.input_mut().events.push(egui::Event::PointerMoved(r.center())); + h.input_mut().events.push(egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }); + h.input_mut().events.push(egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(3); + let st = h.state_mut().session.execute("track.status", json!({})).unwrap(); + assert_eq!(st["tracker"]["name"], "Tracker 1", "{st}"); + h.state_mut().session.execute("track.setPoint", json!({"point": 1, "featureSize": [60, 60], "searchSize": [120, 120]})).unwrap(); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + let all = ids(&h); + for want in ["viewer.track.1.feature", "viewer.track.1.search", "viewer.track.1.attach", "viewer.track.1.feature.0", "viewer.track.1.search.2"] { + assert!(all.iter().any(|i| i == want), "missing {want}"); + } + // Drag the feature region: the point moves by the drag in layer pixels (one undo step). + let c0 = st["tracker"]["points"][0]["center"].clone(); + let fr = rect(&h, "viewer.track.1.feature"); + let zoom = { + let a = rect(&h, "viewer.comp"); + a.width() / 640.0 + }; + // Inside the feature region, away from the attach point crosshair (which sits on the centre). + let from = fr.center() + egui::vec2(15.0, 0.0); + let to = from + egui::vec2(40.0, 20.0); + h.input_mut().events.push(egui::Event::PointerMoved(from)); + h.input_mut().events.push(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let st = h.state_mut().session.execute("track.status", json!({})).unwrap(); + let c1 = &st["tracker"]["points"][0]["center"]; + let dx = c1[0].as_f64().unwrap() - c0[0].as_f64().unwrap(); + let dy = c1[1].as_f64().unwrap() - c0[1].as_f64().unwrap(); + assert!((dx - 40.0 / zoom as f64).abs() < 1.0 && (dy - 20.0 / zoom as f64).abs() < 1.0, "moved by ({dx}, {dy}) at zoom {zoom}"); + let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Move Track Point").count(); + assert_eq!(undo, 2, "one undo step per drag"); + // Options and Edit Target dialogs. + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "track.optionsDialog", json!({})).unwrap(); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::TrackOptions)); + h.step(); + assert!(ids(&h).iter().any(|i| i == "dialog.trackOptions.ok")); + h.state_mut().dialog = None; + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "track.editTargetDialog", json!({})).unwrap(); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::TrackTarget)); +} diff --git a/docs/agents.md b/docs/agents.md index 54a5ff38..6b1badb0 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -77,6 +77,29 @@ instead of being ignored. 4. `execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}`, then `set_property {"layer":2,"path":"effects/#1/blurriness","value":8}` 5. `render_frame {"time":1.0}` lets you look at the result, and `save_project {"path":"intro.ecproj"}` saves it. +### Motion tracking + +Trackers live on the tracked layer (`Motion Trackers ▸ Tracker n ▸ Track Point n`) and are driven +with `track.*` commands. `track.analyze` runs in the background in the app (poll `track.status`); +pass `"wait": true` to block until it finishes, which is what the CLI and headless MCP want. + +1. `execute_command {"command":"track.motion","params":{"layer":"clip.mov"}}` creates a Transform + tracker (Motion Target: the layer above). Use `track.stabilize` for Stabilize, or `track.new` with + `kind` `transform|stabilize|affine|perspective|raw` and `rotation`/`scale`. +2. `execute_command {"command":"track.setPoint","params":{"point":1,"center":[812,440],"featureSize":[40,40],"searchSize":[96,96]}}` +3. `execute_command {"command":"track.analyze","params":{"direction":"forward","wait":true}}` keys Feature + Center, Confidence and Attach Point on every frame from the current time to the layer's end. +4. `execute_command {"command":"track.apply","params":{"dimensions":"xy"}}` keys the target's Position + (and Rotation/Scale), the layer's own Anchor Point and Position for Stabilize, or a Corner Pin + effect for `affine`/`perspective` tracks. `track.options` sets the channel, blur/enhance, + adapt-feature and "If Confidence is Below" behaviour; `track.status` reports the track points. + +```sh +effectcraft-cli run clip.ecproj track.motion '{"layer":"#2"}' \ + track.setPoint '{"point":1,"center":[812,440]}' \ + track.analyze '{"wait":true}' track.apply '{}' --save +``` + ## CLI Each invocation runs a headless engine with no window. It opens the demo project unless you pass