Animation presets: preserve text animators and selectors when saving and applying (#3168)

* Text animators: fit one animator to a Per-character 3D setting

* Animation presets: save text animators whole and add them to the target layer (#2530)

* Animation presets: a preset that fits no layer is an error, not silence

* Tests: text animators in animation presets (#2530)

* Fix text preset review edge cases

* Keep existing animators consistent when presets turn 3D off

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
Tyler JenningsandFredApps authored and GitHub committed 2026-11-13 09:13:02 +01:00
1 parent 0a6d264554
commit a5ddc93257
4 files changed
+529 -38

No files matched your search

+107 -15
View File
@@ -11,13 +11,14 @@ use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::effect::{effect_time_error, retime_effect};
use super::text_anim::{per_char_on, set_animator_3d};
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, 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";
/// The preset format version that `anim.savePreset` writes.
pub const PRESET_VERSION: u64 = 2;
pub const PRESET_VERSION: u64 = 3;
/// The first preset version that stores effect keys relative to the earliest key (#1971).
const RELATIVE_EFFECT_KEYS: u64 = 2;
@@ -50,6 +51,25 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
.collect()
}
/// The text animators that hold a selected property, each once, in stack order. An animator is
/// saved whole: its Properties say what changes and its Selectors where, so neither works alone.
fn selected_animators(s: &Session) -> Vec<PropGroup> {
let Some(c) = s.active_comp() else { return vec![] };
let leaves = selected_leaf_props(s);
let mut layers: Vec<LayerId> = vec![];
for (lid, _) in &leaves {
if !layers.contains(lid) {
layers.push(*lid);
}
}
let mut out = vec![];
for lid in layers {
let Some(list) = c.layer(lid).and_then(|l| l.props.group("text/animators")) else { continue };
out.extend(list.groups().filter(|g| leaves.iter().any(|(l, u)| *l == lid && g.find(*u).is_some())).cloned());
}
out
}
/// The earliest key anywhere in `groups`, nested controls included.
fn earliest_key<'a>(groups: impl IntoIterator<Item = &'a PropGroup>) -> Option<Tick> {
let mut first: Option<Tick> = None;
@@ -72,6 +92,7 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.time();
let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
let effect_uids: Vec<Uid> = effects.iter().map(|(_, g)| g.uid).collect();
let mut animators = selected_animators(s);
let mut props = vec![];
for (lid, uid) in selected_leaf_props(s) {
let Some(l) = comp.layer(lid) else { continue };
@@ -79,16 +100,24 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
if l.effects().is_some_and(|fx| fx.groups().any(|g| effect_uids.contains(&g.uid) && g.find(uid).is_some())) {
continue;
}
// So do properties inside a saved text animator.
if animators.iter().any(|g| 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() {
if props.is_empty() && effects.is_empty() && animators.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)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO);
for (_, g) in &mut effects {
let first = props
.iter()
.flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time))
.chain(earliest_key(effects.iter().map(|(_, g)| g).chain(&animators)))
.min()
.unwrap_or(Tick::ZERO);
for g in effects.iter_mut().map(|(_, g)| g).chain(&mut animators) {
retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?;
}
let props_json: Vec<Value> = props
@@ -105,10 +134,10 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
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: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json});
let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json, "textAnimators": animators});
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.write_output(&path, text.as_bytes())?;
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len(), "textAnimators": animators.len()}))
}
/// Rewrite an effect property path in a preset (`effects/<effect id>[#n]/<param>`) to the current
@@ -126,6 +155,22 @@ fn upgrade_effect_path(path: &str) -> String {
}
}
/// The saved animators of a preset. A file that names one wrongly is refused, not partly applied.
fn preset_animators(doc: &Value) -> Result<Vec<PropGroup>> {
let bad_file = |why: String| bad("anim.applyPreset", format!("bad text animator in the preset: {why}"));
match doc.get("textAnimators") {
None => Ok(vec![]),
Some(Value::Array(list)) => list
.iter()
.map(|v| {
let g: PropGroup = serde_json::from_value(v.clone()).map_err(|e| bad_file(e.to_string()))?;
if g.match_id == "animator" { Ok(g) } else { Err(bad_file(format!("`{}` is not an animator", g.match_id))) }
})
.collect(),
Some(_) => Err(bad_file("`textAnimators` is not a list".into())),
}
}
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(),
@@ -138,16 +183,11 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
if doc.get(PRESET_TAG).is_none() {
return Err(bad("anim.applyPreset", "not an EffectCraft animation preset"));
}
let animators = preset_animators(&doc)?;
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 mut 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();
// Older presets stored effect keys at the source layer's times.
@@ -175,6 +215,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
.collect()
})
.unwrap_or_default();
let saved_3d = props.iter().any(|(path, src)| path == "text/perChar3d" && src.value.as_bool());
let t = s.time();
// Effect bounds per layer size the point defaults of parameters added since the preset was saved.
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -188,6 +229,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let was_three_d = per_char_on(l);
if let Some(fx) = l.props.sub_mut("effects") {
for g in &effects {
let mut g = g.clone();
@@ -228,10 +270,52 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
_ => skipped.push(path.clone()),
}
}
// After the properties, so a saved Per-character 3D setting counts: it needs a 3D layer
// to draw, as `layer.enablePerChar3D` makes one. Without it the layer's setting stays.
// Existing animators follow changes to the saved Per-character 3D setting too.
let three_d = per_char_on(l);
l.switches.three_d |= three_d && saved_3d;
let Some(list) = l.props.group_mut("text/animators") else {
skipped.extend(animators.iter().map(|g| format!("text/animators/{}", g.name)));
continue;
};
if three_d != was_three_d {
for existing in &mut list.children {
if let Node::Group(existing) = existing {
set_animator_3d(&mut Ids(&mut next), existing, three_d);
}
}
}
for g in &animators {
let mut g = g.clone();
// `reassign_uids` replaces every uid, the ones of properties added here included.
set_animator_3d(&mut Ids(&mut 0), &mut g, three_d);
retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
g.reassign_uids(&mut next);
next += 1;
list.children.push(Node::Group(g));
applied += 1;
}
}
// Without this, a preset that fits no layer does nothing and says nothing.
if applied == 0 && !skipped.is_empty() {
let legacy_text = doc[PRESET_TAG].as_u64().unwrap_or(1) < PRESET_VERSION && props.iter().any(|(path, _)| path.starts_with("text/animators/"));
let message = if legacy_text {
"nothing in this preset fits the layer. A text animation preset saved by an earlier version only updates animators a layer already has: save it again"
} else {
"nothing in this preset fits the selected layer(s)"
};
return Err(bad("anim.applyPreset", message));
}
proj.next_id = next;
Ok(())
})?;
// 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;
}
Ok(json!({"applied": applied, "skipped": skipped}))
}
@@ -540,11 +624,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Animation Preset...",
["Animation"],
None,
"{path (.ecpreset), name?} — saves the selected properties/effects",
"{path (.ecpreset), name?} — saves the selected properties/effects; a text animator with a selected property is saved whole",
has_selected_props,
save_preset
),
cmd!("anim.applyPreset", "Apply Animation Preset...", ["Animation"], None, "{path | preset, layers?}", has_layers, apply_preset),
cmd!(
"anim.applyPreset",
"Apply Animation Preset...",
["Animation"],
None,
"{path | preset, layers?} — saved effects and text animators are added to each layer, with their keys at the CTI",
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",
+29 -23
View File
@@ -45,7 +45,7 @@ fn has_animator(s: &Session) -> std::result::Result<(), String> {
if any { Ok(()) } else { Err("add a text animator first".into()) }
}
fn per_char_on(l: &Layer) -> bool {
pub(super) fn per_char_on(l: &Layer) -> bool {
l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool())
}
@@ -203,32 +203,38 @@ fn set_animators_3d(ids: &mut Ids, l: &mut Layer, on: bool) {
let Some(anims) = l.props.group_mut("text/animators") else { return };
for c in anims.children.iter_mut() {
let Node::Group(a) = c else { continue };
let Some(props) = a.sub_mut("properties") else { continue };
for c in props.children.iter_mut() {
if let Node::Prop(p) = c
&& matches!(p.match_id.as_str(), "anchor" | "position" | "scale")
{
p.shown_dims = if on { 3 } else { 2 };
if let KV::Vec2(v) = p.value {
p.value = KV::Vec3([v[0], v[1], if p.match_id == "scale" { 100.0 } else { 0.0 }]);
}
}
}
let has_rot = props.get("rotation").is_some();
if on && has_rot && props.get("rotationX").is_none() {
let at = props.children.iter().position(|c| c.match_id() == "rotation").unwrap_or(0);
let rx = ids.prop("rotationX", "X Rotation", KV::Scalar(0.0)).with_ui(effectcraft_project::ParamUi::Angle);
let ry = ids.prop("rotationY", "Y Rotation", KV::Scalar(0.0)).with_ui(effectcraft_project::ParamUi::Angle);
props.children.insert(at, ry.into());
props.children.insert(at, rx.into());
}
if !on {
set_animator_3d(ids, a, on);
if !on && let Some(props) = a.sub_mut("properties") {
props.children.retain(|c| !matches!(c.match_id(), "rotationX" | "rotationY"));
}
if let Some(r) = props.get_mut("rotation") {
r.name = if on { "Z Rotation".into() } else { "Rotation".into() };
}
}
/// Show or hide an animator's Z components and add the X / Y rotation it lacks. X / Y rotation it
/// has stays when `on` is false: a 2D layer doesn't draw it, and a preset's keys aren't lost.
pub(super) fn set_animator_3d(ids: &mut Ids, a: &mut PropGroup, on: bool) {
let Some(props) = a.sub_mut("properties") else { return };
for c in props.children.iter_mut() {
if let Node::Prop(p) = c
&& matches!(p.match_id.as_str(), "anchor" | "position" | "scale")
{
p.shown_dims = if on { 3 } else { 2 };
if let KV::Vec2(v) = p.value {
p.value = KV::Vec3([v[0], v[1], if p.match_id == "scale" { 100.0 } else { 0.0 }]);
}
}
}
let has_rot = props.get("rotation").is_some();
if on && has_rot && props.get("rotationX").is_none() {
let at = props.children.iter().position(|c| c.match_id() == "rotation").unwrap_or(0);
let rx = ids.prop("rotationX", "X Rotation", KV::Scalar(0.0)).with_ui(effectcraft_project::ParamUi::Angle);
let ry = ids.prop("rotationY", "Y Rotation", KV::Scalar(0.0)).with_ui(effectcraft_project::ParamUi::Angle);
props.children.insert(at, ry.into());
props.children.insert(at, rx.into());
}
if let Some(r) = props.get_mut("rotation") {
r.name = if on { "Z Rotation".into() } else { "Rotation".into() };
}
}
fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
+1
View File
@@ -63,6 +63,7 @@ mod styles;
mod tail;
mod text;
mod text_edit;
mod text_presets;
mod three_d;
mod timeline;
mod timeline_interchange;
+392
View File
@@ -0,0 +1,392 @@
//! Animation presets that hold text animators (#2530): an animator is saved whole and made again
//! on the layer it is applied to, with its keys at the playhead.
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::{LayerId, PropGroup};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s
}
fn new_text(s: &mut Session, text: &str) -> u64 {
s.execute("layer.newText", json!({"text": text, "size": 60})).unwrap()["layer"].as_u64().unwrap()
}
fn animators(s: &Session, layer: u64) -> Vec<PropGroup> {
let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
l.props.group("text/animators").unwrap().groups().cloned().collect()
}
fn names(s: &Session, layer: u64) -> Vec<String> {
animators(s, layer).into_iter().map(|g| g.name).collect()
}
/// No two properties or groups on any layer share a uid.
fn assert_uids_are_unique(s: &Session) {
fn collect(v: &Value, out: &mut Vec<u64>) {
match v {
Value::Object(o) => {
out.extend(o.get("uid").and_then(Value::as_u64));
o.values().for_each(|c| collect(c, out));
}
Value::Array(a) => a.iter().for_each(|c| collect(c, out)),
_ => {}
}
}
let mut uids = vec![];
for l in &s.active_comp().unwrap().layers {
collect(&serde_json::to_value(&l.props).unwrap(), &mut uids);
}
let all = uids.len();
uids.sort_unstable();
uids.dedup();
assert_eq!(uids.len(), all);
}
/// A named animator that fades characters on: Opacity 0, a Range Selector's End keyed from 0 at
/// 1s to 100 at 2s, a Based On setting, an expression on its Offset, and a Wiggly Selector.
fn fade_on(s: &mut Session, layer: u64, name: &str) -> u64 {
let animator = s.execute("layer.addTextAnimator", json!({"layer": layer, "properties": ["opacity"], "name": name})).unwrap()["animator"].as_u64().unwrap();
s.execute("layer.addTextSelector", json!({"layer": layer, "animator": animator, "kind": "wiggly"})).unwrap();
let g = animators(s, layer).into_iter().find(|g| g.uid == animator).unwrap();
let selector = |path: &str| g.prop(&format!("selectors/rangeSelector/{path}")).unwrap().uid;
s.execute("prop.set", json!({"layer": layer, "prop": g.prop("properties/opacity").unwrap().uid, "value": 0})).unwrap();
s.execute("prop.set", json!({"layer": layer, "prop": selector("advanced/basedOn"), "value": 2})).unwrap();
s.execute("prop.setExpression", json!({"layer": layer, "prop": selector("offset"), "expression": "time * 10"})).unwrap();
for (time, value) in [(1.0, 0.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": layer, "prop": selector("end"), "time": time, "value": value})).unwrap();
}
animator
}
fn preset_path(name: &str) -> String {
std::env::temp_dir().join(format!("ec-2530-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string()
}
/// Select each property or group in `selected` on `layer` and save them to a new preset file.
fn save(s: &mut Session, name: &str, layer: u64, selected: &[u64]) -> String {
for (i, prop) in selected.iter().enumerate() {
s.execute("prop.select", json!({"layer": layer, "prop": prop, "add": i > 0})).unwrap();
}
let path = preset_path(name);
let _ = std::fs::remove_file(&path);
s.execute("anim.savePreset", json!({"path": path})).unwrap();
path
}
fn apply(s: &mut Session, path: &str, layer: u64, at: f64) -> Value {
s.execute("time.set", json!({"time": at})).unwrap();
s.execute("anim.applyPreset", json!({"path": path, "layers": [layer]})).unwrap()
}
/// `g` as JSON without its uids, with its keys moved `by` seconds: what must match between a
/// saved animator and the animator made from it.
fn content(g: &PropGroup, by: f64) -> Value {
fn drop_uids(v: &mut Value) {
match v {
Value::Object(o) => {
o.remove("uid");
o.values_mut().for_each(drop_uids);
}
Value::Array(a) => a.iter_mut().for_each(drop_uids),
_ => {}
}
}
let mut g = g.clone();
g.walk_mut(&mut |p| p.keys.iter_mut().for_each(|k| k.time += Tick::from_seconds_f64(by)));
let mut v = serde_json::to_value(g).unwrap();
drop_uids(&mut v);
v
}
/// #2530, #3114: the steps in the reports. The whole animator is selected and saved, then applied
/// to a new text layer with the playhead at 3s.
#[test]
fn a_saved_animator_applies_to_a_new_text_layer() {
let mut s = session();
let source = new_text(&mut s, "Source");
let animator = fade_on(&mut s, source, "Fade In");
let path = save(&mut s, "new-layer", source, &[animator]);
let saved = animators(&s, source);
let target = new_text(&mut s, "Target");
let r = apply(&mut s, &path, target, 3.0);
let _ = std::fs::remove_file(&path);
assert_eq!(r["skipped"], json!([]), "{r}");
let made = animators(&s, target);
assert_eq!(made.len(), 1, "{r}");
// The earliest key, at 1s on the source, lands on the playhead: 2s later.
assert_eq!(content(&made[0], 0.0), content(&saved[0], 2.0));
let keys: Vec<f64> = made[0].prop("selectors/rangeSelector/end").unwrap().keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(keys, [3.0, 4.0]);
}
/// One selected property brings its whole animator: End alone would key a selector with nothing
/// to reveal. The file holds the animator, not the property as well.
#[test]
fn one_selected_property_saves_its_whole_animator() {
let mut s = session();
let source = new_text(&mut s, "Source");
fade_on(&mut s, source, "Fade In");
let saved = animators(&s, source);
let end = saved[0].prop("selectors/rangeSelector/end").unwrap().uid;
let path = save(&mut s, "one-property", source, &[end]);
let file: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(file["effectcraftAnimationPreset"], 3);
assert_eq!(file["properties"], json!([]));
assert_eq!(file["textAnimators"].as_array().unwrap().len(), 1);
let target = new_text(&mut s, "Target");
let r = apply(&mut s, &path, target, 0.0);
let _ = std::fs::remove_file(&path);
assert_eq!(r["applied"], 1, "{r}");
assert_eq!(content(&animators(&s, target)[0], 0.0), content(&saved[0], -1.0));
}
/// A preset adds to the animators a layer has, which stay as they are, in the order the animators
/// were saved in whatever order they were selected, with new uids, as one Undo step.
#[test]
fn a_preset_adds_to_the_animators_a_layer_has() {
let mut s = session();
let source = new_text(&mut s, "Source");
let one = fade_on(&mut s, source, "One");
let two = fade_on(&mut s, source, "Two");
let path = save(&mut s, "existing", source, &[two, one]);
let target = new_text(&mut s, "Target");
let mine = fade_on(&mut s, target, "Mine");
s.execute("prop.select", json!({"layer": target, "prop": mine})).unwrap();
let before = animators(&s, target);
let selected = s.state.selected_props.clone();
let steps = s.history.undo.len();
apply(&mut s, &path, target, 3.0);
assert_eq!(names(&s, target), ["Mine", "One", "Two"]);
assert_eq!(animators(&s, target)[0], before[0]);
assert_eq!(s.state.selected_props, selected);
assert_eq!(s.history.undo.len(), steps + 1);
assert_uids_are_unique(&s);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(animators(&s, target), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(names(&s, target), ["Mine", "One", "Two"]);
// The layer it was saved from gets another copy, as it does of a saved effect.
apply(&mut s, &path, source, 3.0);
let _ = std::fs::remove_file(&path);
assert_eq!(names(&s, source), ["One", "Two", "One", "Two"]);
assert_uids_are_unique(&s);
}
/// An animator fits the Per-character 3D setting of the layer it lands on and nothing else
/// changes. X / Y rotation saved with a 3D animator stays on a 2D layer, which doesn't draw it,
/// and is there when the layer turns 3D.
#[test]
fn an_animator_fits_the_per_character_3d_setting_of_its_layer() {
let mut s = session();
let round = new_text(&mut s, "Round");
s.execute("layer.enablePerChar3D", json!({"layer": round, "enabled": true})).unwrap();
let spin =
s.execute("layer.addTextAnimator", json!({"layer": round, "properties": ["rotation", "scale"], "name": "Spin"})).unwrap()["animator"].as_u64().unwrap();
let x = animators(&s, round)[0].prop("properties/rotationX").unwrap().uid;
for (time, value) in [(1.0, 0.0), (2.0, 90.0)] {
s.execute("prop.addKey", json!({"layer": round, "prop": x, "time": time, "value": value})).unwrap();
}
let round_preset = save(&mut s, "round", round, &[spin]);
let flat = new_text(&mut s, "Flat");
let tilt =
s.execute("layer.addTextAnimator", json!({"layer": flat, "properties": ["rotation", "scale"], "name": "Tilt"})).unwrap()["animator"].as_u64().unwrap();
let flat_preset = save(&mut s, "flat", flat, &[tilt]);
let get = |g: &PropGroup, path: &str| g.prop(&format!("properties/{path}")).unwrap().clone();
// 3D animator, 2D layer.
let keeps = animators(&s, flat)[0].clone();
apply(&mut s, &round_preset, flat, 0.0);
assert_eq!(animators(&s, flat)[0], keeps);
let made = animators(&s, flat)[1].clone();
assert_eq!(get(&made, "rotationX").keys.len(), 2);
assert_eq!(get(&made, "rotation").name, "Rotation");
assert_eq!(get(&made, "scale").shown_dims, 2);
let l = s.active_comp().unwrap().layer(LayerId(flat)).unwrap();
assert!(!l.switches.three_d && !l.props.prop("text/perChar3d").unwrap().value.as_bool());
s.execute("layer.enablePerChar3D", json!({"layer": flat, "enabled": true})).unwrap();
let made = animators(&s, flat)[1].clone();
assert_eq!(get(&made, "rotationX").keys.len(), 2);
assert_eq!(made.sub("properties").unwrap().children.iter().filter(|c| c.match_id() == "rotationX").count(), 1);
// 2D animator, 3D layer.
let keeps = animators(&s, round);
apply(&mut s, &flat_preset, round, 0.0);
let _ = std::fs::remove_file(&round_preset);
let _ = std::fs::remove_file(&flat_preset);
assert_eq!(animators(&s, round)[0], keeps[0]);
let made = animators(&s, round)[1].clone();
assert_eq!(get(&made, "rotation").name, "Z Rotation");
assert_eq!(get(&made, "rotationY").name, "Y Rotation");
assert_eq!(get(&made, "scale").shown_dims, 3);
assert_uids_are_unique(&s);
}
/// With the Per-character 3D setting saved too (the whole Text group is selected), a layer turns
/// 3D as `layer.enablePerChar3D` makes it, in the one Undo step: the setting alone wouldn't draw.
#[test]
fn a_saved_per_character_3d_setting_makes_the_layer_3d() {
let mut s = session();
let round = new_text(&mut s, "Round");
s.execute("layer.enablePerChar3D", json!({"layer": round, "enabled": true})).unwrap();
s.execute("layer.addTextAnimator", json!({"layer": round, "properties": ["rotation"]})).unwrap();
let text = s.active_comp().unwrap().layer(LayerId(round)).unwrap().props.sub("text").unwrap().uid;
let path = save(&mut s, "per-char", round, &[text]);
let target = new_text(&mut s, "Target");
s.execute("layer.addTextAnimator", json!({"layer": target, "properties": ["rotation"], "name": "Existing"})).unwrap();
let existing = animators(&s, target)[0].clone();
assert!(existing.prop("properties/rotationX").is_none());
let is_3d = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(target)).unwrap();
(l.switches.three_d, l.props.prop("text/perChar3d").unwrap().value.as_bool())
};
apply(&mut s, &path, target, 0.0);
let _ = std::fs::remove_file(&path);
assert_eq!(is_3d(&s), (true, true));
let after = animators(&s, target);
assert_eq!(after.len(), 2);
assert!(after[0].prop("properties/rotationX").is_some());
assert!(after[0].prop("properties/rotationY").is_some());
assert!(after[1].prop("properties/rotationX").is_some());
assert_uids_are_unique(&s);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(is_3d(&s), (false, false));
assert_eq!(animators(&s, target), vec![existing]);
}
/// Turning per-character 3D off through a saved Text group also updates existing controls.
#[test]
fn a_saved_2d_setting_updates_existing_3d_animators_without_losing_rotation() {
let mut s = session();
let source = new_text(&mut s, "Flat source");
let text = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.sub("text").unwrap().uid;
let path = save(&mut s, "disable-3d", source, &[text]);
let target = new_text(&mut s, "3D target");
s.execute("layer.enablePerChar3D", json!({"layer": target, "enabled": true})).unwrap();
s.execute("layer.addTextAnimator", json!({"layer": target, "properties": ["rotation", "scale"]})).unwrap();
let before = animators(&s, target)[0].clone();
let rotation_x = before.prop("properties/rotationX").unwrap().uid;
s.execute("prop.addKey", json!({"layer": target, "prop": rotation_x, "time": 1.0, "value": 45})).unwrap();
apply(&mut s, &path, target, 0.0);
let _ = std::fs::remove_file(&path);
let after = animators(&s, target)[0].clone();
assert_eq!(after.prop("properties/scale").unwrap().shown_dims, 2);
assert_eq!(after.prop("properties/rotation").unwrap().name, "Rotation");
assert_eq!(after.prop("properties/rotationX").unwrap().keys.len(), 1);
assert_uids_are_unique(&s);
}
/// A preset that fits no layer says so and leaves the project and history as they were. With a
/// text layer among the targets it applies there and reports the other layer.
#[test]
fn a_preset_that_fits_no_layer_is_an_error() {
let mut s = session();
let source = new_text(&mut s, "Source");
let animator = fade_on(&mut s, source, "Fade In");
let path = save(&mut s, "no-fit", source, &[animator]);
let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let steps = s.history.undo.len();
let recent = s.prefs.recent_presets.clone();
let revision = s.prefs_revision;
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [solid]}));
assert!(r.unwrap_err().to_string().contains("nothing in this preset fits"));
assert_eq!(s.history.undo.len(), steps);
assert_eq!(s.prefs.recent_presets, recent);
assert_eq!(s.prefs_revision, revision);
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [solid, source]})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!((r["applied"].as_u64(), r["skipped"].clone()), (Some(1), json!(["text/animators/Fade In"])));
}
/// A preset saved by an earlier version holds loose paths. It updates an animator a layer has as
/// it always did, and on a layer without one it says to save the preset again.
#[test]
fn an_older_preset_updates_animators_and_asks_to_be_saved_again_without_them() {
let mut s = session();
let source = new_text(&mut s, "Source");
fade_on(&mut s, source, "Fade In");
let path = "text/animators/animator/properties/opacity";
let preset = json!({"effectcraftAnimationPreset": 2, "properties": [{"path": path, "value": KV::Scalar(40.0), "keys": []}], "effects": []});
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [source]})).unwrap();
assert_eq!(r["applied"], 1, "{r}");
assert_eq!(animators(&s, source).len(), 1);
assert_eq!(s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop(path).unwrap().value, KV::Scalar(40.0));
let fresh = new_text(&mut s, "Fresh");
let steps = s.history.undo.len();
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [fresh]}));
assert!(r.unwrap_err().to_string().contains("save it again"));
assert_eq!(animators(&s, fresh), vec![]);
assert_eq!(s.history.undo.len(), steps);
}
/// A malformed file, or a key that can't move to a later layer's playhead, is an error that
/// changes nothing, on the layers before it too.
#[test]
fn a_bad_preset_changes_nothing() {
let mut s = session();
let source = new_text(&mut s, "Source");
fade_on(&mut s, source, "Fade In");
let good = serde_json::to_value(&animators(&s, source)[0]).unwrap();
let mut other = good.clone();
other["match"] = json!("rangeSelector");
let early = new_text(&mut s, "Early");
let late = new_text(&mut s, "Late");
let steps = s.history.undo.len();
let unchanged = |s: &Session| {
assert_eq!((animators(s, early), animators(s, late)), (vec![], vec![]));
assert_eq!(s.history.undo.len(), steps);
};
for list in [json!("not a list"), json!([good, 5]), json!([good, other])] {
let preset = json!({"effectcraftAnimationPreset": 3, "textAnimators": list});
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [early]}));
assert!(r.unwrap_err().to_string().contains("bad text animator"));
unchanged(&s);
}
// The parser refuses a file nested past its limit, before anything walks it.
let path = preset_path("deep");
let deep = format!("{{\"effectcraftAnimationPreset\":3,\"textAnimators\":{}{}}}", "[".repeat(20_000), "]".repeat(20_000));
std::fs::write(&path, deep).unwrap();
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [early]}));
let _ = std::fs::remove_file(&path);
assert!(r.unwrap_err().to_string().contains("not a preset"));
unchanged(&s);
// `late` starts before `early`, so only `late` has a layer-time playhead past 0.
let cid = s.active_comp_id().unwrap();
s.edit("Start Earlier", None, |proj, _| {
proj.comp_mut(cid).unwrap().layer_mut(LayerId(late)).unwrap().start_time = Tick::from_seconds_f64(-1.0);
Ok(())
})
.unwrap();
let steps = s.history.undo.len();
let mut last = animators(&s, source)[0].clone();
last.walk_mut(&mut |p| {
if p.match_id == "end" {
p.keys = vec![Keyframe::new(Tick(i64::MAX), KV::Scalar(0.0))];
}
});
let preset = json!({"effectcraftAnimationPreset": 3, "textAnimators": [last]});
assert!(s.execute("anim.applyPreset", json!({"preset": preset, "layers": [early, late]})).is_err());
assert_eq!((animators(&s, early), animators(&s, late)), (vec![], vec![]));
assert_eq!(s.history.undo.len(), steps);
}