effectcraft/crates/engine/src/commands/link.rs

215 lines
10 KiB
Rust

//! Separate Dimensions and the property pick-whip (AE-style reference expressions).
use effectcraft_keyframe::{Ease, Interp, Keyframe, Value as KV};
use effectcraft_project::{Comp, Layer, Node, ParamUi, Property, Uid};
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde_json::{Value, json};
use super::prop::prop_ref;
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
use crate::{EngineError, Result, Session, cmd};
const AXES: [(&str, &str); 3] = [("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")];
/// Per-dimension scalar keys from a (spatial) position key list: same times and interpolation;
/// Bezier sides get the per-dimension velocity the combined property had at the key.
fn split_keys(keys: &[Keyframe], spatial: bool, d: usize) -> Vec<Keyframe> {
let h = Tick(TICKS_PER_SECOND / 2000);
keys.iter()
.map(|k| {
let mut s = Keyframe::new(k.time, KV::Scalar(k.value.components().get(d).copied().unwrap_or(0.0)));
s.in_interp = k.in_interp;
s.out_interp = k.out_interp;
s.auto_bezier = k.auto_bezier;
s.continuous = k.continuous;
s.roving = false;
let vel = |t: Tick| effectcraft_keyframe::velocity(keys, t, spatial).get(d).copied().unwrap_or(0.0);
if k.in_interp == Interp::Bezier {
let inf = k.in_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0);
s.in_ease = vec![Ease { speed: vel(k.time - h), influence: inf }];
}
if k.out_interp == Interp::Bezier {
let inf = k.out_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0);
s.out_ease = vec![Ease { speed: vel(k.time + h), influence: inf }];
}
s
})
.collect()
}
fn separate(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "prop.separateDimensions")?;
let want = b_p(p, "value");
let on = s.edit("Separate Dimensions", None, |proj, _| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let tr = l.props.sub_mut("transform").ok_or_else(|| bad("prop.separateDimensions", "this layer has no Transform"))?;
let has = tr.get("positionX").is_some();
let target = want.unwrap_or(!has);
if target && !has {
let pos = tr.get("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?.clone();
let at = tr.children.iter().position(|c| c.uid() == pos.uid).map(|i| i + 1).unwrap_or(tr.children.len());
let comps = pos.value.components();
for (d, (m, name)) in AXES.iter().enumerate().rev() {
let mut pr = Property::new(next, m, name, KV::Scalar(comps.get(d).copied().unwrap_or(0.0))).with_ui(ParamUi::Number);
next += 1;
pr.three_d_only = d == 2;
pr.keys = split_keys(&pos.keys, pos.spatial, d);
tr.children.insert(at, Node::Prop(pr));
}
} else if !target && has {
let parts: Vec<Property> = AXES.iter().filter_map(|(m, _)| tr.get(m).cloned()).collect();
let mut times: Vec<Tick> = parts.iter().flat_map(|p| p.keys.iter().map(|k| k.time)).collect();
times.sort();
times.dedup();
let at = |t: Tick| KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0)));
let pos = tr.get_mut("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?;
pos.value = at(Tick::ZERO);
pos.keys = times.iter().map(|t| Keyframe::new(*t, at(*t))).collect();
if pos.keys.is_empty() {
pos.value = KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value.as_f64()).unwrap_or(0.0)));
}
tr.children.retain(|c| !AXES.iter().any(|(m, _)| c.match_id() == *m));
}
proj.next_id = next;
Ok(target)
})?;
Ok(json!(on))
}
// ---------------------------------------------------------------- pick-whip
/// `X Position` → `xPosition`, `Mask Path` → `maskPath`.
fn camel(name: &str) -> String {
let mut out = String::new();
for (i, w) in name.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).enumerate() {
let mut cs = w.chars();
if let Some(f) = cs.next() {
if i == 0 {
out.extend(f.to_lowercase());
} else {
out.extend(f.to_uppercase());
}
out.push_str(cs.as_str());
}
}
out
}
fn quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
/// The AE expression reference to property `uid` of `target` as seen from a property of `from`.
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option<String> {
let chain = target.props.node_chain(uid)?;
let mut parts: Vec<String> = vec![];
let mut pending: Option<&str> = None;
let mut in_effect = false;
for n in chain {
if let Some(f) = pending.take() {
parts.push(format!("{f}({})", quote(n.name())));
in_effect |= f == "effect";
continue;
}
match (n, n.match_id()) {
(Node::Group(_), "masks") => pending = Some("mask"),
(Node::Group(_), "effects") => pending = Some("effect"),
(Node::Group(_), "contents") => pending = Some("content"),
(Node::Group(_), "transform") if !in_effect => parts.push("transform".into()),
(Node::Group(_), "text") if !in_effect => parts.push("text".into()),
(Node::Prop(_), "sourceText") => parts.push("sourceText".into()),
_ if in_effect => parts.push(format!("({})", quote(n.name()))),
_ => parts.push(camel(n.name())),
}
}
let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) };
let _ = comp;
if !compact {
// Match names: language-independent, like After Effects with Compact English off.
let chain = target.props.node_chain(uid)?;
let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) };
for n in chain {
out.push_str(&format!("({})", quote(n.match_id())));
}
return Some(out);
}
for (i, p) in parts.iter().enumerate() {
if i > 0 && !p.starts_with('(') {
out.push('.');
}
out.push_str(p);
}
Some(out)
}
/// Dimensions an expression sees (2D layers see 2D Position/Scale/Anchor).
fn dims(l: &Layer, p: &Property) -> usize {
match &p.value {
KV::Vec3(_) if p.shown_dims == 2 && !l.is_3d() => 2,
v => v.components().len().max(1),
}
}
fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.pickWhip")?;
let t = p.get("target").ok_or_else(|| bad("prop.pickWhip", "missing `target` {layer, path|prop}"))?;
let (_, tl, tu) = prop_ref(s, t, "prop.pickWhip")?;
if (tl, tu) == (lid, uid) {
return Err(bad("prop.pickWhip", "a property can't reference itself"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let from = comp.layer(lid).ok_or(EngineError::NoComp)?;
let target = comp.layer(tl).ok_or(EngineError::NoComp)?;
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
let (r, dt) = match t.get("dimension").and_then(Value::as_u64) {
Some(d) if d < dt as u64 => (format!("{r}[{d}]"), 1),
Some(d) => return Err(bad("prop.pickWhip", format!("the target has {dt} dimension(s), no dimension {d}"))),
None => (r, dt),
};
// While the expression is being edited the reference goes in at the cursor, replacing the
// selected text (After Effects): `expression` is the text being edited, `range` the
// selection [start, end] in characters.
if let Some(base) = str_p(p, "expression") {
let range: Option<Vec<u64>> = p.get("range").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
let n = base.chars().count() as u64;
let (a, b) = match range.as_deref() {
Some(&[a, b]) => (a.min(b).min(n), a.max(b).min(n)),
None => (n, n),
Some(_) => return Err(bad("prop.pickWhip", "`range` must be [start, end] (characters)")),
};
let head: String = base.chars().take(a as usize).collect();
let tail: String = base.chars().skip(b as usize).collect();
let text = format!("{head}{r}{tail}");
s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?;
return Ok(json!(text));
}
let text = if dt == 1 && dm > 1 {
format!("temp = {r};\n[{}]", vec!["temp"; dm].join(", "))
} else if dt > 1 && dm == 1 {
format!("{r}[0]")
} else {
r
};
s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?;
Ok(json!(text))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("prop.separateDimensions", "Separate Dimensions", ["Animation"], None, "{layer?, value?}", has_layers, separate),
cmd!(
"prop.pickWhip",
"Pick Whip (Link Property)",
[],
None,
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
has_layers,
pick_whip
),
]
}