Merge main and retain Cineon Settings in the extracted modules

This commit is contained in:
dotCooCoo
2026-10-27 11:41:43 -07:00
624 changed files with 54872 additions and 43833 deletions
+668
View File
@@ -0,0 +1,668 @@
//! 3D Camera Tracker runtime: the analysis job (`camera.analyze`, `track.camera`), keeping
//! analyses valid, and turning a solve into composition layers (`camera.createFromSolve`).
//!
//! The job renders the layer's input to the effect (`Renderer::layer_input`) for every frame
//! between the layer's In and Out points on a background thread and tracks features through
//! them (step 1 of 2, "Analyzing in background"), then solves the camera (step 2, "Solving
//! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method,
//! deleted points) the stored tracks are re-solved without re-tracking. Results are written into
//! the effect's hidden parameters as one undo step; edits that change the layer's frames clear
//! them and queue the effect again (`invalidate`, run after every edit), like After Effects.
//!
//! Composition space: the solve's canonical frame maps to comp world by
//! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's
//! scale in the comp), so the "3D Tracker Camera" reproduces the footage's view and created
//! layers sit on the selected points.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::camera_tracker::{self as ct, AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY, TRACKS, TRACKS_KEY};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{AutoOrient, GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
use effectcraft_time::Tick;
use effectcraft_track::camtrack::linalg::{self, Similarity, V3};
use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer};
use serde::Serialize;
use crate::offload::JobKind;
use crate::tracking::lock;
use crate::warp::{analysis_times, signature};
use crate::{Event, Session};
/// Live analysis progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraProgress {
/// 1 = tracking features, 2 = solving the camera.
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>,
/// Re-solving stored tracks (no tracking step).
pub solve_only: bool,
}
impl CameraProgress {
/// The viewer banner text, as After Effects words it.
pub fn banner(&self) -> String {
match self.step {
2 => "Solving camera".into(),
_ => {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
format!("Analyzing in background (step 1 of 2): {pc}%")
}
}
}
}
struct CamResult {
/// New tracks (None for a re-solve).
tracks: Option<CameraTracks>,
solve: Result<CameraSolve, String>,
}
#[derive(Default)]
pub struct CameraShared {
pub cancel: AtomicBool,
pub state: Mutex<CameraProgress>,
result: Mutex<Option<CamResult>>,
}
/// A running (or finished, not yet polled) camera analysis.
pub struct CameraJob {
pub shared: Arc<CameraShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
/// The layer signature the tracks are made from, and the solve settings key.
key: String,
solve_key: String,
}
impl CameraJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> CameraProgress {
lock(&self.shared.state).clone()
}
}
struct CamWork {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
cache: Arc<LayerCache>,
comp: ItemId,
layer: LayerId,
effect_index: usize,
size: [f64; 2],
detailed: bool,
times: Vec<Tick>,
/// Stored tracks to re-solve (skips step 1).
tracks: Option<Arc<CameraTracks>>,
settings: SolveSettings,
}
pub(crate) fn is_tracker(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == ct::ID)
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.prop(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
/// What a tracker's tracks are made from: the layer's frames and Detailed Analysis.
fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String {
let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool);
format!("{}{}", signature(project, cid, l), if detailed { "+detailed" } else { "" })
}
/// Parameters of an instance from static values (the tracker's settings are not animated).
pub fn static_params(g: &PropGroup) -> effectcraft_effects::Params {
effectcraft_effects::flatten_params(g, &mut |p| p.value.clone())
}
fn clear(g: &mut PropGroup, all: bool) {
let mut ids = vec![SOLVE, SOLVE_KEY, METHOD_USED];
if all {
ids.extend([TRACKS, TRACKS_KEY]);
}
for m in ids {
if let Some(p) = g.prop_mut(m) {
p.value = Value::Str(String::new());
p.keys.clear();
}
}
if let Some(p) = g.prop_mut(AVERAGE_ERROR) {
p.value = Value::Scalar(0.0);
}
}
/// Clear analyses that no longer match, after an edit: tracks made from other frames (and their
/// solve), and solves made with other settings. Returns the trackers 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 clears = 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_tracker) {
continue;
}
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
for g in fx.groups().filter(|g| is_tracker(g)) {
let tracked = !str_of(g, TRACKS).is_empty();
if tracked && str_of(g, TRACKS_KEY) != tracks_key(after, *cid, l, g) {
clears.push((*cid, l.id, g.uid, true));
todo.push((*cid, l.id, g.uid));
} else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) {
clears.push((*cid, l.id, g.uid, false));
todo.push((*cid, l.id, g.uid));
} else if !tracked && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid, all) in clears {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
clear(g, all);
}
}
todo
}
fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc<effectcraft_raster::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))
}
async fn run_work(w: CamWork, shared: &CameraShared) {
let t0 = web_time::Instant::now();
// A browser job worker's GPU (deferred readbacks): frames render in passes.
let accel = crate::offload::job_accel();
let accel = accel.as_deref();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let cancelled = || {
let mut s = lock(&shared.state);
s.cancelled = true;
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 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 (tracks, fresh) = match w.tracks.clone() {
Some(t) => (t, false),
None => {
let mut r = Renderer::new(&w.project, w.footage.as_ref(), crate::offload::analysis_opts(accel));
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 ctx0 = &ctx0;
let frame_at = |t: Tick| crate::offload::in_passes(accel, move |a| input_frame(&r.with_accel(a), &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 = TrackAnalyzer::new(w.size, AnalyzeOpts { detailed: w.detailed });
let mut next = frame_at(w.times[0]).await;
for k in 0..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
return cancelled();
}
let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) };
let ((), nf) = crate::offload::join_fetch(
accel.is_some(),
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|| async { if k + 1 < w.times.len() { frame_at(w.times[k + 1]).await } else { None } },
)
.await;
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 };
crate::offload::report(s.done, s.total);
}
(Arc::new(an.finish(lt0, fd)), true)
}
};
lock(&shared.state).step = 2;
let solve = effectcraft_track::camtrack::solve(&tracks, &w.settings, Some(&shared.cancel)).map_err(|e| e.0);
if shared.cancel.load(Ordering::Relaxed) {
return cancelled();
}
*lock(&shared.result) = Some(CamResult { tracks: fresh.then(|| (*tracks).clone()), solve });
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
impl Session {
pub fn is_camera_analyzing(&self) -> bool {
self.camera_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Camera).is_some()
}
/// Progress of the running (or just finished) analysis.
pub fn camera_progress(&self) -> Option<CameraProgress> {
self.camera_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::Camera).map(|j| {
let p = &j.progress;
CameraProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: crate::offload::rate(p.done, p.elapsed), ..Default::default() }
})
})
}
/// The tracker being analysed: (comp, layer, effect uid).
pub fn camera_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.camera_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Camera).map(|j| (j.comp, j.layer, j.uid)))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_camera(&mut self) -> bool {
if self.cancel_offloaded(JobKind::Camera) {
return true;
}
match &self.camera_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start analysing 3D Camera Tracker `effect` on `layer` (re-solving the stored tracks when
/// they are still valid). `wait`: block until done (wasm32 always does). Returns the number
/// of frames.
pub fn start_camera(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result<usize, String> {
if self.is_camera_analyzing() {
return Err("a 3D Camera Tracker analysis is already running".into());
}
self.poll_camera_job();
self.camera_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_tracker(g)).ok_or("not a 3D Camera Tracker")?;
if l.switches.adjustment {
return Err("3D Camera Tracker 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 size = effectcraft_render::effect_bounds(&self.project, c, l).0;
let params = static_params(g);
let key = tracks_key(&self.project, comp, l, g);
let settings = ct::settings(&params);
let solve_key = ct::solve_key(&settings);
let tracks = ct::tracks(&params).filter(|_| str_of(g, TRACKS_KEY) == key);
let n = times.len();
let work = CamWork {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
size,
detailed: params.values.iter().any(|(k, v)| k.ends_with("detailedAnalysis") && v.as_bool()),
times,
tracks: tracks.clone(),
settings,
};
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::Camera { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(CameraShared::default());
{
let mut st = lock(&shared.state);
st.solve_only = tracks.is_some();
st.step = if tracks.is_some() { 2 } else { 1 };
st.total = n as u64;
}
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
async move { run_work(work, &sh).await }
});
None
} else {
let sh = shared.clone();
Some(
std::thread::Builder::new()
.name("camera-track".into())
.spawn(move || effectcraft_render::passes::block_on(run_work(work, &sh)))
.map_err(|e| e.to_string())?,
)
};
self.camera_job = Some(CameraJob { shared, thread, comp, layer, effect, key, solve_key });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_camera_job();
Ok(n)
}
/// Write a finished analysis into its effect (one undo step) and drop the job.
fn poll_camera_job(&mut self) -> bool {
let Some(job) = &self.camera_job else { return false };
if !job.is_finished() {
return false;
}
let Some(mut job) = self.camera_job.take() else { return false };
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(res), None) if !st.cancelled => {
let (cid, lid, uid) = (job.comp, job.layer, job.effect);
let (key, skey) = (job.key.clone(), job.solve_key.clone());
let tracks_json = res.tracks.as_ref().map(CameraTracks::to_json);
let (solve_json, method, avg, msg, failed) = match &res.solve {
Ok(s) => (
s.to_json(),
s.method_used.label().to_string(),
s.average_error,
format!(
"3D Camera Tracker: solved {} frame(s), {} points, average error {:.2} px ({:.1} s)",
s.frames.len(),
s.points.len(),
s.average_error,
st.elapsed
),
false,
),
Err(e) => (String::new(), String::new(), 0.0, format!("3D Camera Tracker: {e}"), true),
};
let r = self.edit(if st.solve_only { "3D Camera Tracker Solve" } else { "3D Camera Tracker 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 3D Camera Tracker was removed during analysis".into()))?;
let mut set = |m: &str, v: Value| {
if let Some(pr) = g.prop_mut(m) {
pr.value = v;
}
};
if let Some(t) = &tracks_json {
set(TRACKS, Value::Str(t.clone()));
set(TRACKS_KEY, Value::Str(key.clone()));
}
set(SOLVE, Value::Str(solve_json.clone()));
set(SOLVE_KEY, Value::Str(skey.clone()));
set(METHOD_USED, Value::Str(method.clone()));
set(AVERAGE_ERROR, Value::Scalar(avg));
Ok(())
});
let (msg, failed) = match r {
Ok(()) => (msg, failed),
Err(e) => (format!("3D Camera Tracker: {e}"), true),
};
self.events.push(Event::Toast { message: msg, error: failed });
}
_ => {
let msg = match &st.error {
Some(e) => format!("3D Camera Tracker analysis failed: {e}"),
None => "3D Camera Tracker 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_camera(&mut self, auto: bool) -> bool {
let changed = self.poll_camera_job();
if auto && self.camera_job.is_none() && self.offloaded(JobKind::Camera).is_none() {
while let Some((c, l, e)) = self.camera_pending.first().copied() {
self.camera_pending.remove(0);
if self.start_camera(c, l, e, false).is_ok() {
return true;
}
}
}
changed
}
}
// ---------------------------------------------------------------- solve → composition
/// A tracker's solve placed in its composition.
pub struct Placed {
pub solve: Arc<CameraSolve>,
/// Canonical → comp world.
pub world: Similarity,
/// Comp pixels per layer pixel.
pub scale: f64,
/// Comp times of the analysed frames.
pub times: Vec<Tick>,
pub comp_size: [f64; 2],
}
impl Placed {
/// Comp-world position of a canonical point.
pub fn to_world(&self, x: V3) -> V3 {
self.world.apply(x)
}
/// Canonical position of a comp-world point.
pub fn from_world(&self, x: V3) -> V3 {
let r = &self.world;
linalg::scale(linalg::mtv(&r.r, linalg::sub(x, r.t)), 1.0 / r.s)
}
/// The 3D Tracker Camera on frame `k`: (position, orientation degrees, zoom).
pub fn camera(&self, k: usize) -> (V3, V3, f64) {
let c = &self.solve.frames[k];
let m = linalg::mm(&self.world.r, &linalg::mt(&c.r()));
(self.to_world(c.center), linalg::euler_xyz(&m), c.focal * self.scale)
}
/// Analysed frame at comp time `t`.
pub fn frame_at(&self, t: Tick) -> usize {
self.times.iter().enumerate().min_by_key(|(_, x)| (x.0 - t.0).abs()).map(|(i, _)| i).unwrap_or(0).min(self.solve.frames.len().saturating_sub(1))
}
}
/// Comp pixels per layer pixel of `layer` (its 2D transform at its In point).
fn layer_scale(project: &Project, cid: ItemId, layer: &Layer) -> f64 {
let Some(comp) = project.comp(cid) else { return 1.0 };
let ctx = EvalCtx::new(project, cid, comp, layer.in_point.max(Tick::ZERO));
let m = ctx.world_matrix(layer).0;
let s = m[0][0].hypot(m[1][0]);
if s.is_finite() && s > 1e-9 { s } else { 1.0 }
}
/// The solve of tracker `uid` on `lid`, placed in the comp.
pub fn placed(project: &Project, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Placed> {
let comp = project.comp(cid)?;
let layer = comp.layer(lid)?;
let g = layer.props.find_group(uid).filter(|g| is_tracker(g))?;
let solve = ct::solve(&static_params(g))?;
let scale = layer_scale(project, cid, layer);
let k0 = solve.first_solved()?;
let comp_size = [comp.width as f64, comp.height as f64];
let world = solve.world(comp_size, solve.frames[k0].focal * scale);
let mut times = analysis_times(comp, layer);
times.truncate(solve.frames.len());
Some(Placed { solve, world, scale, times, comp_size })
}
/// What to create from a target (`camera.createFromSolve`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CreateKind {
Text,
Solid,
Null,
ShadowCatcher,
/// Only the camera.
Camera,
}
impl CreateKind {
pub fn from_name(s: &str) -> Option<CreateKind> {
match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"text" => Some(CreateKind::Text),
"solid" => Some(CreateKind::Solid),
"null" => Some(CreateKind::Null),
"shadowcatcher" | "shadow" => Some(CreateKind::ShadowCatcher),
"camera" => Some(CreateKind::Camera),
_ => None,
}
}
}
/// Rotation (layer → world) of a layer lying on a plane with unit `normal` (towards the camera),
/// its x axis along `right` projected on the plane: the layer's front (−z) faces the camera.
pub fn plane_orientation(normal: V3, right: V3) -> V3 {
let z = linalg::scale(normal, -1.0);
let mut x = linalg::sub(right, linalg::scale(z, linalg::dot(right, z)));
if linalg::norm(x) < 1e-9 {
x = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
x = linalg::sub(x, linalg::scale(z, linalg::dot(x, z)));
}
let x = linalg::normalize(x);
let y = linalg::cross(z, x);
let m = linalg::from_cols(x, y, z);
norm_angles(linalg::euler_xyz(&m))
}
fn norm_angles(a: V3) -> V3 {
a.map(|v| {
let r = v.rem_euclid(360.0);
if (r - 360.0).abs() < 1e-9 { 0.0 } else { r }
})
}
/// Key `path` on `l` at every `(time, value)`.
pub(crate) fn key_all(l: &mut Layer, path: &str, keys: &[(Tick, Value)]) {
if let Some(pr) = l.props.prop_mut(path) {
pr.keys = keys.iter().map(|(t, v)| Keyframe::new(*t, v.clone())).collect();
if let Some((_, v)) = keys.first() {
pr.value = v.clone();
}
}
}
/// Write the solved camera path into camera layer `l` (one-node, keyed on every analysed frame;
/// Zoom keyed for Variable Zoom).
pub(crate) fn write_camera(l: &mut Layer, pl: &Placed) {
l.auto_orient = AutoOrient::Off;
let n = pl.times.len().min(pl.solve.frames.len());
let mut pos = Vec::with_capacity(n);
let mut ori = Vec::with_capacity(n);
let mut zoom = Vec::with_capacity(n);
let mut prev: Option<V3> = None;
for k in 0..n {
let (p, o, z) = pl.camera(k);
// Unwrap the angles so per-frame keys interpolate the short way.
let o = match prev {
None => norm_angles(o),
Some(q) => [0, 1, 2].map(|i| q[i] + (o[i] - q[i] + 180.0).rem_euclid(360.0) - 180.0),
};
prev = Some(o);
let t = l.layer_time(pl.times[k]);
pos.push((t, Value::Vec3(p)));
ori.push((t, Value::Vec3(o)));
zoom.push((t, Value::Scalar(z)));
}
key_all(l, "transform/position", &pos);
key_all(l, "transform/orientation", &ori);
let variable = pl.solve.frames.iter().any(|f| (f.focal - pl.solve.frames[0].focal).abs() > 1e-6 * f.focal);
if variable {
key_all(l, "cameraOptions/zoom", &zoom);
} else if let Some((_, z)) = zoom.first()
&& let Some(pr) = l.props.prop_mut("cameraOptions/zoom")
{
pr.keys.clear();
pr.value = z.clone();
}
if let Some((_, z)) = zoom.first()
&& let Some(pr) = l.props.prop_mut("cameraOptions/focusDistance")
{
pr.value = z.clone();
}
// A one-node camera has no Point of Interest.
if let Some(tr) = l.props.sub_mut("transform") {
tr.children.retain(|c| !matches!(c, effectcraft_project::Node::Prop(p) if p.match_id == "poi"));
}
for rot in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
if let Some(pr) = l.props.prop_mut(rot) {
pr.keys.clear();
pr.value = Value::Scalar(0.0);
}
}
}
/// The target for `ids` on frame `k`: when fewer than three points are given, the nearest
/// solved neighbours (in 3D) complete the plane.
pub fn target_for(solve: &CameraSolve, ids: &[u32], k: usize) -> Option<Target> {
let mut use_ids: Vec<u32> = ids.iter().copied().filter(|i| solve.point(*i).is_some()).collect();
if use_ids.is_empty() {
return None;
}
if use_ids.len() < 3 {
let c = solve.point(use_ids[0])?.pos;
let mut others: Vec<(f64, u32)> = solve.visible(k).filter(|p| !use_ids.contains(&p.id)).map(|p| (linalg::norm(linalg::sub(p.pos, c)), p.id)).collect();
others.sort_by(|a, b| a.0.total_cmp(&b.0));
for (_, id) in others.into_iter().take(6usize.saturating_sub(use_ids.len())) {
use_ids.push(id);
}
let mut t = solve.target(&use_ids, k)?;
// The target sits on the chosen point(s).
let pts: Vec<V3> = ids.iter().filter_map(|i| solve.point(*i)).map(|p| p.pos).collect();
t.center = linalg::scale(pts.iter().fold([0.0; 3], |a, b| linalg::add(a, *b)), 1.0 / pts.len() as f64);
return Some(t);
}
solve.target(&use_ids, k)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plane_orientation_faces_the_normal() {
let n = linalg::normalize([0.2, -0.9, -0.3]);
let o = plane_orientation(n, [1.0, 0.0, 0.0]);
let m = linalg::from_euler_xyz(o);
// Layer −z (its front) is the normal.
let front = linalg::mv(&m, [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, n)) < 1e-9);
}
}
+482
View File
@@ -0,0 +1,482 @@
//! 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.
//!
//! The two **Face Tracking** methods use `effectcraft_track::face` instead: the mask (drawn
//! around a face) seeds the tracker, and every frame's face outline becomes the Mask Path
//! (*Outline Only*); *Detailed Features* also keys the facial landmarks into a **Face Track
//! Points** effect on the layer (created on first use), from which Extract & Copy Face
//! Measurements (`track.extractFaceMeasurements`) derives a keyed **Face Measurements** effect.
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, 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,
/// Face Tracking (Outline Only).
FaceOutline,
/// Face Tracking (Detailed Features).
FaceDetailed,
}
impl MaskMethod {
pub const ALL: [MaskMethod; 7] = [
MaskMethod::Position,
MaskMethod::PositionScale,
MaskMethod::PositionScaleRotation,
MaskMethod::PositionScaleRotationSkew,
MaskMethod::Perspective,
MaskMethod::FaceOutline,
MaskMethod::FaceDetailed,
];
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",
MaskMethod::FaceOutline => "Face Tracking (Outline Only)",
MaskMethod::FaceDetailed => "Face Tracking (Detailed Features)",
}
}
/// One of the Face Tracking methods.
pub fn is_face(self) -> bool {
matches!(self, MaskMethod::FaceOutline | MaskMethod::FaceDetailed)
}
pub fn id(self) -> &'static str {
match self {
MaskMethod::Position => "position",
MaskMethod::PositionScale => "positionScale",
MaskMethod::PositionScaleRotation => "positionScaleRotation",
MaskMethod::PositionScaleRotationSkew => "positionScaleRotationSkew",
MaskMethod::Perspective => "perspective",
MaskMethod::FaceOutline => "faceOutline",
MaskMethod::FaceDetailed => "faceDetailed",
}
}
pub fn from_name(s: &str) -> Option<MaskMethod> {
let n: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
MaskMethod::ALL
.into_iter()
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(char::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,
MaskMethod::FaceOutline | MaskMethod::FaceDetailed => Model::Similarity,
}
}
}
/// 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,
feather: p.feather.clone(),
}
}
/// A closed, smooth mask path through a face outline polygon: `n` vertices with Catmull-Rom
/// tangents. `like` lends its feather points.
pub fn outline_path(poly: &[[f64; 2]], n: usize, like: &ShapePath) -> ShapePath {
let m = poly.len();
let n = n.clamp(3, m.max(3));
let v: Vec<[f64; 2]> = (0..n).map(|i| poly[(i * m / n) % m.max(1)]).collect();
let tan: Vec<[f64; 2]> = (0..n)
.map(|i| {
let (a, b) = (v[(i + n - 1) % n], v[(i + 1) % n]);
[(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0]
})
.collect();
ShapePath {
vertices: v,
in_tangents: tan.iter().map(|t| [-t[0], -t[1]]).collect(),
out_tangents: tan,
closed: true,
feather: if like.len() == n { like.feather.clone() } else { vec![] },
}
}
/// One tracked frame: (layer time, comp time, mask path, face landmarks for Detailed Features).
type MaskFrame = (Tick, Tick, ShapePath, Option<[[f64; 2]; trk::face::N]>);
#[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>,
/// Face tracking's trained model (Settings ▸ Face Tracking), if one is in use.
pub face_model: Option<Arc<dyn effectcraft_segment::face::FaceModel>>,
}
async fn run_work(w: MaskWork, shared: &MaskTrackShared) {
let t0 = web_time::Instant::now();
// A browser job worker's GPU (deferred readbacks): frames render in passes.
let accel = crate::offload::job_accel();
let accel = accel.as_deref();
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(), crate::offload::analysis_opts(accel));
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let wr = &w;
let frame_at =
|t: Tick| crate::offload::in_passes(accel, move |a| source_frame(&r.with_accel(a), &wr.project, wr.comp, comp, layer, t, wr.expr.as_deref()));
lock(&shared.state).total = w.times.len().saturating_sub(1) as u64;
let Some(first) = frame_at(w.times[0]).await else { return fail("the layer has no pixels to track") };
if w.method.is_face() {
return run_face(&w, shared, first, &frame_at, accel.is_some(), layer, t0).await;
}
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(), None));
let mut next = if w.times.len() > 1 { frame_at(w.times[1]).await } 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) = crate::offload::join_fetch(
accel.is_some(),
|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }),
|| async { if k + 1 < w.times.len() { frame_at(w.times[k + 1]).await } else { None } },
)
.await;
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(), None));
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();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Face tracking: the face outline keys the mask; Detailed Features adds the landmarks.
/// `deferred`: frames render in passes (awaited one after the other).
async fn run_face<F: std::future::Future<Output = Option<(Arc<effectcraft_raster::Image>, [f64; 2])>>>(
w: &MaskWork,
shared: &MaskTrackShared,
first: (Arc<effectcraft_raster::Image>, [f64; 2]),
frame_at: &(impl Fn(Tick) -> F + Sync),
deferred: bool,
layer: &effectcraft_project::Layer,
t0: web_time::Instant,
) {
let detailed = w.method == MaskMethod::FaceDetailed;
let b = trk::mask::bounds(&flatten(&w.path, 8));
let Some((mut tracker, fit)) = trk::face::FaceTracker::new_with(&trk::Frame { img: &first.0, offset: first.1 }, b, w.face_model.clone()) else {
let mut s = lock(&shared.state);
s.error = Some("no face found inside the mask (draw the mask around a face)".into());
s.finished = true;
return;
};
let key = |fit: &trk::face::FaceFit| (outline_path(&fit.outline, 16, &w.path), detailed.then_some(fit.landmarks));
let (path, pts) = key(&fit);
lock(&shared.frames).push((layer.layer_time(w.times[0]), w.times[0], path, pts));
let mut next = if w.times.len() > 1 { frame_at(w.times[1]).await } 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) = crate::offload::join_fetch(
deferred,
|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }),
|| async { if k + 1 < w.times.len() { frame_at(w.times[k + 1]).await } else { None } },
)
.await;
next = nf;
let Some(fit) = res else {
lock(&shared.state).error = Some(format!("lost the face at {:.3} s", w.times[k].seconds()));
break;
};
let (path, pts) = key(&fit);
lock(&shared.frames).push((layer.layer_time(w.times[k]), w.times[k], path, pts));
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();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// The layer's Face Track Points effect (created when missing). Returns its uid.
pub(crate) fn face_points_group(p: &mut Project, comp: ItemId, layer: LayerId) -> Option<Uid> {
face_effect(p, comp, layer, effectcraft_effects::face_track::POINTS_ID)
}
/// The layer's effect `id`, applied when missing (data effects of face tracking).
pub(crate) fn face_effect(p: &mut Project, comp: ItemId, layer: LayerId, id: &str) -> Option<Uid> {
let spec = effectcraft_effects::find(id)?;
let l = p.comp(comp)?.layer(layer)?;
if let Some(g) = l.effects().and_then(|fx| fx.groups().find(|g| g.match_id == spec.id)) {
return Some(g.uid);
}
let (w, h) = effectcraft_render::source_size(p, l);
let size = if w == 0 { p.comp(comp).map(|c| [c.width as f64, c.height as f64]).unwrap_or([1920.0, 1080.0]) } else { [w as f64, h as f64] };
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut effectcraft_project::build::Ids(&mut next), spec.name, size);
let uid = g.uid;
let fx = p.comp_mut(comp)?.layer_mut(layer)?.props.sub_mut("effects")?;
fx.children.push(g.into());
p.next_id = next;
Some(uid)
}
fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
let points = if frames.iter().any(|f| f.3.is_some()) { face_points_group(p, job.comp, job.layer) } else { None };
let Some(layer) = p.comp_mut(job.comp).and_then(|c| c.layer_mut(job.layer)) else { return };
if let Some(g) = layer.props.find_group_mut(job.mask)
&& let Some(pr) = g.get_mut("path")
{
for (lt, _, path, _) in frames {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Path(path.clone())));
}
}
if let Some(g) = points.and_then(|u| layer.props.find_group_mut(u)) {
for (lt, _, _, pts) in frames {
let Some(pts) = pts else { continue };
for (i, (id, _, _)) in trk::face::LANDMARKS.iter().enumerate() {
if let Some(pr) = g.get_mut(id) {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Vec2(pts[i])));
}
}
}
}
}
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()) || self.offloaded(crate::offload::JobKind::MaskTrack).is_some()
}
/// 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()).or_else(|| self.offloaded(crate::offload::JobKind::MaskTrack).map(|j| j.progress.track()))
}
/// Cancel the running mask track (frames tracked so far are kept).
pub fn stop_mask_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::MaskTrack) {
return true;
}
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();
if !wait && self.offloads() && !work.method.is_face() {
let MaskWork { comp, layer, path, method, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
let levels = self.prefs.general.undo_levels as usize;
self.history.record("Track Mask", self.project.clone(), levels);
let shared = Arc::new(MaskTrackShared::default());
let (comp, layer) = (work.comp, work.layer);
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
async move { run_work(work, &sh).await }
});
None
} else {
let sh = shared.clone();
Some(
std::thread::Builder::new()
.name("mask-track".into())
.spawn(move || effectcraft_render::passes::block_on(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 Some(mut job) = self.mask_job.take() {
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);
}
}
}
+463
View File
@@ -0,0 +1,463 @@
//! Trained models (Settings ▸ Roto Brush and Settings ▸ Face Tracking; `roto.model*` and
//! `face.model*` commands): which models the registry offers (`effectcraft_segment::MODELS`, all
//! open source), which are installed (in the `models` folder), installing one from a file or
//! downloading the official weights (verified against the registry's SHA-256 either way, and kept
//! with a notice naming their authors and licence), choosing one per task, and loading it in the
//! background: into the Roto Brush effect (`effects::roto::set_model`) or the face tracker
//! ([`Models::face`]).
//!
//! Weights are never bundled: the install stays small and nothing is fetched unless asked for.
//! Downloads use the system's `curl` (shipped with Windows 10+, macOS and most Linux systems),
//! so no networking code is linked into EffectCraft; without it, the file can be downloaded in a
//! browser and installed with Install from File. The browser build uses the built-in engines
//! only, for now.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use effectcraft_segment::face::FaceModel;
use effectcraft_segment::{self as seg, Loaded, ModelInfo, Task};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, b_p, bad, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// What a task's background worker reports.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Status {
/// What is happening now ("Downloading MobileSAM…").
pub busy: Option<String>,
/// The model in use.
pub active: Option<String>,
/// The last failure, for the Settings page.
pub error: Option<String>,
/// A finished job's message and whether it failed, for a toast (taken by
/// [`Session::poll_models`]).
pub done: Option<(String, bool)>,
}
/// The session's trained models: per task, the worker's status; and the face model in use.
#[derive(Default)]
pub struct Models {
roto: Arc<Mutex<Status>>,
face: Arc<Mutex<Status>>,
face_model: FaceSlot,
}
impl Models {
fn lane(&self, task: Task) -> &Arc<Mutex<Status>> {
match task {
Task::Mask => &self.roto,
Task::Face => &self.face,
}
}
pub fn status(&self, task: Task) -> Status {
self.lane(task).lock().map(|s| s.clone()).unwrap_or_default()
}
/// The face model face tracking uses (`None`: the classic tracker).
pub fn face(&self) -> Option<Arc<dyn FaceModel>> {
self.face_model.lock().ok().and_then(|m| m.clone())
}
fn set_active(&self, task: Task, model: Option<Loaded>) {
activate(self.lane(task), &self.face_model, task, model);
}
}
type FaceSlot = Arc<Mutex<Option<Arc<dyn FaceModel>>>>;
/// Put a loaded model to use (or drop `task`'s): Roto Brush's lives in the effect, the face
/// tracker's in `face`.
fn activate(lane: &Arc<Mutex<Status>>, face: &FaceSlot, task: Task, model: Option<Loaded>) {
let id = model.as_ref().map(|m| match m {
Loaded::Mask(m) => m.info().id.to_string(),
Loaded::Face(m) => m.info().id.to_string(),
});
match (task, model) {
(Task::Mask, Some(Loaded::Mask(m))) => crate::effects::roto::set_model(Some(m)),
(Task::Mask, _) => crate::effects::roto::set_model(None),
(Task::Face, m) => {
if let Ok(mut slot) = face.lock() {
*slot = match m {
Some(Loaded::Face(f)) => Some(f),
_ => None,
};
}
}
}
set(lane, |s| s.active = id);
}
fn set(status: &Arc<Mutex<Status>>, f: impl FnOnce(&mut Status)) {
if let Ok(mut s) = status.lock() {
f(&mut s);
}
}
/// How a task shows in Settings, toasts and the command list.
struct Kind {
/// Command prefix and settings key section.
prefix: &'static str,
label: &'static str,
classic_name: &'static str,
classic_description: &'static str,
}
const ROTO: Kind = Kind {
prefix: "roto",
label: "Roto Brush",
classic_name: "Classic (graph cut)",
classic_description: "Built in: colour models and graph cuts, propagated by optical flow. Always available.",
};
const FACE: Kind = Kind {
prefix: "face",
label: "Face Tracking",
classic_name: "Classic (shape model)",
classic_description: "Built in: a skin colour model, facial feature shapes and a fitted face shape model. Best on front-facing, evenly lit faces. Always available.",
};
fn kind(task: Task) -> &'static Kind {
match task {
Task::Mask => &ROTO,
Task::Face => &FACE,
}
}
impl Session {
/// The folder installed models live in: the host's, else `models` next to the settings.
pub fn models_dir(&self) -> Option<PathBuf> {
self.models_dir.clone().or_else(|| Some(self.config.as_ref()?.dir()?.join("models")))
}
/// Where `m` is installed, if it is (a file of the published size).
pub fn model_path(&self, m: &ModelInfo) -> Option<PathBuf> {
let p = self.models_dir()?.join(m.file_name);
std::fs::metadata(&p).ok().filter(|md| md.len() == m.size).map(|_| p)
}
/// The model chosen for `task` (Settings).
pub fn chosen_model(&self, task: Task) -> &str {
match task {
Task::Mask => &self.prefs.roto.model,
Task::Face => &self.prefs.face.model,
}
}
fn choose_model(&mut self, task: Task, id: &str) {
let slot = match task {
Task::Mask => &mut self.prefs.roto.model,
Task::Face => &mut self.prefs.face.model,
};
*slot = id.to_string();
}
/// Load (or drop) the models Settings chooses; called whenever settings change.
pub fn apply_models(&mut self) {
for task in [Task::Mask, Task::Face] {
self.apply_model(task, false);
}
}
/// Load (or drop) `task`'s chosen model; `wait`: before returning (scripts and agents).
fn apply_model(&mut self, task: Task, wait: bool) {
let want = self.chosen_model(task).to_string();
let lane = self.models.lane(task).clone();
let st = self.models.status(task);
let info = seg::info(&want).filter(|m| m.task == task);
let Some(info) = info else {
if st.active.is_some() {
self.models.set_active(task, None);
}
return;
};
if st.active.as_deref() == Some(info.id) || st.busy.is_some() {
return;
}
let Some(path) = self.model_path(info) else {
self.models.set_active(task, None);
return;
};
let (face_slot, label) = (self.models.face_model.clone(), kind(task).label);
spawn(&lane, label, format!("Loading {}…", info.name), wait, move |status| {
let m = load_file(&path, info.id)?;
activate(status, &face_slot, task, Some(m));
Ok(format!("{label}: {} loaded", info.name))
});
}
/// Finish what the model workers did: a toast, and loading a model that just arrived.
pub fn poll_models(&mut self) {
for task in [Task::Mask, Task::Face] {
let done = self.models.lane(task).lock().ok().and_then(|mut s| s.done.take());
if let Some((message, error)) = done {
self.events.push(crate::Event::Toast { message, error });
self.apply_model(task, false);
}
}
}
}
fn load_file(path: &Path, id: &str) -> std::result::Result<Loaded, String> {
let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
seg::load(id, &bytes)
}
/// Run `job` on a worker thread (`wait`: right here), reporting through `status` (desktop; the
/// browser build has no trained models yet).
fn spawn(
status: &Arc<Mutex<Status>>,
label: &'static str,
what: String,
wait: bool,
job: impl FnOnce(&Arc<Mutex<Status>>) -> std::result::Result<String, String> + Send + 'static,
) {
if cfg!(target_arch = "wasm32") {
set(status, |s| s.error = Some("trained models are available in the desktop app".into()));
return;
}
set(status, |s| {
s.busy = Some(what);
s.error = None;
});
let st = status.clone();
let run = move || {
let r = job(&st);
set(&st, |s| {
s.busy = None;
match r {
Ok(msg) => s.done = Some((msg, false)),
Err(e) => {
s.done = Some((format!("{label} model: {e}"), true));
s.error = Some(e);
}
}
});
};
if wait {
run();
} else if let Err(e) = std::thread::Builder::new().name("ec-model".into()).spawn(run) {
set(status, |s| {
s.busy = None;
s.error = Some(format!("cannot start: {e}"));
});
}
}
/// The notice kept next to `m`'s weights.
fn notice_path(dir: &Path, m: &ModelInfo) -> PathBuf {
dir.join(format!("{}.NOTICE.txt", m.file_name))
}
/// Copy verified weights into the models folder (atomically), with their notice.
fn install_bytes(dir: &Path, m: &ModelInfo, bytes: &[u8]) -> std::result::Result<PathBuf, String> {
if bytes.len() as u64 != m.size || seg::sha256::hex(bytes) != m.sha256 {
return Err(format!("this is not the published {} file (size or SHA-256 differs)", m.name));
}
use crate::config::FileOps;
let path = dir.join(m.file_name);
let files = crate::config::StdFiles;
files.write(&path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
files.write(&notice_path(dir, m), seg::notice(m).as_bytes()).map_err(|e| format!("cannot write the notice: {e}"))?;
Ok(path)
}
fn model_p(p: &Value, cmd: &str, task: Task) -> Result<&'static ModelInfo> {
let id = str_p(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
seg::info(id).filter(|m| m.task == task).ok_or_else(|| bad(cmd, format!("unknown model `{id}` (see {}.models)", kind(task).prefix)))
}
fn list(s: &mut Session, task: Task) -> Result<Value> {
let k = kind(task);
let st = s.models.status(task);
let chosen = s.chosen_model(task).to_string();
let mut v = vec![json!({
"id": seg::CLASSICAL,
"name": k.classic_name,
"description": k.classic_description,
"licence": "MIT OR Apache-2.0",
"installed": true,
"selected": chosen == seg::CLASSICAL,
"active": st.active.is_none(),
})];
for m in seg::models(task) {
v.push(json!({
"id": m.id,
"name": m.name,
"description": m.description,
"authors": m.authors,
"licence": m.licence,
"licenceUrl": m.licence_url,
"homepage": m.homepage,
"url": m.url,
"size": m.size,
"sha256": m.sha256,
"installed": s.model_path(m).is_some(),
"selected": chosen == m.id,
"active": st.active.as_deref() == Some(m.id),
}));
}
Ok(json!({"models": v, "busy": st.busy, "error": st.error, "folder": s.models_dir().map(|d| d.display().to_string())}))
}
/// After a `wait`: the job's failure, as the command's.
fn waited(s: &Session, task: Task, cmd: &str) -> Result<()> {
match s.models.status(task).error {
Some(e) => Err(bad(cmd, e)),
None => Ok(()),
}
}
fn select(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let cmd = format!("{}.model.select", kind(task).prefix);
let id = str_p(p, "id").ok_or_else(|| bad(&cmd, "missing `id`"))?;
if id != seg::CLASSICAL {
model_p(p, &cmd, task)?;
}
let wait = b_p(p, "wait").unwrap_or(false);
s.choose_model(task, id);
if wait {
s.apply_model(task, true);
waited(s, task, &cmd)?;
}
s.save_prefs();
s.prefs_changed();
Ok(json!({"model": id, "active": s.models.status(task).active}))
}
fn install(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let cmd = format!("{}.model.install", kind(task).prefix);
let path = str_p(p, "path").ok_or_else(|| bad(&cmd, "missing `path` (the downloaded weights file)"))?;
let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
// Which model it is: the one asked for, or the one whose checksum it has.
let m = match str_p(p, "id") {
Some(_) => model_p(p, &cmd, task)?,
None => {
let h = seg::sha256::hex(&bytes);
seg::models(task).find(|m| m.sha256 == h).ok_or_else(|| bad(&cmd, "this file is not a published model in the registry (SHA-256 differs)"))?
}
};
let dest = install_bytes(&dir, m, &bytes).map_err(|e| bad(&cmd, e))?;
let wait = b_p(p, "wait").unwrap_or(false);
s.apply_model(task, wait);
if wait {
waited(s, task, &cmd)?;
}
Ok(json!({"id": m.id, "path": dest.display().to_string()}))
}
fn download(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let k = kind(task);
let cmd = format!("{}.model.download", k.prefix);
let m = model_p(p, &cmd, task)?;
let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?;
if s.models.status(task).busy.is_some() {
return Err(bad(&cmd, "a model is already downloading or loading"));
}
let size = if m.size >= 10_000_000 { format!("{} MB", m.size / 1_000_000) } else { format!("{:.1} MB", m.size as f64 / 1e6) };
let wait = b_p(p, "wait").unwrap_or(false);
spawn(s.models.lane(task), k.label, format!("Downloading {} ({size})…", m.name), wait, move |_| {
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let tmp = dir.join(format!("{}.part", m.file_name));
let out = std::process::Command::new("curl")
.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-o"])
.arg(&tmp)
.arg(m.url)
.output()
.map_err(|e| format!("downloading needs curl ({e}); or download {} in a browser and use Install from File", m.url))?;
if !out.status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(format!("download failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
}
let bytes = std::fs::read(&tmp).map_err(|e| format!("cannot read the download: {e}"))?;
let _ = std::fs::remove_file(&tmp);
install_bytes(&dir, m, &bytes)?;
Ok(format!("{}: {} downloaded and verified", k.label, m.name))
});
if wait {
waited(s, task, &cmd)?;
s.apply_model(task, true);
waited(s, task, &cmd)?;
return Ok(json!({"id": m.id, "installed": true}));
}
Ok(json!({"id": m.id, "downloading": true}))
}
fn remove(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
let m = model_p(p, &format!("{}.model.remove", kind(task).prefix), task)?;
let Some(path) = s.model_path(m) else { return Ok(json!({"removed": false})) };
if s.models.status(task).active.as_deref() == Some(m.id) {
s.models.set_active(task, None);
}
std::fs::remove_file(&path).map_err(|e| EngineError::Other(format!("cannot remove {}: {e}", path.display())))?;
if let Some(dir) = path.parent() {
let _ = std::fs::remove_file(notice_path(dir, m));
}
Ok(json!({"removed": true}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("roto.models", "Roto Brush Models", "{}", |s, _| list(s, Task::Mask)),
cmd!(
"roto.model.select",
"Use Roto Brush Model",
[],
None,
"{id: classical|mobilesam, wait?} (Roto Brush 2.0 / 3.0 use it; wait: loaded before returning)",
always,
|s, p| select(s, p, Task::Mask)
),
cmd!(
"roto.model.install",
"Install Roto Brush Model",
[],
None,
"{path: weights file, id?, wait?} — verified against the registry's SHA-256",
always,
|s, p| install(s, p, Task::Mask)
),
cmd!(
"roto.model.download",
"Download Roto Brush Model",
[],
None,
"{id, wait?} — the official weights, verified (desktop; uses the system curl)",
always,
|s, p| download(s, p, Task::Mask)
),
cmd!("roto.model.remove", "Remove Roto Brush Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Mask)),
query!("face.models", "Face Tracking Models", "{}", |s, _| list(s, Task::Face)),
cmd!(
"face.model.select",
"Use Face Tracking Model",
[],
None,
"{id: classical|mediapipe-face, wait?} (face tracking uses it; wait: loaded before returning)",
always,
|s, p| select(s, p, Task::Face)
),
cmd!(
"face.model.install",
"Install Face Tracking Model",
[],
None,
"{path: model file, id?, wait?} — verified against the registry's SHA-256",
always,
|s, p| install(s, p, Task::Face)
),
cmd!(
"face.model.download",
"Download Face Tracking Model",
[],
None,
"{id, wait?} — the official model, verified (desktop; uses the system curl)",
always,
|s, p| download(s, p, Task::Face)
),
cmd!("face.model.remove", "Remove Face Tracking Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Face)),
]
}
+779
View File
@@ -0,0 +1,779 @@
//! Roto Brush & Refine Edge runtime: propagation and Freeze jobs, Input Key maintenance and the
//! tool options.
//!
//! Segmentations are derived data in `effectcraft_effects::roto`'s content-keyed cache (see that
//! module). `roto.propagate` fills it at full resolution in the background (or blocking with
//! `wait`; in the browser the background is a Web Worker whose segmentations stream back,
//! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
//!
//! Object Matte instances run through the same jobs (`objectMatte.propagate` / `.freeze`): the
//! frames are [`om::job_frames`] around the reference frame, each segmented through
//! [`om::ensure`]; Freeze stores [`om::frame_matte`]'s mattes under [`om::frozen_key`]. An edit
//! to an instance's frames or prompt queues it for background tracking like a Roto Brush.
use crate::offload::JobKind;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::object_matte as om;
use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY};
use effectcraft_effects::{Params, flatten_params};
use effectcraft_keyframe::Value;
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use crate::tracking::lock;
use crate::{Event, Session};
/// Roto Brush / Refine Edge tool options (serde: `roto.options`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RotoOptions {
/// Roto Brush tip diameter (layer pixels).
pub diameter: f64,
/// Refine Edge tip diameter (layer pixels).
pub refine_diameter: f64,
/// Layer panel view: `alphaBoundary` (pink outline), `alpha`, `alphaOverlay`, `none`.
pub view: String,
/// Alpha Overlay colour (straight RGB 0–1) and opacity (percent).
pub overlay_color: [f64; 3],
pub overlay_opacity: f64,
/// Alpha Boundary colour.
pub boundary_color: [f64; 3],
/// Start propagating in the background after each stroke.
pub auto_propagate: bool,
}
impl Default for RotoOptions {
fn default() -> Self {
RotoOptions {
diameter: 20.0,
refine_diameter: 10.0,
view: "alphaBoundary".into(),
overlay_color: [1.0, 0.0, 0.0],
overlay_opacity: 50.0,
boundary_color: [1.0, 0.25, 0.75],
auto_propagate: true,
}
}
}
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
/// What a job does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RotoTask {
Propagate,
Freeze,
}
/// Which way to propagate.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
Forward,
Backward,
#[default]
Both,
}
impl Direction {
pub fn from_name(s: &str) -> Option<Direction> {
Some(match s.to_ascii_lowercase().as_str() {
"forward" | "fwd" => Direction::Forward,
"backward" | "back" => Direction::Backward,
"both" | "all" => Direction::Both,
_ => return None,
})
}
}
/// Live job progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RotoProgress {
pub task: Option<RotoTask>,
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
/// The job works on an Object Matte (else a Roto Brush & Refine Edge).
pub object_matte: bool,
}
impl RotoProgress {
pub fn banner(&self) -> String {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
match (self.task, self.object_matte) {
(Some(RotoTask::Freeze), false) => format!("Freezing Roto Brush & Refine Edge: {pc}%"),
(Some(RotoTask::Freeze), true) => format!("Freezing Object Matte: {pc}%"),
(_, false) => format!("Propagating Roto Brush: {pc}%"),
(_, true) => format!("Tracking Object Matte: {pc}%"),
}
}
}
#[derive(Default)]
pub struct RotoShared {
pub cancel: AtomicBool,
pub state: Mutex<RotoProgress>,
frozen: Mutex<Option<FrozenCache>>,
}
pub struct RotoJob {
pub shared: Arc<RotoShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
pub task: RotoTask,
/// What the job was started from: the strokes (Roto Brush) or the prompt (Object Matte); a
/// Freeze result is dropped when it changed.
strokes: String,
/// The job works on an Object Matte.
pub object_matte: bool,
}
impl RotoJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> RotoProgress {
lock(&self.shared.state).clone()
}
}
pub(crate) fn is_roto(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == fx::ID)
}
/// An Object Matte instance (its Input Key is kept current like Roto Brush's; it segments on
/// demand while rendering).
pub(crate) fn is_object_matte(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::object_matte::ID)
}
/// What an Object Matte's segmentation is made from besides its frames: the prompt and tracking
/// parameters (static values), for queueing it after edits and guarding a Freeze.
pub(crate) fn om_prompt(g: &PropGroup) -> String {
let ps = params_static(g);
let mut keys: Vec<&String> = ps.values.keys().filter(|k| ["objectPoint", "useBox", "box", "objectTracking/"].iter().any(|p| k.starts_with(p))).collect();
keys.sort();
keys.iter().map(|k| format!("{k}={:?};", ps.values.get(*k))).collect()
}
/// The job-relevant guard of an instance: its strokes (Roto Brush) or prompt (Object Matte).
fn guard_of(g: &PropGroup) -> String {
if is_object_matte(g) { om_prompt(g) } else { str_of(g, fx::STROKES) }
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.get(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
fn set_str(g: &mut PropGroup, m: &str, v: String) {
if let Some(p) = g.get_mut(m) {
p.value = Value::Str(v);
p.keys.clear();
}
}
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 the effect's input frames are made of: the layer's source and timing, its masks and the
/// effects above the Roto Brush.
pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
let mut s = crate::warp::signature(p, comp, layer);
if let Some(m) = layer.masks() {
s.push_str(&format!("{m:?}"));
}
if let Some(fx) = layer.effects() {
for g in fx.groups() {
if g.uid == uid {
break;
}
s.push_str(&format!("{g:?}"));
}
}
format!("{:016x}", fnv(&s))
}
/// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from
/// other frames are dropped. Returns the instances whose segmentation changed (to propagate
/// again): new frames, strokes (Roto Brush) or prompt (Object Matte).
pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut set = vec![];
for (cid, it) in &after.items {
let ItemKind::Comp(c) = &it.kind else { continue };
for l in &c.layers {
let Some(fxg) = l.effects() else { continue };
for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) {
let key = input_key(after, *cid, l, g.uid);
let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid));
if str_of(g, INPUT_KEY) != key {
set.push((*cid, l.id, g.uid, key));
todo.push((*cid, l.id, g.uid));
} else if old.is_none_or(|o| guard_of(o) != guard_of(g)) {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid, key) in set {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
let had = !str_of(g, INPUT_KEY).is_empty();
set_str(g, INPUT_KEY, key);
if had {
set_str(g, FROZEN, String::new());
}
}
}
todo.retain(|(c, l, u)| {
after
.comp(*c)
.and_then(|c| c.layer(*l))
.and_then(|l| l.props.find_group(*u))
.is_some_and(|g| is_object_matte(g) || !fx::data(&params_static(g)).is_empty())
});
todo
}
/// Parameters of an instance with static values (strokes and keys are never animated).
pub(crate) fn params_static(g: &PropGroup) -> Params {
flatten_params(g, &mut |p| p.value.clone())
}
/// Evaluated parameters of an instance at comp time `t`.
pub(crate) fn params_at(p: &Project, cid: ItemId, layer: &Layer, g: &PropGroup, t: Tick, expr: Option<&dyn ExprHost>) -> Params {
match p.comp(cid) {
Some(c) => {
let ctx = EvalCtx { expr, ..EvalCtx::new(p, cid, c, t) };
flatten_params(g, &mut |pr| ctx.value(layer, pr))
}
None => params_static(g),
}
}
/// The layer's source size (layer pixels).
pub(crate) fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] {
effectcraft_render::effect_bounds(p, comp, layer).0
}
/// First and last layer frame of the layer inside the comp (inclusive).
pub fn frame_limits(comp: &effectcraft_project::Comp, layer: &Layer) -> [i64; 2] {
let fps = comp.frame_rate.as_f64().max(1e-6);
let lo = layer.layer_time(layer.in_point.max(Tick::ZERO)).seconds();
let hi = layer.layer_time(layer.out_point.min(comp.duration)).seconds();
let a = fx::frame_of(lo, fps);
let b = (fx::frame_of(hi, fps) - 1).max(a);
[a, b]
}
/// Comp time of layer frame `f`.
pub(crate) fn comp_time_of(comp: &effectcraft_project::Comp, layer: &Layer, f: i64) -> Tick {
layer.comp_time(comp.frame_rate.tick_of(f))
}
/// What the worker needs, detached from the session.
struct Work {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
cache: Arc<LayerCache>,
comp: ItemId,
layer: LayerId,
effect_index: usize,
params: Params,
size: [f64; 2],
fps: f64,
frames: Vec<i64>,
task: RotoTask,
object_matte: bool,
}
/// Renders the effect's input frames for a worker, keeping the last few. With a browser job
/// worker's GPU (deferred readbacks), [`FrameSource::prefetch`] renders the frames a step
/// will read in passes beforehand; [`FrameSource::get`] renders on the CPU what it lacks.
pub(crate) struct FrameSource<'a> {
r: Renderer<'a>,
accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Frames in `memo`, oldest first.
order: std::collections::VecDeque<i64>,
ctx0: EvalCtx<'a>,
comp: &'a effectcraft_project::Comp,
layer: &'a Layer,
index: usize,
size: [f64; 2],
memo: HashMap<i64, Arc<Image>>,
}
impl<'a> FrameSource<'a> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
project: &'a Project,
footage: &'a dyn FootageSource,
expr: Option<&'a dyn ExprHost>,
cache: &'a LayerCache,
cid: ItemId,
comp: &'a effectcraft_project::Comp,
layer: &'a Layer,
index: usize,
size: [f64; 2],
) -> FrameSource<'a> {
let mut r = Renderer::new(project, footage, RenderOpts { scale: 1.0, ..Default::default() });
r.expr = expr;
r.cache = Some(cache);
let ctx0 = EvalCtx { expr, ..EvalCtx::new(project, cid, comp, Tick::ZERO) };
FrameSource { r, accel: None, order: Default::default(), ctx0, comp, layer, index, size, memo: HashMap::new() }
}
/// Render with `accel` (deferred readbacks: see [`FrameSource::prefetch`]).
pub(crate) fn with_accel(mut self, accel: Option<&'a dyn effectcraft_render::Accelerator>) -> Self {
self.accel = accel;
self.r.opts = crate::offload::analysis_opts(accel);
self
}
/// Render frames `fs` (those not kept yet) in passes on the GPU, so the step that reads
/// them finds them ([`FrameSource::get`] can't wait for the GPU).
pub(crate) async fn prefetch(&mut self, fs: &[i64]) {
let Some(a) = self.accel else { return };
for &f in fs {
if self.memo.contains_key(&f) {
continue;
}
let this = &*self;
let img = crate::offload::in_passes(Some(a), |acc| this.render(f, &this.r.with_accel(acc))).await;
if let Some(img) = img {
self.keep(f, img);
}
}
}
fn keep(&mut self, f: i64, img: Arc<Image>) {
while self.order.len() >= 8 {
if let Some(old) = self.order.pop_front() {
self.memo.remove(&old);
}
}
self.order.push_back(f);
self.memo.insert(f, img);
}
pub(crate) fn get(&mut self, f: i64) -> Option<Arc<Image>> {
if let Some(i) = self.memo.get(&f) {
return Some(i.clone());
}
// Not prefetched: the CPU (a pass of the GPU could only return placeholders here).
let img = self.render(f, &self.r.with_accel(None))?;
self.keep(f, img.clone());
Some(img)
}
fn render(&self, f: i64, r: &Renderer) -> Option<Arc<Image>> {
let t = comp_time_of(self.comp, self.layer, f);
let buf = r.layer_input(&self.ctx0.at(t), self.layer, self.index)?;
let buf = 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;
effectcraft_effects::Buf {
img: effectcraft_raster::resample(&buf.img, w, h),
offset: [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale],
scale: 1.0,
}
} else {
(*buf).clone()
};
Some(Arc::new(fx::layer_grid(&buf, self.size)))
}
}
async fn run_work(w: Work, shared: &RotoShared) {
let t0 = web_time::Instant::now();
// A browser job worker's GPU (deferred readbacks): input frames render in passes.
let accel = crate::offload::job_accel();
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 src =
FrameSource::new(&w.project, w.footage.as_ref(), w.expr.as_deref(), &w.cache, w.comp, comp, layer, w.effect_index, w.size).with_accel(accel.as_deref());
{
let mut s = lock(&shared.state);
s.task = Some(w.task);
s.total = w.frames.len() as u64;
s.object_matte = w.object_matte;
}
let cancel = || shared.cancel.load(Ordering::Relaxed);
let chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0);
let model = fx::installed_model();
let key = if w.object_matte { om::frozen_key(&w.params, w.size, w.fps, model.as_deref()) } else { fx::frozen_key(&w.params, w.size, w.fps) };
let mut frozen = FrozenCache { key, ..Default::default() };
for (k, f) in w.frames.iter().enumerate() {
if cancel() {
let mut s = lock(&shared.state);
s.cancelled = true;
s.finished = true;
return;
}
// The frame and its neighbours (what a step reads), on the GPU.
src.prefetch(&[*f, f - 1, f + 1]).await;
let mut get = |g: i64| src.get(g);
let ok = match (w.task, w.object_matte) {
(RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(),
(RotoTask::Freeze, true) => {
let img = get(*f);
match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) {
Some((om::FrameMatte::Alpha(a), img)) => {
frozen.size = [img.width as usize, img.height as usize];
frozen.frames.insert(*f, fx::freeze_frame(&a, &[]));
true
}
_ => false,
}
}
(RotoTask::Propagate, false) => chain.ensure(*f, &mut get, &cancel).is_some(),
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
Some((a, b)) => {
if let Some(img) = src.get(*f) {
frozen.size = [img.width as usize, img.height as usize];
}
frozen.frames.insert(*f, fx::freeze_frame(&a, &b));
true
}
None => false,
},
};
if !ok && !cancel() {
return fail(&format!("frame {f} could not be segmented"));
}
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 };
crate::offload::report(s.done, s.total);
}
if w.task == RotoTask::Freeze {
*lock(&shared.frozen) = Some(frozen);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Frames to compute, outward from the base frame.
pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction) -> Vec<i64> {
let Some(base) = d.base else { return vec![] };
let mut v = vec![base];
if dir != Direction::Backward {
v.extend(base + 1..=d.span[1]);
}
if dir != Direction::Forward {
v.extend((d.span[0]..base).rev());
}
v
}
impl Session {
/// The Roto Brush job running in a worker (Freeze or propagation), with its task.
fn offloaded_roto(&self) -> Option<(&crate::offload::OffloadedJob, RotoTask)> {
self.offloaded(JobKind::RotoFreeze).map(|j| (j, RotoTask::Freeze)).or_else(|| self.offloaded(JobKind::RotoPropagate).map(|j| (j, RotoTask::Propagate)))
}
pub fn is_roto_running(&self) -> bool {
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded_roto().is_some()
}
pub fn roto_progress(&self) -> Option<RotoProgress> {
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded_roto().map(|(j, task)| {
let p = &j.progress;
let object_matte =
self.project.comp(j.comp).and_then(|c| c.layer(j.layer)).and_then(|l| l.props.find_group(j.uid)).is_some_and(is_object_matte);
RotoProgress {
task: Some(task),
done: p.done,
total: p.total,
elapsed: p.elapsed,
fps: crate::offload::rate(p.done, p.elapsed),
object_matte,
..Default::default()
}
})
})
}
/// (comp, layer, effect uid, task) of the running job.
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task)).or_else(|| self.offloaded_roto().map(|(j, task)| (j.comp, j.layer, j.uid, task)))
}
/// The segmentation chain a propagation of Roto Brush `effect` computes (its frames' cache
/// keys).
pub fn roto_chain(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option<fx::Chain> {
let c = self.project.comp(comp)?;
let l = c.layer(layer)?;
let g = l.effects()?.groups().find(|g| g.uid == effect && is_roto(g))?;
let d = fx::data(&params_static(g));
let t = comp_time_of(c, l, d.base?);
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
Some(fx::Chain::new(&params, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0))
}
/// The cache keys of the segmentations a propagation of `effect` (Roto Brush or Object
/// Matte) computes, by layer frame.
pub fn roto_keys(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option<Vec<(i64, u64)>> {
if let Some(ch) = self.roto_chain(comp, layer, effect) {
return Some(ch.keys.into_iter().collect());
}
let c = self.project.comp(comp)?;
let l = c.layer(layer)?;
let g = l.effects()?.groups().find(|g| g.uid == effect && is_object_matte(g))?;
let params = self.om_params(comp, l, g)?;
let frames = om::job_frames(&params, frame_limits(c, l));
Some(om::frame_keys(&params, layer_size(&self.project, c, l), c.frame_rate.as_f64(), &frames, fx::installed_model()))
}
/// An Object Matte's parameters at its reference frame (what its jobs segment with).
fn om_params(&self, comp: ItemId, l: &Layer, g: &PropGroup) -> Option<Params> {
let c = self.project.comp(comp)?;
let t = comp_time_of(c, l, om::reference_frame(&params_static(g)));
Some(params_at(&self.project, comp, l, g, t, self.expr.as_deref()))
}
pub fn stop_roto(&mut self) -> bool {
if self.cancel_offloaded(JobKind::RotoFreeze) || self.cancel_offloaded(JobKind::RotoPropagate) {
return true;
}
match &self.roto_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start propagating (or freezing) Roto Brush `effect` on `layer`. `wait`: block until done
/// (wasm32 always does). Returns the number of frames.
pub fn start_roto(&mut self, comp: ItemId, layer: LayerId, effect: Uid, task: RotoTask, dir: Direction, wait: bool) -> Result<usize, String> {
if self.is_roto_running() {
if !wait {
return Err("a Roto Brush job is already running".into());
}
self.stop_roto();
if let Some(mut j) = self.roto_job.take()
&& let Some(t) = j.thread.take()
{
let _ = t.join();
}
}
self.poll_roto_job();
self.roto_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 fxg = l.effects().ok_or("the layer has no effects")?;
let index = fxg.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fxg.groups().nth(index).filter(|g| is_roto(g) || is_object_matte(g)).ok_or("not a Roto Brush & Refine Edge or Object Matte effect")?;
let object_matte = is_object_matte(g);
let size = layer_size(&self.project, c, l);
let fps = c.frame_rate.as_f64();
let (params, frames) = if object_matte {
let params = self.om_params(comp, l, g).ok_or("no composition")?;
let base = om::reference_frame(&params);
if om::roto_data(&params, size, base, fx::installed_model().is_some()).is_none() {
return Err("set the object first: click it with the Object Matte tool (its point is off the layer or outside its box)".into());
}
let all = om::job_frames(&params, frame_limits(c, l));
if all.is_empty() {
return Err(format!("the reference frame {base} is outside the layer"));
}
// Forward / backward from the reference frame, as for a Roto Brush.
let frames = all
.into_iter()
.filter(|f| task == RotoTask::Freeze || dir == Direction::Both || *f == base || (*f > base) == (dir == Direction::Forward))
.collect();
(params, frames)
} else {
let pstat = params_static(g);
let d = fx::data(&pstat);
if d.is_empty() {
return Err("draw a foreground stroke with the Roto Brush tool first".into());
}
let t = comp_time_of(c, l, d.base.unwrap_or(0));
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
(params, job_frames(&d, if task == RotoTask::Freeze { Direction::Both } else { dir }))
};
let n = frames.len();
let work = Work {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
params,
size,
fps,
frames,
task,
object_matte,
};
let strokes = guard_of(g);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(match task {
RotoTask::Freeze => crate::offload::WorkerJob::RotoFreeze { comp, layer, effect },
RotoTask::Propagate => crate::offload::WorkerJob::RotoPropagate { comp, layer, effect, direction: dir },
})?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(RotoShared::default());
{
// Known before the worker starts (the progress shows at once).
let mut st = lock(&shared.state);
st.task = Some(task);
st.total = n as u64;
st.object_matte = object_matte;
}
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
async move { run_work(work, &sh).await }
});
None
} else {
let sh = shared.clone();
Some(
std::thread::Builder::new()
.name("roto".into())
.spawn(move || effectcraft_render::passes::block_on(run_work(work, &sh)))
.map_err(|e| e.to_string())?,
)
};
self.roto_job = Some(RotoJob { shared, thread, comp, layer, effect, task, strokes, object_matte });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_roto_job();
Ok(n)
}
/// Finish a done job: Freeze writes the frozen mattes (one undo step).
fn poll_roto_job(&mut self) -> bool {
let Some(job) = &self.roto_job else { return false };
if !job.is_finished() {
return false;
}
let Some(mut job) = self.roto_job.take() else { return false };
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let name = if job.object_matte { "Object Matte" } else { "Roto Brush" };
if let Some(e) = &st.error {
self.events.push(Event::Toast { message: format!("{name}: {e}"), error: true });
self.bump();
return true;
}
if st.cancelled {
self.events.push(Event::Toast { message: format!("{name}: stopped"), error: false });
self.bump();
return true;
}
match job.task {
RotoTask::Propagate => {
let verb = if job.object_matte { "tracked" } else { "propagated" };
self.events.push(Event::Toast { message: format!("{name}: {verb} {} frame(s) in {:.1} s", st.done, st.elapsed), error: false });
// Rendered frames depend on the cache: redraw.
self.bump();
}
RotoTask::Freeze => {
let Some(fc) = lock(&job.shared.frozen).take() else { return true };
let (cid, lid, uid, strokes) = (job.comp, job.layer, job.effect, job.strokes.clone());
let what = if job.object_matte { "prompt" } else { "strokes" };
let r = self.edit("Freeze", 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(format!("the {name} effect was removed")))?;
if guard_of(g) != strokes {
return Err(crate::EngineError::Other(format!("the {what} changed while freezing")));
}
set_str(g, FROZEN, fc.to_json());
Ok(())
});
let msg = match r {
Ok(()) => format!("{name}: froze {} frame(s) in {:.1} s", fc.frames.len(), st.elapsed),
Err(e) => format!("{name}: {e}"),
};
self.events.push(Event::Toast { message: msg, error: false });
}
}
true
}
/// Frontends call this every frame: finishes jobs and (with `auto`) propagates edited
/// instances in the background.
pub fn poll_roto(&mut self, auto: bool) -> bool {
self.poll_models();
let changed = self.poll_roto_job();
if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate {
while let Some((c, l, e)) = self.roto_pending.first().copied() {
self.roto_pending.remove(0);
if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() {
return true;
}
}
}
changed
}
/// The computed frames of an instance (full resolution), by layer frame.
pub fn roto_computed(&self, comp: ItemId, layer: LayerId, effect: Uid) -> BTreeMap<i64, bool> {
let mut out = BTreeMap::new();
let Some(c) = self.project.comp(comp) else { return out };
let Some(l) = c.layer(layer) else { return out };
let Some(g) = l.props.find_group(effect) else { return out };
if is_object_matte(g) {
for (f, k) in self.roto_keys(comp, layer, effect).unwrap_or_default() {
out.insert(f, fx::cached(k, 1.0).is_some());
}
return out;
}
let ps = params_static(g);
let ch = fx::Chain::new(&ps, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0);
for f in ch.keys.keys() {
out.insert(*f, ch.is_cached(*f, 1.0));
}
out
}
}
+630
View File
@@ -0,0 +1,630 @@
//! Motion tracking runtime: the analysis job (background thread with progress and cancel, or
//! blocking for the CLI / agents / wasm), writing track results into the tracker's Track Point
//! properties, and applying tracks (Transform, Stabilize, Corner Pin) to layers.
//!
//! The algorithms are in `effectcraft-track`; the property layout in
//! `effectcraft_project::tracking`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_project::tracking::{LowConfidence, TrackChannel, TrackKind, TrackerOptions, TrackerSettings};
use effectcraft_project::{Comp, ItemId, Keyframe, Layer, LayerId, Project, PropGroup, Uid, Value};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
use effectcraft_time::Tick;
use effectcraft_track as trk;
use serde::{Deserialize, Serialize};
use crate::{Event, Session};
/// Tracker panel ▸ Analyze buttons.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
#[default]
Forward,
Backward,
FrameForward,
FrameBackward,
}
impl Direction {
pub fn from_name(s: &str) -> Option<Direction> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
Some(match n.as_str() {
"forward" | "fwd" => Direction::Forward,
"backward" | "back" | "bwd" => Direction::Backward,
"frameforward" | "oneframeforward" | "1frameforward" => Direction::FrameForward,
"framebackward" | "oneframebackward" | "1framebackward" => Direction::FrameBackward,
_ => return None,
})
}
pub fn forward(self) -> bool {
matches!(self, Direction::Forward | Direction::FrameForward)
}
}
/// Track options for the algorithms.
pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
trk::TrackOptions {
channel: match o.channel {
TrackChannel::Rgb => trk::Channel::Rgb,
TrackChannel::Luminance => trk::Channel::Luminance,
TrackChannel::Saturation => trk::Channel::Saturation,
},
blur: o.blur,
enhance: o.enhance,
subpixel: o.subpixel,
adapt_every_frame: o.adapt_every_frame,
threshold: o.threshold,
action: match o.action {
LowConfidence::Continue => trk::ConfidenceAction::Continue,
LowConfidence::Stop => trk::ConfidenceAction::Stop,
LowConfidence::Extrapolate => trk::ConfidenceAction::Extrapolate,
LowConfidence::Adapt => trk::ConfidenceAction::Adapt,
},
track_shape: o.track_shape,
}
}
/// A track point's regions and attach offset read from its properties at a layer time.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PointValues {
pub uid: Uid,
pub spec: trk::PointSpec,
pub attach_offset: [f64; 2],
}
pub fn point_values(tp: &PropGroup, lt: Tick) -> PointValues {
let v = |m: &str, d: [f64; 2]| tp.get(m).map(|p| p.value_at(lt).as_vec2()).unwrap_or(d);
PointValues {
uid: tp.uid,
spec: trk::PointSpec {
center: v("featureCenter", [0.0; 2]),
feature_size: v("featureSize", [32.0; 2]),
search_offset: v("searchOffset", [0.0; 2]),
search_size: v("searchSize", [64.0; 2]),
},
attach_offset: v("attachPointOffset", [0.0; 2]),
}
}
/// Attach points of a frame from the feature centres, given the start frame's centres and attach
/// points: two-point transforms rotate and scale the offsets with the track, Parallel Corner Pin
/// maps them through the 3-point affine, Perspective Corner Pin through the 4-point homography.
pub fn attach_points(kind: TrackKind, rot_scale: bool, ref_c: &[[f64; 2]], ref_a: &[[f64; 2]], cur: &[[f64; 2]]) -> Vec<[f64; 2]> {
let translate = || cur.iter().zip(ref_c.iter().zip(ref_a)).map(|(c, (r, a))| [c[0] + a[0] - r[0], c[1] + a[1] - r[1]]).collect::<Vec<_>>();
let through = |h: Option<trk::Homography>| match h {
Some(h) => ref_a.iter().map(|a| h.apply(*a)).collect(),
None => translate(),
};
match kind {
TrackKind::Perspective if cur.len() >= 4 => {
let h = trk::Homography::from_points([ref_c[0], ref_c[1], ref_c[2], ref_c[3]], [cur[0], cur[1], cur[2], cur[3]]);
through(h)
}
TrackKind::Affine if cur.len() >= 3 => through(trk::Homography::affine_from_points([ref_c[0], ref_c[1], ref_c[2]], [cur[0], cur[1], cur[2]])),
_ if rot_scale && cur.len() >= 2 => {
let Some(s) = trk::Homography::similarity_from_points([ref_c[0], ref_c[1]], [cur[0], cur[1]]) else { return translate() };
let m = s.0;
cur.iter()
.zip(ref_c.iter().zip(ref_a))
.map(|(c, (r, a))| {
let d = [a[0] - r[0], a[1] - r[1]];
[c[0] + m[0][0] * d[0] + m[0][1] * d[1], c[1] + m[1][0] * d[0] + m[1][1] * d[1]]
})
.collect()
}
_ => translate(),
}
}
/// Live analysis progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackProgress {
pub done: u64,
pub total: u64,
pub elapsed: f64,
/// Analysed frames per second.
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
/// Stopped by "If Confidence is Below … ▸ Stop Tracking".
pub low_confidence_stop: bool,
pub error: Option<String>,
/// Comp time (seconds) of the last analysed frame.
pub time: f64,
}
/// One analysed frame.
#[derive(Clone, Debug)]
pub struct FrameResult {
pub comp_time: Tick,
pub layer_time: Tick,
/// Per point: (point group uid, result, attach point).
pub points: Vec<(Uid, trk::PointResult, [f64; 2])>,
}
#[derive(Default)]
pub struct TrackShared {
pub cancel: AtomicBool,
pub state: Mutex<TrackProgress>,
pub frames: Mutex<Vec<FrameResult>>,
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A running (or finished, not yet polled) analysis.
pub struct TrackJob {
pub shared: Arc<TrackShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub tracker: Uid,
pub direction: Direction,
}
impl TrackJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> TrackProgress {
lock(&self.shared.state).clone()
}
pub fn cancel(&self) {
self.shared.cancel.store(true, Ordering::Relaxed);
}
}
/// What the worker needs, detached from the session.
pub(crate) struct Work {
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,
pub settings: TrackerSettings,
pub points: Vec<PointValues>,
/// Comp times to analyse; the first is the start frame.
pub times: Vec<Tick>,
}
/// 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 unless its
/// Cineon Settings convert it).
pub(crate) fn source_frame(
r: &Renderer,
project: &Project,
cid: ItemId,
comp: &Comp,
layer: &Layer,
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video
&& let Some(img) = r.footage.frame(*item, f, ctx.source_time(layer))
&& img.width == f.width
&& img.height == f.height
{
return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2]));
}
let buf = r.layer_source(&ctx, layer)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
// Footage decoded at another size: bring it to layer pixels.
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), buf.offset))
}
pub(crate) async fn run_work(w: Work, shared: &TrackShared) {
let t0 = web_time::Instant::now();
// A browser job worker's GPU (deferred readbacks): frames render in passes.
let accel = crate::offload::job_accel();
let accel = accel.as_deref();
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(), crate::offload::analysis_opts(accel));
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let wr = &w;
let frame_at =
|t: Tick| crate::offload::in_passes(accel, move |a| source_frame(&r.with_accel(a), &wr.project, wr.comp, comp, layer, t, wr.expr.as_deref()));
{
let mut s = lock(&shared.state);
s.total = w.times.len().saturating_sub(1) as u64;
}
let Some(first) = frame_at(w.times[0]).await else { return fail("the layer has no pixels to track") };
let specs: Vec<trk::PointSpec> = w.points.iter().map(|p| p.spec).collect();
let mut tracker = trk::Tracker::new(track_options(&w.settings.options), &specs, &trk::Frame { img: &first.0, offset: first.1 });
let ref_c: Vec<[f64; 2]> = specs.iter().map(|s| s.center).collect();
let ref_a: Vec<[f64; 2]> = w.points.iter().map(|p| [p.spec.center[0] + p.attach_offset[0], p.spec.center[1] + p.attach_offset[1]]).collect();
let rot_scale = w.settings.rotation || w.settings.scale;
// The start frame keeps its regions and gets keys too.
lock(&shared.frames).push(FrameResult {
comp_time: w.times[0],
layer_time: layer.layer_time(w.times[0]),
points: w
.points
.iter()
.zip(&ref_a)
.map(|(p, a)| (p.uid, trk::PointResult { center: p.spec.center, confidence: 100.0, rotation: 0.0, scale: 1.0, status: trk::Status::Tracked }, *a))
.collect(),
});
let mut next = if w.times.len() > 1 { frame_at(w.times[1]).await } 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;
};
// Track this frame while the next one renders.
let (res, nf) = crate::offload::join_fetch(
accel.is_some(),
|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }),
|| async { if k + 1 < w.times.len() { frame_at(w.times[k + 1]).await } else { None } },
)
.await;
next = nf;
if res.iter().any(|p| p.status == trk::Status::Stopped) {
lock(&shared.state).low_confidence_stop = true;
break;
}
let centers: Vec<[f64; 2]> = res.iter().map(|p| p.center).collect();
let attach = attach_points(w.settings.kind, rot_scale, &ref_c, &ref_a, &centers);
lock(&shared.frames).push(FrameResult {
comp_time: w.times[k],
layer_time: layer.layer_time(w.times[k]),
points: w.points.iter().zip(res).zip(attach).map(|((p, r), a)| (p.uid, r, a)).collect(),
});
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();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Set (or add) a key at layer time `t`.
pub(crate) fn key(g: &mut PropGroup, m: &str, t: Tick, v: Value) {
if let Some(p) = g.get_mut(m) {
effectcraft_keyframe::set_key(&mut p.keys, Keyframe::new(t, v));
}
}
/// Write analysed frames into the tracker's points.
fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
let Some(comp) = p.comp_mut(job.comp) else { return };
let Some(layer) = comp.layer_mut(job.layer) else { return };
for f in frames {
for (uid, r, a) in &f.points {
let Some(tp) = layer.props.find_group_mut(*uid) else { continue };
key(tp, "featureCenter", f.layer_time, Value::Vec2(r.center));
key(tp, "confidence", f.layer_time, Value::Scalar(r.confidence));
key(tp, "attachPoint", f.layer_time, Value::Vec2(*a));
}
}
}
impl Session {
/// Whether a track analysis is running.
pub fn is_tracking(&self) -> bool {
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
}
/// Live progress of the running (or just finished) analysis.
pub fn track_progress(&self) -> Option<TrackProgress> {
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
}
/// Cancel the running analysis (results so far are kept).
pub fn stop_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::Track) {
return true;
}
match &self.track_job {
Some(j) if !j.is_finished() => {
j.cancel();
true
}
_ => false,
}
}
/// Start analysing. `wait`: block until done (wasm32 always does). One undo step covers the
/// whole analysis.
pub(crate) fn start_track(&mut self, work: Work, tracker: Uid, direction: Direction, wait: bool) -> Result<(), String> {
if self.is_tracking() {
return Err("a track analysis is already running".into());
}
self.poll_track();
if !wait && self.offloads() {
let Work { comp, layer, settings, points, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
let levels = self.prefs.general.undo_levels as usize;
self.history.record("Analyze Track", self.project.clone(), levels);
let shared = Arc::new(TrackShared::default());
let (comp, layer) = (work.comp, work.layer);
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
async move { run_work(work, &sh).await }
});
None
} else {
let sh = shared.clone();
Some(
std::thread::Builder::new()
.name("track-analyze".into())
.spawn(move || effectcraft_render::passes::block_on(run_work(work, &sh)))
.map_err(|e| e.to_string())?,
)
};
self.track_job = Some(TrackJob { shared, thread, comp, layer, tracker, direction });
self.poll_track();
Ok(())
}
/// Write new results into the project and move the CTI to the last analysed frame; drop the
/// job once it finished. Frontends call this every frame while tracking.
pub fn poll_track(&mut self) -> bool {
let Some(job) = &self.track_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.comp_time);
}
self.bump();
}
if finished && let Some(mut job) = self.track_job.take() {
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let msg = match (&st.error, st.cancelled, st.low_confidence_stop) {
(Some(e), _, _) => format!("Tracking stopped: {e}"),
(None, true, _) => format!("Tracking stopped after {} frame(s)", st.done),
(None, false, true) => format!("Tracking stopped: confidence below threshold after {} frame(s)", st.done),
_ => format!("Tracked {} frame(s) in {:.1} s", st.done, st.elapsed),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
}
changed || finished
}
}
// ---------------------------------------------------------------- apply
/// Apply Dimensions.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Dims {
#[default]
XY,
X,
Y,
}
impl Dims {
pub fn from_name(s: &str) -> Option<Dims> {
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"xy" | "xandy" | "both" => Some(Dims::XY),
"x" | "xonly" => Some(Dims::X),
"y" | "yonly" => Some(Dims::Y),
_ => None,
}
}
fn mix(self, old: [f64; 3], new: [f64; 2]) -> [f64; 3] {
match self {
Dims::XY => [new[0], new[1], old[2]],
Dims::X => [new[0], old[1], old[2]],
Dims::Y => [old[0], new[1], old[2]],
}
}
}
fn apply_m(m: &Mat3, p: [f64; 2]) -> [f64; 2] {
let q = m.apply(vec2(p[0], p[1]));
[q.x, q.y]
}
/// Parent space of a layer (comp space when unparented), as a 2D matrix from comp space.
fn comp_to_parent(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
match layer.parent.and_then(|p| ctx.comp.layer(p)) {
Some(p) => ctx.layer_to_comp(p).0.inverse().unwrap_or(Mat3::IDENTITY),
None => Mat3::IDENTITY,
}
}
/// Per analysed time: (source layer time, attach points in source layer space).
pub fn attach_track(tracker: &PropGroup) -> Vec<(Tick, Vec<[f64; 2]>)> {
let pts: Vec<&PropGroup> = tracker.track_points().collect();
let Some(first) = pts.first().and_then(|g| g.get("attachPoint")) else { return vec![] };
first.keys.iter().map(|k| (k.time, pts.iter().map(|g| g.get("attachPoint").map(|p| p.value_at(k.time).as_vec2()).unwrap_or([0.0; 2])).collect())).collect()
}
/// Apply a tracker (Tracker panel ▸ Apply). Returns the number of keyframed frames.
pub fn apply(p: &mut Project, cid: ItemId, src: LayerId, tracker: Uid, dims: Dims) -> Result<usize, String> {
let comp = p.comp(cid).ok_or("no composition")?.clone();
let source = comp.layer(src).ok_or("no source layer")?.clone();
let (tg, settings) = source.tracker(tracker).ok_or("no such tracker")?;
let settings = settings.clone();
let track = attach_track(tg);
if track.is_empty() {
return Err("analyze the track first".into());
}
let target_id = match settings.kind {
TrackKind::Stabilize => src,
TrackKind::Raw => return Err("a Raw track has nothing to apply".into()),
_ => settings.target.ok_or("set a Motion Target first (Edit Target…)")?,
};
let target = comp.layer(target_id).ok_or("the motion target layer is gone")?.clone();
let ctx0 = EvalCtx::new(p, cid, &comp, Tick::ZERO);
let t_first = source.comp_time(track[0].0);
let rot_scale = settings.point_count() >= 2 && track[0].1.len() >= 2;
let (ang0, len0) = if rot_scale { effectcraft_track::angle_and_length(track[0].1[0], track[0].1[1]) } else { (0.0, 1.0) };
// (target layer time, values) to write.
let mut pos_keys = vec![];
let mut anchor_keys = vec![];
let mut rot_keys = vec![];
let mut scale_keys = vec![];
let mut corner_keys = vec![];
let ttr = target.transform().ok_or("the target has no transform")?;
let c0 = ctx0.at(t_first);
let rot0 = c0.f(&target, ttr, "rotation", 0.0);
let scale0 = c0.v3(&target, ttr, "scale", [100.0; 3]);
let mut prev_ang = 0.0;
// Stabilize: the feature stays where it was on the first tracked frame.
let stab_pos = {
let (m, _) = c0.layer_to_comp(&source);
apply_m(&comp_to_parent(&c0, &source), apply_m(&m, track[0].1[0]))
};
for (lt, pts) in &track {
let ct = source.comp_time(*lt);
let ctx = ctx0.at(ct);
let tt = target.layer_time(ct);
let (ang, len) = if rot_scale { effectcraft_track::angle_and_length(pts[0], pts[1]) } else { (ang0, len0) };
let dang = effectcraft_track::unwrap_degrees(prev_ang, ang - ang0);
prev_ang = dang;
let ratio = if len0 > 1e-9 { len / len0 } else { 1.0 };
match settings.kind {
TrackKind::Stabilize => {
if settings.position {
let old = ctx.v3(&target, ttr, "anchor", [0.0; 3]);
anchor_keys.push((tt, dims.mix(old, pts[0])));
let oldp = ctx.position(&target, ttr);
pos_keys.push((tt, dims.mix(oldp, stab_pos)));
}
if settings.rotation && rot_scale {
rot_keys.push((tt, rot0 - dang));
}
if settings.scale && rot_scale {
scale_keys.push((tt, [scale0[0] / ratio, scale0[1] / ratio, scale0[2]]));
}
}
TrackKind::Transform => {
let (m, _) = ctx.layer_to_comp(&source);
if settings.position {
let c = apply_m(&m, pts[0]);
let pp = apply_m(&comp_to_parent(&ctx, &target), c);
pos_keys.push((tt, dims.mix(ctx.position(&target, ttr), pp)));
}
if settings.rotation && rot_scale {
rot_keys.push((tt, rot0 + dang));
}
if settings.scale && rot_scale {
scale_keys.push((tt, [scale0[0] * ratio, scale0[1] * ratio, scale0[2]]));
}
}
TrackKind::Affine | TrackKind::Perspective => {
let (ms, _) = ctx.layer_to_comp(&source);
// Corner Pin's corners are effect points: in the target's effect space.
let inv = ctx.effect_to_comp(&target).inverse().ok_or("the target layer's transform is not invertible")?;
let n = pts.len();
if n < 3 {
return Err("corner pin tracks need 3 or 4 track points".into());
}
let lr =
if settings.kind == TrackKind::Affine || n < 4 { [pts[1][0] + pts[2][0] - pts[0][0], pts[1][1] + pts[2][1] - pts[0][1]] } else { pts[3] };
// Track Points 1–4: upper left, upper right, lower left, lower right.
let map = |q: [f64; 2]| apply_m(&inv, apply_m(&ms, q));
corner_keys.push((tt, [map(pts[0]), map(pts[1]), map(pts[2]), map(lr)]));
}
TrackKind::Raw => {}
}
}
let n = track.len();
let size = effectcraft_render::effect_bounds(p, &comp, &target).0;
let mut ids_next = p.next_id;
let layer = p.comp_mut(cid).and_then(|c| c.layer_mut(target_id)).ok_or("no target layer")?;
if !corner_keys.is_empty() {
let spec = effectcraft_effects::find("ec.distort.cornerpin").ok_or("Corner Pin is not available")?;
let fx = layer.props.sub_mut("effects").ok_or("the target layer can't have effects")?;
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let name = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same + 1) };
let mut g = effectcraft_effects::instantiate(spec, &mut effectcraft_project::build::Ids(&mut ids_next), &name, size);
for (t, c) in &corner_keys {
for (m, v) in ["ul", "ur", "ll", "lr"].iter().zip(c) {
key(&mut g, m, *t, Value::Vec2(*v));
}
}
fx.children.push(g.into());
}
let tr = layer.transform_mut().ok_or("no transform")?;
for (t, v) in anchor_keys {
key(tr, "anchor", t, Value::Vec3(v));
}
let separated = tr.get("positionX").is_some();
for (t, v) in pos_keys {
if separated {
key(tr, "positionX", t, Value::Scalar(v[0]));
key(tr, "positionY", t, Value::Scalar(v[1]));
} else {
key(tr, "position", t, Value::Vec3(v));
}
}
for (t, v) in rot_keys {
key(tr, "rotation", t, Value::Scalar(v));
}
for (t, v) in scale_keys {
key(tr, "scale", t, Value::Vec3(v));
}
p.next_id = ids_next;
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attach_points_follow_the_track() {
let rc = [[0.0, 0.0], [10.0, 0.0]];
let ra = [[0.0, 5.0], [10.0, 5.0]];
// Rotated 90° and doubled.
let cur = [[100.0, 100.0], [100.0, 120.0]];
let a = attach_points(TrackKind::Transform, true, &rc, &ra, &cur);
assert!((a[0][0] - 90.0).abs() < 1e-9 && (a[0][1] - 100.0).abs() < 1e-9, "{a:?}");
let a = attach_points(TrackKind::Transform, false, &rc, &ra, &cur);
assert_eq!(a[0], [100.0, 105.0]);
let h = trk::Homography([[1.1, 0.1, 5.0], [0.05, 0.9, -3.0], [0.001, 0.0005, 1.0]]);
let rc4 = [[0.0, 0.0], [100.0, 0.0], [0.0, 100.0], [100.0, 100.0]];
let ra4 = [[10.0, 10.0], [90.0, 10.0], [10.0, 90.0], [90.0, 90.0]];
let cur4: Vec<[f64; 2]> = rc4.iter().map(|p| h.apply(*p)).collect();
let a = attach_points(TrackKind::Perspective, false, &rc4, &ra4, &cur4);
for (got, want) in a.iter().zip(ra4.iter().map(|p| h.apply(*p))) {
assert!((got[0] - want[0]).abs() < 1e-6 && (got[1] - want[1]).abs() < 1e-6);
}
}
}
+432
View File
@@ -0,0 +1,432 @@
//! 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 crate::offload::{JobKind, rate};
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, 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))
}
async fn run_work(w: WarpWork, shared: &WarpShared) {
let t0 = web_time::Instant::now();
// A browser job worker's GPU (deferred readbacks): frames render in passes.
let accel = crate::offload::job_accel();
let accel = accel.as_deref();
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(), crate::offload::analysis_opts(accel));
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 ctx0 = &ctx0;
let frame_at = |t: Tick| crate::offload::in_passes(accel, move |a| input_frame(&r.with_accel(a), &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]).await;
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) = crate::offload::join_fetch(
accel.is_some(),
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|| async { if k + 1 < w.times.len() { frame_at(w.times[k + 1]).await } else { None } },
)
.await;
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 };
crate::offload::report(s.done, s.total);
}
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()) || self.offloaded(JobKind::Warp).is_some()
}
/// Progress of the running (or just finished) analysis.
pub fn warp_progress(&self) -> Option<WarpProgress> {
self.warp_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::Warp).map(|j| {
let p = &j.progress;
WarpProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: rate(p.done, p.elapsed), ..Default::default() }
})
})
}
/// 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)).or_else(|| self.offloaded(JobKind::Warp).map(|j| (j.comp, j.layer, j.uid)))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_warp(&mut self) -> bool {
if self.cancel_offloaded(JobKind::Warp) {
return true;
}
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 size = effectcraft_render::effect_bounds(&self.project, c, l).0;
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);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(WarpShared::default());
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
async move { run_work(work, &sh).await }
});
None
} else {
let sh = shared.clone();
Some(
std::thread::Builder::new()
.name("warp-analyze".into())
.spawn(move || effectcraft_render::passes::block_on(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 Some(mut job) = self.warp_job.take() else { return false };
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() && self.offloaded(JobKind::Warp).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
}
}