mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
243098a144
commit
e57e6a5120
11 files changed
+961
-6
No files matched your search
+18
-5
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
/// 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<LayerId> {
|
||||
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<u64> = 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<String> = 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::<u64>(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<String> = 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<String> = 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<Hit> {
|
||||
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<Pos2> = 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<Hit> {
|
||||
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<Drag> {
|
||||
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)
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user