mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -06:00
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>
378 lines
16 KiB
Rust
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(×[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),
|
|
]
|
|
}
|