Merge branch 'worktree-agent-a11ba905c1bd9a917'

This commit is contained in:
Brandon Thomas 2026-10-02 21:15:14 -04:00
commit e911cc5c93
27 changed files with 2560 additions and 50 deletions

1
Cargo.lock generated
View File

@ -1182,6 +1182,7 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-track",
"rayon",
"serde_json",
]

View File

@ -15,6 +15,7 @@ effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-track = { workspace = true }
rayon = { workspace = true }
[dev-dependencies]

View File

@ -42,6 +42,7 @@ mod transition2;
pub mod util;
mod utility;
mod utility2;
pub mod warp_stab;
use std::collections::HashMap;
use std::sync::OnceLock;
@ -352,6 +353,7 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(audio_fx::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(warp_stab::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
for s in v.iter_mut() {
if GPU_EFFECTS.contains(&s.id) {
@ -396,14 +398,20 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
g.kind = GroupKind::Effect { effect: spec.id.to_string() };
for ps in &spec.params {
let default = default_value(ps, layer_size);
let mut pr = Property::new(ids.alloc(), ps.id, ps.name, default).with_ui(ps.ui.clone());
// `group/sub/param` ids nest the parameter in twirl-down groups (Warp Stabilizer's
// Stabilization / Borders / Advanced).
let (path, leaf) = ps.id.rsplit_once('/').map(|(g, l)| (Some(g), l)).unwrap_or((None, ps.id));
let mut pr = Property::new(ids.alloc(), leaf, ps.name, default).with_ui(ps.ui.clone());
if matches!(ps.ui, ParamUi::Point) {
pr.spatial = true;
}
if matches!(ps.ui, ParamUi::Checkbox | ParamUi::Popup { .. } | ParamUi::Layer) {
pr.hold_only = true;
}
g.children.push(pr.into());
match path {
Some(path) => nested_group(&mut g, ids, path).children.push(pr.into()),
None => g.children.push(pr.into()),
}
// Layer parameters carry a source choice (Source / Masks / Effects & Masks) unless the
// effect declares its own (with its own default).
let sid = layer_source_id(ps.id);
@ -416,6 +424,27 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
g
}
/// Display names of nested parameter groups by match id.
fn group_name(m: &str) -> &str {
warp_stab::GROUPS.iter().find(|(id, _)| *id == m).map(|(_, n)| *n).unwrap_or(m)
}
/// The nested group at `path` (`borders/autoScale`) under `g`, created on first use.
fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut PropGroup {
let (first, rest) = match path.split_once('/') {
Some((a, b)) => (a, Some(b)),
None => (path, None),
};
if g.sub(first).is_none() {
g.children.push(ids.group(first, group_name(first)).into());
}
let sub = g.sub_mut(first).expect("just added");
match rest {
Some(r) => nested_group(sub, ids, r),
None => sub,
}
}
/// Effects the GPU compositor (`effectcraft-gpu`) implements with the CPU effect's semantics
/// (Mercury GPU Acceleration; the Effects & Presets GPU badge). Expression controls are
/// pass-throughs and count as GPU effects too.
@ -463,6 +492,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.time.ccforcemotionblur",
"ec.time.ccwidetime",
"ec.time.pixelmotionblur",
// Each frame gets its own stabilizing warp.
warp_stab::ID,
];
/// See [`TIME_DEPENDENT`].

View File

@ -0,0 +1,493 @@
//! Warp Stabilizer (Effect ▸ Distort ▸ Warp Stabilizer).
//!
//! The analysis (feature tracking and per-frame motion fits) runs as a background job in the
//! engine (`warp.analyze`) and is stored, as JSON, in the instance's hidden **Analysis**
//! parameter ([`effectcraft_track::stabilize::WarpAnalysis`]); **Analysis Key** records what
//! the analysis was made from (the layer's source, in/out points and time mapping) so edits that
//! change the frames invalidate it. Rendering derives the stabilization plan from the analysis
//! and the Stabilization / Borders / Advanced settings ([`effectcraft_track::stabilize::plan`],
//! cached per analysis and settings) and warps each frame by its corrective transform:
//!
//! - **Stabilize Only** shows the moving frame edges, **Stabilize, Crop** crops to the region
//! valid on every frame, **Stabilize, Crop, Auto-scale** scales that region back up to the
//! frame (up to Maximum Scale, minus the Action-safe Margin), Additional Scale scales on top;
//! - **Stabilize, Synthesize Edges** fills the uncovered borders from neighbouring frames
//! (within Synthesize Input Range), mapped through the analysed frame-to-frame motion and read
//! with [`crate::EffectHost::self_at`]; Synthesize Edge Feather blends the seam and Synthesize
//! Edge Cropping ignores bad edge pixels of the input frames;
//! - **Show Track Points** draws the analysed background features.
//!
//! *Subspace Warp* is approximated by a per-frame perspective warp (no content-preserving
//! mesh warp yet), and *Rolling Shutter Ripple* is stored but has no effect.
//!
//! Without an analysis (or with one made for another layer size) the input passes through.
use std::collections::HashMap;
use std::hash::Hasher;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use effectcraft_track::Homography;
use effectcraft_track::stabilize::{Framing, Method, Plan, StabResult, StabSettings, WarpAnalysis, plan};
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
/// The effect's id.
pub const ID: &str = "ec.distort.warpstabilizer";
/// Hidden parameters: the stored analysis (JSON) and its key (see the module docs).
pub const ANALYSIS: &str = "analysis";
pub const ANALYSIS_KEY: &str = "analysisKey";
/// Display names of the instance's parameter groups (by match id).
pub const GROUPS: &[(&str, &str)] = &[
("stabilization", "Stabilization"),
("borders", "Borders"),
("autoScale", "Auto-scale"),
("advanced", "Advanced"),
("edgeCropping", "Synthesize Edge Cropping"),
];
pub const RESULTS: [&str; 2] = ["Smooth Motion", "No Motion"];
pub const METHODS: [&str; 4] = ["Position", "Position, Scale, Rotation", "Perspective", "Subspace Warp"];
pub const FRAMINGS: [&str; 4] = ["Stabilize Only", "Stabilize, Crop", "Stabilize, Crop, Auto-scale", "Stabilize, Synthesize Edges"];
pub fn specs() -> Vec<EffectSpec> {
vec![EffectSpec {
id: ID,
name: "Warp Stabilizer",
category: "Distort",
params: vec![
p("stabilization/result", "Result", Value::Enum(0), popup(&RESULTS)),
p("stabilization/smoothness", "Smoothness", num(50.0), slider(0.0, 1000.0, 0.0, 100.0, 0)),
p("stabilization/method", "Method", Value::Enum(3), popup(&METHODS)),
p("stabilization/preserveScale", "Preserve Scale", Value::Bool(false), ParamUi::Checkbox),
p("borders/framing", "Framing", Value::Enum(2), popup(&FRAMINGS)),
p("borders/autoScale/maximumScale", "Maximum Scale", num(150.0), slider(100.0, 1000.0, 100.0, 200.0, 0)),
p("borders/autoScale/actionSafeMargin", "Action-safe Margin", num(0.0), slider(0.0, 45.0, 0.0, 20.0, 0)),
p("borders/additionalScale", "Additional Scale", num(100.0), slider(1.0, 1000.0, 50.0, 150.0, 0)),
p("advanced/detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
p("advanced/rollingShutterRipple", "Rolling Shutter Ripple", Value::Enum(0), popup(&["Automatic Reduction", "Enhanced Reduction"])),
p("advanced/cropLessSmoothMore", "Crop Less <-> Smooth More", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("advanced/synthesizeInputRange", "Synthesize Input Range (seconds)", num(1.0), slider(0.0, 10.0, 0.0, 5.0, 2)),
p("advanced/synthesizeEdgeFeather", "Synthesize Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("advanced/edgeCropping/left", "Left", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/edgeCropping/top", "Top", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/edgeCropping/right", "Right", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/edgeCropping/bottom", "Bottom", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
p("advanced/hideWarningBanner", "Hide Warning Banner", Value::Bool(false), ParamUi::Checkbox),
p("advanced/showTrackPoints", "Show Track Points", Value::Bool(false), ParamUi::Checkbox),
p("advanced/trackPointSize", "Track Point Size", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 0)),
p(ANALYSIS, "Analysis", Value::Str(String::new()), ParamUi::Hidden),
p(ANALYSIS_KEY, "Analysis Key", Value::Str(String::new()), ParamUi::Hidden),
],
render,
gpu: false,
float: true,
}]
}
/// A parameter by its spec id (`stabilization/smoothness`), from either flattened instance
/// params (nested groups become `#n/` prefixes, see [`crate::flatten_params`]) or plain ids.
pub fn param<'a>(params: &'a Params, id: &str) -> Option<&'a Value> {
if let Some(v) = params.get(id) {
return Some(v);
}
let mut pre = String::new();
let segs: Vec<&str> = id.split('/').collect();
let (last, groups) = segs.split_last()?;
for g in groups {
pre = params.group(&pre, g)?;
}
params.get(&format!("{pre}{last}"))
}
fn f(params: &Params, id: &str) -> f64 {
param(params, id).map(Value::as_f64).unwrap_or(0.0)
}
fn e(params: &Params, id: &str) -> u32 {
param(params, id).map(Value::as_enum).unwrap_or(0)
}
fn b(params: &Params, id: &str) -> bool {
param(params, id).map(Value::as_bool).unwrap_or(false)
}
fn s<'a>(params: &'a Params, id: &str) -> &'a str {
match param(params, id) {
Some(Value::Str(s)) => s,
_ => "",
}
}
/// Stabilization settings from the instance's parameters (`fps`: the analysis' frame rate).
pub fn settings(params: &Params, fps: f64) -> StabSettings {
StabSettings {
result: if e(params, "stabilization/result") == 1 { StabResult::NoMotion } else { StabResult::SmoothMotion },
smoothness: f(params, "stabilization/smoothness"),
method: match e(params, "stabilization/method") {
0 => Method::Position,
1 => Method::Similarity,
2 => Method::Perspective,
_ => Method::SubspaceWarp,
},
preserve_scale: b(params, "stabilization/preserveScale"),
framing: match e(params, "borders/framing") {
0 => Framing::StabilizeOnly,
1 => Framing::StabilizeCrop,
3 => Framing::SynthesizeEdges,
_ => Framing::StabilizeCropAutoScale,
},
max_scale: f(params, "borders/autoScale/maximumScale"),
action_safe: f(params, "borders/autoScale/actionSafeMargin"),
additional_scale: f(params, "borders/additionalScale"),
// AE's 0…100 slider (50 = balanced) onto the planner's −100…100.
crop_less_smooth_more: (f(params, "advanced/cropLessSmoothMore") - 50.0) * 2.0,
fps,
}
}
fn fnv(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
struct KeyHasher(u64);
impl Hasher for KeyHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.0 ^= *b as u64;
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
}
}
}
fn settings_key(s: &StabSettings) -> u64 {
let mut h = KeyHasher(0x9e37_79b9_7f4a_7c15);
for v in [s.smoothness, s.max_scale, s.action_safe, s.additional_scale, s.crop_less_smooth_more, s.fps] {
h.write_u64(v.to_bits());
}
h.write(format!("{:?}{:?}{:?}{:?}", s.result, s.method, s.preserve_scale, s.framing).as_bytes());
h.finish()
}
type AnalysisCache = Mutex<HashMap<u64, Arc<WarpAnalysis>>>;
type PlanCache = Mutex<HashMap<(u64, u64), Arc<Plan>>>;
/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate).
fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
static C: OnceLock<AnalysisCache> = OnceLock::new();
let key = fnv(json.as_bytes());
let c = C.get_or_init(Default::default);
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return Some((key, a.clone()));
}
let a = Arc::new(WarpAnalysis::from_json(json)?);
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 8 {
m.clear();
}
m.insert(key, a.clone());
Some((key, a))
}
fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
static C: OnceLock<PlanCache> = OnceLock::new();
let key = (akey, settings_key(s));
let c = C.get_or_init(Default::default);
if let Some(p) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return p.clone();
}
let p = Arc::new(plan(a, s));
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 32 {
m.clear();
}
m.insert(key, p.clone());
p
}
/// The stored analysis of an instance (`None` when not analysed).
pub fn analysis(params: &Params) -> Option<Arc<WarpAnalysis>> {
let j = s(params, ANALYSIS);
if j.is_empty() {
return None;
}
analysis_cached(j).map(|(_, a)| a).filter(|a| !a.is_empty())
}
/// The stabilization of an instance at layer time `time`: (frame index, plan).
pub fn plan_at(params: &Params, time: f64) -> Option<(usize, Arc<WarpAnalysis>, Arc<Plan>)> {
let j = s(params, ANALYSIS);
if j.is_empty() {
return None;
}
let (key, a) = analysis_cached(j)?;
let k = a.frame_at(time)?;
let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
let pl = plan_cached(key, &a, &settings(params, fps));
(k < pl.warps.len()).then_some((k, a, pl))
}
/// Motion from frame `k` to frame `j` (scene points of frame k → frame j), from the analysed
/// frame-to-frame homographies.
pub fn motion_between(a: &WarpAnalysis, k: usize, j: usize) -> Homography {
let mut m = Homography::IDENTITY;
if j > k {
for i in k + 1..=j {
m = a.frames[i].motion(Method::Perspective).then_after(&m);
}
} else {
for i in (j + 1..=k).rev() {
let inv = a.frames[i].motion(Method::Perspective).inverse().unwrap_or(Homography::IDENTITY);
m = inv.then_after(&m);
}
}
m
}
fn inside(r: &[f64; 4], p: [f64; 2]) -> bool {
p[0] >= r[0] && p[0] <= r[2] && p[1] >= r[1] && p[1] <= r[3]
}
/// Distance from `p` to the nearest edge of `r` (positive inside).
fn inset(r: &[f64; 4], p: [f64; 2]) -> f64 {
(p[0] - r[0]).min(r[2] - p[0]).min(p[1] - r[1]).min(r[3] - p[1])
}
fn lerp_px(a: Px, b: Px, t: f32) -> Px {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
}
fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
let Some((k, a, pl)) = plan_at(ctx.params, ctx.time) else { return buf };
let size = a.size;
if (size[0] - ctx.layer_size[0]).abs() > 0.5 || (size[1] - ctx.layer_size[1]).abs() > 0.5 || ctx.adjustment {
return buf;
}
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
let Some(inv) = pl.warps[k].inverse() else { return buf };
let (ow, oh) = (((size[0] * sc).ceil() as u32).max(1), ((size[1] * sc).ceil() as u32).max(1));
let synth = matches!(settings(ctx.params, 30.0).framing, Framing::SynthesizeEdges);
let crop = [
f(ctx.params, "advanced/edgeCropping/left").max(0.0),
f(ctx.params, "advanced/edgeCropping/top").max(0.0),
size[0] - f(ctx.params, "advanced/edgeCropping/right").max(0.0),
size[1] - f(ctx.params, "advanced/edgeCropping/bottom").max(0.0),
];
let valid = if synth { crop } else { [0.0, 0.0, size[0], size[1]] };
let feather = if synth { f(ctx.params, "advanced/synthesizeEdgeFeather").max(0.0) } else { 0.0 };
let src = &buf;
// Inside the frame, edge pixels extend to the frame's edge; outside it is transparent.
let sample = |b: &Buf, q: [f64; 2]| {
if q[0] < 0.0 || q[1] < 0.0 || q[0] > size[0] || q[1] > size[1] {
return [0.0; 4];
}
b.img.sample_bilinear_clamped(q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1])
};
let mut out = Image::new(ow, oh);
// Source position (frame k layer pixels) of every output pixel, and how much of the pixel
// still needs synthesizing (0 = none).
let need_m = Mutex::new(if synth { vec![0.0f32; (ow * oh) as usize] } else { vec![] });
out.rows_mut().for_each(|(y, row)| {
let mut local = vec![];
for (x, px) in row.iter_mut().enumerate() {
let p = [(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc];
if let Some(c) = &pl.crop
&& !inside(c, p)
{
continue;
}
let q = inv.apply(p);
if !synth {
*px = sample(src, q);
continue;
}
let d = inset(&valid, q);
if d >= feather.max(0.5) {
*px = sample(src, q);
} else {
let w = if d <= 0.0 {
1.0
} else if feather > 0.0 {
(1.0 - d / feather) as f32
} else {
1.0
};
if d > 0.0 {
*px = sample(src, q);
}
local.push((x, w));
}
}
if !local.is_empty() {
let mut n = need_m.lock().unwrap_or_else(|e| e.into_inner());
for (x, w) in local {
n[y * ow as usize + x] = w;
}
}
});
let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner());
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &inv, &crop, sc, &mut out, &need);
}
if b(ctx.params, "advanced/showTrackPoints") {
let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5);
for q in &a.frames[k].points {
let o = pl.warps[k].apply([q[0] as f64, q[1] as f64]);
dot(&mut out, [o[0] * sc, o[1] * sc], r, [0.25, 0.75, 1.0, 1.0]);
}
}
Buf { img: out, offset: [0.0; 2], scale: sc }
}
/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from
/// neighbouring frames, nearest first.
#[allow(clippy::too_many_arguments)]
fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, inv: &Homography, crop: &[f64; 4], sc: f64, out: &mut Image, need: &[f32]) {
let Some(host) = ctx.env.host else { return };
let n = a.frames.len();
let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60);
let ow = out.width as usize;
let mut filled = vec![false; need.len()];
let todo: Vec<usize> = (0..need.len()).filter(|i| need[*i] > 0.0).collect();
let mut left = todo.len();
for d in 1..=range {
if left == 0 {
break;
}
for j in [k as i64 + d as i64, k as i64 - d as i64] {
if j < 0 || j as usize >= n || left == 0 {
continue;
}
let j = j as usize;
let Some(nb) = host.self_at(a.start + j as f64 * a.frame_duration, ctx.env.effect_index) else { continue };
let m = motion_between(a, k, j);
let hits: Vec<(usize, Px)> = todo
.par_iter()
.filter(|i| !filled[**i])
.filter_map(|i| {
let (x, y) = (i % ow, i / ow);
let q = inv.apply([(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc]);
let qj = m.apply(q);
inside(crop, qj).then(|| (*i, nb.img.sample_bilinear_clamped(qj[0] * nb.scale + nb.offset[0], qj[1] * nb.scale + nb.offset[1])))
})
.collect();
for (i, px) in hits {
filled[i] = true;
left -= 1;
out.data[i] = lerp_px(out.data[i], px, need[i]);
}
}
}
}
fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
let (x0, x1) = ((c[0] - r - 1.0).floor().max(0.0) as i64, (c[0] + r + 1.0).ceil() as i64);
let (y0, y1) = ((c[1] - r - 1.0).floor().max(0.0) as i64, (c[1] + r + 1.0).ceil() as i64);
for y in y0..y1.min(img.height as i64) {
for x in x0..x1.min(img.width as i64) {
let d = ((x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1]) - r).clamp(-0.5, 0.5);
let cov = (0.5 - d) as f32;
if cov > 0.0 {
let i = img.idx(x as u32, y as u32);
img.data[i] = lerp_px(img.data[i], col, cov);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EffectEnv;
use effectcraft_track::stabilize::FrameMotion;
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 25.0, size: [w, h], detailed: false, frames: vec![] };
for k in 0..n {
let mut fm = FrameMotion::default();
if k > 0 {
let (p, q) = (shift(k - 1), shift(k));
let d = [q[0] - p[0], q[1] - p[1]];
fm.t = d;
fm.s = [1.0, 0.0, d[0], d[1]];
fm.h = [1.0, 0.0, d[0], 0.0, 1.0, d[1], 0.0, 0.0];
}
a.frames.push(fm);
}
a.to_json()
}
fn params(json: &str, framing: u32, result: u32) -> Params {
let s = &specs()[0];
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
p.values.insert(ANALYSIS.into(), Value::Str(json.into()));
p.values.insert("borders/framing".into(), Value::Enum(framing));
p.values.insert("stabilization/result".into(), Value::Enum(result));
p
}
/// A white dot at `c` on black.
fn frame(c: [f64; 2]) -> Image {
let mut im = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]);
dot(&mut im, c, 3.0, [1.0; 4]);
im
}
fn centroid(im: &Image) -> [f64; 2] {
let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0);
for y in 0..im.height {
for x in 0..im.width {
let v = im.get(x as i64, y as i64)[0] as f64;
sx += v * (x as f64 + 0.5);
sy += v * (y as f64 + 0.5);
sw += v;
}
}
[sx / sw, sy / sw]
}
#[test]
fn no_motion_locks_the_scene_and_unanalysed_passes_through() {
let shift = |k: usize| [((k * 7) % 5) as f64 - 2.0, ((k * 3) % 4) as f64 - 1.5];
let json = analysis_json(10, 64.0, 48.0, shift);
let ps = params(&json, 0, 1);
let mut cs = vec![];
for k in 0..10 {
let d = shift(k);
let img = frame([32.0 + d[0], 24.0 + d[1]]);
let ctx = EffectCtx { params: &ps, time: k as f64 / 25.0, layer_size: [64.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let out = render(&ctx, Buf { img, offset: [0.0; 2], scale: 1.0 });
cs.push(centroid(&out.img));
}
for c in &cs {
assert!((c[0] - cs[0][0]).abs() < 0.05 && (c[1] - cs[0][1]).abs() < 0.05, "{cs:?}");
}
// Not analysed: untouched.
let ps = params("", 0, 1);
let img = frame([10.0, 10.0]);
let ctx = EffectCtx { params: &ps, time: 0.0, layer_size: [64.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data);
}
#[test]
fn nested_param_lookup() {
let mut p = Params::default();
p.values.insert("#2/@match".into(), Value::Str("borders".into()));
p.values.insert("#1/@match".into(), Value::Str("stabilization".into()));
p.values.insert("#2/#1/@match".into(), Value::Str("autoScale".into()));
p.values.insert("#2/#1/maximumScale".into(), num(120.0));
p.values.insert("#1/smoothness".into(), num(12.0));
assert_eq!(f(&p, "borders/autoScale/maximumScale"), 120.0);
assert_eq!(f(&p, "stabilization/smoothness"), 12.0);
}
}

View File

@ -0,0 +1,390 @@
//! Mask tracking (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode) and Smart Mask
//! Interpolation (Window ▸ Mask Interpolation).
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
use effectcraft_path::interp::{self, AddVertices, InterpOpts, Matching};
use effectcraft_project::{Layer, LayerId, Uid};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, layer_mut, layer_p, str_p};
use crate::mask_track::{MaskMethod, MaskWork};
use crate::tracking::Direction;
use crate::{EngineError, Result, Session, cmd};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"track.mask",
"Track Mask",
["Animation"],
None,
"{layer?, mask?: uid|index|name, method?: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective, direction?: forward|backward|frameForward|frameBackward, start? (s), end? (s), wait?: block until done}",
has_mask,
track_mask
),
cmd!(
"track.maskMethod",
"Mask Tracking Method",
[],
None,
"{method: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective}",
super::always,
mask_method
),
cmd!(
"mask.interpolate",
"Apply Mask Interpolation",
[],
None,
"{layer?, mask?, times?: [from, to] (s; default the selected Mask Path keys), keyframeRate?: n|auto, keyframeFields?, linearVertexPaths?, bendingResistance? (0-100), quality? (0-100), addVertices?: n|false, addVerticesUnit?: pixels|total|percent, matchingMethod?: auto|curve|polyline, oneToOne?, firstVerticesMatch?}",
has_comp,
interpolate
),
cmd!(
"mask.interpolationOptions",
"Mask Interpolation Options",
[],
None,
"{keyframeRate?: n|auto, keyframeFields?, linearVertexPaths?, bendingResistance?, quality?, addVertices?: n|false, addVerticesUnit?: pixels|total|percent, matchingMethod?: auto|curve|polyline, oneToOne?, firstVerticesMatch?}",
super::always,
interp_options
),
]
}
// ---------------------------------------------------------------- mask tracking
/// The mask a command works on: `mask` (uid, 1-based index or name), else the selected mask
/// (Timeline selection or selected vertices), else the layer's only mask.
pub(crate) fn mask_uid(s: &Session, layer: &Layer, p: &Value) -> Option<Uid> {
let masks = layer.masks()?;
if let Some(k) = p.get("mask") {
return match k {
Value::Number(n) => {
let n = n.as_u64()?;
masks.groups().find(|g| g.uid == n).or_else(|| masks.groups().nth((n as usize).checked_sub(1)?)).map(|g| g.uid)
}
Value::String(name) => masks.groups().find(|g| &g.name == name).map(|g| g.uid),
_ => None,
};
}
let is_mask = |u: Uid| masks.groups().any(|g| g.uid == u);
if let Some(u) = s.state.selected_props.iter().filter(|(l, _)| *l == layer.id).map(|(_, u)| *u).find(|u| is_mask(*u)) {
return Some(u);
}
// A selected Mask Path property selects its mask.
if let Some(u) = s
.state
.selected_props
.iter()
.filter(|(l, _)| *l == layer.id)
.find_map(|(_, u)| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == *u)).map(|g| g.uid))
{
return Some(u);
}
if let Some(v) = s.state.selected_vertices.iter().find(|v| v.layer == layer.id && is_mask(v.mask)) {
return Some(v.mask);
}
let mut it = masks.groups();
match (it.next(), it.next()) {
(Some(g), None) => Some(g.uid),
_ => None,
}
}
/// The selected layer and mask, if any (the Tracker panel's mask mode).
pub fn selected_mask(s: &Session) -> Option<(LayerId, Uid)> {
let comp = s.active_comp()?;
for lid in &s.state.selected_layers {
let Some(l) = comp.layer(*lid) else { continue };
let explicit =
s.state.selected_props.iter().any(|(pl, u)| *pl == *lid && l.masks().is_some_and(|m| m.find_group(*u).is_some() || m.find(*u).is_some()))
|| s.state.selected_vertices.iter().any(|v| v.layer == *lid);
if explicit && let Some(u) = mask_uid(s, l, &json!({})) {
return Some((*lid, u));
}
}
None
}
fn has_mask(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
selected_mask(s).map(|_| ()).ok_or_else(|| "select a mask to track".into())
}
fn method_p(p: &Value, cmd: &str) -> Result<Option<MaskMethod>> {
match str_p(p, "method") {
Some(m) => MaskMethod::from_name(m)
.map(Some)
.ok_or_else(|| bad(cmd, format!("unknown method `{m}` (position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective)"))),
None => Ok(None),
}
}
fn mask_method(s: &mut Session, p: &Value) -> Result<Value> {
let m = method_p(p, "track.maskMethod")?.ok_or_else(|| bad("track.maskMethod", "missing `method`"))?;
s.state.mask_track_method = m;
s.bump();
Ok(json!(m.id()))
}
fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "track.mask";
let (cid, lid) = match p.get("layer") {
Some(_) => layer_p(s, p, CMD)?,
None => match selected_mask(s) {
Some((l, _)) => (s.active_comp_id().ok_or(EngineError::NoComp)?, l),
None => layer_p(s, p, CMD)?,
},
};
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let uid = mask_uid(s, layer, p).ok_or_else(|| bad(CMD, format!("layer `{}` has no such mask (select one or pass `mask`)", layer.name)))?;
if !layer.has_video() || matches!(layer.source, effectcraft_project::LayerSource::Text | effectcraft_project::LayerSource::Shape) {
return Err(bad(CMD, "the layer has no pixels to track"));
}
let method = method_p(p, CMD)?.unwrap_or(s.state.mask_track_method);
let dir = match str_p(p, "direction") {
Some(d) => Direction::from_name(d).ok_or_else(|| bad(CMD, "direction: forward|backward|frameForward|frameBackward"))?,
None => Direction::Forward,
};
let now = comp.frame_rate.snap_nearest(if s.state.active_comp == Some(cid) { s.time() } else { Tick::ZERO });
let start = f_p(p, "start").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let end = f_p(p, "end").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let times = super::track::analysis_times(comp, layer, dir, now, start, end);
if times.len() < 2 {
return Err(bad(CMD, "nothing to track in that direction (at the layer's end)"));
}
let mg = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "no mask"))?;
let KV::Path(path) = mg.get("path").map(|pr| pr.value_at(layer.layer_time(times[0]))).unwrap_or(KV::Scalar(0.0)) else {
return Err(bad(CMD, "the mask has no path"));
};
if path.len() < 2 {
return Err(bad(CMD, "the mask needs at least two vertices"));
}
let work = MaskWork {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp: cid,
layer: lid,
path,
method,
times: times.clone(),
};
s.state.mask_track_method = method;
let wait = b_p(p, "wait").unwrap_or(false);
s.start_mask_track(work, uid, dir, wait).map_err(EngineError::Other)?;
let prog = s.mask_track_progress();
Ok(json!({
"mask": uid,
"method": method.id(),
"frames": times.len() - 1,
"start": times[0].seconds(),
"end": times.last().map(|t| t.seconds()),
"running": s.is_mask_tracking(),
"progress": prog,
}))
}
// ---------------------------------------------------------------- mask interpolation
/// Mask Interpolation panel options (kept in the editor state, serde for agents).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct MaskInterpOptions {
/// Keyframes per second; `None` = Auto (the comp frame rate).
pub keyframe_rate: Option<f64>,
/// Keyframe Fields (doubles the rate).
pub keyframe_fields: bool,
pub linear_vertex_paths: bool,
pub bending_resistance: f64,
pub quality: f64,
/// Add Mask Shape Vertices (value, unit: pixels | total | percent).
pub add_vertices: Option<f64>,
pub add_vertices_unit: String,
pub matching_method: String,
pub one_to_one: bool,
pub first_vertices_match: bool,
}
impl Default for MaskInterpOptions {
fn default() -> Self {
MaskInterpOptions {
keyframe_rate: None,
keyframe_fields: false,
linear_vertex_paths: false,
bending_resistance: 50.0,
quality: 50.0,
add_vertices: Some(5.0),
add_vertices_unit: "pixels".into(),
matching_method: "auto".into(),
one_to_one: false,
first_vertices_match: true,
}
}
}
impl MaskInterpOptions {
pub fn interp(&self) -> InterpOpts {
InterpOpts {
linear: self.linear_vertex_paths,
bending_resistance: self.bending_resistance.clamp(0.0, 100.0),
quality: self.quality.clamp(0.0, 100.0),
add_vertices: self.add_vertices.map(|v| match self.add_vertices_unit.as_str() {
"total" => AddVertices::Total(v.round().max(2.0) as usize),
"percent" => AddVertices::Percent(v),
_ => AddVertices::Pixels(v),
}),
matching: match self.matching_method.as_str() {
"curve" => Matching::Curve,
"polyline" => Matching::Polyline,
_ => Matching::Auto,
},
one_to_one: self.one_to_one,
first_vertices_match: self.first_vertices_match,
}
}
}
/// Merge option parameters into `o`.
fn apply_options(o: &mut MaskInterpOptions, p: &Value, cmd: &str) -> Result<()> {
match p.get("keyframeRate") {
Some(Value::String(s)) if s.eq_ignore_ascii_case("auto") => o.keyframe_rate = None,
Some(Value::Null) => o.keyframe_rate = None,
Some(v) => o.keyframe_rate = Some(v.as_f64().filter(|r| *r > 0.0).ok_or_else(|| bad(cmd, "keyframeRate: a positive number or \"auto\""))?.min(1000.0)),
None => {}
}
for (k, f) in [
("keyframeFields", &mut o.keyframe_fields),
("linearVertexPaths", &mut o.linear_vertex_paths),
("oneToOne", &mut o.one_to_one),
("firstVerticesMatch", &mut o.first_vertices_match),
] {
if let Some(v) = b_p(p, k) {
*f = v;
}
}
if let Some(v) = f_p(p, "bendingResistance") {
o.bending_resistance = v.clamp(0.0, 100.0);
}
if let Some(v) = f_p(p, "quality") {
o.quality = v.clamp(0.0, 100.0);
}
match p.get("addVertices") {
Some(Value::Bool(false)) | Some(Value::Null) => o.add_vertices = None,
Some(Value::Bool(true)) => o.add_vertices = Some(o.add_vertices.unwrap_or(5.0)),
Some(v) => o.add_vertices = Some(v.as_f64().filter(|x| *x > 0.0).ok_or_else(|| bad(cmd, "addVertices: a positive number or false"))?),
None => {}
}
if let Some(u) = str_p(p, "addVerticesUnit") {
let u = u.to_ascii_lowercase();
o.add_vertices_unit = match u.as_str() {
"pixels" | "px" | "pixelsbetweenvertices" => "pixels",
"total" | "totalvertices" => "total",
"percent" | "%" | "percentageofoutline" => "percent",
_ => return Err(bad(cmd, "addVerticesUnit: pixels|total|percent")),
}
.into();
}
if let Some(m) = str_p(p, "matchingMethod") {
let m = m.to_ascii_lowercase();
if !["auto", "curve", "polyline"].contains(&m.as_str()) {
return Err(bad(cmd, "matchingMethod: auto|curve|polyline"));
}
o.matching_method = m;
}
Ok(())
}
fn interp_options(s: &mut Session, p: &Value) -> Result<Value> {
let mut o = s.state.mask_interp.clone();
apply_options(&mut o, p, "mask.interpolationOptions")?;
s.state.mask_interp = o.clone();
s.bump();
Ok(serde_json::to_value(o).unwrap_or(Value::Null))
}
fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "mask.interpolate";
let mut o = s.state.mask_interp.clone();
apply_options(&mut o, p, CMD)?;
// The layer: given, else the layer of selected Mask Path keys, else the selection.
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let lid = match p.get("layer") {
Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?,
None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
Some(k) => k.layer,
None => layer_p(s, p, CMD)?.1,
},
};
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?;
let uid = match p.get("mask") {
Some(_) => mask_uid(s, layer, p),
None => s
.state
.selected_keys
.iter()
.filter(|k| k.layer == lid)
.find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid))
.or_else(|| mask_uid(s, layer, p)),
}
.ok_or_else(|| bad(CMD, "no such mask (select two Mask Path keyframes or pass `mask`)"))?;
let mg = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "no mask"))?;
let path_prop = mg.get("path").ok_or_else(|| bad(CMD, "the mask has no path"))?;
// Key times (layer time): explicit, else selected keys on this Mask Path.
let mut times: Vec<Tick> = match p.get("times").and_then(Value::as_array) {
Some(a) => {
let ts: Vec<Tick> = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect();
ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect()
}
None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
};
times.sort();
times.dedup();
if times.len() < 2 {
return Err(bad(CMD, "select two or more Mask Path keyframes (or pass `times` of existing keys)"));
}
let rate = o.keyframe_rate.unwrap_or_else(|| comp.frame_rate.as_f64()) * if o.keyframe_fields { 2.0 } else { 1.0 };
let step = Tick::from_seconds_f64(1.0 / rate.max(0.01));
let opts = o.interp();
let shapes: Vec<ShapePath> = times.iter().filter_map(|t| path_prop.keys.iter().find(|k| k.time == *t).and_then(|k| k.value.as_path().cloned())).collect();
if shapes.len() != times.len() {
return Err(bad(CMD, "the selected keys must be Mask Path keys"));
}
// (time, shape) for every new or replaced key.
let mut out: Vec<(Tick, ShapePath)> = vec![];
let mut vertices = 0;
for i in 0..times.len() - 1 {
let (t0, t1) = (times[i], times[i + 1]);
let (a, b) = interp::correspond(&shapes[i], &shapes[i + 1], &opts);
vertices = vertices.max(a.len());
out.push((t0, a.clone()));
let mut t = t0 + step;
while t1.0 - t.0 > step.0 / 4 {
let u = (t.0 - t0.0) as f64 / (t1.0 - t0.0) as f64;
out.push((t, interp::interpolate(&a, &b, u, &opts)));
t += step;
}
out.push((t1, b));
}
let created = out.len() - times.len().min(out.len());
let (first, last) = (times[0], times[times.len() - 1]);
s.state.mask_interp = o;
s.edit("Mask Interpolation", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let pr = l.props.find_group_mut(uid).and_then(|g| g.get_mut("path")).ok_or_else(|| bad(CMD, "no mask path"))?;
// Keys strictly inside the interpolated span are replaced.
pr.keys.retain(|k| k.time <= first || k.time >= last || times.contains(&k.time));
for (t, sp) in &out {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*t, KV::Path(sp.clone())));
}
Ok(())
})?;
Ok(json!({"mask": uid, "keys": created, "vertices": vertices, "rate": rate}))
}

