M6.6: mask tracking + mask interpolation; M12.5: Warp Stabilizer
Mask tracking (track.mask, background job with progress/cancel, one undo step, Mask Path keys; Tracker panel mask mode; Layer > Mask > Track Mask), Mask Interpolation panel + mask.interpolate, and the Warp Stabilizer effect (Effect > Distort, AE parameter groups, warp.analyze/cancel/status, analysis stored in the effect, invalidation on source/in/out/remap edits, viewer banner, crop/auto-scale/synthesize edges, track points). Adds the effects -> track same-layer edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4bcf1f447c
commit
9645ff9a66
|
|
@ -1182,6 +1182,7 @@ dependencies = [
|
|||
"effectcraft-keyframe",
|
||||
"effectcraft-project",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-track",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -0,0 +1,493 @@
|
|||
//! Warp Stabilizer (Effect ▸ Distort ▸ Warp Stabilizer).
|
||||
//!
|
||||
//! The analysis (feature tracking and per-frame motion fits) runs as a background job in the
|
||||
//! engine (`warp.analyze`) and is stored, as JSON, in the instance's hidden **Analysis**
|
||||
//! parameter ([`effectcraft_track::stabilize::WarpAnalysis`]); **Analysis Key** records what
|
||||
//! the analysis was made from (the layer's source, in/out points and time mapping) so edits that
|
||||
//! change the frames invalidate it. Rendering derives the stabilization plan from the analysis
|
||||
//! and the Stabilization / Borders / Advanced settings ([`effectcraft_track::stabilize::plan`],
|
||||
//! cached per analysis and settings) and warps each frame by its corrective transform:
|
||||
//!
|
||||
//! - **Stabilize Only** shows the moving frame edges, **Stabilize, Crop** crops to the region
|
||||
//! valid on every frame, **Stabilize, Crop, Auto-scale** scales that region back up to the
|
||||
//! frame (up to Maximum Scale, minus the Action-safe Margin), Additional Scale scales on top;
|
||||
//! - **Stabilize, Synthesize Edges** fills the uncovered borders from neighbouring frames
|
||||
//! (within Synthesize Input Range), mapped through the analysed frame-to-frame motion and read
|
||||
//! with [`crate::EffectHost::self_at`]; Synthesize Edge Feather blends the seam and Synthesize
|
||||
//! Edge Cropping ignores bad edge pixels of the input frames;
|
||||
//! - **Show Track Points** draws the analysed background features.
|
||||
//!
|
||||
//! *Subspace Warp* is approximated by a per-frame perspective warp (no content-preserving
|
||||
//! mesh warp yet), and *Rolling Shutter Ripple* is stored but has no effect.
|
||||
//!
|
||||
//! Without an analysis (or with one made for another layer size) the input passes through.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hasher;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use effectcraft_track::Homography;
|
||||
use effectcraft_track::stabilize::{Framing, Method, Plan, StabResult, StabSettings, WarpAnalysis, plan};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
|
||||
|
||||
/// The effect's id.
|
||||
pub const ID: &str = "ec.distort.warpstabilizer";
|
||||
|
||||
/// Hidden parameters: the stored analysis (JSON) and its key (see the module docs).
|
||||
pub const ANALYSIS: &str = "analysis";
|
||||
pub const ANALYSIS_KEY: &str = "analysisKey";
|
||||
|
||||
/// Display names of the instance's parameter groups (by match id).
|
||||
pub const GROUPS: &[(&str, &str)] = &[
|
||||
("stabilization", "Stabilization"),
|
||||
("borders", "Borders"),
|
||||
("autoScale", "Auto-scale"),
|
||||
("advanced", "Advanced"),
|
||||
("edgeCropping", "Synthesize Edge Cropping"),
|
||||
];
|
||||
|
||||
pub const RESULTS: [&str; 2] = ["Smooth Motion", "No Motion"];
|
||||
pub const METHODS: [&str; 4] = ["Position", "Position, Scale, Rotation", "Perspective", "Subspace Warp"];
|
||||
pub const FRAMINGS: [&str; 4] = ["Stabilize Only", "Stabilize, Crop", "Stabilize, Crop, Auto-scale", "Stabilize, Synthesize Edges"];
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![EffectSpec {
|
||||
id: ID,
|
||||
name: "Warp Stabilizer",
|
||||
category: "Distort",
|
||||
params: vec![
|
||||
p("stabilization/result", "Result", Value::Enum(0), popup(&RESULTS)),
|
||||
p("stabilization/smoothness", "Smoothness", num(50.0), slider(0.0, 1000.0, 0.0, 100.0, 0)),
|
||||
p("stabilization/method", "Method", Value::Enum(3), popup(&METHODS)),
|
||||
p("stabilization/preserveScale", "Preserve Scale", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("borders/framing", "Framing", Value::Enum(2), popup(&FRAMINGS)),
|
||||
p("borders/autoScale/maximumScale", "Maximum Scale", num(150.0), slider(100.0, 1000.0, 100.0, 200.0, 0)),
|
||||
p("borders/autoScale/actionSafeMargin", "Action-safe Margin", num(0.0), slider(0.0, 45.0, 0.0, 20.0, 0)),
|
||||
p("borders/additionalScale", "Additional Scale", num(100.0), slider(1.0, 1000.0, 50.0, 150.0, 0)),
|
||||
p("advanced/detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("advanced/rollingShutterRipple", "Rolling Shutter Ripple", Value::Enum(0), popup(&["Automatic Reduction", "Enhanced Reduction"])),
|
||||
p("advanced/cropLessSmoothMore", "Crop Less <-> Smooth More", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||||
p("advanced/synthesizeInputRange", "Synthesize Input Range (seconds)", num(1.0), slider(0.0, 10.0, 0.0, 5.0, 2)),
|
||||
p("advanced/synthesizeEdgeFeather", "Synthesize Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||||
p("advanced/edgeCropping/left", "Left", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
|
||||
p("advanced/edgeCropping/top", "Top", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
|
||||
p("advanced/edgeCropping/right", "Right", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
|
||||
p("advanced/edgeCropping/bottom", "Bottom", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 0)),
|
||||
p("advanced/hideWarningBanner", "Hide Warning Banner", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("advanced/showTrackPoints", "Show Track Points", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("advanced/trackPointSize", "Track Point Size", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 0)),
|
||||
p(ANALYSIS, "Analysis", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p(ANALYSIS_KEY, "Analysis Key", Value::Str(String::new()), ParamUi::Hidden),
|
||||
],
|
||||
render,
|
||||
gpu: false,
|
||||
float: true,
|
||||
}]
|
||||
}
|
||||
|
||||
/// A parameter by its spec id (`stabilization/smoothness`), from either flattened instance
|
||||
/// params (nested groups become `#n/` prefixes, see [`crate::flatten_params`]) or plain ids.
|
||||
pub fn param<'a>(params: &'a Params, id: &str) -> Option<&'a Value> {
|
||||
if let Some(v) = params.get(id) {
|
||||
return Some(v);
|
||||
}
|
||||
let mut pre = String::new();
|
||||
let segs: Vec<&str> = id.split('/').collect();
|
||||
let (last, groups) = segs.split_last()?;
|
||||
for g in groups {
|
||||
pre = params.group(&pre, g)?;
|
||||
}
|
||||
params.get(&format!("{pre}{last}"))
|
||||
}
|
||||
|
||||
fn f(params: &Params, id: &str) -> f64 {
|
||||
param(params, id).map(Value::as_f64).unwrap_or(0.0)
|
||||
}
|
||||
fn e(params: &Params, id: &str) -> u32 {
|
||||
param(params, id).map(Value::as_enum).unwrap_or(0)
|
||||
}
|
||||
fn b(params: &Params, id: &str) -> bool {
|
||||
param(params, id).map(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
fn s<'a>(params: &'a Params, id: &str) -> &'a str {
|
||||
match param(params, id) {
|
||||
Some(Value::Str(s)) => s,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stabilization settings from the instance's parameters (`fps`: the analysis' frame rate).
|
||||
pub fn settings(params: &Params, fps: f64) -> StabSettings {
|
||||
StabSettings {
|
||||
result: if e(params, "stabilization/result") == 1 { StabResult::NoMotion } else { StabResult::SmoothMotion },
|
||||
smoothness: f(params, "stabilization/smoothness"),
|
||||
method: match e(params, "stabilization/method") {
|
||||
0 => Method::Position,
|
||||
1 => Method::Similarity,
|
||||
2 => Method::Perspective,
|
||||
_ => Method::SubspaceWarp,
|
||||
},
|
||||
preserve_scale: b(params, "stabilization/preserveScale"),
|
||||
framing: match e(params, "borders/framing") {
|
||||
0 => Framing::StabilizeOnly,
|
||||
1 => Framing::StabilizeCrop,
|
||||
3 => Framing::SynthesizeEdges,
|
||||
_ => Framing::StabilizeCropAutoScale,
|
||||
},
|
||||
max_scale: f(params, "borders/autoScale/maximumScale"),
|
||||
action_safe: f(params, "borders/autoScale/actionSafeMargin"),
|
||||
additional_scale: f(params, "borders/additionalScale"),
|
||||
// AE's 0…100 slider (50 = balanced) onto the planner's −100…100.
|
||||
crop_less_smooth_more: (f(params, "advanced/cropLessSmoothMore") - 50.0) * 2.0,
|
||||
fps,
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
struct KeyHasher(u64);
|
||||
impl Hasher for KeyHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for b in bytes {
|
||||
self.0 ^= *b as u64;
|
||||
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_key(s: &StabSettings) -> u64 {
|
||||
let mut h = KeyHasher(0x9e37_79b9_7f4a_7c15);
|
||||
for v in [s.smoothness, s.max_scale, s.action_safe, s.additional_scale, s.crop_less_smooth_more, s.fps] {
|
||||
h.write_u64(v.to_bits());
|
||||
}
|
||||
h.write(format!("{:?}{:?}{:?}{:?}", s.result, s.method, s.preserve_scale, s.framing).as_bytes());
|
||||
h.finish()
|
||||
}
|
||||
|
||||
type AnalysisCache = Mutex<HashMap<u64, Arc<WarpAnalysis>>>;
|
||||
type PlanCache = Mutex<HashMap<(u64, u64), Arc<Plan>>>;
|
||||
|
||||
/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate).
|
||||
fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
|
||||
static C: OnceLock<AnalysisCache> = OnceLock::new();
|
||||
let key = fnv(json.as_bytes());
|
||||
let c = C.get_or_init(Default::default);
|
||||
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return Some((key, a.clone()));
|
||||
}
|
||||
let a = Arc::new(WarpAnalysis::from_json(json)?);
|
||||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, a.clone());
|
||||
Some((key, a))
|
||||
}
|
||||
|
||||
fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
|
||||
static C: OnceLock<PlanCache> = OnceLock::new();
|
||||
let key = (akey, settings_key(s));
|
||||
let c = C.get_or_init(Default::default);
|
||||
if let Some(p) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let p = Arc::new(plan(a, s));
|
||||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if m.len() > 32 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
|
||||
/// The stored analysis of an instance (`None` when not analysed).
|
||||
pub fn analysis(params: &Params) -> Option<Arc<WarpAnalysis>> {
|
||||
let j = s(params, ANALYSIS);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
analysis_cached(j).map(|(_, a)| a).filter(|a| !a.is_empty())
|
||||
}
|
||||
|
||||
/// The stabilization of an instance at layer time `time`: (frame index, plan).
|
||||
pub fn plan_at(params: &Params, time: f64) -> Option<(usize, Arc<WarpAnalysis>, Arc<Plan>)> {
|
||||
let j = s(params, ANALYSIS);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (key, a) = analysis_cached(j)?;
|
||||
let k = a.frame_at(time)?;
|
||||
let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
|
||||
let pl = plan_cached(key, &a, &settings(params, fps));
|
||||
(k < pl.warps.len()).then_some((k, a, pl))
|
||||
}
|
||||
|
||||
/// Motion from frame `k` to frame `j` (scene points of frame k → frame j), from the analysed
|
||||
/// frame-to-frame homographies.
|
||||
pub fn motion_between(a: &WarpAnalysis, k: usize, j: usize) -> Homography {
|
||||
let mut m = Homography::IDENTITY;
|
||||
if j > k {
|
||||
for i in k + 1..=j {
|
||||
m = a.frames[i].motion(Method::Perspective).then_after(&m);
|
||||
}
|
||||
} else {
|
||||
for i in (j + 1..=k).rev() {
|
||||
let inv = a.frames[i].motion(Method::Perspective).inverse().unwrap_or(Homography::IDENTITY);
|
||||
m = inv.then_after(&m);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn inside(r: &[f64; 4], p: [f64; 2]) -> bool {
|
||||
p[0] >= r[0] && p[0] <= r[2] && p[1] >= r[1] && p[1] <= r[3]
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest edge of `r` (positive inside).
|
||||
fn inset(r: &[f64; 4], p: [f64; 2]) -> f64 {
|
||||
(p[0] - r[0]).min(r[2] - p[0]).min(p[1] - r[1]).min(r[3] - p[1])
|
||||
}
|
||||
|
||||
fn lerp_px(a: Px, b: Px, t: f32) -> Px {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
|
||||
}
|
||||
|
||||
fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
|
||||
let Some((k, a, pl)) = plan_at(ctx.params, ctx.time) else { return buf };
|
||||
let size = a.size;
|
||||
if (size[0] - ctx.layer_size[0]).abs() > 0.5 || (size[1] - ctx.layer_size[1]).abs() > 0.5 || ctx.adjustment {
|
||||
return buf;
|
||||
}
|
||||
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
|
||||
let Some(inv) = pl.warps[k].inverse() else { return buf };
|
||||
let (ow, oh) = (((size[0] * sc).ceil() as u32).max(1), ((size[1] * sc).ceil() as u32).max(1));
|
||||
let synth = matches!(settings(ctx.params, 30.0).framing, Framing::SynthesizeEdges);
|
||||
let crop = [
|
||||
f(ctx.params, "advanced/edgeCropping/left").max(0.0),
|
||||
f(ctx.params, "advanced/edgeCropping/top").max(0.0),
|
||||
size[0] - f(ctx.params, "advanced/edgeCropping/right").max(0.0),
|
||||
size[1] - f(ctx.params, "advanced/edgeCropping/bottom").max(0.0),
|
||||
];
|
||||
let valid = if synth { crop } else { [0.0, 0.0, size[0], size[1]] };
|
||||
let feather = if synth { f(ctx.params, "advanced/synthesizeEdgeFeather").max(0.0) } else { 0.0 };
|
||||
let src = &buf;
|
||||
// Inside the frame, edge pixels extend to the frame's edge; outside it is transparent.
|
||||
let sample = |b: &Buf, q: [f64; 2]| {
|
||||
if q[0] < 0.0 || q[1] < 0.0 || q[0] > size[0] || q[1] > size[1] {
|
||||
return [0.0; 4];
|
||||
}
|
||||
b.img.sample_bilinear_clamped(q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1])
|
||||
};
|
||||
let mut out = Image::new(ow, oh);
|
||||
// Source position (frame k layer pixels) of every output pixel, and how much of the pixel
|
||||
// still needs synthesizing (0 = none).
|
||||
let need_m = Mutex::new(if synth { vec![0.0f32; (ow * oh) as usize] } else { vec![] });
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
let mut local = vec![];
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let p = [(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc];
|
||||
if let Some(c) = &pl.crop
|
||||
&& !inside(c, p)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let q = inv.apply(p);
|
||||
if !synth {
|
||||
*px = sample(src, q);
|
||||
continue;
|
||||
}
|
||||
let d = inset(&valid, q);
|
||||
if d >= feather.max(0.5) {
|
||||
*px = sample(src, q);
|
||||
} else {
|
||||
let w = if d <= 0.0 {
|
||||
1.0
|
||||
} else if feather > 0.0 {
|
||||
(1.0 - d / feather) as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
if d > 0.0 {
|
||||
*px = sample(src, q);
|
||||
}
|
||||
local.push((x, w));
|
||||
}
|
||||
}
|
||||
if !local.is_empty() {
|
||||
let mut n = need_m.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (x, w) in local {
|
||||
n[y * ow as usize + x] = w;
|
||||
}
|
||||
}
|
||||
});
|
||||
let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
if synth && need.iter().any(|w| *w > 0.0) {
|
||||
synthesize(ctx, &a, k, &inv, &crop, sc, &mut out, &need);
|
||||
}
|
||||
if b(ctx.params, "advanced/showTrackPoints") {
|
||||
let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5);
|
||||
for q in &a.frames[k].points {
|
||||
let o = pl.warps[k].apply([q[0] as f64, q[1] as f64]);
|
||||
dot(&mut out, [o[0] * sc, o[1] * sc], r, [0.25, 0.75, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
Buf { img: out, offset: [0.0; 2], scale: sc }
|
||||
}
|
||||
|
||||
/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from
|
||||
/// neighbouring frames, nearest first.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, inv: &Homography, crop: &[f64; 4], sc: f64, out: &mut Image, need: &[f32]) {
|
||||
let Some(host) = ctx.env.host else { return };
|
||||
let n = a.frames.len();
|
||||
let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60);
|
||||
let ow = out.width as usize;
|
||||
let mut filled = vec![false; need.len()];
|
||||
let todo: Vec<usize> = (0..need.len()).filter(|i| need[*i] > 0.0).collect();
|
||||
let mut left = todo.len();
|
||||
for d in 1..=range {
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
for j in [k as i64 + d as i64, k as i64 - d as i64] {
|
||||
if j < 0 || j as usize >= n || left == 0 {
|
||||
continue;
|
||||
}
|
||||
let j = j as usize;
|
||||
let Some(nb) = host.self_at(a.start + j as f64 * a.frame_duration, ctx.env.effect_index) else { continue };
|
||||
let m = motion_between(a, k, j);
|
||||
let hits: Vec<(usize, Px)> = todo
|
||||
.par_iter()
|
||||
.filter(|i| !filled[**i])
|
||||
.filter_map(|i| {
|
||||
let (x, y) = (i % ow, i / ow);
|
||||
let q = inv.apply([(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc]);
|
||||
let qj = m.apply(q);
|
||||
inside(crop, qj).then(|| (*i, nb.img.sample_bilinear_clamped(qj[0] * nb.scale + nb.offset[0], qj[1] * nb.scale + nb.offset[1])))
|
||||
})
|
||||
.collect();
|
||||
for (i, px) in hits {
|
||||
filled[i] = true;
|
||||
left -= 1;
|
||||
out.data[i] = lerp_px(out.data[i], px, need[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
|
||||
let (x0, x1) = ((c[0] - r - 1.0).floor().max(0.0) as i64, (c[0] + r + 1.0).ceil() as i64);
|
||||
let (y0, y1) = ((c[1] - r - 1.0).floor().max(0.0) as i64, (c[1] + r + 1.0).ceil() as i64);
|
||||
for y in y0..y1.min(img.height as i64) {
|
||||
for x in x0..x1.min(img.width as i64) {
|
||||
let d = ((x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1]) - r).clamp(-0.5, 0.5);
|
||||
let cov = (0.5 - d) as f32;
|
||||
if cov > 0.0 {
|
||||
let i = img.idx(x as u32, y as u32);
|
||||
img.data[i] = lerp_px(img.data[i], col, cov);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EffectEnv;
|
||||
use effectcraft_track::stabilize::FrameMotion;
|
||||
|
||||
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
|
||||
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 25.0, size: [w, h], detailed: false, frames: vec![] };
|
||||
for k in 0..n {
|
||||
let mut fm = FrameMotion::default();
|
||||
if k > 0 {
|
||||
let (p, q) = (shift(k - 1), shift(k));
|
||||
let d = [q[0] - p[0], q[1] - p[1]];
|
||||
fm.t = d;
|
||||
fm.s = [1.0, 0.0, d[0], d[1]];
|
||||
fm.h = [1.0, 0.0, d[0], 0.0, 1.0, d[1], 0.0, 0.0];
|
||||
}
|
||||
a.frames.push(fm);
|
||||
}
|
||||
a.to_json()
|
||||
}
|
||||
|
||||
fn params(json: &str, framing: u32, result: u32) -> Params {
|
||||
let s = &specs()[0];
|
||||
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
p.values.insert(ANALYSIS.into(), Value::Str(json.into()));
|
||||
p.values.insert("borders/framing".into(), Value::Enum(framing));
|
||||
p.values.insert("stabilization/result".into(), Value::Enum(result));
|
||||
p
|
||||
}
|
||||
|
||||
/// A white dot at `c` on black.
|
||||
fn frame(c: [f64; 2]) -> Image {
|
||||
let mut im = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]);
|
||||
dot(&mut im, c, 3.0, [1.0; 4]);
|
||||
im
|
||||
}
|
||||
|
||||
fn centroid(im: &Image) -> [f64; 2] {
|
||||
let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0);
|
||||
for y in 0..im.height {
|
||||
for x in 0..im.width {
|
||||
let v = im.get(x as i64, y as i64)[0] as f64;
|
||||
sx += v * (x as f64 + 0.5);
|
||||
sy += v * (y as f64 + 0.5);
|
||||
sw += v;
|
||||
}
|
||||
}
|
||||
[sx / sw, sy / sw]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_motion_locks_the_scene_and_unanalysed_passes_through() {
|
||||
let shift = |k: usize| [((k * 7) % 5) as f64 - 2.0, ((k * 3) % 4) as f64 - 1.5];
|
||||
let json = analysis_json(10, 64.0, 48.0, shift);
|
||||
let ps = params(&json, 0, 1);
|
||||
let mut cs = vec![];
|
||||
for k in 0..10 {
|
||||
let d = shift(k);
|
||||
let img = frame([32.0 + d[0], 24.0 + d[1]]);
|
||||
let ctx = EffectCtx { params: &ps, time: k as f64 / 25.0, layer_size: [64.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
let out = render(&ctx, Buf { img, offset: [0.0; 2], scale: 1.0 });
|
||||
cs.push(centroid(&out.img));
|
||||
}
|
||||
for c in &cs {
|
||||
assert!((c[0] - cs[0][0]).abs() < 0.05 && (c[1] - cs[0][1]).abs() < 0.05, "{cs:?}");
|
||||
}
|
||||
// Not analysed: untouched.
|
||||
let ps = params("", 0, 1);
|
||||
let img = frame([10.0, 10.0]);
|
||||
let ctx = EffectCtx { params: &ps, time: 0.0, layer_size: [64.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_param_lookup() {
|
||||
let mut p = Params::default();
|
||||
p.values.insert("#2/@match".into(), Value::Str("borders".into()));
|
||||
p.values.insert("#1/@match".into(), Value::Str("stabilization".into()));
|
||||
p.values.insert("#2/#1/@match".into(), Value::Str("autoScale".into()));
|
||||
p.values.insert("#2/#1/maximumScale".into(), num(120.0));
|
||||
p.values.insert("#1/smoothness".into(), num(12.0));
|
||||
assert_eq!(f(&p, "borders/autoScale/maximumScale"), 120.0);
|
||||
assert_eq!(f(&p, "stabilization/smoothness"), 12.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
//! Mask tracking (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode) and Smart Mask
|
||||
//! Interpolation (Window ▸ Mask Interpolation).
|
||||
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
|
||||
use effectcraft_path::interp::{self, AddVertices, InterpOpts, Matching};
|
||||
use effectcraft_project::{Layer, LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, layer_mut, layer_p, str_p};
|
||||
use crate::mask_track::{MaskMethod, MaskWork};
|
||||
use crate::tracking::Direction;
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"track.mask",
|
||||
"Track Mask",
|
||||
["Animation"],
|
||||
None,
|
||||
"{layer?, mask?: uid|index|name, method?: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective, direction?: forward|backward|frameForward|frameBackward, start? (s), end? (s), wait?: block until done}",
|
||||
has_mask,
|
||||
track_mask
|
||||
),
|
||||
cmd!(
|
||||
"track.maskMethod",
|
||||
"Mask Tracking Method",
|
||||
[],
|
||||
None,
|
||||
"{method: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective}",
|
||||
super::always,
|
||||
mask_method
|
||||
),
|
||||
cmd!(
|
||||
"mask.interpolate",
|
||||
"Apply Mask Interpolation",
|
||||
[],
|
||||
None,
|
||||
"{layer?, mask?, times?: [from, to] (s; default the selected Mask Path keys), keyframeRate?: n|auto, keyframeFields?, linearVertexPaths?, bendingResistance? (0-100), quality? (0-100), addVertices?: n|false, addVerticesUnit?: pixels|total|percent, matchingMethod?: auto|curve|polyline, oneToOne?, firstVerticesMatch?}",
|
||||
has_comp,
|
||||
interpolate
|
||||
),
|
||||
cmd!(
|
||||
"mask.interpolationOptions",
|
||||
"Mask Interpolation Options",
|
||||
[],
|
||||
None,
|
||||
"{keyframeRate?: n|auto, keyframeFields?, linearVertexPaths?, bendingResistance?, quality?, addVertices?: n|false, addVerticesUnit?: pixels|total|percent, matchingMethod?: auto|curve|polyline, oneToOne?, firstVerticesMatch?}",
|
||||
super::always,
|
||||
interp_options
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mask tracking
|
||||
|
||||
/// The mask a command works on: `mask` (uid, 1-based index or name), else the selected mask
|
||||
/// (Timeline selection or selected vertices), else the layer's only mask.
|
||||
pub(crate) fn mask_uid(s: &Session, layer: &Layer, p: &Value) -> Option<Uid> {
|
||||
let masks = layer.masks()?;
|
||||
if let Some(k) = p.get("mask") {
|
||||
return match k {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64()?;
|
||||
masks.groups().find(|g| g.uid == n).or_else(|| masks.groups().nth((n as usize).checked_sub(1)?)).map(|g| g.uid)
|
||||
}
|
||||
Value::String(name) => masks.groups().find(|g| &g.name == name).map(|g| g.uid),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let is_mask = |u: Uid| masks.groups().any(|g| g.uid == u);
|
||||
if let Some(u) = s.state.selected_props.iter().filter(|(l, _)| *l == layer.id).map(|(_, u)| *u).find(|u| is_mask(*u)) {
|
||||
return Some(u);
|
||||
}
|
||||
// A selected Mask Path property selects its mask.
|
||||
if let Some(u) = s
|
||||
.state
|
||||
.selected_props
|
||||
.iter()
|
||||
.filter(|(l, _)| *l == layer.id)
|
||||
.find_map(|(_, u)| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == *u)).map(|g| g.uid))
|
||||
{
|
||||
return Some(u);
|
||||
}
|
||||
if let Some(v) = s.state.selected_vertices.iter().find(|v| v.layer == layer.id && is_mask(v.mask)) {
|
||||
return Some(v.mask);
|
||||
}
|
||||
let mut it = masks.groups();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(g), None) => Some(g.uid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected layer and mask, if any (the Tracker panel's mask mode).
|
||||
pub fn selected_mask(s: &Session) -> Option<(LayerId, Uid)> {
|
||||
let comp = s.active_comp()?;
|
||||
for lid in &s.state.selected_layers {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let explicit =
|
||||
s.state.selected_props.iter().any(|(pl, u)| *pl == *lid && l.masks().is_some_and(|m| m.find_group(*u).is_some() || m.find(*u).is_some()))
|
||||
|| s.state.selected_vertices.iter().any(|v| v.layer == *lid);
|
||||
if explicit && let Some(u) = mask_uid(s, l, &json!({})) {
|
||||
return Some((*lid, u));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn has_mask(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
selected_mask(s).map(|_| ()).ok_or_else(|| "select a mask to track".into())
|
||||
}
|
||||
|
||||
fn method_p(p: &Value, cmd: &str) -> Result<Option<MaskMethod>> {
|
||||
match str_p(p, "method") {
|
||||
Some(m) => MaskMethod::from_name(m)
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("unknown method `{m}` (position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective)"))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_method(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = method_p(p, "track.maskMethod")?.ok_or_else(|| bad("track.maskMethod", "missing `method`"))?;
|
||||
s.state.mask_track_method = m;
|
||||
s.bump();
|
||||
Ok(json!(m.id()))
|
||||
}
|
||||
|
||||
fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "track.mask";
|
||||
let (cid, lid) = match p.get("layer") {
|
||||
Some(_) => layer_p(s, p, CMD)?,
|
||||
None => match selected_mask(s) {
|
||||
Some((l, _)) => (s.active_comp_id().ok_or(EngineError::NoComp)?, l),
|
||||
None => layer_p(s, p, CMD)?,
|
||||
},
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let uid = mask_uid(s, layer, p).ok_or_else(|| bad(CMD, format!("layer `{}` has no such mask (select one or pass `mask`)", layer.name)))?;
|
||||
if !layer.has_video() || matches!(layer.source, effectcraft_project::LayerSource::Text | effectcraft_project::LayerSource::Shape) {
|
||||
return Err(bad(CMD, "the layer has no pixels to track"));
|
||||
}
|
||||
let method = method_p(p, CMD)?.unwrap_or(s.state.mask_track_method);
|
||||
let dir = match str_p(p, "direction") {
|
||||
Some(d) => Direction::from_name(d).ok_or_else(|| bad(CMD, "direction: forward|backward|frameForward|frameBackward"))?,
|
||||
None => Direction::Forward,
|
||||
};
|
||||
let now = comp.frame_rate.snap_nearest(if s.state.active_comp == Some(cid) { s.time() } else { Tick::ZERO });
|
||||
let start = f_p(p, "start").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
|
||||
let end = f_p(p, "end").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
|
||||
let times = super::track::analysis_times(comp, layer, dir, now, start, end);
|
||||
if times.len() < 2 {
|
||||
return Err(bad(CMD, "nothing to track in that direction (at the layer's end)"));
|
||||
}
|
||||
let mg = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "no mask"))?;
|
||||
let KV::Path(path) = mg.get("path").map(|pr| pr.value_at(layer.layer_time(times[0]))).unwrap_or(KV::Scalar(0.0)) else {
|
||||
return Err(bad(CMD, "the mask has no path"));
|
||||
};
|
||||
if path.len() < 2 {
|
||||
return Err(bad(CMD, "the mask needs at least two vertices"));
|
||||
}
|
||||
let work = MaskWork {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
cache: s.layer_cache.clone(),
|
||||
comp: cid,
|
||||
layer: lid,
|
||||
path,
|
||||
method,
|
||||
times: times.clone(),
|
||||
};
|
||||
s.state.mask_track_method = method;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
s.start_mask_track(work, uid, dir, wait).map_err(EngineError::Other)?;
|
||||
let prog = s.mask_track_progress();
|
||||
Ok(json!({
|
||||
"mask": uid,
|
||||
"method": method.id(),
|
||||
"frames": times.len() - 1,
|
||||
"start": times[0].seconds(),
|
||||
"end": times.last().map(|t| t.seconds()),
|
||||
"running": s.is_mask_tracking(),
|
||||
"progress": prog,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mask interpolation
|
||||
|
||||
/// Mask Interpolation panel options (kept in the editor state, serde for agents).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct MaskInterpOptions {
|
||||
/// Keyframes per second; `None` = Auto (the comp frame rate).
|
||||
pub keyframe_rate: Option<f64>,
|
||||
/// Keyframe Fields (doubles the rate).
|
||||
pub keyframe_fields: bool,
|
||||
pub linear_vertex_paths: bool,
|
||||
pub bending_resistance: f64,
|
||||
pub quality: f64,
|
||||
/// Add Mask Shape Vertices (value, unit: pixels | total | percent).
|
||||
pub add_vertices: Option<f64>,
|
||||
pub add_vertices_unit: String,
|
||||
pub matching_method: String,
|
||||
pub one_to_one: bool,
|
||||
pub first_vertices_match: bool,
|
||||
}
|
||||
|
||||
impl Default for MaskInterpOptions {
|
||||
fn default() -> Self {
|
||||
MaskInterpOptions {
|
||||
keyframe_rate: None,
|
||||
keyframe_fields: false,
|
||||
linear_vertex_paths: false,
|
||||
bending_resistance: 50.0,
|
||||
quality: 50.0,
|
||||
add_vertices: Some(5.0),
|
||||
add_vertices_unit: "pixels".into(),
|
||||
matching_method: "auto".into(),
|
||||
one_to_one: false,
|
||||
first_vertices_match: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MaskInterpOptions {
|
||||
pub fn interp(&self) -> InterpOpts {
|
||||
InterpOpts {
|
||||
linear: self.linear_vertex_paths,
|
||||
bending_resistance: self.bending_resistance.clamp(0.0, 100.0),
|
||||
quality: self.quality.clamp(0.0, 100.0),
|
||||
add_vertices: self.add_vertices.map(|v| match self.add_vertices_unit.as_str() {
|
||||
"total" => AddVertices::Total(v.round().max(2.0) as usize),
|
||||
"percent" => AddVertices::Percent(v),
|
||||
_ => AddVertices::Pixels(v),
|
||||
}),
|
||||
matching: match self.matching_method.as_str() {
|
||||
"curve" => Matching::Curve,
|
||||
"polyline" => Matching::Polyline,
|
||||
_ => Matching::Auto,
|
||||
},
|
||||
one_to_one: self.one_to_one,
|
||||
first_vertices_match: self.first_vertices_match,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge option parameters into `o`.
|
||||
fn apply_options(o: &mut MaskInterpOptions, p: &Value, cmd: &str) -> Result<()> {
|
||||
match p.get("keyframeRate") {
|
||||
Some(Value::String(s)) if s.eq_ignore_ascii_case("auto") => o.keyframe_rate = None,
|
||||
Some(Value::Null) => o.keyframe_rate = None,
|
||||
Some(v) => o.keyframe_rate = Some(v.as_f64().filter(|r| *r > 0.0).ok_or_else(|| bad(cmd, "keyframeRate: a positive number or \"auto\""))?.min(1000.0)),
|
||||
None => {}
|
||||
}
|
||||
for (k, f) in [
|
||||
("keyframeFields", &mut o.keyframe_fields),
|
||||
("linearVertexPaths", &mut o.linear_vertex_paths),
|
||||
("oneToOne", &mut o.one_to_one),
|
||||
("firstVerticesMatch", &mut o.first_vertices_match),
|
||||
] {
|
||||
if let Some(v) = b_p(p, k) {
|
||||
*f = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = f_p(p, "bendingResistance") {
|
||||
o.bending_resistance = v.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(v) = f_p(p, "quality") {
|
||||
o.quality = v.clamp(0.0, 100.0);
|
||||
}
|
||||
match p.get("addVertices") {
|
||||
Some(Value::Bool(false)) | Some(Value::Null) => o.add_vertices = None,
|
||||
Some(Value::Bool(true)) => o.add_vertices = Some(o.add_vertices.unwrap_or(5.0)),
|
||||
Some(v) => o.add_vertices = Some(v.as_f64().filter(|x| *x > 0.0).ok_or_else(|| bad(cmd, "addVertices: a positive number or false"))?),
|
||||
None => {}
|
||||
}
|
||||
if let Some(u) = str_p(p, "addVerticesUnit") {
|
||||
let u = u.to_ascii_lowercase();
|
||||
o.add_vertices_unit = match u.as_str() {
|
||||
"pixels" | "px" | "pixelsbetweenvertices" => "pixels",
|
||||
"total" | "totalvertices" => "total",
|
||||
"percent" | "%" | "percentageofoutline" => "percent",
|
||||
_ => return Err(bad(cmd, "addVerticesUnit: pixels|total|percent")),
|
||||
}
|
||||
.into();
|
||||
}
|
||||
if let Some(m) = str_p(p, "matchingMethod") {
|
||||
let m = m.to_ascii_lowercase();
|
||||
if !["auto", "curve", "polyline"].contains(&m.as_str()) {
|
||||
return Err(bad(cmd, "matchingMethod: auto|curve|polyline"));
|
||||
}
|
||||
o.matching_method = m;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn interp_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut o = s.state.mask_interp.clone();
|
||||
apply_options(&mut o, p, "mask.interpolationOptions")?;
|
||||
s.state.mask_interp = o.clone();
|
||||
s.bump();
|
||||
Ok(serde_json::to_value(o).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "mask.interpolate";
|
||||
let mut o = s.state.mask_interp.clone();
|
||||
apply_options(&mut o, p, CMD)?;
|
||||
// The layer: given, else the layer of selected Mask Path keys, else the selection.
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let lid = match p.get("layer") {
|
||||
Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?,
|
||||
None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
|
||||
Some(k) => k.layer,
|
||||
None => layer_p(s, p, CMD)?.1,
|
||||
},
|
||||
};
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?;
|
||||
let uid = match p.get("mask") {
|
||||
Some(_) => mask_uid(s, layer, p),
|
||||
None => s
|
||||
.state
|
||||
.selected_keys
|
||||
.iter()
|
||||
.filter(|k| k.layer == lid)
|
||||
.find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid))
|
||||
.or_else(|| mask_uid(s, layer, p)),
|
||||
}
|
||||
.ok_or_else(|| bad(CMD, "no such mask (select two Mask Path keyframes or pass `mask`)"))?;
|
||||
let mg = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "no mask"))?;
|
||||
let path_prop = mg.get("path").ok_or_else(|| bad(CMD, "the mask has no path"))?;
|
||||
// Key times (layer time): explicit, else selected keys on this Mask Path.
|
||||
let mut times: Vec<Tick> = match p.get("times").and_then(Value::as_array) {
|
||||
Some(a) => {
|
||||
let ts: Vec<Tick> = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect();
|
||||
ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect()
|
||||
}
|
||||
None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
|
||||
};
|
||||
times.sort();
|
||||
times.dedup();
|
||||
if times.len() < 2 {
|
||||
return Err(bad(CMD, "select two or more Mask Path keyframes (or pass `times` of existing keys)"));
|
||||
}
|
||||
let rate = o.keyframe_rate.unwrap_or_else(|| comp.frame_rate.as_f64()) * if o.keyframe_fields { 2.0 } else { 1.0 };
|
||||
let step = Tick::from_seconds_f64(1.0 / rate.max(0.01));
|
||||
let opts = o.interp();
|
||||
let shapes: Vec<ShapePath> = times.iter().filter_map(|t| path_prop.keys.iter().find(|k| k.time == *t).and_then(|k| k.value.as_path().cloned())).collect();
|
||||
if shapes.len() != times.len() {
|
||||
return Err(bad(CMD, "the selected keys must be Mask Path keys"));
|
||||
}
|
||||
// (time, shape) for every new or replaced key.
|
||||
let mut out: Vec<(Tick, ShapePath)> = vec![];
|
||||
let mut vertices = 0;
|
||||
for i in 0..times.len() - 1 {
|
||||
let (t0, t1) = (times[i], times[i + 1]);
|
||||
let (a, b) = interp::correspond(&shapes[i], &shapes[i + 1], &opts);
|
||||
vertices = vertices.max(a.len());
|
||||
out.push((t0, a.clone()));
|
||||
let mut t = t0 + step;
|
||||
while t1.0 - t.0 > step.0 / 4 {
|
||||
let u = (t.0 - t0.0) as f64 / (t1.0 - t0.0) as f64;
|
||||
out.push((t, interp::interpolate(&a, &b, u, &opts)));
|
||||
t += step;
|
||||
}
|
||||
out.push((t1, b));
|
||||
}
|
||||
let created = out.len() - times.len().min(out.len());
|
||||
let (first, last) = (times[0], times[times.len() - 1]);
|
||||
s.state.mask_interp = o;
|
||||
s.edit("Mask Interpolation", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_group_mut(uid).and_then(|g| g.get_mut("path")).ok_or_else(|| bad(CMD, "no mask path"))?;
|
||||
// Keys strictly inside the interpolated span are replaced.
|
||||
pr.keys.retain(|k| k.time <= first || k.time >= last || times.contains(&k.time));
|
||||
for (t, sp) in &out {
|
||||
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*t, KV::Path(sp.clone())));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"mask": uid, "keys": created, "vertices": vertices, "rate": rate}))
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -60,8 +60,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
|
||||
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
|
||||
stub!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], "{}"),
|
||||
stub!("track.mask", "Track Mask", ["Animation"], "{}"),
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_track,
|
||||
analyze
|
||||
),
|
||||
cmd!("track.stop", "Stop Analysis", [], None, "{}", is_tracking, |s, _| Ok(json!({"stopped": s.stop_track()}))),
|
||||
cmd!("track.stop", "Stop Analysis", [], None, "{}", is_tracking, |s, _| Ok(json!({"stopped": s.stop_track() | s.stop_mask_track() | s.stop_warp()}))),
|
||||
cmd!("track.apply", "Apply", [], None, "{layer?, tracker?, dimensions?: xy|x|y}", has_track, apply),
|
||||
cmd!("track.reset", "Reset", [], None, "{layer?, tracker?}", has_track, reset),
|
||||
cmd!("track.delete", "Delete Tracker", [], None, "{layer?, tracker?}", has_track, delete),
|
||||
|
|
@ -105,7 +105,7 @@ fn has_track(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
|
||||
fn is_tracking(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_tracking() { Ok(()) } else { Err("no track analysis is running".into()) }
|
||||
if s.is_tracking() || s.is_mask_tracking() || s.is_warp_analyzing() { Ok(()) } else { Err("no track analysis is running".into()) }
|
||||
}
|
||||
|
||||
// ---------- addressing ----------
|
||||
|
|
@ -432,31 +432,10 @@ fn analyze(s: &mut Session, p: &V) -> Result<V> {
|
|||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let (tg, settings) = layer.tracker(uid).ok_or_else(|| bad("track.analyze", "no tracker"))?;
|
||||
let fd = comp.frame_duration();
|
||||
let now = comp.frame_rate.snap_nearest(if s.state.active_comp == Some(cid) { s.time() } else { Tick::ZERO });
|
||||
let start = f_p(p, "start").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
|
||||
let end = f_p(p, "end").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
|
||||
let lo = layer.in_point.max(Tick::ZERO);
|
||||
let hi = layer.out_point.min(comp.duration) - fd;
|
||||
let mut times = vec![];
|
||||
match dir {
|
||||
Direction::Forward | Direction::FrameForward => {
|
||||
let mut t = start.unwrap_or(now).clamp(lo, hi);
|
||||
let stop = if dir == Direction::FrameForward { t + fd } else { end.unwrap_or(hi).min(hi) };
|
||||
while t <= stop && t <= hi {
|
||||
times.push(t);
|
||||
t += fd;
|
||||
}
|
||||
}
|
||||
Direction::Backward | Direction::FrameBackward => {
|
||||
let mut t = end.unwrap_or(now).clamp(lo, hi);
|
||||
let stop = if dir == Direction::FrameBackward { t - fd } else { start.unwrap_or(lo).max(lo) };
|
||||
while t >= stop && t >= lo {
|
||||
times.push(t);
|
||||
t -= fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
let times = analysis_times(comp, layer, dir, now, start, end);
|
||||
if times.len() < 2 {
|
||||
return Err(bad("track.analyze", "nothing to analyze in that direction (at the layer's end)"));
|
||||
}
|
||||
|
|
@ -489,6 +468,34 @@ fn analyze(s: &mut Session, p: &V) -> Result<V> {
|
|||
}))
|
||||
}
|
||||
|
||||
/// Comp times to analyse from the CTI (`now`) in `dir`, bounded by `start` / `end` and the
|
||||
/// layer's In/Out points; the first is the start frame.
|
||||
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer, dir: Direction, now: Tick, start: Option<Tick>, end: Option<Tick>) -> Vec<Tick> {
|
||||
let fd = comp.frame_duration();
|
||||
let lo = layer.in_point.max(Tick::ZERO);
|
||||
let hi = layer.out_point.min(comp.duration) - fd;
|
||||
let mut times = vec![];
|
||||
match dir {
|
||||
Direction::Forward | Direction::FrameForward => {
|
||||
let mut t = start.unwrap_or(now).clamp(lo, hi);
|
||||
let stop = if dir == Direction::FrameForward { t + fd } else { end.unwrap_or(hi).min(hi) };
|
||||
while t <= stop && t <= hi {
|
||||
times.push(t);
|
||||
t += fd;
|
||||
}
|
||||
}
|
||||
Direction::Backward | Direction::FrameBackward => {
|
||||
let mut t = end.unwrap_or(now).clamp(lo, hi);
|
||||
let stop = if dir == Direction::FrameBackward { t - fd } else { start.unwrap_or(lo).max(lo) };
|
||||
while t >= stop && t >= lo {
|
||||
times.push(t);
|
||||
t -= fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
times
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &V) -> Result<V> {
|
||||
let (cid, lid, uid) = current(s, p, "track.apply")?;
|
||||
let dims = match str_p(p, "dimensions") {
|
||||
|
|
@ -560,9 +567,10 @@ fn delete(s: &mut Session, p: &V) -> Result<V> {
|
|||
|
||||
fn status(s: &mut Session, p: &V) -> Result<V> {
|
||||
s.poll_track();
|
||||
s.poll_mask_track();
|
||||
let progress = s.track_progress();
|
||||
let cur = current(s, p, "track.status").ok();
|
||||
let mut out = json!({"running": s.is_tracking(), "progress": progress});
|
||||
let mut out = json!({"running": s.is_tracking() || s.is_mask_tracking(), "progress": progress, "mask": s.mask_track_progress(), "maskMethod": s.state.mask_track_method.id()});
|
||||
if let Some((cid, lid, uid)) = cur
|
||||
&& let Some(layer) = s.project.comp(cid).and_then(|c| c.layer(lid))
|
||||
&& let Some((g, st)) = layer.tracker(uid)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
//! Warp Stabilizer commands (Effect ▸ Distort ▸ Warp Stabilizer's Analyze / Cancel, Animation ▸
|
||||
//! Warp Stabilizer VFX, the Tracker panel button) and its status query.
|
||||
|
||||
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_comp, layer_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], None, "{layer?, wait?}", can_stabilize, warp_apply),
|
||||
cmd!("warp.analyze", "Analyze", [], None, "{layer?, effect?: uid|name|index, wait?: block until done}", has_comp, warp_analyze),
|
||||
cmd!("warp.cancel", "Cancel", [], None, "{}", is_analyzing, |s, _| Ok(json!({"stopped": s.stop_warp()}))),
|
||||
query!("warp.status", "Warp Stabilizer Status", "{layer?, effect?}", warp_status),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warp Stabilizer
|
||||
|
||||
fn can_stabilize(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let c = s.active_comp().ok_or("no composition is open")?;
|
||||
match s.state.selected_layers.first().and_then(|l| c.layer(*l)) {
|
||||
Some(l) if l.has_video() && !l.switches.adjustment => Ok(()),
|
||||
Some(_) => Err("Warp Stabilizer needs a footage or composition layer".into()),
|
||||
None => Err("select a layer to stabilize".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_analyzing(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_warp_analyzing() { Ok(()) } else { Err("no Warp Stabilizer analysis is running".into()) }
|
||||
}
|
||||
|
||||
fn is_warp(g: &PropGroup) -> bool {
|
||||
matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::warp_stab::ID)
|
||||
}
|
||||
|
||||
/// (comp, layer, effect uid) of a Warp Stabilizer: `layer` / `effect` (uid, name or 1-based
|
||||
/// index among the layer's effects), else a selected one, else the layer's first.
|
||||
fn warp_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let candidates: Vec<LayerId> = match p.get("layer") {
|
||||
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
|
||||
None => {
|
||||
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
|
||||
v.extend(s.state.selected_layers.iter().copied());
|
||||
v.extend(comp.layers.iter().map(|l| l.id));
|
||||
v
|
||||
}
|
||||
};
|
||||
for lid in candidates {
|
||||
let Some(fx) = comp.layer(lid).and_then(Layer::effects) else { continue };
|
||||
let g = match p.get("effect") {
|
||||
Some(Value::Number(n)) => {
|
||||
let n = n.as_u64().unwrap_or(0);
|
||||
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth((n as usize).saturating_sub(1)))
|
||||
}
|
||||
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name),
|
||||
_ => s
|
||||
.state
|
||||
.selected_props
|
||||
.iter()
|
||||
.find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u && is_warp(g))).flatten())
|
||||
.or_else(|| fx.groups().find(|g| is_warp(g))),
|
||||
};
|
||||
if let Some(g) = g.filter(|g| is_warp(g)) {
|
||||
return Ok((cid, lid, g.uid));
|
||||
}
|
||||
if p.get("layer").is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(bad(cmd, "no Warp Stabilizer found (apply Effect ▸ Distort ▸ Warp Stabilizer first)"))
|
||||
}
|
||||
|
||||
fn warp_analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = warp_p(s, p, "warp.analyze")?;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
let n = s.start_warp(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_warp_analyzing(), "progress": s.warp_progress()});
|
||||
if wait {
|
||||
out["status"] = warp_status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Animation ▸ Warp Stabilizer VFX / the Tracker panel button: apply the effect to the selected
|
||||
/// layer and start analysing it.
|
||||
fn warp_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "track.warpStabilizer")?;
|
||||
let r = s.execute("effect.apply", json!({"effect": effectcraft_effects::warp_stab::ID, "layers": [lid.0], "comp": cid.0}))?;
|
||||
let uid = r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
let n = s.start_warp(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
Ok(json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_warp_analyzing(), "progress": s.warp_progress()}))
|
||||
}
|
||||
|
||||
fn warp_status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.poll_warp(false);
|
||||
let mut out = json!({"running": s.is_warp_analyzing(), "progress": s.warp_progress(), "banner": s.warp_progress().map(|p| p.banner())});
|
||||
let Ok((cid, lid, uid)) = warp_p(s, p, "warp.status") else { return Ok(out) };
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid));
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let analysis = effectcraft_effects::warp_stab::analysis(¶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)
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ pub mod commands;
|
|||
pub mod config;
|
||||
pub mod demo;
|
||||
pub mod links;
|
||||
pub mod mask_track;
|
||||
pub mod menus;
|
||||
pub mod prefs;
|
||||
pub mod render_queue;
|
||||
|
|
@ -20,6 +21,7 @@ mod session_settings;
|
|||
pub mod shortcuts;
|
||||
pub mod tracking;
|
||||
pub mod viewer;
|
||||
pub mod warp;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -165,6 +167,12 @@ pub struct EditorState {
|
|||
/// Tracker panel ▸ Current Track: (tracked layer, tracker group uid) in the active comp.
|
||||
#[serde(default)]
|
||||
pub current_track: Option<(LayerId, Uid)>,
|
||||
/// Tracker panel ▸ Method in mask mode (Track Mask).
|
||||
#[serde(default)]
|
||||
pub mask_track_method: mask_track::MaskMethod,
|
||||
/// Mask Interpolation panel options.
|
||||
#[serde(default)]
|
||||
pub mask_interp: commands::mask_interp::MaskInterpOptions,
|
||||
/// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools).
|
||||
#[serde(default)]
|
||||
pub paint: commands::paint::PaintOptions,
|
||||
|
|
@ -249,6 +257,12 @@ pub struct Session {
|
|||
pub render_job: Option<render_queue::RenderJob>,
|
||||
/// The running (or finished, not yet polled) track analysis.
|
||||
pub track_job: Option<tracking::TrackJob>,
|
||||
/// The running (or finished, not yet polled) mask track.
|
||||
pub mask_job: Option<mask_track::MaskTrackJob>,
|
||||
/// The running (or finished, not yet polled) Warp Stabilizer analysis.
|
||||
pub warp_job: Option<warp::WarpJob>,
|
||||
/// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid).
|
||||
pub warp_pending: Vec<(ItemId, LayerId, Uid)>,
|
||||
pub events: Vec<Event>,
|
||||
/// Commands executed: (id, params).
|
||||
pub journal: Vec<(String, Value)>,
|
||||
|
|
@ -291,6 +305,9 @@ impl Default for Session {
|
|||
exporter: None,
|
||||
render_job: None,
|
||||
track_job: None,
|
||||
mask_job: None,
|
||||
warp_job: None,
|
||||
warp_pending: vec![],
|
||||
events: vec![],
|
||||
journal: vec![],
|
||||
layer_cache: Arc::new(LayerCache::default()),
|
||||
|
|
@ -351,6 +368,12 @@ impl Session {
|
|||
let r = f(&mut p, &mut st)?;
|
||||
// Layer styles: one Global Light per comp, whichever layer edited it.
|
||||
effectcraft_project::styles::sync_global_light(&before, &mut p);
|
||||
// Warp Stabilizer analyses made from other frames are cleared (and queued again).
|
||||
for w in warp::invalidate(&before, &mut p) {
|
||||
if !self.warp_pending.contains(&w) {
|
||||
self.warp_pending.push(w);
|
||||
}
|
||||
}
|
||||
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
|
||||
if !same {
|
||||
self.history.undo.push((label.to_string(), before));
|
||||
|
|
@ -523,6 +546,11 @@ impl Session {
|
|||
self.render_job = None;
|
||||
self.stop_track();
|
||||
self.track_job = None;
|
||||
self.stop_mask_track();
|
||||
self.mask_job = None;
|
||||
self.stop_warp();
|
||||
self.warp_job = None;
|
||||
self.warp_pending.clear();
|
||||
p.fix_next_id();
|
||||
self.project = Arc::new(p);
|
||||
self.history = History::default();
|
||||
|
|
@ -554,6 +582,8 @@ mod tests_lottie;
|
|||
#[cfg(test)]
|
||||
mod tests_markers;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_warp;
|
||||
#[cfg(test)]
|
||||
mod tests_menu_cmds;
|
||||
#[cfg(test)]
|
||||
mod tests_project_items;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
//! Mask tracking runtime (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode): follow
|
||||
//! the pixels inside a mask from frame to frame and key its Mask Path on every analysed frame.
|
||||
//!
|
||||
//! Analysis runs on a background thread (progress, cancel; blocking with `wait` and on wasm32)
|
||||
//! like the point tracker, over the layer's source frames; one undo step covers it. The
|
||||
//! algorithm is `effectcraft_track::mask` (KLT features inside the mask + a RANSAC fit of the
|
||||
//! chosen motion model), and every frame's motion is applied to the mask's vertices and
|
||||
//! tangents, so the shape's Bezier structure is kept.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
|
||||
use effectcraft_project::{ItemId, LayerId, Project, Uid};
|
||||
use effectcraft_render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track as trk;
|
||||
use effectcraft_track::fit::Model;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::tracking::{Direction, TrackProgress, lock, source_frame};
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// Tracker panel ▸ Method (mask tracking).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MaskMethod {
|
||||
Position,
|
||||
PositionScale,
|
||||
#[default]
|
||||
PositionScaleRotation,
|
||||
PositionScaleRotationSkew,
|
||||
Perspective,
|
||||
}
|
||||
|
||||
impl MaskMethod {
|
||||
pub const ALL: [MaskMethod; 5] =
|
||||
[MaskMethod::Position, MaskMethod::PositionScale, MaskMethod::PositionScaleRotation, MaskMethod::PositionScaleRotationSkew, MaskMethod::Perspective];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
MaskMethod::Position => "Position",
|
||||
MaskMethod::PositionScale => "Position & Scale",
|
||||
MaskMethod::PositionScaleRotation => "Position, Scale & Rotation",
|
||||
MaskMethod::PositionScaleRotationSkew => "Position, Scale, Rotation & Skew",
|
||||
MaskMethod::Perspective => "Perspective",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
MaskMethod::Position => "position",
|
||||
MaskMethod::PositionScale => "positionScale",
|
||||
MaskMethod::PositionScaleRotation => "positionScaleRotation",
|
||||
MaskMethod::PositionScaleRotationSkew => "positionScaleRotationSkew",
|
||||
MaskMethod::Perspective => "perspective",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<MaskMethod> {
|
||||
let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
MaskMethod::ALL
|
||||
.into_iter()
|
||||
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == n)
|
||||
}
|
||||
pub fn model(self) -> Model {
|
||||
match self {
|
||||
MaskMethod::Position => Model::Translation,
|
||||
MaskMethod::PositionScale => Model::TranslationScale,
|
||||
MaskMethod::PositionScaleRotation => Model::Similarity,
|
||||
MaskMethod::PositionScaleRotationSkew => Model::Affine,
|
||||
MaskMethod::Perspective => Model::Homography,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed polyline through a Bezier path (`steps` samples per segment), in layer pixels.
|
||||
pub fn flatten(p: &ShapePath, steps: usize) -> Vec<[f64; 2]> {
|
||||
let n = p.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let segs = if p.closed { n } else { n.saturating_sub(1) };
|
||||
let mut out = Vec::with_capacity(segs * steps + 1);
|
||||
for i in 0..segs {
|
||||
let j = (i + 1) % n;
|
||||
let (a, b) = (p.vertices[i], p.vertices[j]);
|
||||
let o = p.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
|
||||
let it = p.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
|
||||
let c = [a, [a[0] + o[0], a[1] + o[1]], [b[0] + it[0], b[1] + it[1]], b];
|
||||
for k in 0..steps {
|
||||
let t = k as f64 / steps as f64;
|
||||
let u = 1.0 - t;
|
||||
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
|
||||
out.push([0, 1].map(|d| w[0] * c[0][d] + w[1] * c[1][d] + w[2] * c[2][d] + w[3] * c[3][d]));
|
||||
}
|
||||
}
|
||||
if !p.closed {
|
||||
out.push(p.vertices[n - 1]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A path moved by a frame-to-frame motion (vertices and tangent handles).
|
||||
pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath {
|
||||
ShapePath {
|
||||
vertices: p.vertices.iter().map(|v| h.apply(*v)).collect(),
|
||||
in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
|
||||
out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
|
||||
closed: p.closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// One tracked frame: (layer time, comp time, mask path).
|
||||
type MaskFrame = (Tick, Tick, ShapePath);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MaskTrackShared {
|
||||
pub cancel: AtomicBool,
|
||||
pub state: Mutex<TrackProgress>,
|
||||
frames: Mutex<Vec<MaskFrame>>,
|
||||
}
|
||||
|
||||
/// A running (or finished, not yet polled) mask track.
|
||||
pub struct MaskTrackJob {
|
||||
pub shared: Arc<MaskTrackShared>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
pub mask: Uid,
|
||||
pub direction: Direction,
|
||||
}
|
||||
|
||||
impl MaskTrackJob {
|
||||
pub fn is_finished(&self) -> bool {
|
||||
lock(&self.shared.state).finished
|
||||
}
|
||||
pub fn progress(&self) -> TrackProgress {
|
||||
lock(&self.shared.state).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the worker needs, detached from the session.
|
||||
pub(crate) struct MaskWork {
|
||||
pub project: Arc<Project>,
|
||||
pub footage: Arc<dyn FootageSource>,
|
||||
pub expr: Option<Arc<dyn ExprHost>>,
|
||||
pub cache: Arc<LayerCache>,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
/// The mask path on the first frame.
|
||||
pub path: ShapePath,
|
||||
pub method: MaskMethod,
|
||||
/// Comp times; the first is the start frame.
|
||||
pub times: Vec<Tick>,
|
||||
}
|
||||
|
||||
fn run_work(w: MaskWork, shared: &MaskTrackShared) {
|
||||
let t0 = web_time::Instant::now();
|
||||
let fail = |msg: &str| {
|
||||
let mut s = lock(&shared.state);
|
||||
s.error = Some(msg.to_string());
|
||||
s.finished = true;
|
||||
};
|
||||
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
|
||||
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
|
||||
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
|
||||
r.expr = w.expr.as_deref();
|
||||
r.cache = Some(&w.cache);
|
||||
let r = &r;
|
||||
let frame_at = |t: Tick| source_frame(r, &w.project, w.comp, comp, layer, t, w.expr.as_deref());
|
||||
lock(&shared.state).total = w.times.len().saturating_sub(1) as u64;
|
||||
let Some(first) = frame_at(w.times[0]) else { return fail("the layer has no pixels to track") };
|
||||
let mut tracker = trk::mask::MaskTracker::new(w.method.model(), flatten(&w.path, 8), &trk::Frame { img: &first.0, offset: first.1 });
|
||||
let mut path = w.path.clone();
|
||||
lock(&shared.frames).push((layer.layer_time(w.times[0]), w.times[0], path.clone()));
|
||||
let mut next = if w.times.len() > 1 { frame_at(w.times[1]) } else { None };
|
||||
for k in 1..w.times.len() {
|
||||
if shared.cancel.load(Ordering::Relaxed) {
|
||||
lock(&shared.state).cancelled = true;
|
||||
break;
|
||||
}
|
||||
let Some(cur) = next.take() else {
|
||||
lock(&shared.state).error = Some(format!("no frame at {:.3} s", w.times[k].seconds()));
|
||||
break;
|
||||
};
|
||||
let (res, nf) =
|
||||
rayon::join(|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }), || if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None });
|
||||
next = nf;
|
||||
let Some(step) = res else {
|
||||
lock(&shared.state).error = Some(format!("lost the mask's pixels at {:.3} s (too little texture inside the mask)", w.times[k].seconds()));
|
||||
break;
|
||||
};
|
||||
path = transform_path(&path, &step.motion);
|
||||
lock(&shared.frames).push((layer.layer_time(w.times[k]), w.times[k], path.clone()));
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = k as u64;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
|
||||
s.time = w.times[k].seconds();
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.finished = true;
|
||||
}
|
||||
|
||||
fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
|
||||
let Some(layer) = p.comp_mut(job.comp).and_then(|c| c.layer_mut(job.layer)) else { return };
|
||||
let Some(g) = layer.props.find_group_mut(job.mask) else { return };
|
||||
let Some(pr) = g.get_mut("path") else { return };
|
||||
for (lt, _, path) in frames {
|
||||
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Path(path.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Whether a mask track is running.
|
||||
pub fn is_mask_tracking(&self) -> bool {
|
||||
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
}
|
||||
|
||||
/// Live progress of the running (or just finished) mask track.
|
||||
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
|
||||
self.mask_job.as_ref().map(|j| j.progress())
|
||||
}
|
||||
|
||||
/// Cancel the running mask track (frames tracked so far are kept).
|
||||
pub fn stop_mask_track(&mut self) -> bool {
|
||||
match &self.mask_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_mask_track(&mut self, work: MaskWork, mask: Uid, direction: Direction, wait: bool) -> Result<(), String> {
|
||||
if self.is_mask_tracking() || self.is_tracking() {
|
||||
return Err("a track analysis is already running".into());
|
||||
}
|
||||
self.poll_mask_track();
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
self.history.undo.push(("Track Mask".into(), self.project.clone()));
|
||||
self.history.redo.clear();
|
||||
self.history.merge_key = None;
|
||||
let shared = Arc::new(MaskTrackShared::default());
|
||||
let (comp, layer) = (work.comp, work.layer);
|
||||
let thread = if wait {
|
||||
run_work(work, &shared);
|
||||
None
|
||||
} else {
|
||||
let sh = shared.clone();
|
||||
Some(std::thread::Builder::new().name("mask-track".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
|
||||
};
|
||||
self.mask_job = Some(MaskTrackJob { shared, thread, comp, layer, mask, direction });
|
||||
self.poll_mask_track();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Key newly tracked frames and move the CTI with them; drop the job once it finished.
|
||||
/// Frontends call this every frame while tracking.
|
||||
pub fn poll_mask_track(&mut self) -> bool {
|
||||
let Some(job) = &self.mask_job else { return false };
|
||||
let frames = std::mem::take(&mut *lock(&job.shared.frames));
|
||||
let finished = job.is_finished();
|
||||
let changed = !frames.is_empty();
|
||||
if changed {
|
||||
write_frames(Arc::make_mut(&mut self.project), job, &frames);
|
||||
if self.state.active_comp == Some(job.comp)
|
||||
&& let Some(last) = frames.last()
|
||||
{
|
||||
self.state.times.insert(job.comp, last.1);
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished {
|
||||
let mut job = self.mask_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
let st = job.progress();
|
||||
let msg = match (&st.error, st.cancelled) {
|
||||
(Some(e), _) => format!("Mask tracking stopped: {e}"),
|
||||
(None, true) => format!("Mask tracking stopped after {} frame(s)", st.done),
|
||||
_ => format!("Tracked the mask over {} frame(s) in {:.1} s", st.done, st.elapsed),
|
||||
};
|
||||
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
|
||||
}
|
||||
changed || finished
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn methods_parse_and_paths_transform() {
|
||||
assert_eq!(MaskMethod::from_name("positionScaleRotationSkew"), Some(MaskMethod::PositionScaleRotationSkew));
|
||||
assert_eq!(MaskMethod::from_name("Position & Scale"), Some(MaskMethod::PositionScale));
|
||||
assert_eq!(MaskMethod::from_name("perspective"), Some(MaskMethod::Perspective));
|
||||
let p = ShapePath::ellipse([50.0, 40.0], 40.0, 20.0);
|
||||
let poly = flatten(&p, 8);
|
||||
assert_eq!(poly.len(), p.len() * 8);
|
||||
assert!(poly.iter().all(|q| ((q[0] - 50.0) / 20.0).powi(2) + ((q[1] - 40.0) / 10.0).powi(2) < 1.01));
|
||||
let h = trk::Homography([[0.0, -2.0, 10.0], [2.0, 0.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
let q = transform_path(&p, &h);
|
||||
for i in 0..p.len() {
|
||||
let v = h.apply(p.vertices[i]);
|
||||
assert!((q.vertices[i][0] - v[0]).abs() < 1e-9);
|
||||
let t = p.out_tangents[i];
|
||||
assert!((q.out_tangents[i][0] + 2.0 * t[1]).abs() < 1e-9 && (q.out_tangents[i][1] - 2.0 * t[0]).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,384 @@
|
|||
//! Mask tracking, Mask Interpolation and the Warp Stabilizer end to end through the commands, on
|
||||
//! synthetic footage generated in-test (a rotating/scaling textured patch, a shaky camera).
|
||||
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
|
||||
use effectcraft_path::interp::paths_cross;
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::Homography;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::tests_track::{FPS, H, W, frame_time, patch_frames, rotating_pose, setup, texture};
|
||||
|
||||
fn pose_h(f: u32) -> Homography {
|
||||
let (c, rot, sc) = rotating_pose(f);
|
||||
let (sn, cs) = rot.to_radians().sin_cos();
|
||||
Homography([[sc * cs, -sc * sn, c[0]], [sc * sn, sc * cs, c[1]], [0.0, 0.0, 1.0]])
|
||||
}
|
||||
|
||||
fn mask_path(s: &Session, layer: LayerId, mask: Uid) -> effectcraft_project::Property {
|
||||
let l = s.active_comp().unwrap().layer(layer).unwrap();
|
||||
l.props.find_group(mask).unwrap().get("path").unwrap().clone()
|
||||
}
|
||||
|
||||
fn new_mask(s: &mut Session, layer: LayerId, path: &ShapePath) -> Uid {
|
||||
let ins: Vec<[f64; 2]> = path.in_tangents.clone();
|
||||
let outs: Vec<[f64; 2]> = path.out_tangents.clone();
|
||||
let r = s.execute("mask.new", json!({"layer": layer.0, "vertices": path.vertices, "inTangents": ins, "outTangents": outs, "closed": true})).unwrap();
|
||||
r["mask"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_mask_follows_a_rotating_patch() {
|
||||
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
|
||||
let (c0, _, _) = rotating_pose(0);
|
||||
// An ellipse inside the textured patch.
|
||||
let shape = ShapePath::ellipse(c0, 64.0, 52.0);
|
||||
let uid = new_mask(&mut s, clip, &shape);
|
||||
// Selecting the mask enables Track Mask (the Tracker panel's mask mode).
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
s.state.selected_props = vec![(clip, uid)];
|
||||
assert!(s.is_enabled("track.mask"));
|
||||
assert_eq!(crate::commands::mask_interp::selected_mask(&s), Some((clip, uid)));
|
||||
s.execute("time.set", json!({"time": 0})).unwrap();
|
||||
let r = s.execute_checked("track.mask", json!({"method": "positionScaleRotation", "direction": "forward", "wait": true})).unwrap();
|
||||
assert_eq!(r["frames"], 24, "{r}");
|
||||
assert!(s.mask_job.is_none());
|
||||
let pr = mask_path(&s, clip, uid);
|
||||
assert_eq!(pr.keys.len(), 25);
|
||||
let inv0 = pose_h(0).inverse().unwrap();
|
||||
let mut worst: f64 = 0.0;
|
||||
for f in 0..25 {
|
||||
let KV::Path(p) = pr.value_at(frame_time(f)) else { panic!() };
|
||||
let m = pose_h(f).then_after(&inv0);
|
||||
for (v, o) in p.vertices.iter().zip(&shape.vertices) {
|
||||
let want = m.apply(*o);
|
||||
worst = worst.max((v[0] - want[0]).hypot(v[1] - want[1]));
|
||||
}
|
||||
// Tangents rotate and scale with the patch.
|
||||
let want_t = m.apply_tangent(shape.vertices[0], shape.out_tangents[0]);
|
||||
assert!((p.out_tangents[0][0] - want_t[0]).hypot(p.out_tangents[0][1] - want_t[1]) < 1.5);
|
||||
}
|
||||
assert!(worst < 1.5, "worst vertex error {worst} px");
|
||||
assert_eq!(s.time(), frame_time(24));
|
||||
// One undo step; redo restores the keys.
|
||||
assert!(s.undo());
|
||||
assert!(mask_path(&s, clip, uid).keys.is_empty());
|
||||
assert!(s.redo());
|
||||
assert_eq!(mask_path(&s, clip, uid).keys.len(), 25);
|
||||
// Track backward one frame from the end, with another method.
|
||||
let r = s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "position", "direction": "frameBackward", "wait": true})).unwrap();
|
||||
assert_eq!(r["frames"], 1);
|
||||
assert_eq!(s.state.mask_track_method, crate::mask_track::MaskMethod::Position);
|
||||
// Serde keeps the keys.
|
||||
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
|
||||
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(clip).unwrap();
|
||||
assert_eq!(l.props.find_group(uid).unwrap().get("path").unwrap().keys.len(), 25);
|
||||
// Bad parameters are reported.
|
||||
assert!(s.execute_checked("track.mask", json!({"layer": clip.0, "mask": uid, "method": "warp"})).is_err());
|
||||
assert!(s.execute_checked("track.mask", json!({"layer": clip.0, "mask": uid, "bogus": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_mask_in_background_and_cancel() {
|
||||
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
|
||||
let uid = new_mask(&mut s, clip, &ShapePath::rect(rotating_pose(0).0, 60.0, 60.0));
|
||||
s.execute("time.set", json!({"time": 0})).unwrap();
|
||||
let r = s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "perspective"})).unwrap();
|
||||
assert_eq!(r["running"], true);
|
||||
// The analysis keeps running in the background; Stop keeps what was tracked.
|
||||
assert!(s.is_enabled("track.stop"));
|
||||
s.execute("track.stop", json!({})).unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.mask_job.is_some() {
|
||||
assert!(t0.elapsed().as_secs() < 60);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
s.poll_mask_track();
|
||||
}
|
||||
let n = mask_path(&s, clip, uid).keys.len();
|
||||
assert!((1..25).contains(&n), "{n} keys");
|
||||
// A full background run.
|
||||
s.execute("time.set", json!({"time": 0})).unwrap();
|
||||
s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "perspective"})).unwrap();
|
||||
while s.mask_job.is_some() {
|
||||
assert!(t0.elapsed().as_secs() < 120);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
s.poll_mask_track();
|
||||
}
|
||||
let pr = mask_path(&s, clip, uid);
|
||||
assert_eq!(pr.keys.len(), 25);
|
||||
let KV::Path(p) = pr.value_at(frame_time(20)) else { panic!() };
|
||||
let m = pose_h(20).then_after(&pose_h(0).inverse().unwrap());
|
||||
let want = m.apply(ShapePath::rect(rotating_pose(0).0, 60.0, 60.0).vertices[2]);
|
||||
assert!((p.vertices[2][0] - want[0]).hypot(p.vertices[2][1] - want[1]) < 2.0);
|
||||
}
|
||||
|
||||
/// A five-pointed star (10 vertices) around `c`, rotated by `rot` degrees.
|
||||
fn star(c: [f64; 2], r: f64, rot: f64) -> ShapePath {
|
||||
let pts: Vec<[f64; 2]> = (0..10)
|
||||
.map(|i| {
|
||||
let a = (rot + i as f64 * 36.0 - 90.0).to_radians();
|
||||
let rr = if i % 2 == 0 { r } else { r * 0.45 };
|
||||
[c[0] + rr * a.cos(), c[1] + rr * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
ShapePath::polygon(&pts, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_interpolation_creates_matching_keys() {
|
||||
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
|
||||
let a = ShapePath::rect([100.0, 100.0], 80.0, 60.0);
|
||||
let b = star([200.0, 120.0], 50.0, 30.0);
|
||||
let uid = new_mask(&mut s, clip, &a);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let (t0, t1) = (frame_time(0), frame_time(20));
|
||||
s.edit("keys", None, |p, _| {
|
||||
let pr = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
|
||||
pr.keys = vec![Keyframe::new(t0, KV::Path(a.clone())), Keyframe::new(t1, KV::Path(b.clone()))];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let prop = mask_path(&s, clip, uid).uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": clip.0, "prop": prop, "time": 0.0}, {"layer": clip.0, "prop": prop, "time": 0.8}]})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
let r = s
|
||||
.execute_checked(
|
||||
"mask.interpolate",
|
||||
json!({"keyframeRate": "auto", "bendingResistance": 50, "quality": 50, "addVertices": 10, "addVerticesUnit": "pixels", "matchingMethod": "auto", "firstVerticesMatch": false}),
|
||||
)
|
||||
.unwrap();
|
||||
// 19 in-betweens at the comp rate (frames 1…19).
|
||||
assert_eq!(r["keys"], 19, "{r}");
|
||||
let pr = mask_path(&s, clip, uid);
|
||||
assert_eq!(pr.keys.len(), 21);
|
||||
let shapes: Vec<ShapePath> = pr.keys.iter().map(|k| k.value.as_path().unwrap().clone()).collect();
|
||||
let n = shapes[0].len();
|
||||
assert!(n >= 28, "{n} vertices");
|
||||
assert!(shapes.iter().all(|p| p.len() == n));
|
||||
// Ends keep their outlines; vertex paths between consecutive keys don't cross.
|
||||
for (i, w) in shapes.windows(2).enumerate() {
|
||||
assert_eq!(paths_cross(&w[0], &w[1]), 0, "keys {i}/{}", i + 1);
|
||||
}
|
||||
assert!(
|
||||
shapes[0]
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|v| (v[0] - 60.0).abs() < 1e-6 || (v[0] - 140.0).abs() < 1e-6 || (v[1] - 70.0).abs() < 1e-6 || (v[1] - 130.0).abs() < 1e-6)
|
||||
);
|
||||
// Options persist; Keyframe Fields doubles the rate; undo restores.
|
||||
assert_eq!(s.state.mask_interp.add_vertices, Some(10.0));
|
||||
assert!(s.undo());
|
||||
assert_eq!(mask_path(&s, clip, uid).keys.len(), 2);
|
||||
let r = s
|
||||
.execute(
|
||||
"mask.interpolate",
|
||||
json!({"layer": clip.0, "mask": uid, "times": [0.0, 0.8], "keyframeRate": 10, "keyframeFields": true, "linearVertexPaths": true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["keys"], 15, "{r}");
|
||||
assert!(s.execute("mask.interpolate", json!({"layer": clip.0, "mask": uid, "times": [0.0]})).is_err());
|
||||
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warp Stabilizer
|
||||
|
||||
/// Camera offset on frame `f`: a slow pan plus jitter.
|
||||
fn cam(f: u32) -> [f64; 2] {
|
||||
let j = [((f * 7919) % 9) as f64 - 4.0, ((f * 104_729) % 7) as f64 - 3.0];
|
||||
[1.2 * f as f64 + j[0], 0.5 * f as f64 + j[1]]
|
||||
}
|
||||
|
||||
fn shaky(f: u32) -> Image {
|
||||
let c = cam(f);
|
||||
let mut img = Image::new(W, H);
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let v = texture(x as f64 + 0.5 + c[0], y as f64 + 0.5 + c[1], 11);
|
||||
img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn jitter(p: &[[f64; 2]]) -> f64 {
|
||||
let mut s = 0.0;
|
||||
for k in 1..p.len() - 1 {
|
||||
s += (p[k + 1][0] - 2.0 * p[k][0] + p[k - 1][0]).hypot(p[k + 1][1] - 2.0 * p[k][1] + p[k - 1][1]);
|
||||
}
|
||||
s / (p.len() - 2) as f64
|
||||
}
|
||||
|
||||
fn warp_at(s: &mut Session, f: u32) -> Homography {
|
||||
s.execute("time.set", json!({"time": frame_time(f).seconds()})).unwrap();
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
let m: [[f64; 3]; 3] = serde_json::from_value(st["warp"].clone()).unwrap_or_else(|_| panic!("{st}"));
|
||||
Homography(m)
|
||||
}
|
||||
|
||||
/// Output positions of the scene point that sits at the frame centre on frame 0.
|
||||
fn scene_track(s: &mut Session) -> (Vec<[f64; 2]>, Vec<[f64; 2]>) {
|
||||
let x0 = [160.0 + cam(0)[0], 120.0 + cam(0)[1]];
|
||||
let before: Vec<[f64; 2]> = (0..25).map(|f| [x0[0] - cam(f)[0], x0[1] - cam(f)[1]]).collect();
|
||||
let after = (0..25).map(|f| warp_at(s, f).apply(before[f as usize])).collect();
|
||||
(before, after)
|
||||
}
|
||||
|
||||
fn render(s: &Session, f: u32) -> Image {
|
||||
s.render(s.active_comp_id().unwrap(), frame_time(f), RenderOpts::default())
|
||||
}
|
||||
|
||||
fn transparent(img: &Image) -> usize {
|
||||
img.data.iter().filter(|p| p[3] < 0.5).count()
|
||||
}
|
||||
|
||||
fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value) {
|
||||
s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_stabilizer_analyzes_and_stabilizes() {
|
||||
let (mut s, clip, solid) = setup(shaky);
|
||||
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
// Effect ▸ Distort ▸ Warp Stabilizer, then analyse (blocking).
|
||||
let r = s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap();
|
||||
let fx = r["effects"][0].as_u64().unwrap();
|
||||
assert!(s.warp_pending.contains(&(s.active_comp_id().unwrap(), clip, fx)), "applying queues an analysis");
|
||||
let st = s.execute_checked("warp.status", json!({})).unwrap();
|
||||
assert_eq!(st["analyzed"], false);
|
||||
let r = s.execute_checked("warp.analyze", json!({"layer": clip.0, "wait": true})).unwrap();
|
||||
assert_eq!(r["frames"], 25, "{r}");
|
||||
assert_eq!(r["status"]["analyzed"], true, "{r}");
|
||||
assert!(s.warp_pending.is_empty() && s.warp_job.is_none());
|
||||
// Groups and AE parameter names.
|
||||
let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone();
|
||||
let names: Vec<&str> = g.groups().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Stabilization", "Borders", "Advanced"]);
|
||||
assert!(g.prop("borders/autoScale/maximumScale").is_some());
|
||||
assert_eq!(g.prop("stabilization/smoothness").unwrap().name, "Smoothness");
|
||||
|
||||
// Smooth Motion: the jitter of a scene point drops by more than 80 %.
|
||||
set_param(&mut s, clip, "borders/framing", json!(0));
|
||||
let (before, after) = scene_track(&mut s);
|
||||
let (j0, j1) = (jitter(&before), jitter(&after));
|
||||
assert!(j1 < 0.2 * j0, "jitter {j0} → {j1}");
|
||||
// Rendering applies the frame's correction: output(p) = source(W⁻¹ p).
|
||||
let w = warp_at(&mut s, 7);
|
||||
let out = render(&s, 7);
|
||||
let src = shaky(7);
|
||||
let inv = w.inverse().unwrap();
|
||||
for p in [[160.0, 120.0], [100.0, 80.0], [230.0, 170.0]] {
|
||||
let q = inv.apply([p[0] + 0.5, p[1] + 0.5]);
|
||||
let want = src.sample_bilinear(q[0], q[1]);
|
||||
let got = out.get(p[0] as i64, p[1] as i64);
|
||||
assert!((want[0] - got[0]).abs() < 0.03, "{p:?}: {want:?} vs {got:?}");
|
||||
}
|
||||
// Stabilize Only shows the moving borders.
|
||||
let shown: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
|
||||
assert!(shown > 500, "{shown}");
|
||||
|
||||
// No Motion keeps a feature fixed.
|
||||
set_param(&mut s, clip, "stabilization/result", json!(1));
|
||||
let (_, fixed) = scene_track(&mut s);
|
||||
for q in &fixed {
|
||||
assert!((q[0] - fixed[12][0]).hypot(q[1] - fixed[12][1]) < 0.6, "{q:?} vs {:?}", fixed[12]);
|
||||
}
|
||||
set_param(&mut s, clip, "stabilization/result", json!(0));
|
||||
|
||||
// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
|
||||
set_param(&mut s, clip, "borders/framing", json!(2));
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
|
||||
for f in 0..25 {
|
||||
assert_eq!(transparent(&render(&s, f)), 0, "frame {f}");
|
||||
}
|
||||
set_param(&mut s, clip, "borders/framing", json!(1));
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
|
||||
for f in [0, 9, 24] {
|
||||
let img = render(&s, f);
|
||||
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
|
||||
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
|
||||
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert!(img.get(0, 0)[3] < 0.01);
|
||||
}
|
||||
// Synthesize Edges fills most of the borders from neighbouring frames.
|
||||
set_param(&mut s, clip, "borders/framing", json!(3));
|
||||
let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
|
||||
assert!(synth * 10 < shown, "synthesized {synth} vs {shown}");
|
||||
// Show Track Points draws the features.
|
||||
set_param(&mut s, clip, "borders/framing", json!(0));
|
||||
let plain = render(&s, 5);
|
||||
set_param(&mut s, clip, "advanced/showTrackPoints", json!(true));
|
||||
assert_ne!(render(&s, 5).data, plain.data);
|
||||
|
||||
// Serde keeps the analysis; undo / redo of the analysis.
|
||||
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
|
||||
let bg = back.comp(s.active_comp_id().unwrap()).unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone();
|
||||
assert_eq!(bg, s.active_comp().unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone());
|
||||
while s.history.undo.last().is_some_and(|(l, _)| l != "Warp Stabilizer Analysis") {
|
||||
s.undo();
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_analysis_invalidates_and_cancels() {
|
||||
let (mut s, clip, solid) = setup(shaky);
|
||||
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
// Animation ▸ Warp Stabilizer VFX applies and analyses.
|
||||
assert!(s.is_enabled("track.warpStabilizer"));
|
||||
let r = s.execute_checked("track.warpStabilizer", json!({"wait": true})).unwrap();
|
||||
let fx = r["effect"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
|
||||
// Trimming the layer clears the analysis and queues it again.
|
||||
s.execute("layer.timing", json!({"layers": [clip.0], "out": 0.8})).unwrap();
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
assert_eq!(st["analyzed"], false, "{st}");
|
||||
assert_eq!(st["pending"], true);
|
||||
// Without an analysis the layer renders unstabilized.
|
||||
let img = s.render(cid, frame_time(3), RenderOpts::default());
|
||||
assert_eq!(transparent(&img), 0);
|
||||
// Frontends start queued analyses in the background (poll_warp with auto).
|
||||
assert!(s.poll_warp(true));
|
||||
assert!(s.warp_job.is_some());
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.warp_job.is_some() {
|
||||
assert!(t0.elapsed().as_secs() < 120);
|
||||
let p = s.warp_progress().unwrap();
|
||||
assert!(p.banner().starts_with("Analyzing in background (step 1 of 2)") || p.banner() == "Stabilizing...");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
s.poll_warp(true);
|
||||
}
|
||||
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
|
||||
// Undoing the slip brings back the old analysis (the key matches again).
|
||||
// Cancel: nothing is written.
|
||||
s.execute("effect.apply", json!({"effect": "ec.distort.warpstabilizer", "layers": [clip.0]})).unwrap();
|
||||
let fx2 = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().nth(1).unwrap().uid;
|
||||
let r = s.execute("warp.analyze", json!({"layer": clip.0, "effect": fx2})).unwrap();
|
||||
assert_eq!(r["running"], true);
|
||||
assert!(s.execute("warp.analyze", json!({"layer": clip.0, "effect": fx})).is_err(), "one analysis at a time");
|
||||
s.execute("warp.cancel", json!({})).unwrap();
|
||||
while s.warp_job.is_some() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
s.poll_warp(false);
|
||||
}
|
||||
let st = s.execute("warp.status", json!({"layer": clip.0, "effect": fx2})).unwrap();
|
||||
assert_eq!(st["analyzed"], false, "{st}");
|
||||
// Time Remap changes invalidate too.
|
||||
s.execute("layer.enableTimeRemap", json!({"layers": [clip.0]})).unwrap();
|
||||
assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": fx})).unwrap()["analyzed"], false);
|
||||
let _ = FPS;
|
||||
let _ = Tick::ZERO;
|
||||
}
|
||||
|
|
@ -17,11 +17,11 @@ use serde_json::json;
|
|||
|
||||
use crate::Session;
|
||||
|
||||
const W: u32 = 320;
|
||||
const H: u32 = 240;
|
||||
const FPS: u32 = 25;
|
||||
pub(crate) const W: u32 = 320;
|
||||
pub(crate) const H: u32 = 240;
|
||||
pub(crate) const FPS: u32 = 25;
|
||||
|
||||
fn texture(x: f64, y: f64, seed: u32) -> f32 {
|
||||
pub(crate) fn texture(x: f64, y: f64, seed: u32) -> f32 {
|
||||
let mut v = 0.0f64;
|
||||
let mut s = seed.wrapping_mul(2654435761).wrapping_add(12345);
|
||||
let mut rnd = || {
|
||||
|
|
@ -41,7 +41,7 @@ fn texture(x: f64, y: f64, seed: u32) -> f32 {
|
|||
}
|
||||
|
||||
/// Footage computed per frame (cached).
|
||||
struct Synth {
|
||||
pub(crate) struct Synth {
|
||||
make: Box<dyn Fn(u32) -> Image + Send + Sync>,
|
||||
cache: Mutex<HashMap<u32, Arc<Image>>>,
|
||||
}
|
||||
|
|
@ -55,9 +55,9 @@ impl FootageSource for Synth {
|
|||
}
|
||||
|
||||
/// Patch pose on frame `f`: centre, rotation (degrees), scale.
|
||||
type Pose = fn(u32) -> ([f64; 2], f64, f64);
|
||||
pub(crate) type Pose = fn(u32) -> ([f64; 2], f64, f64);
|
||||
|
||||
fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
|
||||
pub(crate) fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
|
||||
move |f| {
|
||||
let (c, rot, sc) = pose(f);
|
||||
let (sn, cs) = rot.to_radians().sin_cos();
|
||||
|
|
@ -76,7 +76,7 @@ fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
|
|||
}
|
||||
|
||||
/// A comp with the synthetic clip (layer `clip`) and a 100 × 100 solid above it (layer `solid`).
|
||||
fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, LayerId, LayerId) {
|
||||
pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, LayerId, LayerId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Track", "width": W, "height": H, "frameRate": FPS, "duration": 1})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
|
|
@ -121,7 +121,7 @@ fn tracker_points(s: &Session, layer: LayerId) -> Vec<effectcraft_project::PropG
|
|||
l.trackers().next().unwrap().0.track_points().cloned().collect()
|
||||
}
|
||||
|
||||
fn frame_time(f: u32) -> Tick {
|
||||
pub(crate) fn frame_time(f: u32) -> Tick {
|
||||
FrameRate::new(FPS as i64, 1).tick_of(f as i64)
|
||||
}
|
||||
|
||||
|
|
@ -220,7 +220,7 @@ fn stabilize_keeps_the_feature_fixed() {
|
|||
assert!(worst < 0.25, "feature drifts by {worst} px");
|
||||
}
|
||||
|
||||
fn rotating_pose(f: u32) -> ([f64; 2], f64, f64) {
|
||||
pub(crate) fn rotating_pose(f: u32) -> ([f64; 2], f64, f64) {
|
||||
let t = f as f64;
|
||||
([160.0 + 1.2 * t, 120.0 - 0.7 * t], 1.5 * t, 1.0 + 0.008 * t)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ pub struct TrackShared {
|
|||
pub frames: Mutex<Vec<FrameResult>>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ pub(crate) struct Work {
|
|||
|
||||
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
|
||||
/// Footage at its native size is used straight from the footage source (no copy).
|
||||
fn source_frame(
|
||||
pub(crate) fn source_frame(
|
||||
r: &Renderer,
|
||||
project: &Project,
|
||||
cid: ItemId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
//! Warp Stabilizer runtime: the analysis job (`warp.analyze`) and keeping analyses valid.
|
||||
//!
|
||||
//! The analysis renders the layer's input to the effect (source → masks → the effects above
|
||||
//! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out
|
||||
//! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s
|
||||
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
|
||||
//! stabilization plan once to validate the result. When the job finishes, the analysis is
|
||||
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
|
||||
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
|
||||
//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect
|
||||
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
|
||||
//! automatically too).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::tracking::lock;
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// Live analysis progress (serde for agents / the control channel).
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WarpProgress {
|
||||
/// 1 = analyzing, 2 = stabilizing.
|
||||
pub step: u8,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub elapsed: f64,
|
||||
pub fps: f64,
|
||||
pub finished: bool,
|
||||
pub cancelled: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl WarpProgress {
|
||||
/// The viewer banner text, as After Effects words it.
|
||||
pub fn banner(&self) -> String {
|
||||
match self.step {
|
||||
2 => "Stabilizing...".into(),
|
||||
_ => {
|
||||
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
|
||||
format!("Analyzing in background (step 1 of 2): {pc}%")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WarpShared {
|
||||
pub cancel: AtomicBool,
|
||||
pub state: Mutex<WarpProgress>,
|
||||
result: Mutex<Option<WarpAnalysis>>,
|
||||
}
|
||||
|
||||
/// A running (or finished, not yet polled) Warp Stabilizer analysis.
|
||||
pub struct WarpJob {
|
||||
pub shared: Arc<WarpShared>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
pub effect: Uid,
|
||||
/// The layer signature the analysis is made from.
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl WarpJob {
|
||||
pub fn is_finished(&self) -> bool {
|
||||
lock(&self.shared.state).finished
|
||||
}
|
||||
pub fn progress(&self) -> WarpProgress {
|
||||
lock(&self.shared.state).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the worker needs, detached from the session.
|
||||
pub(crate) struct WarpWork {
|
||||
pub project: Arc<Project>,
|
||||
pub footage: Arc<dyn FootageSource>,
|
||||
pub expr: Option<Arc<dyn ExprHost>>,
|
||||
pub cache: Arc<LayerCache>,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
/// Position of the effect in the layer's stack (the effects before it are analysed).
|
||||
pub effect_index: usize,
|
||||
pub size: [f64; 2],
|
||||
pub detailed: bool,
|
||||
/// Comp times of the analysed frames.
|
||||
pub times: Vec<Tick>,
|
||||
}
|
||||
|
||||
fn fnv(s: &str) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// What an analysis of `layer` depends on (its frames): source (and the footage item's
|
||||
/// interpretation), In/Out, start time, stretch, Time Remap and the comp frame rate.
|
||||
pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
|
||||
let src = match &layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => format!("{f:?}"),
|
||||
Some(ItemKind::Solid(s)) => format!("{s:?}"),
|
||||
Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration),
|
||||
_ => String::new(),
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default();
|
||||
let s = format!(
|
||||
"{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}",
|
||||
layer.source,
|
||||
layer.in_point,
|
||||
layer.out_point,
|
||||
layer.start_time,
|
||||
layer.stretch,
|
||||
layer.props.get("timeRemap")
|
||||
);
|
||||
format!("{:016x}", fnv(&s))
|
||||
}
|
||||
|
||||
fn is_warp(g: &PropGroup) -> bool {
|
||||
matches!(&g.kind, GroupKind::Effect { effect } if effect == warp_stab::ID)
|
||||
}
|
||||
|
||||
fn str_of(g: &PropGroup, m: &str) -> String {
|
||||
match g.get(m).map(|p| &p.value) {
|
||||
Some(Value::Str(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear analyses that no longer match their layer, after an edit. Returns the Warp
|
||||
/// Stabilizers that need (re-)analysis: cleared ones and newly applied, unanalysed ones.
|
||||
pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
|
||||
let mut todo = vec![];
|
||||
let mut clear = vec![];
|
||||
for (cid, it) in &after.items {
|
||||
let ItemKind::Comp(c) = &it.kind else { continue };
|
||||
for l in &c.layers {
|
||||
let Some(fx) = l.effects() else { continue };
|
||||
if !fx.groups().any(is_warp) {
|
||||
continue;
|
||||
}
|
||||
let sig = signature(after, *cid, l);
|
||||
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
|
||||
for g in fx.groups().filter(|g| is_warp(g)) {
|
||||
let analysed = !str_of(g, ANALYSIS).is_empty();
|
||||
if analysed && str_of(g, ANALYSIS_KEY) != sig {
|
||||
clear.push((*cid, l.id, g.uid));
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
} else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cid, lid, uid) in clear {
|
||||
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
|
||||
for m in [ANALYSIS, ANALYSIS_KEY] {
|
||||
if let Some(p) = g.get_mut(m) {
|
||||
p.value = Value::Str(String::new());
|
||||
p.keys.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
todo
|
||||
}
|
||||
|
||||
/// The layer's input to effect `effect_index` at comp time `t`, in layer pixels at 100 %.
|
||||
fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc<Image>, [f64; 2])> {
|
||||
let buf = r.layer_input(ctx, layer, effect_index)?;
|
||||
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
|
||||
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
|
||||
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
|
||||
let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale];
|
||||
return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off));
|
||||
}
|
||||
Some((Arc::new(buf.img.clone()), buf.offset))
|
||||
}
|
||||
|
||||
fn run_work(w: WarpWork, shared: &WarpShared) {
|
||||
let t0 = web_time::Instant::now();
|
||||
let fail = |msg: &str| {
|
||||
let mut s = lock(&shared.state);
|
||||
s.error = Some(msg.to_string());
|
||||
s.finished = true;
|
||||
};
|
||||
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
|
||||
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
|
||||
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
|
||||
r.expr = w.expr.as_deref();
|
||||
r.cache = Some(&w.cache);
|
||||
let r = &r;
|
||||
let ctx0 = EvalCtx::new(&w.project, w.comp, comp, Tick::ZERO);
|
||||
let ctx0 = EvalCtx { expr: w.expr.as_deref(), ..ctx0 };
|
||||
let frame_at = |t: Tick| input_frame(r, &ctx0.at(t), layer, w.effect_index);
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
s.step = 1;
|
||||
s.total = w.times.len() as u64;
|
||||
}
|
||||
let mut an = Analyzer::new(w.size, AnalyzeOpts { detailed: w.detailed });
|
||||
let mut next = frame_at(w.times[0]);
|
||||
for k in 0..w.times.len() {
|
||||
if shared.cancel.load(Ordering::Relaxed) {
|
||||
let mut s = lock(&shared.state);
|
||||
s.cancelled = true;
|
||||
s.finished = true;
|
||||
return;
|
||||
}
|
||||
let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) };
|
||||
let ((), nf) = rayon::join(
|
||||
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|
||||
|| if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None },
|
||||
);
|
||||
next = nf;
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
}
|
||||
lock(&shared.state).step = 2;
|
||||
let lt0 = layer.layer_time(w.times[0]).seconds();
|
||||
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
|
||||
let a = an.finish(lt0, fd);
|
||||
// Step 2: derive a plan once (validates the motion and warms nothing else up).
|
||||
let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() });
|
||||
if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) {
|
||||
return fail("the stabilization could not be computed");
|
||||
}
|
||||
*lock(&shared.result) = Some(a);
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.finished = true;
|
||||
}
|
||||
|
||||
/// Frames to analyse: every comp frame from the layer's In to its Out point (inside the comp).
|
||||
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) -> Vec<Tick> {
|
||||
let fd = comp.frame_duration();
|
||||
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
|
||||
let hi = layer.out_point.min(comp.duration);
|
||||
let mut t = lo;
|
||||
let mut v = vec![];
|
||||
while t < hi {
|
||||
v.push(t);
|
||||
t += fd;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn is_warp_analyzing(&self) -> bool {
|
||||
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
}
|
||||
|
||||
/// Progress of the running (or just finished) analysis.
|
||||
pub fn warp_progress(&self) -> Option<WarpProgress> {
|
||||
self.warp_job.as_ref().map(|j| j.progress())
|
||||
}
|
||||
|
||||
/// The effect being analysed: (comp, layer, effect uid).
|
||||
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (nothing is written).
|
||||
pub fn stop_warp(&mut self) -> bool {
|
||||
match &self.warp_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start analysing Warp Stabilizer `effect` on `layer`. `wait`: block until done (wasm32
|
||||
/// always does). Returns the number of frames.
|
||||
pub fn start_warp(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result<usize, String> {
|
||||
if self.is_warp_analyzing() {
|
||||
return Err("a Warp Stabilizer analysis is already running".into());
|
||||
}
|
||||
self.poll_warp_job();
|
||||
self.warp_pending.retain(|x| *x != (comp, layer, effect));
|
||||
let c = self.project.comp(comp).ok_or("no composition")?;
|
||||
let l = c.layer(layer).ok_or("no layer")?;
|
||||
let fx = l.effects().ok_or("the layer has no effects")?;
|
||||
let index = fx.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
|
||||
let g = fx.groups().nth(index).filter(|g| is_warp(g)).ok_or("not a Warp Stabilizer")?;
|
||||
if l.switches.adjustment {
|
||||
return Err("Warp Stabilizer needs footage, not an adjustment layer".into());
|
||||
}
|
||||
let times = analysis_times(c, l);
|
||||
if times.len() < 2 {
|
||||
return Err("the layer must be at least two frames long".into());
|
||||
}
|
||||
let (w, h) = effectcraft_render::source_size(&self.project, l);
|
||||
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
|
||||
let detailed = g.prop("advanced/detailedAnalysis").is_some_and(|p| p.value.as_bool());
|
||||
let n = times.len();
|
||||
let work = WarpWork {
|
||||
project: self.project.clone(),
|
||||
footage: self.footage.clone(),
|
||||
expr: self.expr.clone(),
|
||||
cache: self.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
effect_index: index,
|
||||
size,
|
||||
detailed,
|
||||
times,
|
||||
};
|
||||
let key = signature(&self.project, comp, l);
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(WarpShared::default());
|
||||
let thread = if wait {
|
||||
run_work(work, &shared);
|
||||
None
|
||||
} else {
|
||||
let sh = shared.clone();
|
||||
Some(std::thread::Builder::new().name("warp-analyze".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
|
||||
};
|
||||
self.warp_job = Some(WarpJob { shared, thread, comp, layer, effect, key });
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
self.poll_warp_job();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write a finished analysis into its effect (one undo step) and drop the job.
|
||||
fn poll_warp_job(&mut self) -> bool {
|
||||
let Some(job) = &self.warp_job else { return false };
|
||||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let mut job = self.warp_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
let st = job.progress();
|
||||
let result = lock(&job.shared.result).take();
|
||||
match (result, &st.error) {
|
||||
(Some(a), None) if !st.cancelled => {
|
||||
let json = a.to_json();
|
||||
let (cid, lid, uid, key) = (job.comp, job.layer, job.effect, job.key.clone());
|
||||
let r = self.edit("Warp Stabilizer Analysis", None, |p, _| {
|
||||
let g = p
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| crate::EngineError::Other("the Warp Stabilizer was removed during analysis".into()))?;
|
||||
for (m, v) in [(ANALYSIS, json), (ANALYSIS_KEY, key)] {
|
||||
if let Some(pr) = g.get_mut(m) {
|
||||
pr.value = Value::Str(v);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let msg = match r {
|
||||
Ok(()) => format!("Warp Stabilizer: analysed {} frame(s) in {:.1} s", a.frames.len(), st.elapsed),
|
||||
Err(e) => format!("Warp Stabilizer: {e}"),
|
||||
};
|
||||
self.events.push(Event::Toast { message: msg, error: false });
|
||||
}
|
||||
_ => {
|
||||
let msg = match &st.error {
|
||||
Some(e) => format!("Warp Stabilizer analysis failed: {e}"),
|
||||
None => "Warp Stabilizer analysis cancelled".into(),
|
||||
};
|
||||
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
|
||||
self.bump();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Frontends call this every frame: finishes analyses and (with `auto`) starts queued
|
||||
/// re-analyses in the background, like After Effects.
|
||||
pub fn poll_warp(&mut self, auto: bool) -> bool {
|
||||
let changed = self.poll_warp_job();
|
||||
if auto && self.warp_job.is_none() {
|
||||
while let Some((c, l, e)) = self.warp_pending.first().copied() {
|
||||
self.warp_pending.remove(0);
|
||||
if self.start_warp(c, l, e, false).is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -133,12 +133,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
// Track Camera / Warp Stabilizer (not available) and Track Motion / Stabilize Motion.
|
||||
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
|
||||
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
|
||||
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
|
||||
button(app, ui, r1, "Track Camera", false, "tracker.trackCamera");
|
||||
button(app, ui, r2, "Warp Stabilizer", false, "tracker.warpStabilizer");
|
||||
let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
|
||||
if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
|
||||
run(app, &ctx, "track.warpStabilizer", json!({}));
|
||||
}
|
||||
y += 28.0;
|
||||
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
|
||||
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
|
||||
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
|
||||
if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
|
||||
run(app, &ctx, "track.motion", src_p.clone());
|
||||
}
|
||||
|
|
@ -150,6 +153,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Open a composition to track motion.", Tokens::ui(12.0), t.text_faint);
|
||||
return;
|
||||
};
|
||||
// A selected mask switches the panel to mask tracking.
|
||||
if let Some((ml, mu)) = effectcraft_engine::commands::mask_interp::selected_mask(&app.session) {
|
||||
mask_mode(app, ui, &ctx, &p, rect, y, ml, mu);
|
||||
return;
|
||||
}
|
||||
let field_x = x0 + 100.0;
|
||||
let field_w = (w - 100.0).max(80.0);
|
||||
|
||||
|
|
@ -334,6 +342,84 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Tracker panel in mask mode: Method and the four track buttons (◀| ◀ ▶ |▶).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p: &egui::Painter, rect: Rect, mut y: f32, layer: LayerId, mask: Uid) {
|
||||
use effectcraft_engine::mask_track::MaskMethod;
|
||||
let t = app.tokens;
|
||||
let x0 = rect.min.x + 10.0;
|
||||
let w = (rect.width() - 20.0).max(120.0);
|
||||
let field_x = x0 + 100.0;
|
||||
let field_w = (w - 100.0).max(80.0);
|
||||
let name = app
|
||||
.session
|
||||
.active_comp()
|
||||
.and_then(|c| c.layer(layer))
|
||||
.and_then(|l| l.props.find_group(mask).map(|g| format!("{} ({})", g.name, l.name)))
|
||||
.unwrap_or_default();
|
||||
label(p, pos2(x0, y + 10.0), "Track Mask:", &t);
|
||||
p.text(pos2(field_x, y + 10.0), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
|
||||
y += ROW;
|
||||
label(p, pos2(x0, y + 10.0), "Method:", &t);
|
||||
let method = app.session.state.mask_track_method;
|
||||
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
|
||||
let did = egui::Id::new("tracker-mask-method-dd");
|
||||
if widgets::dropdown(ui, dd, method.label(), &t, did).clicked() {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
app.auto.add("tracker.mask.method", dd, "Method");
|
||||
let labels: Vec<String> = MaskMethod::ALL.iter().map(|m| m.label().to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, MaskMethod::ALL.iter().position(|m| *m == method)) {
|
||||
run(app, ctx, "track.maskMethod", json!({"method": MaskMethod::ALL[i].id()}));
|
||||
}
|
||||
y += ROW + 6.0;
|
||||
label(p, pos2(x0, y + 11.0), "Track:", &t);
|
||||
let running = app.session.is_mask_tracking();
|
||||
let running_dir = app.session.mask_job.as_ref().map(|j| j.direction);
|
||||
let mut bx = field_x;
|
||||
for (dir, fwd, frame, id, tip) in [
|
||||
(effectcraft_engine::tracking::Direction::FrameBackward, false, true, "frameBackward", "Track selected masks 1 frame backward"),
|
||||
(effectcraft_engine::tracking::Direction::Backward, false, false, "backward", "Track selected masks backward"),
|
||||
(effectcraft_engine::tracking::Direction::Forward, true, false, "forward", "Track selected masks forward"),
|
||||
(effectcraft_engine::tracking::Direction::FrameForward, true, true, "frameForward", "Track selected masks 1 frame forward"),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(bx, y), vec2(30.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tracker-mask-track", id)), Sense::click()).on_hover_text(tip);
|
||||
let stop = running && running_dir == Some(dir) && !frame;
|
||||
let on = !running || stop;
|
||||
p.rect_filled(r, 4.0, if resp.hovered() && on { t.hover } else { t.field_bg });
|
||||
p.rect_stroke(r, 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
analyze_glyph(p, r, fwd, frame, stop, if on { t.text } else { t.text_faint });
|
||||
app.auto.add(&format!("tracker.mask.track.{id}"), r, tip);
|
||||
if resp.clicked() && on {
|
||||
if stop {
|
||||
run(app, ctx, "track.stop", json!({}));
|
||||
} else {
|
||||
run(app, ctx, "track.mask", json!({"layer": layer.0, "mask": mask, "direction": id}));
|
||||
}
|
||||
}
|
||||
bx += 34.0;
|
||||
}
|
||||
y += 34.0;
|
||||
if let Some(pr) = app.session.mask_track_progress() {
|
||||
let bar = Rect::from_min_size(pos2(x0, y), vec2(w, 6.0));
|
||||
p.rect_filled(bar, 3.0, t.field_bg);
|
||||
let f = if pr.total > 0 { pr.done as f32 / pr.total as f32 } else { 0.0 };
|
||||
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f.clamp(0.0, 1.0), 6.0)), 3.0, t.accent);
|
||||
app.auto.add("tracker.mask.progress", bar, "Mask tracking progress");
|
||||
p.text(
|
||||
pos2(x0, y + 18.0),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("Tracking {} / {} frames ({:.0} fps)", pr.done, pr.total, pr.fps),
|
||||
Tokens::ui(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
} else {
|
||||
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Tracks the pixels inside the mask and keys its Mask Path.", Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dialogs
|
||||
|
||||
pub fn open_options(app: &mut EffectcraftApp) -> Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const SAME_LAYER: &[(&str, &str)] = &[
|
|||
("effects", "project"),
|
||||
("effects", "text"),
|
||||
("effects", "path"),
|
||||
("effects", "track"),
|
||||
("media", "render"),
|
||||
("expr", "render"),
|
||||
("export", "render"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue