//! Puppet tools: pins on the layer's Puppet effect (Position, Advanced, Bend, Starch and //! Overlap pins), mesh options, and a query of the current mesh for overlays and agents. //! //! Pin positions are layer space. A new pin is attached to the mesh under it: the click is //! mapped back through the current deformation to the rest mesh (its hidden rest position), and //! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. use effectcraft_effects::puppet::{self, MeshOpts, PinKind}; use effectcraft_effects::{Buf, Params}; use effectcraft_keyframe::Value as KV; use effectcraft_project::build::Ids; use effectcraft_project::{ItemId, LayerId, Node, PropGroup, Uid}; use effectcraft_render::{EvalCtx, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, merge_p, str_p}; use crate::{EngineError, Result, Session, cmd}; fn pt(v: Option<&Value>) -> Option<[f64; 2]> { let a = v?.as_array()?; Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) } /// The Puppet effect's input pixels and evaluated parameters at comp time `t`. pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(Buf, Params)> { let comp = s.project.comp(cid)?; let layer = comp.layer(lid)?; let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); let buf = (*r.layer_input(&ctx, layer, index)?).clone(); let params = effectcraft_effects::flatten_params(g, &mut |p| ctx.value(layer, p)); Some((buf, params)) } fn find_puppet(s: &Session, cid: ItemId, lid: LayerId) -> Option { s.project.comp(cid)?.layer(lid)?.effects()?.groups().find(|g| puppet::is_puppet(g)).map(|g| g.uid) } fn snapped(s: &Session, cid: ItemId, p: &Value) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; Ok(comp.frame_rate.snap_nearest(super::time_p(s, p, Some(comp)))) } fn layer_time(s: &Session, cid: ItemId, lid: LayerId, t: Tick) -> Result { let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; Ok(l.layer_time(t)) } fn add_pin(s: &mut Session, p: &Value) -> Result { let cmd = "puppet.addPin"; let (cid, lid) = layer_p(s, p, cmd)?; let kind = match str_p(p, "kind") { Some(k) => PinKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (position | advanced | bend | starch | overlap)")))?, None => PinKind::Position, }; let at = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?; let t = snapped(s, cid, p)?; let lt = layer_time(s, cid, lid, t)?; { let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; if l.effects().is_none() || !l.source.is_av() { return Err(bad(cmd, "the Puppet tools work on footage, solid, text, shape and precomp layers")); } } // Which mesh: the one under the click (through the current deformation), else a new one. let fx_uid = find_puppet(s, cid, lid); let want_mesh = p.get("mesh").and_then(Value::as_u64); let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false); let mut target: Option<(Uid, [f64; 2])> = None; if let Some(fu) = fx_uid && !new_mesh && let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t) { let meshes = puppet::overlay(&buf, ¶ms); for (uid, mesh, def, _) in &meshes { if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() { continue; } let inside = mesh.tris.iter().any(|tr| { let (a, b, c) = (def[tr[0]], def[tr[1]], def[tr[2]]); let cr = |o: [f64; 2], u: [f64; 2], v: [f64; 2]| (u[0] - o[0]) * (v[1] - o[1]) - (u[1] - o[1]) * (v[0] - o[0]); cr(a, b, at) >= 0.0 && cr(b, c, at) >= 0.0 && cr(c, a, at) >= 0.0 }); let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0; if inside || near || want_mesh.is_some() { target = Some((*uid, puppet::unmap(mesh, def, at))); break; } } if target.is_none() && let Some(w) = want_mesh { return Err(bad(cmd, format!("no mesh {w}"))); } } let opts = MeshOpts { density: f_p(p, "density").unwrap_or(s.state.puppet.density), expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion), triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles), }; let size = { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let l = comp.layer(lid).ok_or(EngineError::NoComp)?; let (w, h) = effectcraft_render::source_size(&s.project, l); if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] } }; let label = match kind { PinKind::Starch => "Add Starch Pin", PinKind::Overlap => "Add Overlap Pin", _ => "Add Puppet Pin", }; let (fx, mesh, pin) = s.edit(label, None, |proj, st| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let fxg = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?; let mut ids = Ids(&mut next); let fx_uid = match fx_uid { Some(u) => u, None => { let spec = effectcraft_effects::find(puppet::ID).ok_or_else(|| EngineError::Other("Puppet effect missing".into()))?; let g = effectcraft_effects::instantiate(spec, &mut ids, "Puppet", size); let u = g.uid; fxg.children.push(g.into()); u } }; let pg = fxg.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Puppet effect gone"))?; let (mesh_uid, rest) = match target { Some((m, r)) => (m, r), None => { let n = puppet::meshes(pg).count(); let g = puppet::mesh_group(&mut ids, &format!("Mesh {}", n + 1), at, &opts); let u = g.uid; pg.children.push(g.into()); (u, at) } }; let name = puppet::next_pin_name(pg, kind); let mg = pg.find_group_mut(mesh_uid).ok_or_else(|| bad(cmd, "mesh gone"))?; let mut pin = puppet::pin_group(&mut ids, &name, kind, rest); if let Some(pos) = pin.get_mut("position") { if kind.moves() { pos.value = KV::Vec2(at); pos.set_animated(true, lt); } else { pos.value = KV::Vec2(rest); } } let pin_uid = pin.uid; let deform = mg.sub_mut("deform").ok_or_else(|| bad(cmd, "mesh has no Deform group"))?; deform.children.push(pin.into()); proj.next_id = next; st.selected_props = vec![(lid, pin_uid)]; Ok((fx_uid, mesh_uid, pin_uid)) })?; Ok(json!({"effect": fx, "mesh": mesh, "pin": pin, "rest": [target.map(|x| x.1).unwrap_or(at)[0], target.map(|x| x.1).unwrap_or(at)[1]]})) } /// Find a pin group (uid or name) in the layer's Puppet effects. fn pin_uid(s: &Session, cid: ItemId, lid: LayerId, key: &Value, cmd: &str) -> Result { let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; let fx = l.effects().ok_or_else(|| bad(cmd, "no effects"))?; for g in fx.groups().filter(|g| puppet::is_puppet(g)) { for m in puppet::meshes(g) { for pin in puppet::pins(m) { let hit = match key { Value::Number(n) => n.as_u64() == Some(pin.uid), Value::String(name) => &pin.name == name, _ => false, }; if hit { return Ok(pin.uid); } } } } Err(bad(cmd, format!("no puppet pin {key}"))) } fn with_pin(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut PropGroup, Tick) -> Result) -> Result { let (cid, lid) = layer_p(s, p, cmd)?; let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?; let uid = pin_uid(s, cid, lid, &key, cmd)?; let t = snapped(s, cid, p)?; let lt = layer_time(s, cid, lid, t)?; s.edit(label, merge_p(p), |proj, st| { let l = layer_mut(proj, cid, lid)?; let g = l.props.find_group_mut(uid).ok_or_else(|| bad(cmd, "pin gone"))?; let r = f(g, lt)?; st.selected_props = vec![(lid, uid)]; Ok(r) }) } fn move_pin(s: &mut Session, p: &Value) -> Result { let cmd = "puppet.movePin"; let to = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?; with_pin(s, p, cmd, "Move Puppet Pin", |g, lt| { let kind = PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0)); let pos = g.get_mut("position").ok_or_else(|| bad(cmd, "Bend pins have no position (use puppet.setPin rotation/scale)"))?; pos.set_value_at(lt, KV::Vec2(to)); // Starch and Overlap pins sit on the rest mesh: moving them moves their region. if !kind.moves() && let Some(r) = g.get_mut("rest") { r.value = KV::Vec2(to); } Ok(()) })?; Ok(Value::Null) } fn set_pin(s: &mut Session, p: &Value) -> Result { let cmd = "puppet.setPin"; let fields: Vec<(&str, KV)> = [("scale", "scale"), ("rotation", "rotation"), ("amount", "amount"), ("extent", "extent"), ("inFront", "in_front")] .iter() .filter_map(|(k, m)| f_p(p, k).map(|v| (*m, KV::Scalar(v)))) .chain(pt(p.get("position")).map(|v| ("position", KV::Vec2(v)))) .collect(); if fields.is_empty() { return Err(bad(cmd, "nothing to set (position, scale, rotation, amount, extent, inFront)")); } with_pin(s, p, cmd, "Edit Puppet Pin", |g, lt| { for (m, v) in fields { let pr = g.get_mut(m).ok_or_else(|| bad(cmd, format!("this pin has no `{m}`")))?; pr.set_value_at(lt, v); } Ok(()) })?; Ok(Value::Null) } fn remove_pin(s: &mut Session, p: &Value) -> Result { let cmd = "puppet.removePin"; let (cid, lid) = layer_p(s, p, cmd)?; let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?; let uid = pin_uid(s, cid, lid, &key, cmd)?; s.edit("Delete Puppet Pin", None, |proj, st| { let l = layer_mut(proj, cid, lid)?; if let Some(parent) = l.props.parent_of_mut(uid) { parent.children.retain(|c| c.uid() != uid); } st.selected_props.retain(|(_, u)| *u != uid); Ok(()) })?; Ok(Value::Null) } fn mesh_opts(s: &mut Session, p: &Value) -> Result { let cmd = "puppet.mesh"; let fields: Vec<(&str, f64)> = ["density", "expansion", "triangles"].iter().filter_map(|k| f_p(p, k).map(|v| (*k, v))).collect(); if let Some(b) = p.get("showMesh").and_then(Value::as_bool) { s.state.puppet.show_mesh = b; } if fields.is_empty() { return Ok(serde_json::to_value(&s.state.puppet).unwrap_or(Value::Null)); } // Without a layer: set the tool defaults for new meshes. let has_layer = p.get("layer").is_some() || !s.state.selected_layers.is_empty(); let (cid, lid) = match layer_p(s, p, cmd) { Ok(x) => x, Err(_) if !has_layer => { for (k, v) in &fields { match *k { "density" => s.state.puppet.density = v.clamp(0.0, 100.0), "expansion" => s.state.puppet.expansion = *v, _ => s.state.puppet.triangles = v.max(10.0), } } return Ok(serde_json::to_value(&s.state.puppet).unwrap_or(Value::Null)); } Err(e) => return Err(e), }; let fx = find_puppet(s, cid, lid).ok_or_else(|| bad(cmd, "the layer has no Puppet effect (add a pin first)"))?; let want = p.get("mesh").and_then(Value::as_u64); let t = snapped(s, cid, p)?; let lt = layer_time(s, cid, lid, t)?; for (k, v) in &fields { match *k { "density" => s.state.puppet.density = v.clamp(0.0, 100.0), "expansion" => s.state.puppet.expansion = *v, _ => s.state.puppet.triangles = v.max(10.0), } } s.edit("Puppet Mesh", merge_p(p), |proj, _| { let l = layer_mut(proj, cid, lid)?; let g = l.props.find_group_mut(fx).ok_or_else(|| bad(cmd, "Puppet effect gone"))?; let mut done = 0; for c in g.children.iter_mut() { let Node::Group(m) = c else { continue }; if m.match_id != "mesh" || want.is_some_and(|w| w != m.uid) { continue; } for (k, v) in &fields { if let Some(pr) = m.get_mut(k) { pr.set_value_at(lt, KV::Scalar(*v)); } } done += 1; } if done == 0 { Err(bad(cmd, "no such mesh")) } else { Ok(()) } })?; Ok(Value::Null) } /// Meshes, pins and the deformed mesh at the current time (layer space). fn info(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "puppet.info")?; let Some(fx) = find_puppet(s, cid, lid) else { return Ok(json!({"meshes": []})) }; let t = snapped(s, cid, p)?; let (buf, params) = puppet_eval(s, cid, lid, fx, t).ok_or_else(|| bad("puppet.info", "the layer has no pixels"))?; let meshes: Vec = puppet::overlay(&buf, ¶ms) .into_iter() .map(|(uid, mesh, def, pins)| { json!({ "mesh": uid, "triangles": mesh.tris.len(), "vertices": mesh.verts.len(), "outline": mesh.outline, "deformed": def, "tris": mesh.tris, "pins": pins.iter().map(|pn| json!({"pin": pn.uid, "kind": pn.kind.name(), "rest": pn.rest, "position": pn.position})).collect::>(), }) }) .collect(); Ok(json!({"effect": fx, "meshes": meshes})) } pub fn specs() -> Vec { vec![ cmd!( "puppet.addPin", "Add Puppet Pin", [], None, "{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (layer space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}", has_comp, add_pin ), cmd!("puppet.movePin", "Move Puppet Pin", [], None, "{layer?, pin: uid | name, position: [x,y], time? | frame?, merge?}", has_comp, move_pin), cmd!( "puppet.setPin", "Edit Puppet Pin", [], None, "{layer?, pin: uid | name, position?, scale? (%), rotation? (°), amount? (%), extent? (px), inFront?, time? | frame?, merge?}", has_comp, set_pin ), cmd!("puppet.removePin", "Delete Puppet Pin", [], None, "{layer?, pin: uid | name}", has_comp, remove_pin), cmd!( "puppet.mesh", "Puppet Mesh Options", [], None, "{layer?, mesh?: uid, density?, expansion?, triangles?, showMesh?, time?, merge?}", super::always, mesh_opts ), crate::query!("puppet.info", "Puppet Mesh Info", "{layer?, time? | frame?}", info), ] } /// Puppet tool options (the Tools bar's Mesh: Show / Expansion / Density for new meshes). #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PuppetOptions { pub density: f64, pub expansion: f64, pub triangles: f64, /// Show the mesh in the viewer. pub show_mesh: bool, } impl Default for PuppetOptions { fn default() -> Self { let d = MeshOpts::default(); PuppetOptions { density: d.density, expansion: d.expansion, triangles: d.triangles, show_mesh: false } } }