View File

@ -20,6 +20,7 @@ mod link;
mod lottie;
mod markers;
mod mask;
pub mod mask_interp;
pub mod paint;
mod paths;
mod project_items;
@ -37,6 +38,7 @@ mod time;
mod track;
mod view;
pub mod viewer_cmds;
mod warp_cmds;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
@ -125,6 +127,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(comp_more::specs());
v.extend(frontend::specs());
v.extend(track::specs());
v.extend(mask_interp::specs());
v.extend(warp_cmds::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(settings::specs());

View File

@ -60,8 +60,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
stub!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], "{}"),
stub!("track.mask", "Track Mask", ["Animation"], "{}"),
// Workspaces and panels that don't exist yet.
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
]

View File

@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_track,
analyze
),
cmd!("track.stop", "Stop Analysis", [], None, "{}", is_tracking, |s, _| Ok(json!({"stopped": s.stop_track()}))),
cmd!("track.stop", "Stop Analysis", [], None, "{}", is_tracking, |s, _| Ok(json!({"stopped": s.stop_track() | s.stop_mask_track() | s.stop_warp()}))),
cmd!("track.apply", "Apply", [], None, "{layer?, tracker?, dimensions?: xy|x|y}", has_track, apply),
cmd!("track.reset", "Reset", [], None, "{layer?, tracker?}", has_track, reset),
cmd!("track.delete", "Delete Tracker", [], None, "{layer?, tracker?}", has_track, delete),
@ -105,7 +105,7 @@ fn has_track(s: &Session) -> std::result::Result<(), String> {
}
fn is_tracking(s: &Session) -> std::result::Result<(), String> {
if s.is_tracking() { Ok(()) } else { Err("no track analysis is running".into()) }
if s.is_tracking() || s.is_mask_tracking() || s.is_warp_analyzing() { Ok(()) } else { Err("no track analysis is running".into()) }
}
// ---------- addressing ----------
@ -432,31 +432,10 @@ fn analyze(s: &mut Session, p: &V) -> Result<V> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let (tg, settings) = layer.tracker(uid).ok_or_else(|| bad("track.analyze", "no tracker"))?;
let fd = comp.frame_duration();
let now = comp.frame_rate.snap_nearest(if s.state.active_comp == Some(cid) { s.time() } else { Tick::ZERO });
let start = f_p(p, "start").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let end = f_p(p, "end").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let lo = layer.in_point.max(Tick::ZERO);
let hi = layer.out_point.min(comp.duration) - fd;
let mut times = vec![];
match dir {
Direction::Forward | Direction::FrameForward => {
let mut t = start.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameForward { t + fd } else { end.unwrap_or(hi).min(hi) };
while t <= stop && t <= hi {
times.push(t);
t += fd;
}
}
Direction::Backward | Direction::FrameBackward => {
let mut t = end.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameBackward { t - fd } else { start.unwrap_or(lo).max(lo) };
while t >= stop && t >= lo {
times.push(t);
t -= fd;
}
}
}
let times = analysis_times(comp, layer, dir, now, start, end);
if times.len() < 2 {
return Err(bad("track.analyze", "nothing to analyze in that direction (at the layer's end)"));
}
@ -489,6 +468,34 @@ fn analyze(s: &mut Session, p: &V) -> Result<V> {
}))
}
/// Comp times to analyse from the CTI (`now`) in `dir`, bounded by `start` / `end` and the
/// layer's In/Out points; the first is the start frame.
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer, dir: Direction, now: Tick, start: Option<Tick>, end: Option<Tick>) -> Vec<Tick> {
let fd = comp.frame_duration();
let lo = layer.in_point.max(Tick::ZERO);
let hi = layer.out_point.min(comp.duration) - fd;
let mut times = vec![];
match dir {
Direction::Forward | Direction::FrameForward => {
let mut t = start.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameForward { t + fd } else { end.unwrap_or(hi).min(hi) };
while t <= stop && t <= hi {
times.push(t);
t += fd;
}
}
Direction::Backward | Direction::FrameBackward => {
let mut t = end.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameBackward { t - fd } else { start.unwrap_or(lo).max(lo) };
while t >= stop && t >= lo {
times.push(t);
t -= fd;
}
}
}
times
}
fn apply(s: &mut Session, p: &V) -> Result<V> {
let (cid, lid, uid) = current(s, p, "track.apply")?;
let dims = match str_p(p, "dimensions") {
@ -560,9 +567,10 @@ fn delete(s: &mut Session, p: &V) -> Result<V> {
fn status(s: &mut Session, p: &V) -> Result<V> {
s.poll_track();
s.poll_mask_track();
let progress = s.track_progress();
let cur = current(s, p, "track.status").ok();
let mut out = json!({"running": s.is_tracking(), "progress": progress});
let mut out = json!({"running": s.is_tracking() || s.is_mask_tracking(), "progress": progress, "mask": s.mask_track_progress(), "maskMethod": s.state.mask_track_method.id()});
if let Some((cid, lid, uid)) = cur
&& let Some(layer) = s.project.comp(cid).and_then(|c| c.layer(lid))
&& let Some((g, st)) = layer.tracker(uid)

View File

@ -0,0 +1,131 @@
//! Warp Stabilizer commands (Effect ▸ Distort ▸ Warp Stabilizer's Analyze / Cancel, Animation ▸
//! Warp Stabilizer VFX, the Tracker panel button) and its status query.
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_comp, layer_p};
use crate::{EngineError, Result, Session, cmd, query};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], None, "{layer?, wait?}", can_stabilize, warp_apply),
cmd!("warp.analyze", "Analyze", [], None, "{layer?, effect?: uid|name|index, wait?: block until done}", has_comp, warp_analyze),
cmd!("warp.cancel", "Cancel", [], None, "{}", is_analyzing, |s, _| Ok(json!({"stopped": s.stop_warp()}))),
query!("warp.status", "Warp Stabilizer Status", "{layer?, effect?}", warp_status),
]
}
// ---------------------------------------------------------------- Warp Stabilizer
fn can_stabilize(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let c = s.active_comp().ok_or("no composition is open")?;
match s.state.selected_layers.first().and_then(|l| c.layer(*l)) {
Some(l) if l.has_video() && !l.switches.adjustment => Ok(()),
Some(_) => Err("Warp Stabilizer needs a footage or composition layer".into()),
None => Err("select a layer to stabilize".into()),
}
}
fn is_analyzing(s: &Session) -> std::result::Result<(), String> {
if s.is_warp_analyzing() { Ok(()) } else { Err("no Warp Stabilizer analysis is running".into()) }
}
fn is_warp(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::warp_stab::ID)
}
/// (comp, layer, effect uid) of a Warp Stabilizer: `layer` / `effect` (uid, name or 1-based
/// index among the layer's effects), else a selected one, else the layer's first.
fn warp_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let candidates: Vec<LayerId> = match p.get("layer") {
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
None => {
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
v.extend(s.state.selected_layers.iter().copied());
v.extend(comp.layers.iter().map(|l| l.id));
v
}
};
for lid in candidates {
let Some(fx) = comp.layer(lid).and_then(Layer::effects) else { continue };
let g = match p.get("effect") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth((n as usize).saturating_sub(1)))
}
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name),
_ => s
.state
.selected_props
.iter()
.find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u && is_warp(g))).flatten())
.or_else(|| fx.groups().find(|g| is_warp(g))),
};
if let Some(g) = g.filter(|g| is_warp(g)) {
return Ok((cid, lid, g.uid));
}
if p.get("layer").is_some() {
break;
}
}
Err(bad(cmd, "no Warp Stabilizer found (apply Effect ▸ Distort ▸ Warp Stabilizer first)"))
}
fn warp_analyze(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = warp_p(s, p, "warp.analyze")?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_warp(cid, lid, uid, wait).map_err(EngineError::Other)?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_warp_analyzing(), "progress": s.warp_progress()});
if wait {
out["status"] = warp_status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
/// Animation ▸ Warp Stabilizer VFX / the Tracker panel button: apply the effect to the selected
/// layer and start analysing it.
fn warp_apply(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "track.warpStabilizer")?;
let r = s.execute("effect.apply", json!({"effect": effectcraft_effects::warp_stab::ID, "layers": [lid.0], "comp": cid.0}))?;
let uid = r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_warp(cid, lid, uid, wait).map_err(EngineError::Other)?;
Ok(json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_warp_analyzing(), "progress": s.warp_progress()}))
}
fn warp_status(s: &mut Session, p: &Value) -> Result<Value> {
s.poll_warp(false);
let mut out = json!({"running": s.is_warp_analyzing(), "progress": s.warp_progress(), "banner": s.warp_progress().map(|p| p.banner())});
let Ok((cid, lid, uid)) = warp_p(s, p, "warp.status") else { return Ok(out) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid));
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let analysis = effectcraft_effects::warp_stab::analysis(&params);
out["layer"] = json!(lid.0);
out["effect"] = json!(uid);
out["analyzed"] = json!(analysis.is_some());
out["pending"] = json!(s.warp_pending.contains(&(cid, lid, uid)));
if let Some(a) = analysis {
out["frames"] = json!(a.frames.len());
out["size"] = json!(a.size);
let lt = layer.layer_time(s.time_of(cid)).seconds();
if let Some((k, _, plan)) = effectcraft_effects::warp_stab::plan_at(&params, lt) {
let m = plan.warps[k].0;
out["frame"] = json!(k);
out["autoScale"] = json!(plan.auto_scale * 100.0);
out["crop"] = json!(plan.crop);
out["validFraction"] = json!(plan.valid_fraction);
out["warp"] = json!(m);
let pts = &a.frames[k];
out["features"] = json!(pts.features);
out["inliers"] = json!(pts.inliers);
}
}
Ok(out)
}

