Files
effectcraft/crates/engine/src/commands/animation.rs
T
Brandon ThomasandClaude Opus 5.5 f855a4b55b M14.5: wire the remaining settings; keyframe assistants and labels; AE menu entries
Settings: 56 of 59 not-yet-wired settings now change behaviour (nested-comp switches,
constant vertex count, related-item time sync, match-name pick whip, split-layer order,
Home on open, motion path span, internal wireframes, zoom quality, label-coloured tabs,
mask colour cycling, gradients, grid/guide styles, text engine, font preview/recent/native
names, missing frames, unlabeled alpha, drag import, default output folder, segmenting,
bit depth names, preview sample rate, disk/media/conformed-audio caches, RAM reserve and
low-memory reduction, Video Preview window, 3D reference axes/extended viewer/draft
shadows, script file gate + File.execute warning, expression editor options and error
banner). Three stay display-only and are documented.

Features: Convert Audio to Keyframes, RPF Camera Import (JSON/CSV camera data),
keyframe colour labels + Select Keyframe Label Group, Recent Animation Presets.
Menus: Edit > History, File > Import Recent Footage, Replace Footage > With Layered
Comp, Save a Copy As XML (.ecprojx, opens again), Composition > VR (Create VR
Environment, Extract Cubemap), Assign Shortcut to 3D view / workspace, Window viewer
labels + open viewers, Help > System Compatibility Report / Enable Logging / Reveal
Logging File. Dynamic menu nodes (@dynamic:…) shared by the egui and native menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:45:44 -04:00

378 lines
16 KiB
Rust

//! Animation menu: animation presets (EffectCraft's own JSON format), Add Keyframe, Keyframe
//! Assistant (Exponential Scale), text animators/selectors, and the
//! Reveal Properties commands.
use std::collections::BTreeMap;
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, selected_leaf_props, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Preset file format version tag (`*.ecpreset`, JSON).
pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if selected_leaf_props(s).is_empty() && selected_effects(s).is_empty() { Err("select properties or effects first".into()) } else { Ok(()) }
}
fn has_text_layer(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| l.props.group("text/animators").is_some()) {
Ok(())
} else {
Err("select a text layer".into())
}
}
/// Selected effect groups (layer, effect group).
fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
let Some(c) = s.active_comp() else { return vec![] };
s.state
.selected_props
.iter()
.filter_map(|(l, u)| {
let layer = c.layer(*l)?;
let fx = layer.effects()?;
fx.groups().find(|g| g.uid == *u).map(|g| (*l, g.clone()))
})
.collect()
}
// ---------------------------------------------------------------- presets
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string();
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let t = s.time();
let effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
let effect_uids: Vec<Uid> = effects.iter().map(|(_, g)| g.uid).collect();
let mut props = vec![];
for (lid, uid) in selected_leaf_props(s) {
let Some(l) = comp.layer(lid) else { continue };
// Properties inside a saved effect travel with the effect.
if l.effects().is_some_and(|fx| fx.groups().any(|g| effect_uids.contains(&g.uid) && g.find(uid).is_some())) {
continue;
}
let (Some(pr), Some(mp)) = (l.props.find(uid), match_path_of(&l.props, uid)) else { continue };
props.push((l.layer_time(t), mp, pr.clone()));
}
if props.is_empty() && effects.is_empty() {
return Err(bad("anim.savePreset", "select properties or effects to save"));
}
// Key times are stored relative to the earliest key so the preset applies at the CTI.
let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO);
let props_json: Vec<Value> = props
.iter()
.map(|(_, mp, pr)| {
let mut pr = pr.clone();
for k in &mut pr.keys {
k.time -= first;
}
json!({"path": mp, "value": pr.value, "keys": pr.keys, "expression": pr.expr})
})
.collect();
let fx_json: Vec<Value> = effects.iter().map(|(_, g)| serde_json::to_value(g).unwrap_or_default()).collect();
let name = str_p(p, "name")
.map(str::to_string)
.unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());
let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
}
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
let doc: Value = match (p.get("preset"), str_p(p, "path")) {
(Some(v), _) => v.clone(),
(None, Some(path)) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
serde_json::from_slice(&bytes).map_err(|e| bad("anim.applyPreset", format!("not a preset: {e}")))?
}
_ => return Err(bad("anim.applyPreset", "missing `path` (or inline `preset`)")),
};
if doc.get(PRESET_TAG).is_none() {
return Err(bad("anim.applyPreset", "not an EffectCraft animation preset"));
}
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("anim.applyPreset", "select a layer"));
}
// Animation ▸ Recent Animation Presets.
if let Some(path) = str_p(p, "path") {
s.prefs.push_recent_preset(path);
s.save_prefs();
s.prefs_revision += 1;
}
let effects: Vec<PropGroup> =
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
let props: Vec<(String, Property)> = doc["properties"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| {
let path = v["path"].as_str()?.to_string();
let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
let keys: Vec<Keyframe> = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
let mut pr = Property::new(0, "", "", value);
pr.keys = keys;
pr.expr = expr;
Some((path, pr))
})
.collect()
})
.unwrap_or_default();
let t = s.time();
let mut applied = 0;
let mut skipped = vec![];
s.edit("Apply Animation Preset", None, |proj, _| {
let mut next = proj.next_id;
for lid in &ids {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(fx) = l.props.sub_mut("effects") {
for g in &effects {
let mut g = g.clone();
g.reassign_uids(&mut next);
next += 1;
fx.children.push(Node::Group(g));
applied += 1;
}
}
for (path, src) in &props {
match l.props.prop_mut(path) {
Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => {
pr.value = src.value.clone();
pr.keys = src
.keys
.iter()
.cloned()
.map(|mut k| {
k.time += lt;
k
})
.collect();
if src.expr.is_some() {
pr.expr = src.expr.clone();
}
applied += 1;
}
_ => skipped.push(path.clone()),
}
}
}
proj.next_id = next;
Ok(())
})?;
Ok(json!({"applied": applied, "skipped": skipped}))
}
// ---------------------------------------------------------------- keyframes
fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
let targets = selected_leaf_props(s);
if targets.is_empty() {
return Err(bad("anim.addKeyframe", "select properties first"));
}
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let t = s.time();
let n = s.edit("Add Keyframe", None, |proj, _| {
let mut n = 0;
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let Some(pr) = l.props.find_mut(*uid) else { continue };
if pr.static_only {
continue;
}
if pr.keys.is_empty() {
pr.set_animated(true, lt);
} else {
let v = pr.value_at(lt);
pr.set_value_at(lt, v);
}
n += 1;
}
Ok(n)
})?;
Ok(json!({"keys": n}))
}
/// Selected keys grouped by (layer, property).
fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec<Tick>> {
let mut m: BTreeMap<(LayerId, Uid), Vec<Tick>> = BTreeMap::new();
for k in &s.state.selected_keys {
m.entry((k.layer, k.prop)).or_default().push(k.time);
}
m
}
fn exponential_scale(s: &mut Session, _: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
let groups = keys_by_prop(s);
let mut done = 0;
let mut work = vec![];
for ((lid, uid), mut times) in groups {
let Some(l) = comp.layer(lid) else { continue };
if l.transform().and_then(|tr| tr.get("scale")).map(|p| p.uid) != Some(uid) {
continue;
}
times.sort();
if times.len() >= 2 {
work.push((lid, uid, times[0], *times.last().unwrap_or(&times[0])));
}
}
if work.is_empty() {
return Err(bad("keys.exponentialScale", "select two Scale keyframes"));
}
s.edit("Exponential Scale", None, |proj, st| {
for (lid, uid, a, b) in &work {
let l = layer_mut(proj, cid, *lid)?;
let Some(pr) = l.props.find_mut(*uid) else { continue };
let (va, vb) = (pr.value_at(*a).as_vec3(), pr.value_at(*b).as_vec3());
pr.keys.retain(|k| k.time <= *a || k.time >= *b);
let n = ((*b - *a).0 / fd.0.max(1)).max(1);
for i in 0..=n {
let u = i as f64 / n as f64;
let t = if i == n { *b } else { *a + Tick(fd.0 * i) };
let f = |x: f64, y: f64| if x.abs() < 1e-9 || y.abs() < 1e-9 || x.signum() != y.signum() { x + (y - x) * u } else { x * (y / x).powf(u) };
let v = [f(va[0], vb[0]), f(va[1], vb[1]), f(va[2], vb[2])];
let mut k = Keyframe::new(t, KV::Vec3(v));
k.in_interp = Interp::Linear;
k.out_interp = Interp::Linear;
effectcraft_keyframe::set_key(&mut pr.keys, k);
st.selected_keys.push(KeyRef { layer: *lid, prop: *uid, time: t });
}
done += 1;
}
st.selected_keys.sort_by_key(|k| (k.layer.0, k.prop, k.time));
st.selected_keys.dedup();
Ok(())
})?;
Ok(json!({"properties": done}))
}
// ---------------------------------------------------------------- text animators
/// Animation ▸ Add Text Selector ▸ Range / Wiggly / Expression (see `layer.addTextSelector`).
fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
super::text_anim::add_selector(s, p)
}
fn remove_all_animators(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
s.edit("Remove All Text Animators", None, |proj, _| {
for lid in &ids {
if let Some(a) = layer_mut(proj, cid, *lid)?.props.group_mut("text/animators") {
a.children.clear();
}
}
Ok(())
})?;
Ok(Value::Null)
}
// ---------------------------------------------------------------- reveal
/// Whether a static property differs from the value a fresh layer would have. Transform
/// defaults depend on the source size and comp size.
fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [f64; 2]) -> bool {
if pr.is_animated() || pr.expr.is_some() {
return true;
}
let v = &pr.value;
let near = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6);
match (group, pr.match_id.as_str()) {
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
("transform", _) => v.as_f64().abs() > 1e-6,
_ => false,
}
}
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layers: Vec<LayerId> = if s.state.selected_layers.is_empty() { comp.layers.iter().map(|l| l.id).collect() } else { s.state.selected_layers.clone() };
let mut out = vec![];
for lid in &layers {
let Some(l) = comp.layer(*lid) else { continue };
let (w, h) = effectcraft_render::source_size(&s.project, l);
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
for top in l.props.groups() {
let hit = |pr: &Property| match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
_ => modified(pr, &top.match_id, anchor, center),
};
let mut found = vec![];
top.walk("", &mut |_, pr| {
if hit(pr) {
found.push(pr.uid);
}
});
// Modified: effects and masks count as modifications in full (AE reveals them).
if kind == "modified" && matches!(top.match_id.as_str(), "effects" | "masks") {
for g in top.groups() {
found.push(g.uid);
}
}
if kind == "modified" && top.match_id == "text" {
for g in top.group("animators").into_iter().flat_map(|a| a.groups()) {
found.push(g.uid);
}
}
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
}
}
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?;
Ok(json!({"props": out}))
}
/// Is `g` an effect/mask group kind (used by tests).
#[allow(dead_code)]
pub(crate) fn is_indexed(g: &PropGroup) -> bool {
matches!(g.kind, GroupKind::Indexed | GroupKind::Mask { .. } | GroupKind::Effect { .. })
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"anim.savePreset",
"Save Animation Preset...",
["Animation"],
None,
"{path (.ecpreset), name?} — saves the selected properties/effects",
has_selected_props,
save_preset
),
cmd!("anim.applyPreset", "Apply Animation Preset...", ["Animation"], None, "{path | preset, layers?}", has_layers, apply_preset),
cmd!("anim.addKeyframe", "Add Keyframe", ["Animation"], None, "{} — keys the selected properties at the CTI", has_selected_props, add_keyframe),
cmd!(
"keys.exponentialScale",
"Exponential Scale",
["Animation", "Keyframe Assistant"],
None,
"{} — two selected Scale keys",
has_keys,
exponential_scale
),
cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, animator?: uid|index, kind: range|wiggly|expression}", has_text_layer, add_selector),
cmd!("text.removeAllAnimators", "Remove All Text Animators", ["Animation"], None, "{layers?}", has_text_layer, remove_all_animators),
cmd!("anim.reveal", "Reveal Properties", [], None, "{kind: keyframes|animation|modified}", has_comp, reveal),
]
}