diff --git a/Cargo.lock b/Cargo.lock index 635fb0f5..46f34e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,6 +1182,7 @@ dependencies = [ "effectcraft-keyframe", "effectcraft-project", "effectcraft-raster", + "effectcraft-track", "rayon", "serde_json", ] diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index db74bf27..e321fa2f 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -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] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 2e29061a..6d11e19a 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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`]. diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs new file mode 100644 index 00000000..19171051 --- /dev/null +++ b/crates/effects/src/warp_stab.rs @@ -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 { + 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>>; +type PlanCache = Mutex>>; + +/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate). +fn analysis_cached(json: &str) -> Option<(u64, Arc)> { + static C: OnceLock = 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 { + static C: OnceLock = 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> { + 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, Arc)> { + 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 = (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); + } +} diff --git a/crates/engine/src/commands/mask_interp.rs b/crates/engine/src/commands/mask_interp.rs new file mode 100644 index 00000000..5df40c6d --- /dev/null +++ b/crates/engine/src/commands/mask_interp.rs @@ -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 { + 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 { + 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> { + 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 { + 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 { + 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, + /// 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, + 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 { + 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 { + 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 = match p.get("times").and_then(Value::as_array) { + Some(a) => { + let ts: Vec = 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 = 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})) +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 26db85c9..4537752f 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index f7868d9c..f670ffd7 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -60,8 +60,6 @@ pub fn specs() -> Vec { 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}"), ] diff --git a/crates/engine/src/commands/track.rs b/crates/engine/src/commands/track.rs index 2588c061..928bf63b 100644 --- a/crates/engine/src/commands/track.rs +++ b/crates/engine/src/commands/track.rs @@ -69,7 +69,7 @@ pub fn specs() -> Vec { 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 { 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 { })) } +/// 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, end: Option) -> Vec { + 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 { 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 { fn status(s: &mut Session, p: &V) -> Result { 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) diff --git a/crates/engine/src/commands/warp_cmds.rs b/crates/engine/src/commands/warp_cmds.rs new file mode 100644 index 00000000..14f0ee4b --- /dev/null +++ b/crates/engine/src/commands/warp_cmds.rs @@ -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 { + 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 = 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 = 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 { + 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 { + 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 { + 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(¶ms); + 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(¶ms, 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) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f64ce817..ba34488a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// The running (or finished, not yet polled) track analysis. pub track_job: Option, + /// The running (or finished, not yet polled) mask track. + pub mask_job: Option, + /// The running (or finished, not yet polled) Warp Stabilizer analysis. + pub warp_job: Option, + /// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid). + pub warp_pending: Vec<(ItemId, LayerId, Uid)>, pub events: Vec, /// 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; diff --git a/crates/engine/src/mask_track.rs b/crates/engine/src/mask_track.rs new file mode 100644 index 00000000..1cdeda1e --- /dev/null +++ b/crates/engine/src/mask_track.rs @@ -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 { + let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().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::().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, + frames: Mutex>, +} + +/// A running (or finished, not yet polled) mask track. +pub struct MaskTrackJob { + pub shared: Arc, + thread: Option>, + 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, + pub footage: Arc, + pub expr: Option>, + pub cache: Arc, + 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, +} + +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 { + 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); + } + } +} diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index a935ebc0..38252a88 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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"} diff --git a/crates/engine/src/tests_mask_warp.rs b/crates/engine/src/tests_mask_warp.rs new file mode 100644 index 00000000..7f915e42 --- /dev/null +++ b/crates/engine/src/tests_mask_warp.rs @@ -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 = 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; +} diff --git a/crates/engine/src/tests_track.rs b/crates/engine/src/tests_track.rs index 7ea69cfa..adcf7df0 100644 --- a/crates/engine/src/tests_track.rs +++ b/crates/engine/src/tests_track.rs @@ -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 Image + Send + Sync>, cache: Mutex>>, } @@ -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 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) } diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/tracking.rs index 43e24cb8..c406edb9 100644 --- a/crates/engine/src/tracking.rs +++ b/crates/engine/src/tracking.rs @@ -155,7 +155,7 @@ pub struct TrackShared { pub frames: Mutex>, } -fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { +pub(crate) fn lock(m: &Mutex) -> 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, diff --git a/crates/engine/src/warp.rs b/crates/engine/src/warp.rs new file mode 100644 index 00000000..0b2dd03d --- /dev/null +++ b/crates/engine/src/warp.rs @@ -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, +} + +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, + result: Mutex>, +} + +/// A running (or finished, not yet polled) Warp Stabilizer analysis. +pub struct WarpJob { + pub shared: Arc, + thread: Option>, + 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, + pub footage: Arc, + pub expr: Option>, + pub cache: Arc, + 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, +} + +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, [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 { + 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 { + 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 { + 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 + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5bb9c8b6..99bc7c5e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65880e33..071c1948 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index aa4b6368..5da48415 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -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); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 78facbcc..0a19d57f 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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), } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 39cf4c5c..3f737502 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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), diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index ad84665b..d1f456b2 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -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 = 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> { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index e82c4d55..d847edb8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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); diff --git a/docs/agents.md b/docs/agents.md index fd2cc8ea..510bc356 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index c3d478e3..033ed6cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`, diff --git a/docs/parity.md b/docs/parity.md index af0c9420..d259fec2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae38adf9..76ff6fa3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -55,6 +55,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("effects", "project"), ("effects", "text"), ("effects", "path"), + ("effects", "track"), ("media", "render"), ("expr", "render"), ("export", "render"),