View File

@ -13,6 +13,7 @@ pub mod commands;
pub mod config;
pub mod demo;
pub mod links;
pub mod mask_track;
pub mod menus;
pub mod prefs;
pub mod render_queue;
@ -20,6 +21,7 @@ mod session_settings;
pub mod shortcuts;
pub mod tracking;
pub mod viewer;
pub mod warp;
use std::sync::Arc;
@ -165,6 +167,12 @@ pub struct EditorState {
/// Tracker panel ▸ Current Track: (tracked layer, tracker group uid) in the active comp.
#[serde(default)]
pub current_track: Option<(LayerId, Uid)>,
/// Tracker panel ▸ Method in mask mode (Track Mask).
#[serde(default)]
pub mask_track_method: mask_track::MaskMethod,
/// Mask Interpolation panel options.
#[serde(default)]
pub mask_interp: commands::mask_interp::MaskInterpOptions,
/// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools).
#[serde(default)]
pub paint: commands::paint::PaintOptions,
@ -249,6 +257,12 @@ pub struct Session {
pub render_job: Option<render_queue::RenderJob>,
/// The running (or finished, not yet polled) track analysis.
pub track_job: Option<tracking::TrackJob>,
/// The running (or finished, not yet polled) mask track.
pub mask_job: Option<mask_track::MaskTrackJob>,
/// The running (or finished, not yet polled) Warp Stabilizer analysis.
pub warp_job: Option<warp::WarpJob>,
/// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid).
pub warp_pending: Vec<(ItemId, LayerId, Uid)>,
pub events: Vec<Event>,
/// Commands executed: (id, params).
pub journal: Vec<(String, Value)>,
@ -291,6 +305,9 @@ impl Default for Session {
exporter: None,
render_job: None,
track_job: None,
mask_job: None,
warp_job: None,
warp_pending: vec![],
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
@ -351,6 +368,12 @@ impl Session {
let r = f(&mut p, &mut st)?;
// Layer styles: one Global Light per comp, whichever layer edited it.
effectcraft_project::styles::sync_global_light(&before, &mut p);
// Warp Stabilizer analyses made from other frames are cleared (and queued again).
for w in warp::invalidate(&before, &mut p) {
if !self.warp_pending.contains(&w) {
self.warp_pending.push(w);
}
}
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
@ -523,6 +546,11 @@ impl Session {
self.render_job = None;
self.stop_track();
self.track_job = None;
self.stop_mask_track();
self.mask_job = None;
self.stop_warp();
self.warp_job = None;
self.warp_pending.clear();
p.fix_next_id();
self.project = Arc::new(p);
self.history = History::default();
@ -554,6 +582,8 @@ mod tests_lottie;
#[cfg(test)]
mod tests_markers;
#[cfg(test)]
mod tests_mask_warp;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_project_items;

View File

@ -0,0 +1,313 @@
//! Mask tracking runtime (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode): follow
//! the pixels inside a mask from frame to frame and key its Mask Path on every analysed frame.
//!
//! Analysis runs on a background thread (progress, cancel; blocking with `wait` and on wasm32)
//! like the point tracker, over the layer's source frames; one undo step covers it. The
//! algorithm is `effectcraft_track::mask` (KLT features inside the mask + a RANSAC fit of the
//! chosen motion model), and every frame's motion is applied to the mask's vertices and
//! tangents, so the shape's Bezier structure is kept.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::{ItemId, LayerId, Project, Uid};
use effectcraft_render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use effectcraft_track as trk;
use effectcraft_track::fit::Model;
use serde::{Deserialize, Serialize};
use crate::tracking::{Direction, TrackProgress, lock, source_frame};
use crate::{Event, Session};
/// Tracker panel ▸ Method (mask tracking).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MaskMethod {
Position,
PositionScale,
#[default]
PositionScaleRotation,
PositionScaleRotationSkew,
Perspective,
}
impl MaskMethod {
pub const ALL: [MaskMethod; 5] =
[MaskMethod::Position, MaskMethod::PositionScale, MaskMethod::PositionScaleRotation, MaskMethod::PositionScaleRotationSkew, MaskMethod::Perspective];
pub fn label(self) -> &'static str {
match self {
MaskMethod::Position => "Position",
MaskMethod::PositionScale => "Position & Scale",
MaskMethod::PositionScaleRotation => "Position, Scale & Rotation",
MaskMethod::PositionScaleRotationSkew => "Position, Scale, Rotation & Skew",
MaskMethod::Perspective => "Perspective",
}
}
pub fn id(self) -> &'static str {
match self {
MaskMethod::Position => "position",
MaskMethod::PositionScale => "positionScale",
MaskMethod::PositionScaleRotation => "positionScaleRotation",
MaskMethod::PositionScaleRotationSkew => "positionScaleRotationSkew",
MaskMethod::Perspective => "perspective",
}
}
pub fn from_name(s: &str) -> Option<MaskMethod> {
let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
MaskMethod::ALL
.into_iter()
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == n)
}
pub fn model(self) -> Model {
match self {
MaskMethod::Position => Model::Translation,
MaskMethod::PositionScale => Model::TranslationScale,
MaskMethod::PositionScaleRotation => Model::Similarity,
MaskMethod::PositionScaleRotationSkew => Model::Affine,
MaskMethod::Perspective => Model::Homography,
}
}
}
/// A closed polyline through a Bezier path (`steps` samples per segment), in layer pixels.
pub fn flatten(p: &ShapePath, steps: usize) -> Vec<[f64; 2]> {
let n = p.len();
if n == 0 {
return vec![];
}
let segs = if p.closed { n } else { n.saturating_sub(1) };
let mut out = Vec::with_capacity(segs * steps + 1);
for i in 0..segs {
let j = (i + 1) % n;
let (a, b) = (p.vertices[i], p.vertices[j]);
let o = p.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
let it = p.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
let c = [a, [a[0] + o[0], a[1] + o[1]], [b[0] + it[0], b[1] + it[1]], b];
for k in 0..steps {
let t = k as f64 / steps as f64;
let u = 1.0 - t;
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
out.push([0, 1].map(|d| w[0] * c[0][d] + w[1] * c[1][d] + w[2] * c[2][d] + w[3] * c[3][d]));
}
}
if !p.closed {
out.push(p.vertices[n - 1]);
}
out
}
/// A path moved by a frame-to-frame motion (vertices and tangent handles).
pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath {
ShapePath {
vertices: p.vertices.iter().map(|v| h.apply(*v)).collect(),
in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
closed: p.closed,
}
}
/// One tracked frame: (layer time, comp time, mask path).
type MaskFrame = (Tick, Tick, ShapePath);
#[derive(Default)]
pub struct MaskTrackShared {
pub cancel: AtomicBool,
pub state: Mutex<TrackProgress>,
frames: Mutex<Vec<MaskFrame>>,
}
/// A running (or finished, not yet polled) mask track.
pub struct MaskTrackJob {
pub shared: Arc<MaskTrackShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub mask: Uid,
pub direction: Direction,
}
impl MaskTrackJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> TrackProgress {
lock(&self.shared.state).clone()
}
}
/// What the worker needs, detached from the session.
pub(crate) struct MaskWork {
pub project: Arc<Project>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub cache: Arc<LayerCache>,
pub comp: ItemId,
pub layer: LayerId,
/// The mask path on the first frame.
pub path: ShapePath,
pub method: MaskMethod,
/// Comp times; the first is the start frame.
pub times: Vec<Tick>,
}
fn run_work(w: MaskWork, shared: &MaskTrackShared) {
let t0 = web_time::Instant::now();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let frame_at = |t: Tick| source_frame(r, &w.project, w.comp, comp, layer, t, w.expr.as_deref());
lock(&shared.state).total = w.times.len().saturating_sub(1) as u64;
let Some(first) = frame_at(w.times[0]) else { return fail("the layer has no pixels to track") };
let mut tracker = trk::mask::MaskTracker::new(w.method.model(), flatten(&w.path, 8), &trk::Frame { img: &first.0, offset: first.1 });
let mut path = w.path.clone();
lock(&shared.frames).push((layer.layer_time(w.times[0]), w.times[0], path.clone()));
let mut next = if w.times.len() > 1 { frame_at(w.times[1]) } else { None };
for k in 1..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
lock(&shared.state).cancelled = true;
break;
}
let Some(cur) = next.take() else {
lock(&shared.state).error = Some(format!("no frame at {:.3} s", w.times[k].seconds()));
break;
};
let (res, nf) =
rayon::join(|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }), || if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None });
next = nf;
let Some(step) = res else {
lock(&shared.state).error = Some(format!("lost the mask's pixels at {:.3} s (too little texture inside the mask)", w.times[k].seconds()));
break;
};
path = transform_path(&path, &step.motion);
lock(&shared.frames).push((layer.layer_time(w.times[k]), w.times[k], path.clone()));
let mut s = lock(&shared.state);
s.done = k as u64;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
let Some(layer) = p.comp_mut(job.comp).and_then(|c| c.layer_mut(job.layer)) else { return };
let Some(g) = layer.props.find_group_mut(job.mask) else { return };
let Some(pr) = g.get_mut("path") else { return };
for (lt, _, path) in frames {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Path(path.clone())));
}
}
impl Session {
/// Whether a mask track is running.
pub fn is_mask_tracking(&self) -> bool {
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
}
/// Live progress of the running (or just finished) mask track.
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
self.mask_job.as_ref().map(|j| j.progress())
}
/// Cancel the running mask track (frames tracked so far are kept).
pub fn stop_mask_track(&mut self) -> bool {
match &self.mask_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
pub(crate) fn start_mask_track(&mut self, work: MaskWork, mask: Uid, direction: Direction, wait: bool) -> Result<(), String> {
if self.is_mask_tracking() || self.is_tracking() {
return Err("a track analysis is already running".into());
}
self.poll_mask_track();
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Track Mask".into(), self.project.clone()));
self.history.redo.clear();
self.history.merge_key = None;
let shared = Arc::new(MaskTrackShared::default());
let (comp, layer) = (work.comp, work.layer);
let thread = if wait {
run_work(work, &shared);
None
} else {
let sh = shared.clone();
Some(std::thread::Builder::new().name("mask-track".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
};
self.mask_job = Some(MaskTrackJob { shared, thread, comp, layer, mask, direction });
self.poll_mask_track();
Ok(())
}
/// Key newly tracked frames and move the CTI with them; drop the job once it finished.
/// Frontends call this every frame while tracking.
pub fn poll_mask_track(&mut self) -> bool {
let Some(job) = &self.mask_job else { return false };
let frames = std::mem::take(&mut *lock(&job.shared.frames));
let finished = job.is_finished();
let changed = !frames.is_empty();
if changed {
write_frames(Arc::make_mut(&mut self.project), job, &frames);
if self.state.active_comp == Some(job.comp)
&& let Some(last) = frames.last()
{
self.state.times.insert(job.comp, last.1);
}
self.bump();
}
if finished {
let mut job = self.mask_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let msg = match (&st.error, st.cancelled) {
(Some(e), _) => format!("Mask tracking stopped: {e}"),
(None, true) => format!("Mask tracking stopped after {} frame(s)", st.done),
_ => format!("Tracked the mask over {} frame(s) in {:.1} s", st.done, st.elapsed),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
}
changed || finished
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn methods_parse_and_paths_transform() {
assert_eq!(MaskMethod::from_name("positionScaleRotationSkew"), Some(MaskMethod::PositionScaleRotationSkew));
assert_eq!(MaskMethod::from_name("Position & Scale"), Some(MaskMethod::PositionScale));
assert_eq!(MaskMethod::from_name("perspective"), Some(MaskMethod::Perspective));
let p = ShapePath::ellipse([50.0, 40.0], 40.0, 20.0);
let poly = flatten(&p, 8);
assert_eq!(poly.len(), p.len() * 8);
assert!(poly.iter().all(|q| ((q[0] - 50.0) / 20.0).powi(2) + ((q[1] - 40.0) / 10.0).powi(2) < 1.01));
let h = trk::Homography([[0.0, -2.0, 10.0], [2.0, 0.0, 0.0], [0.0, 0.0, 1.0]]);
let q = transform_path(&p, &h);
for i in 0..p.len() {
let v = h.apply(p.vertices[i]);
assert!((q.vertices[i][0] - v[0]).abs() < 1e-9);
let t = p.out_tangents[i];
assert!((q.out_tangents[i][0] + 2.0 * t[1]).abs() < 1e-9 && (q.out_tangents[i][1] - 2.0 * t[0]).abs() < 1e-9);
}
}
}

View File

@ -524,6 +524,7 @@ Layer
Reset Mask | layer.mask.reset
Remove Mask | layer.mask.remove
Remove All Masks | layer.mask.removeAll
Track Mask | track.mask
Mode
None | layer.mask.mode {"mode":"None"}
Add | layer.mask.mode {"mode":"Add"}
@ -932,7 +933,7 @@ Window
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
Info | window.panel {"panel":"info"} | Cmd+2
Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"}
Mask Interpolation | window.unavailablePanel {"panel":"maskInterpolation"}
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
Media Browser | window.unavailablePanel {"panel":"mediaBrowser"}
Metadata | window.unavailablePanel {"panel":"metadata"}
Motion Sketch | window.panel {"panel":"motionSketch"}

View File

@ -0,0 +1,384 @@
//! Mask tracking, Mask Interpolation and the Warp Stabilizer end to end through the commands, on
//! synthetic footage generated in-test (a rotating/scaling textured patch, a shaky camera).
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
use effectcraft_path::interp::paths_cross;
use effectcraft_project::{LayerId, Uid};
use effectcraft_raster::Image;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use effectcraft_track::Homography;
use serde_json::json;
use crate::Session;
use crate::tests_track::{FPS, H, W, frame_time, patch_frames, rotating_pose, setup, texture};
fn pose_h(f: u32) -> Homography {
let (c, rot, sc) = rotating_pose(f);
let (sn, cs) = rot.to_radians().sin_cos();
Homography([[sc * cs, -sc * sn, c[0]], [sc * sn, sc * cs, c[1]], [0.0, 0.0, 1.0]])
}
fn mask_path(s: &Session, layer: LayerId, mask: Uid) -> effectcraft_project::Property {
let l = s.active_comp().unwrap().layer(layer).unwrap();
l.props.find_group(mask).unwrap().get("path").unwrap().clone()
}
fn new_mask(s: &mut Session, layer: LayerId, path: &ShapePath) -> Uid {
let ins: Vec<[f64; 2]> = path.in_tangents.clone();
let outs: Vec<[f64; 2]> = path.out_tangents.clone();
let r = s.execute("mask.new", json!({"layer": layer.0, "vertices": path.vertices, "inTangents": ins, "outTangents": outs, "closed": true})).unwrap();
r["mask"].as_u64().unwrap()
}
#[test]
fn track_mask_follows_a_rotating_patch() {
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
let (c0, _, _) = rotating_pose(0);
// An ellipse inside the textured patch.
let shape = ShapePath::ellipse(c0, 64.0, 52.0);
let uid = new_mask(&mut s, clip, &shape);
// Selecting the mask enables Track Mask (the Tracker panel's mask mode).
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
s.state.selected_props = vec![(clip, uid)];
assert!(s.is_enabled("track.mask"));
assert_eq!(crate::commands::mask_interp::selected_mask(&s), Some((clip, uid)));
s.execute("time.set", json!({"time": 0})).unwrap();
let r = s.execute_checked("track.mask", json!({"method": "positionScaleRotation", "direction": "forward", "wait": true})).unwrap();
assert_eq!(r["frames"], 24, "{r}");
assert!(s.mask_job.is_none());
let pr = mask_path(&s, clip, uid);
assert_eq!(pr.keys.len(), 25);
let inv0 = pose_h(0).inverse().unwrap();
let mut worst: f64 = 0.0;
for f in 0..25 {
let KV::Path(p) = pr.value_at(frame_time(f)) else { panic!() };
let m = pose_h(f).then_after(&inv0);
for (v, o) in p.vertices.iter().zip(&shape.vertices) {
let want = m.apply(*o);
worst = worst.max((v[0] - want[0]).hypot(v[1] - want[1]));
}
// Tangents rotate and scale with the patch.
let want_t = m.apply_tangent(shape.vertices[0], shape.out_tangents[0]);
assert!((p.out_tangents[0][0] - want_t[0]).hypot(p.out_tangents[0][1] - want_t[1]) < 1.5);
}
assert!(worst < 1.5, "worst vertex error {worst} px");
assert_eq!(s.time(), frame_time(24));
// One undo step; redo restores the keys.
assert!(s.undo());
assert!(mask_path(&s, clip, uid).keys.is_empty());
assert!(s.redo());
assert_eq!(mask_path(&s, clip, uid).keys.len(), 25);
// Track backward one frame from the end, with another method.
let r = s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "position", "direction": "frameBackward", "wait": true})).unwrap();
assert_eq!(r["frames"], 1);
assert_eq!(s.state.mask_track_method, crate::mask_track::MaskMethod::Position);
// Serde keeps the keys.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(clip).unwrap();
assert_eq!(l.props.find_group(uid).unwrap().get("path").unwrap().keys.len(), 25);
// Bad parameters are reported.
assert!(s.execute_checked("track.mask", json!({"layer": clip.0, "mask": uid, "method": "warp"})).is_err());
assert!(s.execute_checked("track.mask", json!({"layer": clip.0, "mask": uid, "bogus": 1})).is_err());
}
#[test]
fn track_mask_in_background_and_cancel() {
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
let uid = new_mask(&mut s, clip, &ShapePath::rect(rotating_pose(0).0, 60.0, 60.0));
s.execute("time.set", json!({"time": 0})).unwrap();
let r = s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "perspective"})).unwrap();
assert_eq!(r["running"], true);
// The analysis keeps running in the background; Stop keeps what was tracked.
assert!(s.is_enabled("track.stop"));
s.execute("track.stop", json!({})).unwrap();
let t0 = std::time::Instant::now();
while s.mask_job.is_some() {
assert!(t0.elapsed().as_secs() < 60);
std::thread::sleep(std::time::Duration::from_millis(2));
s.poll_mask_track();
}
let n = mask_path(&s, clip, uid).keys.len();
assert!((1..25).contains(&n), "{n} keys");
// A full background run.
s.execute("time.set", json!({"time": 0})).unwrap();
s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "perspective"})).unwrap();
while s.mask_job.is_some() {
assert!(t0.elapsed().as_secs() < 120);
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_mask_track();
}
let pr = mask_path(&s, clip, uid);
assert_eq!(pr.keys.len(), 25);
let KV::Path(p) = pr.value_at(frame_time(20)) else { panic!() };
let m = pose_h(20).then_after(&pose_h(0).inverse().unwrap());
let want = m.apply(ShapePath::rect(rotating_pose(0).0, 60.0, 60.0).vertices[2]);
assert!((p.vertices[2][0] - want[0]).hypot(p.vertices[2][1] - want[1]) < 2.0);
}
/// A five-pointed star (10 vertices) around `c`, rotated by `rot` degrees.
fn star(c: [f64; 2], r: f64, rot: f64) -> ShapePath {
let pts: Vec<[f64; 2]> = (0..10)
.map(|i| {
let a = (rot + i as f64 * 36.0 - 90.0).to_radians();
let rr = if i % 2 == 0 { r } else { r * 0.45 };
[c[0] + rr * a.cos(), c[1] + rr * a.sin()]
})
.collect();
ShapePath::polygon(&pts, true)
}
#[test]
fn mask_interpolation_creates_matching_keys() {
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
let a = ShapePath::rect([100.0, 100.0], 80.0, 60.0);
let b = star([200.0, 120.0], 50.0, 30.0);
let uid = new_mask(&mut s, clip, &a);
let cid = s.active_comp_id().unwrap();
let (t0, t1) = (frame_time(0), frame_time(20));
s.edit("keys", None, |p, _| {
let pr = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
pr.keys = vec![Keyframe::new(t0, KV::Path(a.clone())), Keyframe::new(t1, KV::Path(b.clone()))];
Ok(())
})
.unwrap();
let prop = mask_path(&s, clip, uid).uid;
s.execute("keys.select", json!({"keys": [{"layer": clip.0, "prop": prop, "time": 0.0}, {"layer": clip.0, "prop": prop, "time": 0.8}]})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
let r = s
.execute_checked(
"mask.interpolate",
json!({"keyframeRate": "auto", "bendingResistance": 50, "quality": 50, "addVertices": 10, "addVerticesUnit": "pixels", "matchingMethod": "auto", "firstVerticesMatch": false}),
)
.unwrap();
// 19 in-betweens at the comp rate (frames 1…19).
assert_eq!(r["keys"], 19, "{r}");
let pr = mask_path(&s, clip, uid);
assert_eq!(pr.keys.len(), 21);
let shapes: Vec<ShapePath> = pr.keys.iter().map(|k| k.value.as_path().unwrap().clone()).collect();
let n = shapes[0].len();
assert!(n >= 28, "{n} vertices");
assert!(shapes.iter().all(|p| p.len() == n));
// Ends keep their outlines; vertex paths between consecutive keys don't cross.
for (i, w) in shapes.windows(2).enumerate() {
assert_eq!(paths_cross(&w[0], &w[1]), 0, "keys {i}/{}", i + 1);
}
assert!(
shapes[0]
.vertices
.iter()
.all(|v| (v[0] - 60.0).abs() < 1e-6 || (v[0] - 140.0).abs() < 1e-6 || (v[1] - 70.0).abs() < 1e-6 || (v[1] - 130.0).abs() < 1e-6)
);
// Options persist; Keyframe Fields doubles the rate; undo restores.
assert_eq!(s.state.mask_interp.add_vertices, Some(10.0));
assert!(s.undo());
assert_eq!(mask_path(&s, clip, uid).keys.len(), 2);
let r = s
.execute(
"mask.interpolate",
json!({"layer": clip.0, "mask": uid, "times": [0.0, 0.8], "keyframeRate": 10, "keyframeFields": true, "linearVertexPaths": true}),
)
.unwrap();
assert_eq!(r["keys"], 15, "{r}");
assert!(s.execute("mask.interpolate", json!({"layer": clip.0, "mask": uid, "times": [0.0]})).is_err());
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
}
// ---------------------------------------------------------------- Warp Stabilizer
/// Camera offset on frame `f`: a slow pan plus jitter.
fn cam(f: u32) -> [f64; 2] {
let j = [((f * 7919) % 9) as f64 - 4.0, ((f * 104_729) % 7) as f64 - 3.0];
[1.2 * f as f64 + j[0], 0.5 * f as f64 + j[1]]
}
fn shaky(f: u32) -> Image {
let c = cam(f);
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let v = texture(x as f64 + 0.5 + c[0], y as f64 + 0.5 + c[1], 11);
img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]);
}
}
img
}
fn jitter(p: &[[f64; 2]]) -> f64 {
let mut s = 0.0;
for k in 1..p.len() - 1 {
s += (p[k + 1][0] - 2.0 * p[k][0] + p[k - 1][0]).hypot(p[k + 1][1] - 2.0 * p[k][1] + p[k - 1][1]);
}
s / (p.len() - 2) as f64
}
fn warp_at(s: &mut Session, f: u32) -> Homography {
s.execute("time.set", json!({"time": frame_time(f).seconds()})).unwrap();
let st = s.execute("warp.status", json!({})).unwrap();
let m: [[f64; 3]; 3] = serde_json::from_value(st["warp"].clone()).unwrap_or_else(|_| panic!("{st}"));
Homography(m)
}
/// Output positions of the scene point that sits at the frame centre on frame 0.
fn scene_track(s: &mut Session) -> (Vec<[f64; 2]>, Vec<[f64; 2]>) {
let x0 = [160.0 + cam(0)[0], 120.0 + cam(0)[1]];
let before: Vec<[f64; 2]> = (0..25).map(|f| [x0[0] - cam(f)[0], x0[1] - cam(f)[1]]).collect();
let after = (0..25).map(|f| warp_at(s, f).apply(before[f as usize])).collect();
(before, after)
}
fn render(s: &Session, f: u32) -> Image {
s.render(s.active_comp_id().unwrap(), frame_time(f), RenderOpts::default())
}
fn transparent(img: &Image) -> usize {
img.data.iter().filter(|p| p[3] < 0.5).count()
}
fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value) {
s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap();
}
#[test]
fn warp_stabilizer_analyzes_and_stabilizes() {
let (mut s, clip, solid) = setup(shaky);
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
// Effect ▸ Distort ▸ Warp Stabilizer, then analyse (blocking).
let r = s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap();
let fx = r["effects"][0].as_u64().unwrap();
assert!(s.warp_pending.contains(&(s.active_comp_id().unwrap(), clip, fx)), "applying queues an analysis");
let st = s.execute_checked("warp.status", json!({})).unwrap();
assert_eq!(st["analyzed"], false);
let r = s.execute_checked("warp.analyze", json!({"layer": clip.0, "wait": true})).unwrap();
assert_eq!(r["frames"], 25, "{r}");
assert_eq!(r["status"]["analyzed"], true, "{r}");
assert!(s.warp_pending.is_empty() && s.warp_job.is_none());
// Groups and AE parameter names.
let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone();
let names: Vec<&str> = g.groups().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Stabilization", "Borders", "Advanced"]);
assert!(g.prop("borders/autoScale/maximumScale").is_some());
assert_eq!(g.prop("stabilization/smoothness").unwrap().name, "Smoothness");
// Smooth Motion: the jitter of a scene point drops by more than 80 %.
set_param(&mut s, clip, "borders/framing", json!(0));
let (before, after) = scene_track(&mut s);
let (j0, j1) = (jitter(&before), jitter(&after));
assert!(j1 < 0.2 * j0, "jitter {j0} → {j1}");
// Rendering applies the frame's correction: output(p) = source(W⁻¹ p).
let w = warp_at(&mut s, 7);
let out = render(&s, 7);
let src = shaky(7);
let inv = w.inverse().unwrap();
for p in [[160.0, 120.0], [100.0, 80.0], [230.0, 170.0]] {
let q = inv.apply([p[0] + 0.5, p[1] + 0.5]);
let want = src.sample_bilinear(q[0], q[1]);
let got = out.get(p[0] as i64, p[1] as i64);
assert!((want[0] - got[0]).abs() < 0.03, "{p:?}: {want:?} vs {got:?}");
}
// Stabilize Only shows the moving borders.
let shown: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
assert!(shown > 500, "{shown}");
// No Motion keeps a feature fixed.
set_param(&mut s, clip, "stabilization/result", json!(1));
let (_, fixed) = scene_track(&mut s);
for q in &fixed {
assert!((q[0] - fixed[12][0]).hypot(q[1] - fixed[12][1]) < 0.6, "{q:?} vs {:?}", fixed[12]);
}
set_param(&mut s, clip, "stabilization/result", json!(0));
// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
set_param(&mut s, clip, "borders/framing", json!(2));
let st = s.execute("warp.status", json!({})).unwrap();
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
for f in 0..25 {
assert_eq!(transparent(&render(&s, f)), 0, "frame {f}");
}
set_param(&mut s, clip, "borders/framing", json!(1));
let st = s.execute("warp.status", json!({})).unwrap();
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
for f in [0, 9, 24] {
let img = render(&s, f);
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
}
}
assert!(img.get(0, 0)[3] < 0.01);
}
// Synthesize Edges fills most of the borders from neighbouring frames.
set_param(&mut s, clip, "borders/framing", json!(3));
let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
assert!(synth * 10 < shown, "synthesized {synth} vs {shown}");
// Show Track Points draws the features.
set_param(&mut s, clip, "borders/framing", json!(0));
let plain = render(&s, 5);
set_param(&mut s, clip, "advanced/showTrackPoints", json!(true));
assert_ne!(render(&s, 5).data, plain.data);
// Serde keeps the analysis; undo / redo of the analysis.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
let bg = back.comp(s.active_comp_id().unwrap()).unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone();
assert_eq!(bg, s.active_comp().unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone());
while s.history.undo.last().is_some_and(|(l, _)| l != "Warp Stabilizer Analysis") {
s.undo();
}
assert!(s.undo());
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false);
assert!(s.redo());
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
}
#[test]
fn warp_analysis_invalidates_and_cancels() {
let (mut s, clip, solid) = setup(shaky);
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
// Animation ▸ Warp Stabilizer VFX applies and analyses.
assert!(s.is_enabled("track.warpStabilizer"));
let r = s.execute_checked("track.warpStabilizer", json!({"wait": true})).unwrap();
let fx = r["effect"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
// Trimming the layer clears the analysis and queues it again.
s.execute("layer.timing", json!({"layers": [clip.0], "out": 0.8})).unwrap();
let st = s.execute("warp.status", json!({})).unwrap();
assert_eq!(st["analyzed"], false, "{st}");
assert_eq!(st["pending"], true);
// Without an analysis the layer renders unstabilized.
let img = s.render(cid, frame_time(3), RenderOpts::default());
assert_eq!(transparent(&img), 0);
// Frontends start queued analyses in the background (poll_warp with auto).
assert!(s.poll_warp(true));
assert!(s.warp_job.is_some());
let t0 = std::time::Instant::now();
while s.warp_job.is_some() {
assert!(t0.elapsed().as_secs() < 120);
let p = s.warp_progress().unwrap();
assert!(p.banner().starts_with("Analyzing in background (step 1 of 2)") || p.banner() == "Stabilizing...");
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_warp(true);
}
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
// Undoing the slip brings back the old analysis (the key matches again).
// Cancel: nothing is written.
s.execute("effect.apply", json!({"effect": "ec.distort.warpstabilizer", "layers": [clip.0]})).unwrap();
let fx2 = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().nth(1).unwrap().uid;
let r = s.execute("warp.analyze", json!({"layer": clip.0, "effect": fx2})).unwrap();
assert_eq!(r["running"], true);
assert!(s.execute("warp.analyze", json!({"layer": clip.0, "effect": fx})).is_err(), "one analysis at a time");
s.execute("warp.cancel", json!({})).unwrap();
while s.warp_job.is_some() {
std::thread::sleep(std::time::Duration::from_millis(2));
s.poll_warp(false);
}
let st = s.execute("warp.status", json!({"layer": clip.0, "effect": fx2})).unwrap();
assert_eq!(st["analyzed"], false, "{st}");
// Time Remap changes invalidate too.
s.execute("layer.enableTimeRemap", json!({"layers": [clip.0]})).unwrap();
assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": fx})).unwrap()["analyzed"], false);
let _ = FPS;
let _ = Tick::ZERO;
}

View File

@ -17,11 +17,11 @@ use serde_json::json;
use crate::Session;
const W: u32 = 320;
const H: u32 = 240;
const FPS: u32 = 25;
pub(crate) const W: u32 = 320;
pub(crate) const H: u32 = 240;
pub(crate) const FPS: u32 = 25;
fn texture(x: f64, y: f64, seed: u32) -> f32 {
pub(crate) fn texture(x: f64, y: f64, seed: u32) -> f32 {
let mut v = 0.0f64;
let mut s = seed.wrapping_mul(2654435761).wrapping_add(12345);
let mut rnd = || {
@ -41,7 +41,7 @@ fn texture(x: f64, y: f64, seed: u32) -> f32 {
}
/// Footage computed per frame (cached).
struct Synth {
pub(crate) struct Synth {
make: Box<dyn Fn(u32) -> Image + Send + Sync>,
cache: Mutex<HashMap<u32, Arc<Image>>>,
}
@ -55,9 +55,9 @@ impl FootageSource for Synth {
}
/// Patch pose on frame `f`: centre, rotation (degrees), scale.
type Pose = fn(u32) -> ([f64; 2], f64, f64);
pub(crate) type Pose = fn(u32) -> ([f64; 2], f64, f64);
fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
pub(crate) fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
move |f| {
let (c, rot, sc) = pose(f);
let (sn, cs) = rot.to_radians().sin_cos();
@ -76,7 +76,7 @@ fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
}
/// A comp with the synthetic clip (layer `clip`) and a 100 × 100 solid above it (layer `solid`).
fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, LayerId, LayerId) {
pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, LayerId, LayerId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Track", "width": W, "height": H, "frameRate": FPS, "duration": 1})).unwrap();
let cid = s.active_comp_id().unwrap();
@ -121,7 +121,7 @@ fn tracker_points(s: &Session, layer: LayerId) -> Vec<effectcraft_project::PropG
l.trackers().next().unwrap().0.track_points().cloned().collect()
}
fn frame_time(f: u32) -> Tick {
pub(crate) fn frame_time(f: u32) -> Tick {
FrameRate::new(FPS as i64, 1).tick_of(f as i64)
}
@ -220,7 +220,7 @@ fn stabilize_keeps_the_feature_fixed() {
assert!(worst < 0.25, "feature drifts by {worst} px");
}
fn rotating_pose(f: u32) -> ([f64; 2], f64, f64) {
pub(crate) fn rotating_pose(f: u32) -> ([f64; 2], f64, f64) {
let t = f as f64;
([160.0 + 1.2 * t, 120.0 - 0.7 * t], 1.5 * t, 1.0 + 0.008 * t)
}

View File

@ -155,7 +155,7 @@ pub struct TrackShared {
pub frames: Mutex<Vec<FrameResult>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
@ -197,7 +197,7 @@ pub(crate) struct Work {
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
/// Footage at its native size is used straight from the footage source (no copy).
fn source_frame(
pub(crate) fn source_frame(
r: &Renderer,
project: &Project,
cid: ItemId,

404
crates/engine/src/warp.rs Normal file
View File

@ -0,0 +1,404 @@
//! Warp Stabilizer runtime: the analysis job (`warp.analyze`) and keeping analyses valid.
//!
//! The analysis renders the layer's input to the effect (source → masks → the effects above
//! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out
//! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
//! stabilization plan once to validate the result. When the job finishes, the analysis is
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY};
use effectcraft_keyframe::Value;
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis};
use serde::Serialize;
use crate::tracking::lock;
use crate::{Event, Session};
/// Live analysis progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WarpProgress {
/// 1 = analyzing, 2 = stabilizing.
pub step: u8,
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
impl WarpProgress {
/// The viewer banner text, as After Effects words it.
pub fn banner(&self) -> String {
match self.step {
2 => "Stabilizing...".into(),
_ => {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
format!("Analyzing in background (step 1 of 2): {pc}%")
}
}
}
}
#[derive(Default)]
pub struct WarpShared {
pub cancel: AtomicBool,
pub state: Mutex<WarpProgress>,
result: Mutex<Option<WarpAnalysis>>,
}
/// A running (or finished, not yet polled) Warp Stabilizer analysis.
pub struct WarpJob {
pub shared: Arc<WarpShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
/// The layer signature the analysis is made from.
key: String,
}
impl WarpJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> WarpProgress {
lock(&self.shared.state).clone()
}
}
/// What the worker needs, detached from the session.
pub(crate) struct WarpWork {
pub project: Arc<Project>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub cache: Arc<LayerCache>,
pub comp: ItemId,
pub layer: LayerId,
/// Position of the effect in the layer's stack (the effects before it are analysed).
pub effect_index: usize,
pub size: [f64; 2],
pub detailed: bool,
/// Comp times of the analysed frames.
pub times: Vec<Tick>,
}
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// What an analysis of `layer` depends on (its frames): source (and the footage item's
/// interpretation), In/Out, start time, stretch, Time Remap and the comp frame rate.
pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
let src = match &layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => format!("{f:?}"),
Some(ItemKind::Solid(s)) => format!("{s:?}"),
Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration),
_ => String::new(),
},
_ => String::new(),
};
let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default();
let s = format!(
"{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}",
layer.source,
layer.in_point,
layer.out_point,
layer.start_time,
layer.stretch,
layer.props.get("timeRemap")
);
format!("{:016x}", fnv(&s))
}
fn is_warp(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == warp_stab::ID)
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.get(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
/// Clear analyses that no longer match their layer, after an edit. Returns the Warp
/// Stabilizers that need (re-)analysis: cleared ones and newly applied, unanalysed ones.
pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut clear = vec![];
for (cid, it) in &after.items {
let ItemKind::Comp(c) = &it.kind else { continue };
for l in &c.layers {
let Some(fx) = l.effects() else { continue };
if !fx.groups().any(is_warp) {
continue;
}
let sig = signature(after, *cid, l);
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
for g in fx.groups().filter(|g| is_warp(g)) {
let analysed = !str_of(g, ANALYSIS).is_empty();
if analysed && str_of(g, ANALYSIS_KEY) != sig {
clear.push((*cid, l.id, g.uid));
todo.push((*cid, l.id, g.uid));
} else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid) in clear {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
for m in [ANALYSIS, ANALYSIS_KEY] {
if let Some(p) = g.get_mut(m) {
p.value = Value::Str(String::new());
p.keys.clear();
}
}
}
}
todo
}
/// The layer's input to effect `effect_index` at comp time `t`, in layer pixels at 100 %.
fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc<Image>, [f64; 2])> {
let buf = r.layer_input(ctx, layer, effect_index)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale];
return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off));
}
Some((Arc::new(buf.img.clone()), buf.offset))
}
fn run_work(w: WarpWork, shared: &WarpShared) {
let t0 = web_time::Instant::now();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let ctx0 = EvalCtx::new(&w.project, w.comp, comp, Tick::ZERO);
let ctx0 = EvalCtx { expr: w.expr.as_deref(), ..ctx0 };
let frame_at = |t: Tick| input_frame(r, &ctx0.at(t), layer, w.effect_index);
{
let mut s = lock(&shared.state);
s.step = 1;
s.total = w.times.len() as u64;
}
let mut an = Analyzer::new(w.size, AnalyzeOpts { detailed: w.detailed });
let mut next = frame_at(w.times[0]);
for k in 0..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
let mut s = lock(&shared.state);
s.cancelled = true;
s.finished = true;
return;
}
let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) };
let ((), nf) = rayon::join(
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|| if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None },
);
next = nf;
let mut s = lock(&shared.state);
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
}
lock(&shared.state).step = 2;
let lt0 = layer.layer_time(w.times[0]).seconds();
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
let a = an.finish(lt0, fd);
// Step 2: derive a plan once (validates the motion and warms nothing else up).
let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() });
if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) {
return fail("the stabilization could not be computed");
}
*lock(&shared.result) = Some(a);
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Frames to analyse: every comp frame from the layer's In to its Out point (inside the comp).
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) -> Vec<Tick> {
let fd = comp.frame_duration();
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
let hi = layer.out_point.min(comp.duration);
let mut t = lo;
let mut v = vec![];
while t < hi {
v.push(t);
t += fd;
}
v
}
impl Session {
pub fn is_warp_analyzing(&self) -> bool {
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
}
/// Progress of the running (or just finished) analysis.
pub fn warp_progress(&self) -> Option<WarpProgress> {
self.warp_job.as_ref().map(|j| j.progress())
}
/// The effect being analysed: (comp, layer, effect uid).
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_warp(&mut self) -> bool {
match &self.warp_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start analysing Warp Stabilizer `effect` on `layer`. `wait`: block until done (wasm32
/// always does). Returns the number of frames.
pub fn start_warp(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result<usize, String> {
if self.is_warp_analyzing() {
return Err("a Warp Stabilizer analysis is already running".into());
}
self.poll_warp_job();
self.warp_pending.retain(|x| *x != (comp, layer, effect));
let c = self.project.comp(comp).ok_or("no composition")?;
let l = c.layer(layer).ok_or("no layer")?;
let fx = l.effects().ok_or("the layer has no effects")?;
let index = fx.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fx.groups().nth(index).filter(|g| is_warp(g)).ok_or("not a Warp Stabilizer")?;
if l.switches.adjustment {
return Err("Warp Stabilizer needs footage, not an adjustment layer".into());
}
let times = analysis_times(c, l);
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let detailed = g.prop("advanced/detailedAnalysis").is_some_and(|p| p.value.as_bool());
let n = times.len();
let work = WarpWork {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
size,
detailed,
times,
};
let key = signature(&self.project, comp, l);
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(WarpShared::default());
let thread = if wait {
run_work(work, &shared);
None
} else {
let sh = shared.clone();
Some(std::thread::Builder::new().name("warp-analyze".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
};
self.warp_job = Some(WarpJob { shared, thread, comp, layer, effect, key });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_warp_job();
Ok(n)
}
/// Write a finished analysis into its effect (one undo step) and drop the job.
fn poll_warp_job(&mut self) -> bool {
let Some(job) = &self.warp_job else { return false };
if !job.is_finished() {
return false;
}
let mut job = self.warp_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let result = lock(&job.shared.result).take();
match (result, &st.error) {
(Some(a), None) if !st.cancelled => {
let json = a.to_json();
let (cid, lid, uid, key) = (job.comp, job.layer, job.effect, job.key.clone());
let r = self.edit("Warp Stabilizer Analysis", None, |p, _| {
let g = p
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the Warp Stabilizer was removed during analysis".into()))?;
for (m, v) in [(ANALYSIS, json), (ANALYSIS_KEY, key)] {
if let Some(pr) = g.get_mut(m) {
pr.value = Value::Str(v);
}
}
Ok(())
});
let msg = match r {
Ok(()) => format!("Warp Stabilizer: analysed {} frame(s) in {:.1} s", a.frames.len(), st.elapsed),
Err(e) => format!("Warp Stabilizer: {e}"),
};
self.events.push(Event::Toast { message: msg, error: false });
}
_ => {
let msg = match &st.error {
Some(e) => format!("Warp Stabilizer analysis failed: {e}"),
None => "Warp Stabilizer analysis cancelled".into(),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
self.bump();
}
}
true
}
/// Frontends call this every frame: finishes analyses and (with `auto`) starts queued
/// re-analyses in the background, like After Effects.
pub fn poll_warp(&mut self, auto: bool) -> bool {
let changed = self.poll_warp_job();
if auto && self.warp_job.is_none() {
while let Some((c, l, e)) = self.warp_pending.first().copied() {
self.warp_pending.remove(0);
if self.start_warp(c, l, e, false).is_ok() {
return true;
}
}
}
changed
}
}

View File

@ -33,10 +33,11 @@ pub enum PanelKind {
Flowchart,
History,
Markers,
MaskInterpolation,
}
impl PanelKind {
pub const ALL: [PanelKind; 23] = [
pub const ALL: [PanelKind; 24] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@ -60,6 +61,7 @@ impl PanelKind {
PanelKind::Flowchart,
PanelKind::History,
PanelKind::Markers,
PanelKind::MaskInterpolation,
];
pub fn title(self) -> &'static str {
match self {
@ -86,6 +88,7 @@ impl PanelKind {
PanelKind::Flowchart => "Flowchart",
PanelKind::History => "History",
PanelKind::Markers => "Markers",
PanelKind::MaskInterpolation => "Mask Interpolation",
}
}
pub fn id(self) -> String {

View File

@ -718,6 +718,15 @@ impl EffectcraftApp {
self.session.poll_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
if self.session.mask_job.is_some() {
self.session.poll_mask_track();
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
self.session.poll_warp(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.apply_prefs(&ctx);
self.handle_events(&ctx);
self.tick_autosave(&ctx);

View File

@ -1,4 +1,4 @@
//! Wiggler, Smoother and Motion Sketch panels (Window menu). Their settings live in
//! Wiggler, Smoother, Motion Sketch and Mask Interpolation panels (Window menu). Their settings live in
//! [`crate::state::AnimToolsState`] (serde, so agents can read and set them); Apply runs the
//! engine commands `keys.wiggle`, `keys.smooth` and `motion.sketch`.
//!
@ -294,3 +294,111 @@ pub fn sketch_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
}
}
}
/// Mask Interpolation panel: Keyframe Rate / Fields, Linear Vertex Paths, Bending Resistance,
/// Quality, Add Mask Shape Vertices, Matching Method, 1:1 Vertex Matches, First Vertices Match;
/// Apply runs `mask.interpolate` on the selected Mask Path keyframes. The options live in the
/// editor state (`mask.interpolationOptions`).
pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let x0 = rect.min.x + 12.0;
let xv = x0 + 130.0;
let w = (rect.max.x - xv - 12.0).clamp(80.0, 200.0);
let mut y = rect.min.y + 10.0;
let o = app.session.state.mask_interp.clone();
let mut changes = serde_json::Map::new();
label(&p, x0, y, "Keyframe Rate:", &t);
let auto = o.keyframe_rate.is_none();
if let Some(i) = choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(70.0, 20.0)), "maskInterp.rateMode", &["Auto", "Custom"], usize::from(!auto)) {
let fps = app.session.active_comp().map(|c| c.frame_rate.as_f64()).unwrap_or(30.0);
changes.insert("keyframeRate".into(), if i == 0 { json!("auto") } else { json!(fps) });
}
if let Some(r) = o.keyframe_rate {
let nr = number(app, ui, pos2(xv + 78.0, y), "maskInterp.rate", r, 0.5, (0.1, 1000.0), 1, " per second");
if (nr - r).abs() > 1e-9 {
changes.insert("keyframeRate".into(), json!(nr));
}
} else {
p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim);
}
y += ROW;
let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| {
if checkbox(app, ui, pos2(x0, y + 2.0), &format!("maskInterp.{key}"), text, on) != on {
changes.insert(key.into(), json!(!on));
}
};
check(app, y, "keyframeFields", "Keyframe Fields (doubles rate)", o.keyframe_fields);
y += ROW;
check(app, y, "linearVertexPaths", "Use \"Linear\" Vertex Paths", o.linear_vertex_paths);
y += ROW;
label(&p, x0, y, "Bending Resistance:", &t);
let br = number(app, ui, pos2(xv, y), "maskInterp.bendingResistance", o.bending_resistance, 0.5, (0.0, 100.0), 0, "");
if (br - o.bending_resistance).abs() > 1e-9 {
changes.insert("bendingResistance".into(), json!(br));
}
y += ROW;
label(&p, x0, y, "Quality:", &t);
let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, 0.5, (0.0, 100.0), 0, "");
if (q - o.quality).abs() > 1e-9 {
changes.insert("quality".into(), json!(q));
}
y += ROW;
let add_on = o.add_vertices.is_some();
if checkbox(app, ui, pos2(x0, y + 2.0), "maskInterp.addVerticesOn", "Add Mask Shape Vertices", add_on) != add_on {
changes.insert("addVertices".into(), if add_on { json!(false) } else { json!(5.0) });
}
y += ROW;
if let Some(v) = o.add_vertices {
let nv = number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, 0.2, (0.1, 2000.0), 1, "");
if (nv - v).abs() > 1e-9 {
changes.insert("addVertices".into(), json!(nv));
}
let units = ["pixels", "total", "percent"];
let cur = units.iter().position(|u| *u == o.add_vertices_unit).unwrap_or(0);
if let Some(i) = choice(
app,
ui,
Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)),
"maskInterp.addVerticesUnit",
&["Pixels Between Vertices", "Total Vertices", "Percentage of Outline"],
cur,
) {
changes.insert("addVerticesUnit".into(), json!(units[i]));
}
}
y += ROW;
label(&p, x0, y, "Matching Method:", &t);
let methods = ["auto", "curve", "polyline"];
let cur = methods.iter().position(|m| *m == o.matching_method).unwrap_or(0);
if let Some(i) = choice(app, ui, Rect::from_min_size(pos2(xv, y), vec2(w, 20.0)), "maskInterp.matchingMethod", &["Auto", "Curve", "Polyline"], cur) {
changes.insert("matchingMethod".into(), json!(methods[i]));
}
y += ROW;
let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| {
if checkbox(app, ui, pos2(x0, y + 2.0), &format!("maskInterp.{key}"), text, on) != on {
changes.insert(key.into(), json!(!on));
}
};
check(app, y, "oneToOne", "Use 1:1 Vertex Matches", o.one_to_one);
y += ROW;
check(app, y, "firstVerticesMatch", "First Vertices Match", o.first_vertices_match);
y += ROW + 8.0;
if !changes.is_empty()
&& let Err(e) = app.session.execute("mask.interpolationOptions", serde_json::Value::Object(changes))
{
app.ui.status = e.to_string();
}
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
let enabled = app.session.state.selected_keys.len() >= 2;
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("mi-apply")).clicked() {
match app.session.execute("mask.interpolate", json!({})) {
Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]),
Err(e) => app.ui.status = e.to_string(),
}
}
app.auto.add("maskInterp.apply", b, "Apply");
let msg = if enabled { "Applies to the selected Mask Path keyframes" } else { "Select two or more Mask Path keyframes" };
p.text(pos2(x0, b.max.y + 16.0), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_faint);
let _ = (Color32::WHITE, Sense::hover(), Stroke::NONE);
}

