mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 13:52:16 +00:00
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:
1 parent
0a6d264554
commit
a5ddc93257
4 files changed
+529
-38
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -63,6 +63,7 @@ mod styles;
|
||||
mod tail;
|
||||
mod text;
|
||||
mod text_edit;
|
||||
mod text_presets;
|
||||
mod three_d;
|
||||
mod timeline;
|
||||
mod timeline_interchange;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user