Merge branch 'main' into worktree-agent-a758de7c5b6efcc3d

# Conflicts:
#	crates/effects/src/lib.rs
#	crates/render/src/lib.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-02 21:48:49 -04:00
77 changed files with 9531 additions and 75 deletions
+1
View File
@@ -125,6 +125,7 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
}
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
apply_settings(c, p);
super::model3d::sync_geometry_options(proj, cid);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
+1 -1
View File
@@ -110,7 +110,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let label = match f.kind {
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
FootageKind::Audio => Label::SeaFoam,
FootageKind::Video => Label::Aqua,
FootageKind::Video | FootageKind::Model => Label::Aqua,
};
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
ids.push(id.0);
+8 -1
View File
@@ -24,7 +24,11 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
}
/// Insert a new layer above the selection (or at the top) and select it.
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result<LayerId> {
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
super::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
let id = layer.id;
@@ -235,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
(LayerSource::Footage { item }, (f.width, f.height), d)
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
}
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
+390
View File
@@ -0,0 +1,390 @@
//! Mask tracking (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode) and Smart Mask
//! Interpolation (Window ▸ Mask Interpolation).
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
use effectcraft_path::interp::{self, AddVertices, InterpOpts, Matching};
use effectcraft_project::{Layer, LayerId, Uid};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, layer_mut, layer_p, str_p};
use crate::mask_track::{MaskMethod, MaskWork};
use crate::tracking::Direction;
use crate::{EngineError, Result, Session, cmd};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"track.mask",
"Track Mask",
["Animation"],
None,
"{layer?, mask?: uid|index|name, method?: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective, direction?: forward|backward|frameForward|frameBackward, start? (s), end? (s), wait?: block until done}",
has_mask,
track_mask
),
cmd!(
"track.maskMethod",
"Mask Tracking Method",
[],
None,
"{method: position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective}",
super::always,
mask_method
),
cmd!(
"mask.interpolate",
"Apply Mask Interpolation",
[],
None,
"{layer?, mask?, times?: [from, to] (s; default the selected Mask Path keys), keyframeRate?: n|auto, keyframeFields?, linearVertexPaths?, bendingResistance? (0-100), quality? (0-100), addVertices?: n|false, addVerticesUnit?: pixels|total|percent, matchingMethod?: auto|curve|polyline, oneToOne?, firstVerticesMatch?}",
has_comp,
interpolate
),
cmd!(
"mask.interpolationOptions",
"Mask Interpolation Options",
[],
None,
"{keyframeRate?: n|auto, keyframeFields?, linearVertexPaths?, bendingResistance?, quality?, addVertices?: n|false, addVerticesUnit?: pixels|total|percent, matchingMethod?: auto|curve|polyline, oneToOne?, firstVerticesMatch?}",
super::always,
interp_options
),
]
}
// ---------------------------------------------------------------- mask tracking
/// The mask a command works on: `mask` (uid, 1-based index or name), else the selected mask
/// (Timeline selection or selected vertices), else the layer's only mask.
pub(crate) fn mask_uid(s: &Session, layer: &Layer, p: &Value) -> Option<Uid> {
let masks = layer.masks()?;
if let Some(k) = p.get("mask") {
return match k {
Value::Number(n) => {
let n = n.as_u64()?;
masks.groups().find(|g| g.uid == n).or_else(|| masks.groups().nth((n as usize).checked_sub(1)?)).map(|g| g.uid)
}
Value::String(name) => masks.groups().find(|g| &g.name == name).map(|g| g.uid),
_ => None,
};
}
let is_mask = |u: Uid| masks.groups().any(|g| g.uid == u);
if let Some(u) = s.state.selected_props.iter().filter(|(l, _)| *l == layer.id).map(|(_, u)| *u).find(|u| is_mask(*u)) {
return Some(u);
}
// A selected Mask Path property selects its mask.
if let Some(u) = s
.state
.selected_props
.iter()
.filter(|(l, _)| *l == layer.id)
.find_map(|(_, u)| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == *u)).map(|g| g.uid))
{
return Some(u);
}
if let Some(v) = s.state.selected_vertices.iter().find(|v| v.layer == layer.id && is_mask(v.mask)) {
return Some(v.mask);
}
let mut it = masks.groups();
match (it.next(), it.next()) {
(Some(g), None) => Some(g.uid),
_ => None,
}
}
/// The selected layer and mask, if any (the Tracker panel's mask mode).
pub fn selected_mask(s: &Session) -> Option<(LayerId, Uid)> {
let comp = s.active_comp()?;
for lid in &s.state.selected_layers {
let Some(l) = comp.layer(*lid) else { continue };
let explicit =
s.state.selected_props.iter().any(|(pl, u)| *pl == *lid && l.masks().is_some_and(|m| m.find_group(*u).is_some() || m.find(*u).is_some()))
|| s.state.selected_vertices.iter().any(|v| v.layer == *lid);
if explicit && let Some(u) = mask_uid(s, l, &json!({})) {
return Some((*lid, u));
}
}
None
}
fn has_mask(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
selected_mask(s).map(|_| ()).ok_or_else(|| "select a mask to track".into())
}
fn method_p(p: &Value, cmd: &str) -> Result<Option<MaskMethod>> {
match str_p(p, "method") {
Some(m) => MaskMethod::from_name(m)
.map(Some)
.ok_or_else(|| bad(cmd, format!("unknown method `{m}` (position|positionScale|positionScaleRotation|positionScaleRotationSkew|perspective)"))),
None => Ok(None),
}
}
fn mask_method(s: &mut Session, p: &Value) -> Result<Value> {
let m = method_p(p, "track.maskMethod")?.ok_or_else(|| bad("track.maskMethod", "missing `method`"))?;
s.state.mask_track_method = m;
s.bump();
Ok(json!(m.id()))
}
fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "track.mask";
let (cid, lid) = match p.get("layer") {
Some(_) => layer_p(s, p, CMD)?,
None => match selected_mask(s) {
Some((l, _)) => (s.active_comp_id().ok_or(EngineError::NoComp)?, l),
None => layer_p(s, p, CMD)?,
},
};
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let uid = mask_uid(s, layer, p).ok_or_else(|| bad(CMD, format!("layer `{}` has no such mask (select one or pass `mask`)", layer.name)))?;
if !layer.has_video() || matches!(layer.source, effectcraft_project::LayerSource::Text | effectcraft_project::LayerSource::Shape) {
return Err(bad(CMD, "the layer has no pixels to track"));
}
let method = method_p(p, CMD)?.unwrap_or(s.state.mask_track_method);
let dir = match str_p(p, "direction") {
Some(d) => Direction::from_name(d).ok_or_else(|| bad(CMD, "direction: forward|backward|frameForward|frameBackward"))?,
None => Direction::Forward,
};
let now = comp.frame_rate.snap_nearest(if s.state.active_comp == Some(cid) { s.time() } else { Tick::ZERO });
let start = f_p(p, "start").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let end = f_p(p, "end").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let times = super::track::analysis_times(comp, layer, dir, now, start, end);
if times.len() < 2 {
return Err(bad(CMD, "nothing to track in that direction (at the layer's end)"));
}
let mg = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "no mask"))?;
let KV::Path(path) = mg.get("path").map(|pr| pr.value_at(layer.layer_time(times[0]))).unwrap_or(KV::Scalar(0.0)) else {
return Err(bad(CMD, "the mask has no path"));
};
if path.len() < 2 {
return Err(bad(CMD, "the mask needs at least two vertices"));
}
let work = MaskWork {
project: s.project.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
cache: s.layer_cache.clone(),
comp: cid,
layer: lid,
path,
method,
times: times.clone(),
};
s.state.mask_track_method = method;
let wait = b_p(p, "wait").unwrap_or(false);
s.start_mask_track(work, uid, dir, wait).map_err(EngineError::Other)?;
let prog = s.mask_track_progress();
Ok(json!({
"mask": uid,
"method": method.id(),
"frames": times.len() - 1,
"start": times[0].seconds(),
"end": times.last().map(|t| t.seconds()),
"running": s.is_mask_tracking(),
"progress": prog,
}))
}
// ---------------------------------------------------------------- mask interpolation
/// Mask Interpolation panel options (kept in the editor state, serde for agents).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct MaskInterpOptions {
/// Keyframes per second; `None` = Auto (the comp frame rate).
pub keyframe_rate: Option<f64>,
/// Keyframe Fields (doubles the rate).
pub keyframe_fields: bool,
pub linear_vertex_paths: bool,
pub bending_resistance: f64,
pub quality: f64,
/// Add Mask Shape Vertices (value, unit: pixels | total | percent).
pub add_vertices: Option<f64>,
pub add_vertices_unit: String,
pub matching_method: String,
pub one_to_one: bool,
pub first_vertices_match: bool,
}
impl Default for MaskInterpOptions {
fn default() -> Self {
MaskInterpOptions {
keyframe_rate: None,
keyframe_fields: false,
linear_vertex_paths: false,
bending_resistance: 50.0,
quality: 50.0,
add_vertices: Some(5.0),
add_vertices_unit: "pixels".into(),
matching_method: "auto".into(),
one_to_one: false,
first_vertices_match: true,
}
}
}
impl MaskInterpOptions {
pub fn interp(&self) -> InterpOpts {
InterpOpts {
linear: self.linear_vertex_paths,
bending_resistance: self.bending_resistance.clamp(0.0, 100.0),
quality: self.quality.clamp(0.0, 100.0),
add_vertices: self.add_vertices.map(|v| match self.add_vertices_unit.as_str() {
"total" => AddVertices::Total(v.round().max(2.0) as usize),
"percent" => AddVertices::Percent(v),
_ => AddVertices::Pixels(v),
}),
matching: match self.matching_method.as_str() {
"curve" => Matching::Curve,
"polyline" => Matching::Polyline,
_ => Matching::Auto,
},
one_to_one: self.one_to_one,
first_vertices_match: self.first_vertices_match,
}
}
}
/// Merge option parameters into `o`.
fn apply_options(o: &mut MaskInterpOptions, p: &Value, cmd: &str) -> Result<()> {
match p.get("keyframeRate") {
Some(Value::String(s)) if s.eq_ignore_ascii_case("auto") => o.keyframe_rate = None,
Some(Value::Null) => o.keyframe_rate = None,
Some(v) => o.keyframe_rate = Some(v.as_f64().filter(|r| *r > 0.0).ok_or_else(|| bad(cmd, "keyframeRate: a positive number or \"auto\""))?.min(1000.0)),
None => {}
}
for (k, f) in [
("keyframeFields", &mut o.keyframe_fields),
("linearVertexPaths", &mut o.linear_vertex_paths),
("oneToOne", &mut o.one_to_one),
("firstVerticesMatch", &mut o.first_vertices_match),
] {
if let Some(v) = b_p(p, k) {
*f = v;
}
}
if let Some(v) = f_p(p, "bendingResistance") {
o.bending_resistance = v.clamp(0.0, 100.0);
}
if let Some(v) = f_p(p, "quality") {
o.quality = v.clamp(0.0, 100.0);
}
match p.get("addVertices") {
Some(Value::Bool(false)) | Some(Value::Null) => o.add_vertices = None,
Some(Value::Bool(true)) => o.add_vertices = Some(o.add_vertices.unwrap_or(5.0)),
Some(v) => o.add_vertices = Some(v.as_f64().filter(|x| *x > 0.0).ok_or_else(|| bad(cmd, "addVertices: a positive number or false"))?),
None => {}
}
if let Some(u) = str_p(p, "addVerticesUnit") {
let u = u.to_ascii_lowercase();
o.add_vertices_unit = match u.as_str() {
"pixels" | "px" | "pixelsbetweenvertices" => "pixels",
"total" | "totalvertices" => "total",
"percent" | "%" | "percentageofoutline" => "percent",
_ => return Err(bad(cmd, "addVerticesUnit: pixels|total|percent")),
}
.into();
}
if let Some(m) = str_p(p, "matchingMethod") {
let m = m.to_ascii_lowercase();
if !["auto", "curve", "polyline"].contains(&m.as_str()) {
return Err(bad(cmd, "matchingMethod: auto|curve|polyline"));
}
o.matching_method = m;
}
Ok(())
}
fn interp_options(s: &mut Session, p: &Value) -> Result<Value> {
let mut o = s.state.mask_interp.clone();
apply_options(&mut o, p, "mask.interpolationOptions")?;
s.state.mask_interp = o.clone();
s.bump();
Ok(serde_json::to_value(o).unwrap_or(Value::Null))
}
fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "mask.interpolate";
let mut o = s.state.mask_interp.clone();
apply_options(&mut o, p, CMD)?;
// The layer: given, else the layer of selected Mask Path keys, else the selection.
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let lid = match p.get("layer") {
Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?,
None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
Some(k) => k.layer,
None => layer_p(s, p, CMD)?.1,
},
};
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?;
let uid = match p.get("mask") {
Some(_) => mask_uid(s, layer, p),
None => s
.state
.selected_keys
.iter()
.filter(|k| k.layer == lid)
.find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid))
.or_else(|| mask_uid(s, layer, p)),
}
.ok_or_else(|| bad(CMD, "no such mask (select two Mask Path keyframes or pass `mask`)"))?;
let mg = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "no mask"))?;
let path_prop = mg.get("path").ok_or_else(|| bad(CMD, "the mask has no path"))?;
// Key times (layer time): explicit, else selected keys on this Mask Path.
let mut times: Vec<Tick> = match p.get("times").and_then(Value::as_array) {
Some(a) => {
let ts: Vec<Tick> = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect();
ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect()
}
None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
};
times.sort();
times.dedup();
if times.len() < 2 {
return Err(bad(CMD, "select two or more Mask Path keyframes (or pass `times` of existing keys)"));
}
let rate = o.keyframe_rate.unwrap_or_else(|| comp.frame_rate.as_f64()) * if o.keyframe_fields { 2.0 } else { 1.0 };
let step = Tick::from_seconds_f64(1.0 / rate.max(0.01));
let opts = o.interp();
let shapes: Vec<ShapePath> = times.iter().filter_map(|t| path_prop.keys.iter().find(|k| k.time == *t).and_then(|k| k.value.as_path().cloned())).collect();
if shapes.len() != times.len() {
return Err(bad(CMD, "the selected keys must be Mask Path keys"));
}
// (time, shape) for every new or replaced key.
let mut out: Vec<(Tick, ShapePath)> = vec![];
let mut vertices = 0;
for i in 0..times.len() - 1 {
let (t0, t1) = (times[i], times[i + 1]);
let (a, b) = interp::correspond(&shapes[i], &shapes[i + 1], &opts);
vertices = vertices.max(a.len());
out.push((t0, a.clone()));
let mut t = t0 + step;
while t1.0 - t.0 > step.0 / 4 {
let u = (t.0 - t0.0) as f64 / (t1.0 - t0.0) as f64;
out.push((t, interp::interpolate(&a, &b, u, &opts)));
t += step;
}
out.push((t1, b));
}
let created = out.len() - times.len().min(out.len());
let (first, last) = (times[0], times[times.len() - 1]);
s.state.mask_interp = o;
s.edit("Mask Interpolation", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let pr = l.props.find_group_mut(uid).and_then(|g| g.get_mut("path")).ok_or_else(|| bad(CMD, "no mask path"))?;
// Keys strictly inside the interpolated span are replaced.
pr.keys.retain(|k| k.time <= first || k.time >= last || times.contains(&k.time));
for (t, sp) in &out {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*t, KV::Path(sp.clone())));
}
Ok(())
})?;
Ok(json!({"mask": uid, "keys": created, "vertices": vertices, "rate": rate}))
}
+6
View File
@@ -21,6 +21,8 @@ mod liquify;
mod lottie;
mod markers;
mod mask;
pub mod mask_interp;
pub(crate) mod model3d;
pub mod paint;
mod paths;
mod project_items;
@@ -38,6 +40,7 @@ mod time;
mod track;
mod view;
pub mod viewer_cmds;
mod warp_cmds;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
@@ -100,6 +103,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layer::specs());
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(model3d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());
@@ -126,6 +130,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(comp_more::specs());
v.extend(frontend::specs());
v.extend(track::specs());
v.extend(mask_interp::specs());
v.extend(warp_cmds::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(liquify::specs());
+397
View File
@@ -0,0 +1,397 @@
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
//! shapes, and Layer ▸ Material commands.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Node, PrimitiveKind, PropGroup, Renderer};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::{color_p, insert_layer};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
// ---------------------------------------------------------------- geometry options
/// Add Geometry Options (Bevel Style, Bevel Depth, Hole Bevel Depth, Extrusion Depth) to a text
/// or shape layer that has none.
pub(crate) fn add_geometry_options(next_id: &mut u64, l: &mut Layer) {
if !matches!(l.source, LayerSource::Text | LayerSource::Shape) || l.props.sub("geometryOptions").is_some() {
return;
}
let g = build::extrusion_geometry_options(&mut Ids(next_id));
// After Transform, like After Effects.
let at = l.props.children.iter().position(|c| c.match_id() == "transform").map_or(l.props.children.len(), |i| i + 1);
l.props.children.insert(at, g.into());
}
/// Advanced 3D comps: give every text and shape layer Geometry Options.
pub(crate) fn sync_geometry_options(proj: &mut effectcraft_project::Project, cid: ItemId) {
if proj.comp(cid).is_none_or(|c| c.renderer != Renderer::Advanced3D) {
return;
}
let mut next = proj.next_id;
if let Some(c) = proj.comp_mut(cid) {
for l in &mut c.layers {
add_geometry_options(&mut next, l);
}
}
proj.next_id = next;
}
fn renderer_p(p: &Value, cmd: &str) -> Result<Renderer> {
let v = str_p(p, "renderer").or_else(|| str_p(p, "value")).ok_or_else(|| bad(cmd, "missing `renderer` (advanced3d|classic3d)"))?;
match v.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"advanced3d" | "advanced" => Ok(Renderer::Advanced3D),
"classic3d" | "classic" => Ok(Renderer::Classic3D),
_ => Err(bad(cmd, format!("renderer: advanced3d|classic3d, not `{v}`"))),
}
}
fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
if p.get("renderer").is_none() && p.get("value").is_none() {
let r = s.project.comp(cid).ok_or(EngineError::NoComp)?.renderer;
return Ok(json!({"renderer": renderer_id(r)}));
}
let r = renderer_p(p, "comp.renderer")?;
s.edit("Change Renderer", None, |proj, _| {
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.renderer = r;
sync_geometry_options(proj, cid);
Ok(())
})?;
Ok(json!({"renderer": renderer_id(r)}))
}
fn renderer_id(r: Renderer) -> &'static str {
match r {
Renderer::Advanced3D => "advanced3d",
Renderer::Classic3D => "classic3d",
}
}
// ---------------------------------------------------------------- model layers
/// Set a property's value at comp time `t` (adds a key when it is animated).
fn set_at(l: &mut Layer, path: &str, t: Tick, v: KV) -> bool {
let lt = l.layer_time(t);
match l.props.prop_mut(path) {
Some(pr) => {
pr.set_value_at(lt, v);
true
}
None => false,
}
}
/// New 3D model layer from a model footage item (`item`) or a file (`path`, imported first).
pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let item = match (p.get("item"), str_p(p, "path")) {
(Some(Value::Number(n)), _) => ItemId(n.as_u64().unwrap_or(0)),
(Some(Value::String(name)), _) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.newModel", format!("no item `{name}`")))?,
(_, Some(path)) => {
let r = s.execute("file.import", json!({"paths": [path]}))?;
let id = r.get("items").and_then(|a| a.get(0)).and_then(Value::as_u64);
let err = r.get("errors").and_then(|e| e.get(0)).and_then(Value::as_str).unwrap_or("import failed").to_string();
ItemId(id.ok_or_else(|| bad("layer.newModel", err))?)
}
_ => *s.state.project_selection.first().ok_or_else(|| bad("layer.newModel", "missing `item` or `path`"))?,
};
let it = s.project.item(item).ok_or_else(|| bad("layer.newModel", "no such item"))?.clone();
let ItemKind::Footage(f) = &it.kind else { return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name))) };
if f.kind != FootageKind::Model {
return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name)));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
// Fit the model to half the comp height, and list its animation clips.
let model = s.footage.model(item, f);
let (scale, clips) = match &model {
Some(m) => {
let ext = m.bounds().map_or(1.0, |(lo, hi)| (0..3).map(|k| hi[k] - lo[k]).fold(0.0f64, f64::max));
let scale = if ext > 1e-9 { comp.height as f64 * 0.5 / ext } else { 100.0 };
let clips = m.animations.iter().enumerate().map(|(i, a)| if a.name.is_empty() { format!("Animation {}", i + 1) } else { a.name.clone() }).collect();
(scale, clips)
}
None => (100.0, vec![]),
};
let name = str_p(p, "name").unwrap_or(&it.name).to_string();
let fr = comp.frame_rate;
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
let id = s.edit("New 3D Model Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") {
*slot = g.into();
}
l.start_time = start;
l.in_point = start;
l.out_point = comp.duration.max(start + fr.frame_duration());
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0, "item": item.0}))
}
fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
let k = str_p(p, "kind").or_else(|| str_p(p, "type")).ok_or_else(|| bad(cmd, "missing `kind` (cube|sphere|plane|torus|cone|cylinder)"))?;
PrimitiveKind::parse(k).ok_or_else(|| bad(cmd, format!("kind: cube|sphere|plane|torus|cone|cylinder, not `{k}`")))
}
/// Parse Material Options parameters onto a layer at comp time `t`.
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize> {
let mut n = 0;
let tri = |v: &Value| -> Option<u32> {
match v {
Value::Bool(b) => Some(*b as u32),
Value::Number(x) => x.as_u64().map(|x| x.min(2) as u32),
Value::String(s) => match s.to_ascii_lowercase().as_str() {
"off" | "false" => Some(0),
"on" | "true" => Some(1),
"only" => Some(2),
_ => None,
},
_ => None,
}
};
for k in ["castsShadows", "acceptsShadows"] {
if let Some(v) = p.get(k) {
let e = tri(v).ok_or_else(|| bad(cmd, format!("{k}: off|on|only")))?;
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Enum(e)) as usize;
}
}
for k in ["acceptsLights", "appearsInReflections"] {
if let Some(v) = b_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Bool(v)) as usize;
}
}
for k in ["lightTransmission", "ambient", "diffuse", "specularIntensity", "specularShininess", "metal", "metallic", "roughness"] {
if let Some(v) = f_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Scalar(v)) as usize;
}
}
for k in ["baseColor", "emissive"] {
if let Some(c) = color_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])) as usize;
}
}
Ok(n)
}
fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let kind = kind_p(p, "layer.newPrimitive")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string());
let t = super::time_p(s, p, Some(&comp));
let id = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None);
for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] {
if let Some(v) = f_p(p, k) {
set_at(&mut l, &format!("geometryOptions/{k}"), t, KV::Scalar(v.max(0.0)));
}
}
if let Some(pos) = p.get("position").and_then(Value::as_array) {
let g = |i: usize| pos.get(i).and_then(Value::as_f64).unwrap_or(0.0);
set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)]));
}
apply_material(&mut l, p, t, "layer.newPrimitive")?;
insert_layer(proj, st, cid, l)
})?;
// Primitives are drawn by the Advanced 3D renderer.
let advanced = s.project.comp(cid).is_some_and(|c| c.renderer == Renderer::Advanced3D);
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": advanced}))
}
// ---------------------------------------------------------------- environment layer
fn environment(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("layer.environment", "select a footage or composition layer"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
for id in &ids {
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
if !l.source.is_av() || l.source.is_model() {
return Err(bad("layer.environment", format!("`{}` can't be an environment layer (use a footage, comp or solid layer)", l.name)));
}
}
let on = b_p(p, "on").unwrap_or_else(|| !comp.layer(ids[0]).is_some_and(|l| l.environment));
s.edit("Environment Layer", None, |proj, _| {
for id in &ids {
let l = layer_mut(proj, cid, *id)?;
l.environment = on;
if on {
l.switches.three_d = true;
}
}
Ok(())
})?;
Ok(json!({"environment": on, "layers": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
// ---------------------------------------------------------------- materials
fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("material.set", "no `layers` given and no layer selected"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = super::time_p(s, p, Some(&comp));
let n = s.edit("Material Options", None, |proj, _| {
let mut n = 0;
for id in &ids {
n += apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?;
}
Ok(n)
})?;
if n == 0 {
return Err(bad(
"material.set",
"no Material Options property matched (castsShadows, acceptsShadows, acceptsLights, baseColor, metallic, roughness, …)",
));
}
Ok(json!({"set": n}))
}
/// Fresh Material Options for a layer.
fn default_material(next: &mut u64, l: &Layer) -> Option<PropGroup> {
let ids = &mut Ids(next);
match l.source {
LayerSource::Primitive { .. } => Some(build::model_material_options(ids, true)),
LayerSource::Model { .. } => Some(build::model_material_options(ids, false)),
_ if l.source.is_av() => Some(build::material_options(ids)),
_ => None,
}
}
fn replace_material(l: &mut Layer, g: PropGroup) {
match l.props.children.iter_mut().find(|c| c.match_id() == "materialOptions") {
Some(slot) => *slot = Node::Group(g),
None => l.props.children.push(g.into()),
}
}
fn material_reset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("material.reset", "no `layers` given and no layer selected"));
}
s.edit("Reset Material", None, |proj, _| {
let mut next = proj.next_id;
for id in &ids {
let l = layer_mut(proj, cid, *id)?;
if let Some(g) = default_material(&mut next, l) {
replace_material(l, g);
}
}
proj.next_id = next;
Ok(())
})?;
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
/// Duplicate and Assign Material: the first layer's Material Options are copied onto the other
/// selected layers of the same kind.
fn material_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.len() < 2 {
return Err(bad("material.duplicateAssign", "select the source layer first, then the layers to assign its material to"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let src = comp
.layer(ids[0])
.and_then(|l| l.props.sub("materialOptions"))
.cloned()
.ok_or_else(|| bad("material.duplicateAssign", "the first layer has no material"))?;
let n = s.edit("Duplicate and Assign Material", None, |proj, _| {
let mut n = 0;
for id in &ids[1..] {
let l = layer_mut(proj, cid, *id)?;
let Some(dst) = l.props.sub("materialOptions") else { continue };
// Property sets differ (models, primitives, cards): copy the values they share.
let mut g = dst.clone();
for c in g.children.iter_mut() {
if let Node::Prop(d) = c
&& let Some(sv) = src.get(&d.match_id)
{
d.value = sv.value.clone();
d.keys = sv.keys.clone();
d.expr = sv.expr.clone();
}
}
replace_material(l, g);
n += 1;
}
Ok(n)
})?;
Ok(json!({"assigned": n}))
}
fn material_reveal(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "material.revealSource")?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let LayerSource::Model { item } = l.source else { return Err(bad("material.revealSource", "the layer's material is its own (not from a model file)")) };
s.state.project_selection = vec![item];
Ok(json!({"item": item.0}))
}
fn has_model_or_layers(s: &Session) -> std::result::Result<(), String> {
has_layers(s)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"layer.newModel",
"3D Model Layer",
[],
None,
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s)}",
has_comp,
new_model
),
cmd!(
"layer.new3dPrimitive",
"3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
has_comp,
new_primitive
),
cmd!(
"layer.newPrimitive",
"New 3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
has_comp,
new_primitive
),
cmd!("comp.renderer", "Renderer", [], None, "{renderer?: advanced3d|classic3d (omit to read), value?}", has_comp, comp_renderer),
cmd!("layer.environment", "Environment Layer", ["Layer"], None, "{layers?, on?}", has_layers, environment),
cmd!(
"material.set",
"Material Options",
[],
None,
"{layers?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections?, lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal?, baseColor?, metallic?, roughness?, emissive?, time?}",
has_layers,
material_set
),
cmd!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], None, "{layer?}", has_layers, material_reveal),
cmd!("material.reset", "Reset Material", ["Layer", "Material"], None, "{layers?}", has_model_or_layers, material_reset),
cmd!(
"material.duplicateAssign",
"Duplicate and Assign Material",
["Layer", "Material"],
None,
"{layers? (first = source)}",
has_layers,
material_duplicate
),
]
}
-7
View File
@@ -29,11 +29,9 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
// Layer ▸ New (3D / content-aware fill).
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
stub!("layer.new3dPrimitive", "3D Primitive", [], "{kind: cube|sphere|plane|torus|cone|cylinder}"),
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
// Time (timeline milestone).
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
stub!("layer.environment", "Environment Layer", ["Layer"], "{}"),
stub!("layer.create", "Create", [], "{op}"),
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
@@ -47,9 +45,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
stub!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], "{}"),
stub!("material.reset", "Reset Material", ["Layer", "Material"], "{}"),
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
// Color management.
@@ -60,8 +55,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
stub!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], "{}"),
stub!("track.mask", "Track Mask", ["Animation"], "{}"),
// Workspaces and panels that don't exist yet.
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
]
+33 -25
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_track,
analyze
),
cmd!("track.stop", "Stop Analysis", [], None, "{}", is_tracking, |s, _| Ok(json!({"stopped": s.stop_track()}))),
cmd!("track.stop", "Stop Analysis", [], None, "{}", is_tracking, |s, _| Ok(json!({"stopped": s.stop_track() | s.stop_mask_track() | s.stop_warp()}))),
cmd!("track.apply", "Apply", [], None, "{layer?, tracker?, dimensions?: xy|x|y}", has_track, apply),
cmd!("track.reset", "Reset", [], None, "{layer?, tracker?}", has_track, reset),
cmd!("track.delete", "Delete Tracker", [], None, "{layer?, tracker?}", has_track, delete),
@@ -105,7 +105,7 @@ fn has_track(s: &Session) -> std::result::Result<(), String> {
}
fn is_tracking(s: &Session) -> std::result::Result<(), String> {
if s.is_tracking() { Ok(()) } else { Err("no track analysis is running".into()) }
if s.is_tracking() || s.is_mask_tracking() || s.is_warp_analyzing() { Ok(()) } else { Err("no track analysis is running".into()) }
}
// ---------- addressing ----------
@@ -432,31 +432,10 @@ fn analyze(s: &mut Session, p: &V) -> Result<V> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let (tg, settings) = layer.tracker(uid).ok_or_else(|| bad("track.analyze", "no tracker"))?;
let fd = comp.frame_duration();
let now = comp.frame_rate.snap_nearest(if s.state.active_comp == Some(cid) { s.time() } else { Tick::ZERO });
let start = f_p(p, "start").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let end = f_p(p, "end").map(|v| comp.frame_rate.snap_nearest(Tick::from_seconds_f64(v)));
let lo = layer.in_point.max(Tick::ZERO);
let hi = layer.out_point.min(comp.duration) - fd;
let mut times = vec![];
match dir {
Direction::Forward | Direction::FrameForward => {
let mut t = start.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameForward { t + fd } else { end.unwrap_or(hi).min(hi) };
while t <= stop && t <= hi {
times.push(t);
t += fd;
}
}
Direction::Backward | Direction::FrameBackward => {
let mut t = end.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameBackward { t - fd } else { start.unwrap_or(lo).max(lo) };
while t >= stop && t >= lo {
times.push(t);
t -= fd;
}
}
}
let times = analysis_times(comp, layer, dir, now, start, end);
if times.len() < 2 {
return Err(bad("track.analyze", "nothing to analyze in that direction (at the layer's end)"));
}
@@ -489,6 +468,34 @@ fn analyze(s: &mut Session, p: &V) -> Result<V> {
}))
}
/// Comp times to analyse from the CTI (`now`) in `dir`, bounded by `start` / `end` and the
/// layer's In/Out points; the first is the start frame.
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer, dir: Direction, now: Tick, start: Option<Tick>, end: Option<Tick>) -> Vec<Tick> {
let fd = comp.frame_duration();
let lo = layer.in_point.max(Tick::ZERO);
let hi = layer.out_point.min(comp.duration) - fd;
let mut times = vec![];
match dir {
Direction::Forward | Direction::FrameForward => {
let mut t = start.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameForward { t + fd } else { end.unwrap_or(hi).min(hi) };
while t <= stop && t <= hi {
times.push(t);
t += fd;
}
}
Direction::Backward | Direction::FrameBackward => {
let mut t = end.unwrap_or(now).clamp(lo, hi);
let stop = if dir == Direction::FrameBackward { t - fd } else { start.unwrap_or(lo).max(lo) };
while t >= stop && t >= lo {
times.push(t);
t -= fd;
}
}
}
times
}
fn apply(s: &mut Session, p: &V) -> Result<V> {
let (cid, lid, uid) = current(s, p, "track.apply")?;
let dims = match str_p(p, "dimensions") {
@@ -560,9 +567,10 @@ fn delete(s: &mut Session, p: &V) -> Result<V> {
fn status(s: &mut Session, p: &V) -> Result<V> {
s.poll_track();
s.poll_mask_track();
let progress = s.track_progress();
let cur = current(s, p, "track.status").ok();
let mut out = json!({"running": s.is_tracking(), "progress": progress});
let mut out = json!({"running": s.is_tracking() || s.is_mask_tracking(), "progress": progress, "mask": s.mask_track_progress(), "maskMethod": s.state.mask_track_method.id()});
if let Some((cid, lid, uid)) = cur
&& let Some(layer) = s.project.comp(cid).and_then(|c| c.layer(lid))
&& let Some((g, st)) = layer.tracker(uid)
+131
View File
@@ -0,0 +1,131 @@
//! Warp Stabilizer commands (Effect ▸ Distort ▸ Warp Stabilizer's Analyze / Cancel, Animation ▸
//! Warp Stabilizer VFX, the Tracker panel button) and its status query.
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_comp, layer_p};
use crate::{EngineError, Result, Session, cmd, query};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], None, "{layer?, wait?}", can_stabilize, warp_apply),
cmd!("warp.analyze", "Analyze", [], None, "{layer?, effect?: uid|name|index, wait?: block until done}", has_comp, warp_analyze),
cmd!("warp.cancel", "Cancel", [], None, "{}", is_analyzing, |s, _| Ok(json!({"stopped": s.stop_warp()}))),
query!("warp.status", "Warp Stabilizer Status", "{layer?, effect?}", warp_status),
]
}
// ---------------------------------------------------------------- Warp Stabilizer
fn can_stabilize(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let c = s.active_comp().ok_or("no composition is open")?;
match s.state.selected_layers.first().and_then(|l| c.layer(*l)) {
Some(l) if l.has_video() && !l.switches.adjustment => Ok(()),
Some(_) => Err("Warp Stabilizer needs a footage or composition layer".into()),
None => Err("select a layer to stabilize".into()),
}
}
fn is_analyzing(s: &Session) -> std::result::Result<(), String> {
if s.is_warp_analyzing() { Ok(()) } else { Err("no Warp Stabilizer analysis is running".into()) }
}
fn is_warp(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::warp_stab::ID)
}
/// (comp, layer, effect uid) of a Warp Stabilizer: `layer` / `effect` (uid, name or 1-based
/// index among the layer's effects), else a selected one, else the layer's first.
fn warp_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let candidates: Vec<LayerId> = match p.get("layer") {
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
None => {
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
v.extend(s.state.selected_layers.iter().copied());
v.extend(comp.layers.iter().map(|l| l.id));
v
}
};
for lid in candidates {
let Some(fx) = comp.layer(lid).and_then(Layer::effects) else { continue };
let g = match p.get("effect") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth((n as usize).saturating_sub(1)))
}
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name),
_ => s
.state
.selected_props
.iter()
.find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u && is_warp(g))).flatten())
.or_else(|| fx.groups().find(|g| is_warp(g))),
};
if let Some(g) = g.filter(|g| is_warp(g)) {
return Ok((cid, lid, g.uid));
}
if p.get("layer").is_some() {
break;
}
}
Err(bad(cmd, "no Warp Stabilizer found (apply Effect ▸ Distort ▸ Warp Stabilizer first)"))
}
fn warp_analyze(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = warp_p(s, p, "warp.analyze")?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_warp(cid, lid, uid, wait).map_err(EngineError::Other)?;
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_warp_analyzing(), "progress": s.warp_progress()});
if wait {
out["status"] = warp_status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
}
Ok(out)
}
/// Animation ▸ Warp Stabilizer VFX / the Tracker panel button: apply the effect to the selected
/// layer and start analysing it.
fn warp_apply(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "track.warpStabilizer")?;
let r = s.execute("effect.apply", json!({"effect": effectcraft_effects::warp_stab::ID, "layers": [lid.0], "comp": cid.0}))?;
let uid = r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?;
let wait = b_p(p, "wait").unwrap_or(false);
let n = s.start_warp(cid, lid, uid, wait).map_err(EngineError::Other)?;
Ok(json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_warp_analyzing(), "progress": s.warp_progress()}))
}
fn warp_status(s: &mut Session, p: &Value) -> Result<Value> {
s.poll_warp(false);
let mut out = json!({"running": s.is_warp_analyzing(), "progress": s.warp_progress(), "banner": s.warp_progress().map(|p| p.banner())});
let Ok((cid, lid, uid)) = warp_p(s, p, "warp.status") else { return Ok(out) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid));
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let analysis = effectcraft_effects::warp_stab::analysis(&params);
out["layer"] = json!(lid.0);
out["effect"] = json!(uid);
out["analyzed"] = json!(analysis.is_some());
out["pending"] = json!(s.warp_pending.contains(&(cid, lid, uid)));
if let Some(a) = analysis {
out["frames"] = json!(a.frames.len());
out["size"] = json!(a.size);
let lt = layer.layer_time(s.time_of(cid)).seconds();
if let Some((k, _, plan)) = effectcraft_effects::warp_stab::plan_at(&params, lt) {
let m = plan.warps[k].0;
out["frame"] = json!(k);
out["autoScale"] = json!(plan.auto_scale * 100.0);
out["crop"] = json!(plan.crop);
out["validFraction"] = json!(plan.valid_fraction);
out["warp"] = json!(m);
let pts = &a.frames[k];
out["features"] = json!(pts.features);
out["inliers"] = json!(pts.inliers);
}
}
Ok(out)
}
+32
View File
@@ -13,6 +13,7 @@ pub mod commands;
pub mod config;
pub mod demo;
pub mod links;
pub mod mask_track;
pub mod menus;
pub mod prefs;
pub mod render_queue;
@@ -20,6 +21,7 @@ mod session_settings;
pub mod shortcuts;
pub mod tracking;
pub mod viewer;
pub mod warp;
use std::sync::Arc;
@@ -165,6 +167,12 @@ pub struct EditorState {
/// Tracker panel ▸ Current Track: (tracked layer, tracker group uid) in the active comp.
#[serde(default)]
pub current_track: Option<(LayerId, Uid)>,
/// Tracker panel ▸ Method in mask mode (Track Mask).
#[serde(default)]
pub mask_track_method: mask_track::MaskMethod,
/// Mask Interpolation panel options.
#[serde(default)]
pub mask_interp: commands::mask_interp::MaskInterpOptions,
/// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools).
#[serde(default)]
pub paint: commands::paint::PaintOptions,
@@ -249,6 +257,12 @@ pub struct Session {
pub render_job: Option<render_queue::RenderJob>,
/// The running (or finished, not yet polled) track analysis.
pub track_job: Option<tracking::TrackJob>,
/// The running (or finished, not yet polled) mask track.
pub mask_job: Option<mask_track::MaskTrackJob>,
/// The running (or finished, not yet polled) Warp Stabilizer analysis.
pub warp_job: Option<warp::WarpJob>,
/// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid).
pub warp_pending: Vec<(ItemId, LayerId, Uid)>,
pub events: Vec<Event>,
/// Commands executed: (id, params).
pub journal: Vec<(String, Value)>,
@@ -291,6 +305,9 @@ impl Default for Session {
exporter: None,
render_job: None,
track_job: None,
mask_job: None,
warp_job: None,
warp_pending: vec![],
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
@@ -351,6 +368,12 @@ impl Session {
let r = f(&mut p, &mut st)?;
// Layer styles: one Global Light per comp, whichever layer edited it.
effectcraft_project::styles::sync_global_light(&before, &mut p);
// Warp Stabilizer analyses made from other frames are cleared (and queued again).
for w in warp::invalidate(&before, &mut p) {
if !self.warp_pending.contains(&w) {
self.warp_pending.push(w);
}
}
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
@@ -523,6 +546,11 @@ impl Session {
self.render_job = None;
self.stop_track();
self.track_job = None;
self.stop_mask_track();
self.mask_job = None;
self.stop_warp();
self.warp_job = None;
self.warp_pending.clear();
p.fix_next_id();
self.project = Arc::new(p);
self.history = History::default();
@@ -554,8 +582,12 @@ mod tests_lottie;
#[cfg(test)]
mod tests_markers;
#[cfg(test)]
mod tests_mask_warp;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_model3d;
#[cfg(test)]
mod tests_project_items;
#[cfg(test)]
mod tests_settings;
+313
View File
@@ -0,0 +1,313 @@
//! Mask tracking runtime (Layer ▸ Mask ▸ Track Mask, the Tracker panel in mask mode): follow
//! the pixels inside a mask from frame to frame and key its Mask Path on every analysed frame.
//!
//! Analysis runs on a background thread (progress, cancel; blocking with `wait` and on wasm32)
//! like the point tracker, over the layer's source frames; one undo step covers it. The
//! algorithm is `effectcraft_track::mask` (KLT features inside the mask + a RANSAC fit of the
//! chosen motion model), and every frame's motion is applied to the mask's vertices and
//! tangents, so the shape's Bezier structure is kept.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::{ItemId, LayerId, Project, Uid};
use effectcraft_render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use effectcraft_track as trk;
use effectcraft_track::fit::Model;
use serde::{Deserialize, Serialize};
use crate::tracking::{Direction, TrackProgress, lock, source_frame};
use crate::{Event, Session};
/// Tracker panel ▸ Method (mask tracking).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MaskMethod {
Position,
PositionScale,
#[default]
PositionScaleRotation,
PositionScaleRotationSkew,
Perspective,
}
impl MaskMethod {
pub const ALL: [MaskMethod; 5] =
[MaskMethod::Position, MaskMethod::PositionScale, MaskMethod::PositionScaleRotation, MaskMethod::PositionScaleRotationSkew, MaskMethod::Perspective];
pub fn label(self) -> &'static str {
match self {
MaskMethod::Position => "Position",
MaskMethod::PositionScale => "Position & Scale",
MaskMethod::PositionScaleRotation => "Position, Scale & Rotation",
MaskMethod::PositionScaleRotationSkew => "Position, Scale, Rotation & Skew",
MaskMethod::Perspective => "Perspective",
}
}
pub fn id(self) -> &'static str {
match self {
MaskMethod::Position => "position",
MaskMethod::PositionScale => "positionScale",
MaskMethod::PositionScaleRotation => "positionScaleRotation",
MaskMethod::PositionScaleRotationSkew => "positionScaleRotationSkew",
MaskMethod::Perspective => "perspective",
}
}
pub fn from_name(s: &str) -> Option<MaskMethod> {
let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
MaskMethod::ALL
.into_iter()
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == n)
}
pub fn model(self) -> Model {
match self {
MaskMethod::Position => Model::Translation,
MaskMethod::PositionScale => Model::TranslationScale,
MaskMethod::PositionScaleRotation => Model::Similarity,
MaskMethod::PositionScaleRotationSkew => Model::Affine,
MaskMethod::Perspective => Model::Homography,
}
}
}
/// A closed polyline through a Bezier path (`steps` samples per segment), in layer pixels.
pub fn flatten(p: &ShapePath, steps: usize) -> Vec<[f64; 2]> {
let n = p.len();
if n == 0 {
return vec![];
}
let segs = if p.closed { n } else { n.saturating_sub(1) };
let mut out = Vec::with_capacity(segs * steps + 1);
for i in 0..segs {
let j = (i + 1) % n;
let (a, b) = (p.vertices[i], p.vertices[j]);
let o = p.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
let it = p.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
let c = [a, [a[0] + o[0], a[1] + o[1]], [b[0] + it[0], b[1] + it[1]], b];
for k in 0..steps {
let t = k as f64 / steps as f64;
let u = 1.0 - t;
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
out.push([0, 1].map(|d| w[0] * c[0][d] + w[1] * c[1][d] + w[2] * c[2][d] + w[3] * c[3][d]));
}
}
if !p.closed {
out.push(p.vertices[n - 1]);
}
out
}
/// A path moved by a frame-to-frame motion (vertices and tangent handles).
pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath {
ShapePath {
vertices: p.vertices.iter().map(|v| h.apply(*v)).collect(),
in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
closed: p.closed,
}
}
/// One tracked frame: (layer time, comp time, mask path).
type MaskFrame = (Tick, Tick, ShapePath);
#[derive(Default)]
pub struct MaskTrackShared {
pub cancel: AtomicBool,
pub state: Mutex<TrackProgress>,
frames: Mutex<Vec<MaskFrame>>,
}
/// A running (or finished, not yet polled) mask track.
pub struct MaskTrackJob {
pub shared: Arc<MaskTrackShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub mask: Uid,
pub direction: Direction,
}
impl MaskTrackJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> TrackProgress {
lock(&self.shared.state).clone()
}
}
/// What the worker needs, detached from the session.
pub(crate) struct MaskWork {
pub project: Arc<Project>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub cache: Arc<LayerCache>,
pub comp: ItemId,
pub layer: LayerId,
/// The mask path on the first frame.
pub path: ShapePath,
pub method: MaskMethod,
/// Comp times; the first is the start frame.
pub times: Vec<Tick>,
}
fn run_work(w: MaskWork, shared: &MaskTrackShared) {
let t0 = web_time::Instant::now();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let frame_at = |t: Tick| source_frame(r, &w.project, w.comp, comp, layer, t, w.expr.as_deref());
lock(&shared.state).total = w.times.len().saturating_sub(1) as u64;
let Some(first) = frame_at(w.times[0]) else { return fail("the layer has no pixels to track") };
let mut tracker = trk::mask::MaskTracker::new(w.method.model(), flatten(&w.path, 8), &trk::Frame { img: &first.0, offset: first.1 });
let mut path = w.path.clone();
lock(&shared.frames).push((layer.layer_time(w.times[0]), w.times[0], path.clone()));
let mut next = if w.times.len() > 1 { frame_at(w.times[1]) } else { None };
for k in 1..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
lock(&shared.state).cancelled = true;
break;
}
let Some(cur) = next.take() else {
lock(&shared.state).error = Some(format!("no frame at {:.3} s", w.times[k].seconds()));
break;
};
let (res, nf) =
rayon::join(|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }), || if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None });
next = nf;
let Some(step) = res else {
lock(&shared.state).error = Some(format!("lost the mask's pixels at {:.3} s (too little texture inside the mask)", w.times[k].seconds()));
break;
};
path = transform_path(&path, &step.motion);
lock(&shared.frames).push((layer.layer_time(w.times[k]), w.times[k], path.clone()));
let mut s = lock(&shared.state);
s.done = k as u64;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
let Some(layer) = p.comp_mut(job.comp).and_then(|c| c.layer_mut(job.layer)) else { return };
let Some(g) = layer.props.find_group_mut(job.mask) else { return };
let Some(pr) = g.get_mut("path") else { return };
for (lt, _, path) in frames {
effectcraft_keyframe::set_key(&mut pr.keys, Keyframe::new(*lt, Value::Path(path.clone())));
}
}
impl Session {
/// Whether a mask track is running.
pub fn is_mask_tracking(&self) -> bool {
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
}
/// Live progress of the running (or just finished) mask track.
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
self.mask_job.as_ref().map(|j| j.progress())
}
/// Cancel the running mask track (frames tracked so far are kept).
pub fn stop_mask_track(&mut self) -> bool {
match &self.mask_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
pub(crate) fn start_mask_track(&mut self, work: MaskWork, mask: Uid, direction: Direction, wait: bool) -> Result<(), String> {
if self.is_mask_tracking() || self.is_tracking() {
return Err("a track analysis is already running".into());
}
self.poll_mask_track();
let wait = wait || cfg!(target_arch = "wasm32");
self.history.undo.push(("Track Mask".into(), self.project.clone()));
self.history.redo.clear();
self.history.merge_key = None;
let shared = Arc::new(MaskTrackShared::default());
let (comp, layer) = (work.comp, work.layer);
let thread = if wait {
run_work(work, &shared);
None
} else {
let sh = shared.clone();
Some(std::thread::Builder::new().name("mask-track".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
};
self.mask_job = Some(MaskTrackJob { shared, thread, comp, layer, mask, direction });
self.poll_mask_track();
Ok(())
}
/// Key newly tracked frames and move the CTI with them; drop the job once it finished.
/// Frontends call this every frame while tracking.
pub fn poll_mask_track(&mut self) -> bool {
let Some(job) = &self.mask_job else { return false };
let frames = std::mem::take(&mut *lock(&job.shared.frames));
let finished = job.is_finished();
let changed = !frames.is_empty();
if changed {
write_frames(Arc::make_mut(&mut self.project), job, &frames);
if self.state.active_comp == Some(job.comp)
&& let Some(last) = frames.last()
{
self.state.times.insert(job.comp, last.1);
}
self.bump();
}
if finished {
let mut job = self.mask_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let msg = match (&st.error, st.cancelled) {
(Some(e), _) => format!("Mask tracking stopped: {e}"),
(None, true) => format!("Mask tracking stopped after {} frame(s)", st.done),
_ => format!("Tracked the mask over {} frame(s) in {:.1} s", st.done, st.elapsed),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
}
changed || finished
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn methods_parse_and_paths_transform() {
assert_eq!(MaskMethod::from_name("positionScaleRotationSkew"), Some(MaskMethod::PositionScaleRotationSkew));
assert_eq!(MaskMethod::from_name("Position & Scale"), Some(MaskMethod::PositionScale));
assert_eq!(MaskMethod::from_name("perspective"), Some(MaskMethod::Perspective));
let p = ShapePath::ellipse([50.0, 40.0], 40.0, 20.0);
let poly = flatten(&p, 8);
assert_eq!(poly.len(), p.len() * 8);
assert!(poly.iter().all(|q| ((q[0] - 50.0) / 20.0).powi(2) + ((q[1] - 40.0) / 10.0).powi(2) < 1.01));
let h = trk::Homography([[0.0, -2.0, 10.0], [2.0, 0.0, 0.0], [0.0, 0.0, 1.0]]);
let q = transform_path(&p, &h);
for i in 0..p.len() {
let v = h.apply(p.vertices[i]);
assert!((q.vertices[i][0] - v[0]).abs() < 1e-9);
let t = p.out_tangents[i];
assert!((q.out_tangents[i][0] + 2.0 * t[1]).abs() < 1e-9 && (q.out_tangents[i][1] - 2.0 * t[0]).abs() < 1e-9);
}
}
}
+2 -1
View File
@@ -524,6 +524,7 @@ Layer
Reset Mask | layer.mask.reset
Remove Mask | layer.mask.remove
Remove All Masks | layer.mask.removeAll
Track Mask | track.mask
Mode
None | layer.mask.mode {"mode":"None"}
Add | layer.mask.mode {"mode":"Add"}
@@ -932,7 +933,7 @@ Window
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
Info | window.panel {"panel":"info"} | Cmd+2
Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"}
Mask Interpolation | window.unavailablePanel {"panel":"maskInterpolation"}
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
Media Browser | window.unavailablePanel {"panel":"mediaBrowser"}
Metadata | window.unavailablePanel {"panel":"metadata"}
Motion Sketch | window.panel {"panel":"motionSketch"}
+384
View File
@@ -0,0 +1,384 @@
//! Mask tracking, Mask Interpolation and the Warp Stabilizer end to end through the commands, on
//! synthetic footage generated in-test (a rotating/scaling textured patch, a shaky camera).
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
use effectcraft_path::interp::paths_cross;
use effectcraft_project::{LayerId, Uid};
use effectcraft_raster::Image;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use effectcraft_track::Homography;
use serde_json::json;
use crate::Session;
use crate::tests_track::{FPS, H, W, frame_time, patch_frames, rotating_pose, setup, texture};
fn pose_h(f: u32) -> Homography {
let (c, rot, sc) = rotating_pose(f);
let (sn, cs) = rot.to_radians().sin_cos();
Homography([[sc * cs, -sc * sn, c[0]], [sc * sn, sc * cs, c[1]], [0.0, 0.0, 1.0]])
}
fn mask_path(s: &Session, layer: LayerId, mask: Uid) -> effectcraft_project::Property {
let l = s.active_comp().unwrap().layer(layer).unwrap();
l.props.find_group(mask).unwrap().get("path").unwrap().clone()
}
fn new_mask(s: &mut Session, layer: LayerId, path: &ShapePath) -> Uid {
let ins: Vec<[f64; 2]> = path.in_tangents.clone();
let outs: Vec<[f64; 2]> = path.out_tangents.clone();
let r = s.execute("mask.new", json!({"layer": layer.0, "vertices": path.vertices, "inTangents": ins, "outTangents": outs, "closed": true})).unwrap();
r["mask"].as_u64().unwrap()
}
#[test]
fn track_mask_follows_a_rotating_patch() {
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
let (c0, _, _) = rotating_pose(0);
// An ellipse inside the textured patch.
let shape = ShapePath::ellipse(c0, 64.0, 52.0);
let uid = new_mask(&mut s, clip, &shape);
// Selecting the mask enables Track Mask (the Tracker panel's mask mode).
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
s.state.selected_props = vec![(clip, uid)];
assert!(s.is_enabled("track.mask"));
assert_eq!(crate::commands::mask_interp::selected_mask(&s), Some((clip, uid)));
s.execute("time.set", json!({"time": 0})).unwrap();
let r = s.execute_checked("track.mask", json!({"method": "positionScaleRotation", "direction": "forward", "wait": true})).unwrap();
assert_eq!(r["frames"], 24, "{r}");
assert!(s.mask_job.is_none());
let pr = mask_path(&s, clip, uid);
assert_eq!(pr.keys.len(), 25);
let inv0 = pose_h(0).inverse().unwrap();
let mut worst: f64 = 0.0;
for f in 0..25 {
let KV::Path(p) = pr.value_at(frame_time(f)) else { panic!() };
let m = pose_h(f).then_after(&inv0);
for (v, o) in p.vertices.iter().zip(&shape.vertices) {
let want = m.apply(*o);
worst = worst.max((v[0] - want[0]).hypot(v[1] - want[1]));
}
// Tangents rotate and scale with the patch.
let want_t = m.apply_tangent(shape.vertices[0], shape.out_tangents[0]);
assert!((p.out_tangents[0][0] - want_t[0]).hypot(p.out_tangents[0][1] - want_t[1]) < 1.5);
}
assert!(worst < 1.5, "worst vertex error {worst} px");
assert_eq!(s.time(), frame_time(24));
// One undo step; redo restores the keys.
assert!(s.undo());
assert!(mask_path(&s, clip, uid).keys.is_empty());
assert!(s.redo());
assert_eq!(mask_path(&s, clip, uid).keys.len(), 25);
// Track backward one frame from the end, with another method.
let r = s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "position", "direction": "frameBackward", "wait": true})).unwrap();
assert_eq!(r["frames"], 1);
assert_eq!(s.state.mask_track_method, crate::mask_track::MaskMethod::Position);
// Serde keeps the keys.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(clip).unwrap();
assert_eq!(l.props.find_group(uid).unwrap().get("path").unwrap().keys.len(), 25);
// Bad parameters are reported.
assert!(s.execute_checked("track.mask", json!({"layer": clip.0, "mask": uid, "method": "warp"})).is_err());
assert!(s.execute_checked("track.mask", json!({"layer": clip.0, "mask": uid, "bogus": 1})).is_err());
}
#[test]
fn track_mask_in_background_and_cancel() {
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
let uid = new_mask(&mut s, clip, &ShapePath::rect(rotating_pose(0).0, 60.0, 60.0));
s.execute("time.set", json!({"time": 0})).unwrap();
let r = s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "perspective"})).unwrap();
assert_eq!(r["running"], true);
// The analysis keeps running in the background; Stop keeps what was tracked.
assert!(s.is_enabled("track.stop"));
s.execute("track.stop", json!({})).unwrap();
let t0 = std::time::Instant::now();
while s.mask_job.is_some() {
assert!(t0.elapsed().as_secs() < 60);
std::thread::sleep(std::time::Duration::from_millis(2));
s.poll_mask_track();
}
let n = mask_path(&s, clip, uid).keys.len();
assert!((1..25).contains(&n), "{n} keys");
// A full background run.
s.execute("time.set", json!({"time": 0})).unwrap();
s.execute("track.mask", json!({"layer": clip.0, "mask": uid, "method": "perspective"})).unwrap();
while s.mask_job.is_some() {
assert!(t0.elapsed().as_secs() < 120);
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_mask_track();
}
let pr = mask_path(&s, clip, uid);
assert_eq!(pr.keys.len(), 25);
let KV::Path(p) = pr.value_at(frame_time(20)) else { panic!() };
let m = pose_h(20).then_after(&pose_h(0).inverse().unwrap());
let want = m.apply(ShapePath::rect(rotating_pose(0).0, 60.0, 60.0).vertices[2]);
assert!((p.vertices[2][0] - want[0]).hypot(p.vertices[2][1] - want[1]) < 2.0);
}
/// A five-pointed star (10 vertices) around `c`, rotated by `rot` degrees.
fn star(c: [f64; 2], r: f64, rot: f64) -> ShapePath {
let pts: Vec<[f64; 2]> = (0..10)
.map(|i| {
let a = (rot + i as f64 * 36.0 - 90.0).to_radians();
let rr = if i % 2 == 0 { r } else { r * 0.45 };
[c[0] + rr * a.cos(), c[1] + rr * a.sin()]
})
.collect();
ShapePath::polygon(&pts, true)
}
#[test]
fn mask_interpolation_creates_matching_keys() {
let (mut s, clip, _) = setup(patch_frames(rotating_pose));
let a = ShapePath::rect([100.0, 100.0], 80.0, 60.0);
let b = star([200.0, 120.0], 50.0, 30.0);
let uid = new_mask(&mut s, clip, &a);
let cid = s.active_comp_id().unwrap();
let (t0, t1) = (frame_time(0), frame_time(20));
s.edit("keys", None, |p, _| {
let pr = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
pr.keys = vec![Keyframe::new(t0, KV::Path(a.clone())), Keyframe::new(t1, KV::Path(b.clone()))];
Ok(())
})
.unwrap();
let prop = mask_path(&s, clip, uid).uid;
s.execute("keys.select", json!({"keys": [{"layer": clip.0, "prop": prop, "time": 0.0}, {"layer": clip.0, "prop": prop, "time": 0.8}]})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
let r = s
.execute_checked(
"mask.interpolate",
json!({"keyframeRate": "auto", "bendingResistance": 50, "quality": 50, "addVertices": 10, "addVerticesUnit": "pixels", "matchingMethod": "auto", "firstVerticesMatch": false}),
)
.unwrap();
// 19 in-betweens at the comp rate (frames 1…19).
assert_eq!(r["keys"], 19, "{r}");
let pr = mask_path(&s, clip, uid);
assert_eq!(pr.keys.len(), 21);
let shapes: Vec<ShapePath> = pr.keys.iter().map(|k| k.value.as_path().unwrap().clone()).collect();
let n = shapes[0].len();
assert!(n >= 28, "{n} vertices");
assert!(shapes.iter().all(|p| p.len() == n));
// Ends keep their outlines; vertex paths between consecutive keys don't cross.
for (i, w) in shapes.windows(2).enumerate() {
assert_eq!(paths_cross(&w[0], &w[1]), 0, "keys {i}/{}", i + 1);
}
assert!(
shapes[0]
.vertices
.iter()
.all(|v| (v[0] - 60.0).abs() < 1e-6 || (v[0] - 140.0).abs() < 1e-6 || (v[1] - 70.0).abs() < 1e-6 || (v[1] - 130.0).abs() < 1e-6)
);
// Options persist; Keyframe Fields doubles the rate; undo restores.
assert_eq!(s.state.mask_interp.add_vertices, Some(10.0));
assert!(s.undo());
assert_eq!(mask_path(&s, clip, uid).keys.len(), 2);
let r = s
.execute(
"mask.interpolate",
json!({"layer": clip.0, "mask": uid, "times": [0.0, 0.8], "keyframeRate": 10, "keyframeFields": true, "linearVertexPaths": true}),
)
.unwrap();
assert_eq!(r["keys"], 15, "{r}");
assert!(s.execute("mask.interpolate", json!({"layer": clip.0, "mask": uid, "times": [0.0]})).is_err());
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
}
// ---------------------------------------------------------------- Warp Stabilizer
/// Camera offset on frame `f`: a slow pan plus jitter.
fn cam(f: u32) -> [f64; 2] {
let j = [((f * 7919) % 9) as f64 - 4.0, ((f * 104_729) % 7) as f64 - 3.0];
[1.2 * f as f64 + j[0], 0.5 * f as f64 + j[1]]
}
fn shaky(f: u32) -> Image {
let c = cam(f);
let mut img = Image::new(W, H);
for y in 0..H {
for x in 0..W {
let v = texture(x as f64 + 0.5 + c[0], y as f64 + 0.5 + c[1], 11);
img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]);
}
}
img
}
fn jitter(p: &[[f64; 2]]) -> f64 {
let mut s = 0.0;
for k in 1..p.len() - 1 {
s += (p[k + 1][0] - 2.0 * p[k][0] + p[k - 1][0]).hypot(p[k + 1][1] - 2.0 * p[k][1] + p[k - 1][1]);
}
s / (p.len() - 2) as f64
}
fn warp_at(s: &mut Session, f: u32) -> Homography {
s.execute("time.set", json!({"time": frame_time(f).seconds()})).unwrap();
let st = s.execute("warp.status", json!({})).unwrap();
let m: [[f64; 3]; 3] = serde_json::from_value(st["warp"].clone()).unwrap_or_else(|_| panic!("{st}"));
Homography(m)
}
/// Output positions of the scene point that sits at the frame centre on frame 0.
fn scene_track(s: &mut Session) -> (Vec<[f64; 2]>, Vec<[f64; 2]>) {
let x0 = [160.0 + cam(0)[0], 120.0 + cam(0)[1]];
let before: Vec<[f64; 2]> = (0..25).map(|f| [x0[0] - cam(f)[0], x0[1] - cam(f)[1]]).collect();
let after = (0..25).map(|f| warp_at(s, f).apply(before[f as usize])).collect();
(before, after)
}
fn render(s: &Session, f: u32) -> Image {
s.render(s.active_comp_id().unwrap(), frame_time(f), RenderOpts::default())
}
fn transparent(img: &Image) -> usize {
img.data.iter().filter(|p| p[3] < 0.5).count()
}
fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value) {
s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap();
}
#[test]
fn warp_stabilizer_analyzes_and_stabilizes() {
let (mut s, clip, solid) = setup(shaky);
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
// Effect ▸ Distort ▸ Warp Stabilizer, then analyse (blocking).
let r = s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap();
let fx = r["effects"][0].as_u64().unwrap();
assert!(s.warp_pending.contains(&(s.active_comp_id().unwrap(), clip, fx)), "applying queues an analysis");
let st = s.execute_checked("warp.status", json!({})).unwrap();
assert_eq!(st["analyzed"], false);
let r = s.execute_checked("warp.analyze", json!({"layer": clip.0, "wait": true})).unwrap();
assert_eq!(r["frames"], 25, "{r}");
assert_eq!(r["status"]["analyzed"], true, "{r}");
assert!(s.warp_pending.is_empty() && s.warp_job.is_none());
// Groups and AE parameter names.
let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone();
let names: Vec<&str> = g.groups().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Stabilization", "Borders", "Advanced"]);
assert!(g.prop("borders/autoScale/maximumScale").is_some());
assert_eq!(g.prop("stabilization/smoothness").unwrap().name, "Smoothness");
// Smooth Motion: the jitter of a scene point drops by more than 80 %.
set_param(&mut s, clip, "borders/framing", json!(0));
let (before, after) = scene_track(&mut s);
let (j0, j1) = (jitter(&before), jitter(&after));
assert!(j1 < 0.2 * j0, "jitter {j0} → {j1}");
// Rendering applies the frame's correction: output(p) = source(W⁻¹ p).
let w = warp_at(&mut s, 7);
let out = render(&s, 7);
let src = shaky(7);
let inv = w.inverse().unwrap();
for p in [[160.0, 120.0], [100.0, 80.0], [230.0, 170.0]] {
let q = inv.apply([p[0] + 0.5, p[1] + 0.5]);
let want = src.sample_bilinear(q[0], q[1]);
let got = out.get(p[0] as i64, p[1] as i64);
assert!((want[0] - got[0]).abs() < 0.03, "{p:?}: {want:?} vs {got:?}");
}
// Stabilize Only shows the moving borders.
let shown: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
assert!(shown > 500, "{shown}");
// No Motion keeps a feature fixed.
set_param(&mut s, clip, "stabilization/result", json!(1));
let (_, fixed) = scene_track(&mut s);
for q in &fixed {
assert!((q[0] - fixed[12][0]).hypot(q[1] - fixed[12][1]) < 0.6, "{q:?} vs {:?}", fixed[12]);
}
set_param(&mut s, clip, "stabilization/result", json!(0));
// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
set_param(&mut s, clip, "borders/framing", json!(2));
let st = s.execute("warp.status", json!({})).unwrap();
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
for f in 0..25 {
assert_eq!(transparent(&render(&s, f)), 0, "frame {f}");
}
set_param(&mut s, clip, "borders/framing", json!(1));
let st = s.execute("warp.status", json!({})).unwrap();
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
for f in [0, 9, 24] {
let img = render(&s, f);
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
}
}
assert!(img.get(0, 0)[3] < 0.01);
}
// Synthesize Edges fills most of the borders from neighbouring frames.
set_param(&mut s, clip, "borders/framing", json!(3));
let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
assert!(synth * 10 < shown, "synthesized {synth} vs {shown}");
// Show Track Points draws the features.
set_param(&mut s, clip, "borders/framing", json!(0));
let plain = render(&s, 5);
set_param(&mut s, clip, "advanced/showTrackPoints", json!(true));
assert_ne!(render(&s, 5).data, plain.data);
// Serde keeps the analysis; undo / redo of the analysis.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
let bg = back.comp(s.active_comp_id().unwrap()).unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone();
assert_eq!(bg, s.active_comp().unwrap().layer(clip).unwrap().props.find_group(fx).unwrap().clone());
while s.history.undo.last().is_some_and(|(l, _)| l != "Warp Stabilizer Analysis") {
s.undo();
}
assert!(s.undo());
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false);
assert!(s.redo());
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
}
#[test]
fn warp_analysis_invalidates_and_cancels() {
let (mut s, clip, solid) = setup(shaky);
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
// Animation ▸ Warp Stabilizer VFX applies and analyses.
assert!(s.is_enabled("track.warpStabilizer"));
let r = s.execute_checked("track.warpStabilizer", json!({"wait": true})).unwrap();
let fx = r["effect"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
// Trimming the layer clears the analysis and queues it again.
s.execute("layer.timing", json!({"layers": [clip.0], "out": 0.8})).unwrap();
let st = s.execute("warp.status", json!({})).unwrap();
assert_eq!(st["analyzed"], false, "{st}");
assert_eq!(st["pending"], true);
// Without an analysis the layer renders unstabilized.
let img = s.render(cid, frame_time(3), RenderOpts::default());
assert_eq!(transparent(&img), 0);
// Frontends start queued analyses in the background (poll_warp with auto).
assert!(s.poll_warp(true));
assert!(s.warp_job.is_some());
let t0 = std::time::Instant::now();
while s.warp_job.is_some() {
assert!(t0.elapsed().as_secs() < 120);
let p = s.warp_progress().unwrap();
assert!(p.banner().starts_with("Analyzing in background (step 1 of 2)") || p.banner() == "Stabilizing...");
std::thread::sleep(std::time::Duration::from_millis(5));
s.poll_warp(true);
}
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
// Undoing the slip brings back the old analysis (the key matches again).
// Cancel: nothing is written.
s.execute("effect.apply", json!({"effect": "ec.distort.warpstabilizer", "layers": [clip.0]})).unwrap();
let fx2 = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().nth(1).unwrap().uid;
let r = s.execute("warp.analyze", json!({"layer": clip.0, "effect": fx2})).unwrap();
assert_eq!(r["running"], true);
assert!(s.execute("warp.analyze", json!({"layer": clip.0, "effect": fx})).is_err(), "one analysis at a time");
s.execute("warp.cancel", json!({})).unwrap();
while s.warp_job.is_some() {
std::thread::sleep(std::time::Duration::from_millis(2));
s.poll_warp(false);
}
let st = s.execute("warp.status", json!({"layer": clip.0, "effect": fx2})).unwrap();
assert_eq!(st["analyzed"], false, "{st}");
// Time Remap changes invalidate too.
s.execute("layer.enableTimeRemap", json!({"layers": [clip.0]})).unwrap();
assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": fx})).unwrap()["analyzed"], false);
let _ = FPS;
let _ = Tick::ZERO;
}
+194
View File
@@ -0,0 +1,194 @@
//! Advanced 3D commands: model import → model layers, primitives, the renderer switch with
//! Geometry Options, Environment Layer and Material commands (undo/redo, serde).
use std::sync::Arc;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, ItemId, LayerId, LayerSource, PrimitiveKind, Project, Renderer};
use effectcraft_render::FootageSource;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::{Importer, Session};
fn fixture(name: &str) -> String {
format!("{}/../model/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// Loads models from disk (what `effectcraft-media` does in the app).
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let dir = std::path::Path::new(&f.path).parent()?.to_path_buf();
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|u| std::fs::read(dir.join(u)).ok()).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage {
path: path.into(),
kind: FootageKind::Model,
width: 0,
height: 0,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
native_rate: None,
duration: Tick::ZERO,
has_video: false,
has_audio: false,
alpha: Default::default(),
premul_color: [0.0; 3],
loop_count: 1,
codec: "glTF".into(),
missing: false,
sequence: vec![],
color_profile: None,
})
}
}
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
s
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
#[test]
fn import_gltf_and_add_model_layer() {
let mut s = session();
let r = s.execute("file.import", json!({"paths": [fixture("quad.gltf")]})).unwrap();
let item = r["items"][0].as_u64().unwrap();
assert_eq!(s.project.item(ItemId(item)).unwrap().type_name(), "3D Model");
// Adding the item to the comp makes a model layer (3D, fitted, with the clip popup).
let id = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let l = layer(&s, id);
assert_eq!(l.source, LayerSource::Model { item: ItemId(item) });
assert!(l.switches.three_d);
assert!(l.props.sub("masks").is_none() && l.props.sub("materialOptions").is_some());
// Bounds 2 units tall → half the comp height.
let scale = l.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
assert!((scale - 90.0).abs() < 1e-6, "{scale}");
let anim = l.props.prop("geometryOptions/animation").unwrap();
assert_eq!(anim.value, KV::Enum(1));
match &anim.ui {
effectcraft_project::ParamUi::Popup { options } => assert_eq!(options, &vec!["None".to_string(), "Move".to_string()]),
u => panic!("{u:?}"),
}
s.undo();
assert!(s.active_comp().unwrap().layers.is_empty());
s.redo();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
// layer.newModel imports and places in one step.
let r = s.execute("layer.newModel", json!({"path": fixture("cube.obj")})).unwrap();
assert!(r["layer"].as_u64().is_some());
assert!(s.execute("layer.newModel", json!({"path": "/nonexistent/thing.png"})).is_err());
}
#[test]
fn primitives_commands() {
let mut s = session();
for k in PrimitiveKind::ALL {
let r = s.execute("layer.new3dPrimitive", json!({"kind": k.label().to_lowercase()})).unwrap();
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.source, LayerSource::Primitive { kind: k });
assert_eq!(l.name, k.label());
assert!(l.switches.three_d);
assert!(l.props.sub("geometryOptions").is_some());
}
let r = s
.execute("layer.newPrimitive", json!({"kind": "sphere", "radius": 80, "segments": 12, "baseColor": [1, 0, 0], "metallic": 100, "castsShadows": "on"}))
.unwrap();
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.props.prop("geometryOptions/radius").unwrap().value.as_f64(), 80.0);
assert_eq!(l.props.prop("materialOptions/metallic").unwrap().value.as_f64(), 100.0);
assert_eq!(l.props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1));
assert!(s.execute("layer.newPrimitive", json!({"kind": "teapot"})).is_err());
let n = s.active_comp().unwrap().layers.len();
s.undo();
assert_eq!(s.active_comp().unwrap().layers.len(), n - 1);
// Menu entries point at the real command now.
assert!(s.is_enabled("layer.new3dPrimitive"));
}
#[test]
fn renderer_switch_adds_geometry_options() {
let mut s = session();
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
s.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
let g = layer(&s, t);
let g = g.props.sub("geometryOptions").unwrap();
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
assert_eq!(ids, vec!["bevelStyle", "bevelDepth", "holeBevelDepth", "extrusionDepth"]);
// New shape layers in the Advanced 3D comp get them too.
let sh = s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, sh).props.sub("geometryOptions").is_some());
assert_eq!(s.execute("comp.renderer", json!({})).unwrap()["renderer"], "advanced3d");
s.undo();
s.undo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
s.redo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
assert!(s.execute("comp.renderer", json!({"renderer": "raytraced"})).is_err());
// Extrusion values are ordinary properties.
s.execute("prop.set", json!({"layer": t, "path": "geometryOptions/extrusionDepth", "value": 40})).unwrap();
assert_eq!(layer(&s, t).props.prop("geometryOptions/extrusionDepth").unwrap().value.as_f64(), 40.0);
}
#[test]
fn environment_layer_toggle() {
let mut s = session();
let id = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.environment", json!({"layer": id})).unwrap();
assert!(layer(&s, id).environment && layer(&s, id).switches.three_d);
s.execute("layer.environment", json!({"layer": id})).unwrap();
assert!(!layer(&s, id).environment);
s.undo();
assert!(layer(&s, id).environment);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("layer.environment", json!({"layer": cam})).is_err());
// An Environment light sources a layer.
let li = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, li).props.prop("lightOptions/source").is_some());
}
#[test]
fn material_commands_and_serde() {
let mut s = session();
let a = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newPrimitive", json!({"kind": "torus"})).unwrap()["layer"].as_u64().unwrap();
s.execute("material.set", json!({"layers": [a], "baseColor": "#3366ff", "roughness": 20, "acceptsLights": false})).unwrap();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert!(s.execute("material.set", json!({"layers": [a], "castsShadows": "sometimes"})).is_err());
s.execute("material.duplicateAssign", json!({"layers": [a, b]})).unwrap();
assert_eq!(layer(&s, b).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert_eq!(layer(&s, b).props.prop("materialOptions/acceptsLights").unwrap().value, KV::Bool(false));
s.execute("material.reset", json!({"layers": [a]})).unwrap();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 50.0);
s.undo();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert!(s.execute("material.revealSource", json!({"layer": a})).is_err());
// The project (model and primitive sources, environment flag) round-trips through JSON.
let r = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap();
let item = r["items"][0].as_u64().unwrap();
let m = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.execute("material.revealSource", json!({"layer": m})).unwrap()["item"], item);
let json = s.project.to_json();
let back = Project::from_json(&json).unwrap();
assert_eq!(&back, &*s.project);
}
+10 -10
View File
@@ -17,11 +17,11 @@ use serde_json::json;
use crate::Session;
const W: u32 = 320;
const H: u32 = 240;
const FPS: u32 = 25;
pub(crate) const W: u32 = 320;
pub(crate) const H: u32 = 240;
pub(crate) const FPS: u32 = 25;
fn texture(x: f64, y: f64, seed: u32) -> f32 {
pub(crate) fn texture(x: f64, y: f64, seed: u32) -> f32 {
let mut v = 0.0f64;
let mut s = seed.wrapping_mul(2654435761).wrapping_add(12345);
let mut rnd = || {
@@ -41,7 +41,7 @@ fn texture(x: f64, y: f64, seed: u32) -> f32 {
}
/// Footage computed per frame (cached).
struct Synth {
pub(crate) struct Synth {
make: Box<dyn Fn(u32) -> Image + Send + Sync>,
cache: Mutex<HashMap<u32, Arc<Image>>>,
}
@@ -55,9 +55,9 @@ impl FootageSource for Synth {
}
/// Patch pose on frame `f`: centre, rotation (degrees), scale.
type Pose = fn(u32) -> ([f64; 2], f64, f64);
pub(crate) type Pose = fn(u32) -> ([f64; 2], f64, f64);
fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
pub(crate) fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
move |f| {
let (c, rot, sc) = pose(f);
let (sn, cs) = rot.to_radians().sin_cos();
@@ -76,7 +76,7 @@ fn patch_frames(pose: Pose) -> impl Fn(u32) -> Image + Send + Sync {
}
/// A comp with the synthetic clip (layer `clip`) and a 100 × 100 solid above it (layer `solid`).
fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, LayerId, LayerId) {
pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, LayerId, LayerId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Track", "width": W, "height": H, "frameRate": FPS, "duration": 1})).unwrap();
let cid = s.active_comp_id().unwrap();
@@ -121,7 +121,7 @@ fn tracker_points(s: &Session, layer: LayerId) -> Vec<effectcraft_project::PropG
l.trackers().next().unwrap().0.track_points().cloned().collect()
}
fn frame_time(f: u32) -> Tick {
pub(crate) fn frame_time(f: u32) -> Tick {
FrameRate::new(FPS as i64, 1).tick_of(f as i64)
}
@@ -220,7 +220,7 @@ fn stabilize_keeps_the_feature_fixed() {
assert!(worst < 0.25, "feature drifts by {worst} px");
}
fn rotating_pose(f: u32) -> ([f64; 2], f64, f64) {
pub(crate) fn rotating_pose(f: u32) -> ([f64; 2], f64, f64) {
let t = f as f64;
([160.0 + 1.2 * t, 120.0 - 0.7 * t], 1.5 * t, 1.0 + 0.008 * t)
}
+2 -2
View File
@@ -155,7 +155,7 @@ pub struct TrackShared {
pub frames: Mutex<Vec<FrameResult>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
@@ -197,7 +197,7 @@ pub(crate) struct Work {
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
/// Footage at its native size is used straight from the footage source (no copy).
fn source_frame(
pub(crate) fn source_frame(
r: &Renderer,
project: &Project,
cid: ItemId,
+404
View File
@@ -0,0 +1,404 @@
//! Warp Stabilizer runtime: the analysis job (`warp.analyze`) and keeping analyses valid.
//!
//! The analysis renders the layer's input to the effect (source → masks → the effects above
//! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out
//! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
//! stabilization plan once to validate the result. When the job finishes, the analysis is
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY};
use effectcraft_keyframe::Value;
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis};
use serde::Serialize;
use crate::tracking::lock;
use crate::{Event, Session};
/// Live analysis progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WarpProgress {
/// 1 = analyzing, 2 = stabilizing.
pub step: u8,
pub done: u64,
pub total: u64,
pub elapsed: f64,
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
impl WarpProgress {
/// The viewer banner text, as After Effects words it.
pub fn banner(&self) -> String {
match self.step {
2 => "Stabilizing...".into(),
_ => {
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
format!("Analyzing in background (step 1 of 2): {pc}%")
}
}
}
}
#[derive(Default)]
pub struct WarpShared {
pub cancel: AtomicBool,
pub state: Mutex<WarpProgress>,
result: Mutex<Option<WarpAnalysis>>,
}
/// A running (or finished, not yet polled) Warp Stabilizer analysis.
pub struct WarpJob {
pub shared: Arc<WarpShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
/// The layer signature the analysis is made from.
key: String,
}
impl WarpJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> WarpProgress {
lock(&self.shared.state).clone()
}
}
/// What the worker needs, detached from the session.
pub(crate) struct WarpWork {
pub project: Arc<Project>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub cache: Arc<LayerCache>,
pub comp: ItemId,
pub layer: LayerId,
/// Position of the effect in the layer's stack (the effects before it are analysed).
pub effect_index: usize,
pub size: [f64; 2],
pub detailed: bool,
/// Comp times of the analysed frames.
pub times: Vec<Tick>,
}
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// What an analysis of `layer` depends on (its frames): source (and the footage item's
/// interpretation), In/Out, start time, stretch, Time Remap and the comp frame rate.
pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
let src = match &layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => format!("{f:?}"),
Some(ItemKind::Solid(s)) => format!("{s:?}"),
Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration),
_ => String::new(),
},
_ => String::new(),
};
let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default();
let s = format!(
"{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}",
layer.source,
layer.in_point,
layer.out_point,
layer.start_time,
layer.stretch,
layer.props.get("timeRemap")
);
format!("{:016x}", fnv(&s))
}
fn is_warp(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == warp_stab::ID)
}
fn str_of(g: &PropGroup, m: &str) -> String {
match g.get(m).map(|p| &p.value) {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
}
}
/// Clear analyses that no longer match their layer, after an edit. Returns the Warp
/// Stabilizers that need (re-)analysis: cleared ones and newly applied, unanalysed ones.
pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
let mut todo = vec![];
let mut clear = vec![];
for (cid, it) in &after.items {
let ItemKind::Comp(c) = &it.kind else { continue };
for l in &c.layers {
let Some(fx) = l.effects() else { continue };
if !fx.groups().any(is_warp) {
continue;
}
let sig = signature(after, *cid, l);
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
for g in fx.groups().filter(|g| is_warp(g)) {
let analysed = !str_of(g, ANALYSIS).is_empty();
if analysed && str_of(g, ANALYSIS_KEY) != sig {
clear.push((*cid, l.id, g.uid));
todo.push((*cid, l.id, g.uid));
} else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
todo.push((*cid, l.id, g.uid));
}
}
}
}
for (cid, lid, uid) in clear {
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
for m in [ANALYSIS, ANALYSIS_KEY] {
if let Some(p) = g.get_mut(m) {
p.value = Value::Str(String::new());
p.keys.clear();
}
}
}
}
todo
}
/// The layer's input to effect `effect_index` at comp time `t`, in layer pixels at 100 %.
fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc<Image>, [f64; 2])> {
let buf = r.layer_input(ctx, layer, effect_index)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale];
return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off));
}
Some((Arc::new(buf.img.clone()), buf.offset))
}
fn run_work(w: WarpWork, shared: &WarpShared) {
let t0 = web_time::Instant::now();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
s.finished = true;
};
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let ctx0 = EvalCtx::new(&w.project, w.comp, comp, Tick::ZERO);
let ctx0 = EvalCtx { expr: w.expr.as_deref(), ..ctx0 };
let frame_at = |t: Tick| input_frame(r, &ctx0.at(t), layer, w.effect_index);
{
let mut s = lock(&shared.state);
s.step = 1;
s.total = w.times.len() as u64;
}
let mut an = Analyzer::new(w.size, AnalyzeOpts { detailed: w.detailed });
let mut next = frame_at(w.times[0]);
for k in 0..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
let mut s = lock(&shared.state);
s.cancelled = true;
s.finished = true;
return;
}
let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) };
let ((), nf) = rayon::join(
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|| if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None },
);
next = nf;
let mut s = lock(&shared.state);
s.done = k as u64 + 1;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
}
lock(&shared.state).step = 2;
let lt0 = layer.layer_time(w.times[0]).seconds();
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
let a = an.finish(lt0, fd);
// Step 2: derive a plan once (validates the motion and warms nothing else up).
let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() });
if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) {
return fail("the stabilization could not be computed");
}
*lock(&shared.result) = Some(a);
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Frames to analyse: every comp frame from the layer's In to its Out point (inside the comp).
pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) -> Vec<Tick> {
let fd = comp.frame_duration();
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
let hi = layer.out_point.min(comp.duration);
let mut t = lo;
let mut v = vec![];
while t < hi {
v.push(t);
t += fd;
}
v
}
impl Session {
pub fn is_warp_analyzing(&self) -> bool {
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
}
/// Progress of the running (or just finished) analysis.
pub fn warp_progress(&self) -> Option<WarpProgress> {
self.warp_job.as_ref().map(|j| j.progress())
}
/// The effect being analysed: (comp, layer, effect uid).
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
}
/// Cancel the running analysis (nothing is written).
pub fn stop_warp(&mut self) -> bool {
match &self.warp_job {
Some(j) if !j.is_finished() => {
j.shared.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Start analysing Warp Stabilizer `effect` on `layer`. `wait`: block until done (wasm32
/// always does). Returns the number of frames.
pub fn start_warp(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result<usize, String> {
if self.is_warp_analyzing() {
return Err("a Warp Stabilizer analysis is already running".into());
}
self.poll_warp_job();
self.warp_pending.retain(|x| *x != (comp, layer, effect));
let c = self.project.comp(comp).ok_or("no composition")?;
let l = c.layer(layer).ok_or("no layer")?;
let fx = l.effects().ok_or("the layer has no effects")?;
let index = fx.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
let g = fx.groups().nth(index).filter(|g| is_warp(g)).ok_or("not a Warp Stabilizer")?;
if l.switches.adjustment {
return Err("Warp Stabilizer needs footage, not an adjustment layer".into());
}
let times = analysis_times(c, l);
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let detailed = g.prop("advanced/detailedAnalysis").is_some_and(|p| p.value.as_bool());
let n = times.len();
let work = WarpWork {
project: self.project.clone(),
footage: self.footage.clone(),
expr: self.expr.clone(),
cache: self.layer_cache.clone(),
comp,
layer,
effect_index: index,
size,
detailed,
times,
};
let key = signature(&self.project, comp, l);
let wait = wait || cfg!(target_arch = "wasm32");
let shared = Arc::new(WarpShared::default());
let thread = if wait {
run_work(work, &shared);
None
} else {
let sh = shared.clone();
Some(std::thread::Builder::new().name("warp-analyze".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
};
self.warp_job = Some(WarpJob { shared, thread, comp, layer, effect, key });
self.events.push(Event::ProjectChanged { revision: self.revision });
self.poll_warp_job();
Ok(n)
}
/// Write a finished analysis into its effect (one undo step) and drop the job.
fn poll_warp_job(&mut self) -> bool {
let Some(job) = &self.warp_job else { return false };
if !job.is_finished() {
return false;
}
let mut job = self.warp_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let result = lock(&job.shared.result).take();
match (result, &st.error) {
(Some(a), None) if !st.cancelled => {
let json = a.to_json();
let (cid, lid, uid, key) = (job.comp, job.layer, job.effect, job.key.clone());
let r = self.edit("Warp Stabilizer Analysis", None, |p, _| {
let g = p
.comp_mut(cid)
.and_then(|c| c.layer_mut(lid))
.and_then(|l| l.props.find_group_mut(uid))
.ok_or_else(|| crate::EngineError::Other("the Warp Stabilizer was removed during analysis".into()))?;
for (m, v) in [(ANALYSIS, json), (ANALYSIS_KEY, key)] {
if let Some(pr) = g.get_mut(m) {
pr.value = Value::Str(v);
}
}
Ok(())
});
let msg = match r {
Ok(()) => format!("Warp Stabilizer: analysed {} frame(s) in {:.1} s", a.frames.len(), st.elapsed),
Err(e) => format!("Warp Stabilizer: {e}"),
};
self.events.push(Event::Toast { message: msg, error: false });
}
_ => {
let msg = match &st.error {
Some(e) => format!("Warp Stabilizer analysis failed: {e}"),
None => "Warp Stabilizer analysis cancelled".into(),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
self.bump();
}
}
true
}
/// Frontends call this every frame: finishes analyses and (with `auto`) starts queued
/// re-analyses in the background, like After Effects.
pub fn poll_warp(&mut self, auto: bool) -> bool {
let changed = self.poll_warp_job();
if auto && self.warp_job.is_none() {
while let Some((c, l, e)) = self.warp_pending.first().copied() {
self.warp_pending.remove(0);
if self.start_warp(c, l, e, false).is_ok() {
return true;
}
}
}
changed
}
}