View File

@ -510,6 +510,33 @@ fn group_rows(
}
}
/// Warp Stabilizer's Analyze / Cancel buttons and analysis status.
fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) {
let t = app.tokens;
let running = app.session.warp_target().is_some_and(|(_, l, u)| l == layer.id && u == g.uid) && app.session.is_warp_analyzing();
let busy = app.session.is_warp_analyzing();
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("warp-analyze", g.uid))).clicked() && !busy {
actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("warp-cancel", g.uid))).clicked() {
actions.push(("warp.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel");
}
let analysed = matches!(g.get(effectcraft_engine::effects::warp_stab::ANALYSIS).map(|p| &p.value), Some(Value::Str(s)) if !s.is_empty());
let status = match app.session.warp_progress() {
Some(pr) if running => pr.banner(),
_ if analysed => "Analysis complete".to_string(),
_ if app.session.warp_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => "Waiting to analyze".to_string(),
_ => "Not analyzed: click Analyze".to_string(),
};
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
}
// ---------------------------------------------------------------------------------------------
// Curves / Levels editors
@ -517,6 +544,7 @@ fn group_rows(
fn editor_height(effect: &str, width: f32) -> f32 {
match effect {
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
effectcraft_engine::effects::warp_stab::ID => 50.0,
"ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0,
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0,
_ => 0.0,
@ -891,6 +919,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if er.max.y >= body.min.y && er.min.y <= body.max.y {
match effect.as_str() {
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions),
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
_ => histogram_only(app, ui, &bp, g, er),
}

View File

@ -81,6 +81,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::Audio => misc::audio(app, ui, rect),
PanelKind::History => misc::history(app, ui, rect),
PanelKind::Markers => misc::markers(app, ui, rect),
PanelKind::MaskInterpolation => anim_tools::mask_interpolation(app, ui, rect),
PanelKind::Wiggler => anim_tools::wiggler(app, ui, rect),
PanelKind::Smoother => anim_tools::smoother(app, ui, rect),
PanelKind::MotionSketch => anim_tools::motion_sketch(app, ui, rect),

View File

@ -133,12 +133,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// 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));
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
button(app, ui, r1, "Track Camera", false, "tracker.trackCamera");
button(app, ui, r2, "Warp Stabilizer", false, "tracker.warpStabilizer");
let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
run(app, &ctx, "track.warpStabilizer", json!({}));
}
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());
}
@ -150,6 +153,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Open a composition to track motion.", Tokens::ui(12.0), t.text_faint);
return;
};
// A selected mask switches the panel to mask tracking.
if let Some((ml, mu)) = effectcraft_engine::commands::mask_interp::selected_mask(&app.session) {
mask_mode(app, ui, &ctx, &p, rect, y, ml, mu);
return;
}
let field_x = x0 + 100.0;
let field_w = (w - 100.0).max(80.0);
@ -334,6 +342,84 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The Tracker panel in mask mode: Method and the four track buttons (◀| ◀ ▶ |▶).
#[allow(clippy::too_many_arguments)]
fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p: &egui::Painter, rect: Rect, mut y: f32, layer: LayerId, mask: Uid) {
use effectcraft_engine::mask_track::MaskMethod;
let t = app.tokens;
let x0 = rect.min.x + 10.0;
let w = (rect.width() - 20.0).max(120.0);
let field_x = x0 + 100.0;
let field_w = (w - 100.0).max(80.0);
let name = app
.session
.active_comp()
.and_then(|c| c.layer(layer))
.and_then(|l| l.props.find_group(mask).map(|g| format!("{} ({})", g.name, l.name)))
.unwrap_or_default();
label(p, pos2(x0, y + 10.0), "Track Mask:", &t);
p.text(pos2(field_x, y + 10.0), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
y += ROW;
label(p, pos2(x0, y + 10.0), "Method:", &t);
let method = app.session.state.mask_track_method;
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
let did = egui::Id::new("tracker-mask-method-dd");
if widgets::dropdown(ui, dd, method.label(), &t, did).clicked() {
widgets::open_popup(ui, did);
}
app.auto.add("tracker.mask.method", dd, "Method");
let labels: Vec<String> = MaskMethod::ALL.iter().map(|m| m.label().to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, MaskMethod::ALL.iter().position(|m| *m == method)) {
run(app, ctx, "track.maskMethod", json!({"method": MaskMethod::ALL[i].id()}));
}
y += ROW + 6.0;
label(p, pos2(x0, y + 11.0), "Track:", &t);
let running = app.session.is_mask_tracking();
let running_dir = app.session.mask_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", "Track selected masks 1 frame backward"),
(effectcraft_engine::tracking::Direction::Backward, false, false, "backward", "Track selected masks backward"),
(effectcraft_engine::tracking::Direction::Forward, true, false, "forward", "Track selected masks forward"),
(effectcraft_engine::tracking::Direction::FrameForward, true, true, "frameForward", "Track selected masks 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-mask-track", id)), Sense::click()).on_hover_text(tip);
let stop = running && running_dir == Some(dir) && !frame;
let on = !running || 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.mask.track.{id}"), r, tip);
if resp.clicked() && on {
if stop {
run(app, ctx, "track.stop", json!({}));
} else {
run(app, ctx, "track.mask", json!({"layer": layer.0, "mask": mask, "direction": id}));
}
}
bx += 34.0;
}
y += 34.0;
if let Some(pr) = app.session.mask_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.mask.progress", bar, "Mask tracking progress");
p.text(
pos2(x0, y + 18.0),
Align2::LEFT_CENTER,
format!("Tracking {} / {} frames ({:.0} fps)", pr.done, pr.total, pr.fps),
Tokens::ui(11.0),
t.text_dim,
);
ctx.request_repaint_after(std::time::Duration::from_millis(50));
} else {
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Tracks the pixels inside the mask and keys its Mask Path.", Tokens::ui(11.0), t.text_faint);
}
}
// ---------------------------------------------------------------- dialogs
pub fn open_options(app: &mut EffectcraftApp) -> Result<(), String> {

View File

@ -562,6 +562,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
}
// Warp Stabilizer banner over the layer being analysed ("Analyzing in background (step 1
// of 2)…" / "Stabilizing…").
if let Some((wc, wl, _)) = app.session.warp_target()
&& wc == cid
&& let Some(pr) = app.session.warp_progress()
&& let Some(layer) = comp.layer(wl)
&& layer.is_active_at(time)
{
let (m, _) = l2c(&ectx, layer);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
let at = map.to_screen([c.x, c.y]);
let text = pr.banner();
let galley = painter.layout_no_wrap(text.clone(), Tokens::medium(13.0), Color32::WHITE);
let r = Rect::from_center_size(at, galley.size() + vec2(28.0, 14.0)).intersect(map.area);
painter.rect_filled(r, 3.0, Color32::from_rgba_unmultiplied(0x1d, 0x4f, 0x9c, 0xe0));
painter.galley(pos2(r.center().x - galley.size().x / 2.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
app.auto.add("viewer.warpBanner", r, &text);
}
// Free transform box of a path (double-click a path with the Selection tool).
let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);

View File

@ -100,6 +100,52 @@ effectcraft-cli run clip.ecproj track.motion '{"layer":"#2"}' \
track.analyze '{"wait":true}' track.apply '{}' --save
```
### Mask tracking and Mask Interpolation
`track.mask` follows the pixels inside a mask and keys its Mask Path on every frame (vertices and
tangents move with the fitted motion). `method` is `position`, `positionScale`,
`positionScaleRotation` (the default, also kept as the Tracker panel's Method), `positionScaleRotationSkew`
or `perspective`; `direction` is `forward|backward|frameForward|frameBackward` from the current
time (or `start` / `end` in seconds). Like `track.analyze` it runs in the background in the app
(progress in `track.status` under `mask`, `track.stop` cancels and keeps what was tracked) and is
one undo step; `"wait": true` blocks.
`mask.interpolate` (Window ▸ Mask Interpolation ▸ Apply) adds in-between Mask Path keys between
each pair of selected Mask Path keys (or the existing keys at `times`, in seconds), giving both
ends the same vertex count with a matched correspondence. Options: `keyframeRate` (number or
`"auto"` = the comp rate), `keyframeFields`, `linearVertexPaths`, `bendingResistance`, `quality`,
`addVertices` (number or `false`) with `addVerticesUnit` `pixels|total|percent`, `matchingMethod`
`auto|curve|polyline`, `oneToOne`, `firstVerticesMatch`. `mask.interpolationOptions` sets the
panel's defaults.
```sh
effectcraft-cli exec track.mask '{"layer":"#2","mask":1,"method":"perspective","wait":true}' clip.ecproj --save
effectcraft-cli exec mask.interpolate '{"layer":"#2","mask":"Mask 1","times":[0,2],"addVertices":10}' clip.ecproj --save --json
```
### Warp Stabilizer
Effect ▸ Distort ▸ Warp Stabilizer (`effect.apply {"effect":"Warp Stabilizer"}`), Animation ▸ Warp
Stabilizer VFX and the Tracker panel's button (`track.warpStabilizer`, which applies the effect and
starts analysing) stabilize a layer. `warp.analyze {layer?, effect?, wait?}` analyses every frame
between the layer's In and Out points in the background ("Analyzing in background (step 1 of 2)",
then "Stabilizing..."; `warp.status` reports `progress`, `banner`, whether the effect is
`analyzed`, and the current frame's `warp` matrix, `autoScale` and `crop`); `warp.cancel` stops it
without writing anything. The analysis is stored in the effect (saved with the project) and is
one undo step; trimming, slipping, time-remapping or replacing the layer's source clears it and
the app re-analyses automatically (headless: run `warp.analyze` again). Settings are ordinary
properties under the instance's groups, e.g. `effects/#1/stabilization/result` (0 Smooth Motion,
1 No Motion), `effects/#1/stabilization/smoothness`, `effects/#1/stabilization/method`,
`effects/#1/borders/framing` (0 Stabilize Only … 3 Stabilize, Synthesize Edges),
`effects/#1/borders/autoScale/maximumScale`, `effects/#1/advanced/showTrackPoints`.
```sh
effectcraft-cli run shaky.ecproj track.warpStabilizer '{"layer":"#1","wait":true}' \
prop.set '{"layer":"#1","path":"effects/#1/stabilization/result","value":1}' --save
effectcraft-cli exec warp.analyze '{"layer":"#1","wait":true}' shaky.ecproj --save --json
effectcraft-cli exec warp.status '{"layer":"#1"}' shaky.ecproj --json
```
### Text: styles and editing
Source Text holds character style runs and per-paragraph settings. `layer.setText` changes the

