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:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 08:52:20 -04:00
1 parent 243098a144
commit e57e6a5120
11 files changed
+961 -6

No files matched your search

+18 -5
View File
@@ -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 {
+14
View File
@@ -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);
}
}
}
+10
View File
@@ -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() {
+8
View File
@@ -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
+6
View File
@@ -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),
}
}
+2
View File
@@ -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),
}
}
+722
View File
@@ -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)
}
+37 -1
View File
@@ -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));
+4
View File
@@ -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,
}
}
}
+117
View File
@@ -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));
}
+23
View File
@@ -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