View File

@ -35,7 +35,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L5 | `automation` | The MCP server, headless or bridged to the running app |
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`,
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`,
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`.
## 2. Time
@ -158,6 +158,24 @@ queue; blocking with `wait`), and `effectcraft-track` matches each point in para
covers an analysis. `track.apply` keys the target's Position/Rotation/Scale, the tracked layer's
Anchor Point and Position (Stabilize), or a Corner Pin effect (Parallel / Perspective).
**Mask tracking** (`track.mask`, `engine::mask_track`) runs the same kind of background job over
the layer's source frames: `effectcraft-track`'s mask tracker detects features inside the mask,
tracks them with pyramidal Lucas–Kanade and fits the chosen model (position … perspective) with
RANSAC; the motion moves the Mask Path's vertices and tangents and is keyed per frame. **Mask
Interpolation** (`mask.interpolate`) uses `effectcraft-path`'s smart interpolation (arc-length
vertex insertion, shape-context matching, rigid in-betweens) to key in-between shapes.
**Warp Stabilizer** (`effects::warp_stab`, `engine::warp`): `warp.analyze` renders the layer's
input to the effect (`Renderer::layer_input`: source, masks and the effects above it) for every
frame on a background thread and stores `effectcraft-track`'s `WarpAnalysis` (per-frame
translation / similarity / homography fits) as JSON in the effect's hidden Analysis parameter,
with a key of the layer's source, In/Out, start, stretch and Time Remap. `Session::edit` clears
analyses whose key no longer matches and queues them; the desktop app re-analyses them in the
background. Rendering derives the stabilization plan (smoothed camera path or No Motion, framing,
auto-scale; cached per analysis and settings) and warps each frame; Synthesize Edges fills the
borders from neighbouring frames read with `EffectHost::self_at`. The effect lives in the
effects crate, hence the `effects → track` edge.
## 5. Expressions
Expressions are JavaScript, run by boa, with After Effects' object model (`thisComp`, `thisLayer`,

View File

@ -97,15 +97,15 @@ wasm threads.
| Effects | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more effects, 41 missing effects |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points |
| Masks & roto | 52% | 10.0 | Roto Brush, variable-width mask feather points (mask tracking and Mask Interpolation landed in M6.6) |
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking |
| Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking, Subspace Warp's mesh warp and rolling-shutter repair (point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
## Effects still missing
Immersive Video (all 12), 3D Channel (all 8, including Cryptomatte), Color Stabilizer and the five
OCIO transforms, CC Flo Motion, Liquify, Rolling Shutter Repair, Warp Stabilizer, Compressor,
OCIO transforms, CC Flo Motion, Liquify, Rolling Shutter Repair, Compressor,
Distortion and Gate (audio), Camera-Shake Deblur, CC Radial Blur, CC Hair, Particle Playground,
Keylight (our Screen Key stands in), Color Profile Converter and the 3D Camera Tracker. Boris FX
Mocha and Cineware are third-party and not counted.

View File

@ -55,6 +55,7 @@ const SAME_LAYER: &[(&str, &str)] = &[
("effects", "project"),
("effects", "text"),
("effects", "path"),
("effects", "track"),
("media", "render"),
("expr", "render"),
("export", "render"),