mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -06:00
Removes the engine's production expect() calls: background-job polling (camera, mask, roto,
point and warp tracking, render queue) uses let-else / if-let instead of take().expect("job"),
command helpers bind what they checked instead of re-reading it with expect, the menu bar falls
back to an empty tree (logged) instead of expect()ing that a constant parses, and
autotrace/text animator group lookups no longer expect a just-inserted group. perf.rs, the
benchmark harness behind `effectcraft-cli bench --ops`, opts out with a module-level allow.
Test-only unreachable!() calls become panic!() (clippy.toml allows panics in tests). Denies the
panic lints in the crate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
479 lines
21 KiB
Rust
479 lines
21 KiB
Rust
//! Text animation: Animation ▸ Animate Text / Add Text Selector, the Timeline's "Animate:" and
|
|
//! "Add:" pop-ups, Enable Per-character 3D, and EffectCraft's own text animator presets.
|
|
|
|
use effectcraft_keyframe::{Keyframe, Value as KV};
|
|
use effectcraft_project::build::{self, Ids};
|
|
use effectcraft_project::{Layer, LayerSource, Node, PropGroup};
|
|
use effectcraft_time::Tick;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p};
|
|
use crate::{Result, Session, cmd, query};
|
|
|
|
/// The original text animator presets (`presets/text_animators.json`).
|
|
pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json");
|
|
|
|
fn presets() -> Vec<Value> {
|
|
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
|
}
|
|
|
|
/// (id, name) of every preset.
|
|
pub fn preset_names() -> Vec<(String, String)> {
|
|
presets().iter().filter_map(|p| Some((p.get("id")?.as_str()?.to_string(), p.get("name")?.as_str()?.to_string()))).collect()
|
|
}
|
|
|
|
/// Enabled when a text layer is selected.
|
|
fn has_text_layer(s: &Session) -> std::result::Result<(), String> {
|
|
has_comp(s)?;
|
|
let comp = s.active_comp().ok_or("no composition is open")?;
|
|
if s.state.selected_layers.iter().any(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))) {
|
|
Ok(())
|
|
} else {
|
|
Err("select a text layer first".into())
|
|
}
|
|
}
|
|
|
|
/// Enabled when a selected text layer has an animator.
|
|
fn has_animator(s: &Session) -> std::result::Result<(), String> {
|
|
has_text_layer(s)?;
|
|
let comp = s.active_comp().ok_or("no composition is open")?;
|
|
let any =
|
|
s.state.selected_layers.iter().filter_map(|id| comp.layer(*id)).any(|l| l.props.group("text/animators").is_some_and(|a| a.groups().next().is_some()));
|
|
if any { Ok(()) } else { Err("add a text animator first".into()) }
|
|
}
|
|
|
|
fn per_char_on(l: &Layer) -> bool {
|
|
l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool())
|
|
}
|
|
|
|
/// The animator a command targets: `animator` (uid or 1-based index), else the animator holding
|
|
/// a selected property, else the last one.
|
|
fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
|
|
let anims = l.props.group("text/animators").ok_or_else(|| bad(cmd, "not a text layer"))?;
|
|
let list: Vec<&PropGroup> = anims.groups().collect();
|
|
if let Some(v) = p.get("animator") {
|
|
let n = v.as_u64().ok_or_else(|| bad(cmd, "`animator` must be a uid or 1-based index"))?;
|
|
if let Some(g) = list.iter().find(|g| g.uid == n) {
|
|
return Ok(g.uid);
|
|
}
|
|
return list.get((n as usize).wrapping_sub(1)).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no animator {n}")));
|
|
}
|
|
for (lid, uid) in &s.state.selected_props {
|
|
if *lid != l.id {
|
|
continue;
|
|
}
|
|
if let Some(g) = list.iter().find(|g| g.uid == *uid || g.find(*uid).is_some() || g.find_group(*uid).is_some()) {
|
|
return Ok(g.uid);
|
|
}
|
|
}
|
|
list.last().map(|g| g.uid).ok_or_else(|| bad(cmd, "the layer has no text animators"))
|
|
}
|
|
|
|
/// Add the properties of `kind` to an animator's Properties group (skipping ones it has).
|
|
fn add_props(ids: &mut Ids, anim: &mut PropGroup, kind: &str, three_d: bool) -> usize {
|
|
let list = build::text_anim_props(ids, kind, three_d);
|
|
if anim.sub("properties").is_none() {
|
|
let g = ids.group("properties", "Properties");
|
|
anim.children.push(g.into());
|
|
}
|
|
let Some(props) = anim.sub_mut("properties") else { return 0 };
|
|
let mut n = 0;
|
|
for pr in list {
|
|
if props.get(&pr.match_id).is_none() {
|
|
props.children.push(pr.into());
|
|
n += 1;
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
fn kinds_p(p: &Value) -> Vec<String> {
|
|
match p.get("properties").or(p.get("property")) {
|
|
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
|
Some(Value::String(x)) => vec![x.clone()],
|
|
_ => vec!["opacity".into()],
|
|
}
|
|
}
|
|
|
|
fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "layer.addTextAnimator";
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let kinds = kinds_p(p);
|
|
if kinds.iter().any(|k| k == "perChar3d") {
|
|
return enable_per_char(s, &json!({"layer": lid.0, "comp": cid.0}));
|
|
}
|
|
let uid = s.edit("Add Text Animator", None, |proj, _| {
|
|
let mut next = proj.next_id;
|
|
let mut ids = Ids(&mut next);
|
|
let l = layer_mut(proj, cid, lid)?;
|
|
let three = per_char_on(l);
|
|
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
|
|
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1));
|
|
let mut g = build::text_animator(&mut ids, &name, vec![]);
|
|
let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum();
|
|
// `properties: []` adds an empty animator (scripting's addProperty("ADBE Text Animator")).
|
|
if added == 0 && !kinds.is_empty() {
|
|
return Err(bad(C, "unknown animator property"));
|
|
}
|
|
let uid = g.uid;
|
|
anims.children.push(g.into());
|
|
proj.next_id = next;
|
|
Ok(uid)
|
|
})?;
|
|
Ok(json!({"animator": uid}))
|
|
}
|
|
|
|
fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "layer.addTextAnimatorProperty";
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let kinds = kinds_p(p);
|
|
if let Some(k) = kinds.iter().find(|k| !build::TEXT_ANIMATOR_KINDS.iter().any(|(id, _)| id == k)) {
|
|
return Err(bad(C, format!("unknown animator property {k}")));
|
|
}
|
|
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
|
let auid = animator_uid(s, &layer, p, C)?;
|
|
s.edit("Add Text Animator Property", None, |proj, _| {
|
|
let mut next = proj.next_id;
|
|
let mut ids = Ids(&mut next);
|
|
let l = layer_mut(proj, cid, lid)?;
|
|
let three = per_char_on(l);
|
|
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
|
|
for k in &kinds {
|
|
add_props(&mut ids, anim, k, three);
|
|
}
|
|
proj.next_id = next;
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"animator": auid}))
|
|
}
|
|
|
|
pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "layer.addTextSelector";
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let kind = str_p(p, "kind").unwrap_or("range").to_ascii_lowercase();
|
|
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
|
let auid = animator_uid(s, &layer, p, C)?;
|
|
let uid = s.edit("Add Text Selector", None, |proj, _| {
|
|
let mut next = proj.next_id;
|
|
let mut ids = Ids(&mut next);
|
|
let l = layer_mut(proj, cid, lid)?;
|
|
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
|
|
if anim.sub("selectors").is_none() {
|
|
let g = ids.group("selectors", "Selectors");
|
|
anim.children.insert(0, g.into());
|
|
}
|
|
let sels = anim.sub_mut("selectors").ok_or_else(|| bad(C, "the animator has no Selectors group"))?;
|
|
let (m, label) = match kind.as_str() {
|
|
"range" => ("rangeSelector", "Range Selector"),
|
|
"wiggly" => ("wigglySelector", "Wiggly Selector"),
|
|
"expression" => ("expressionSelector", "Expression Selector"),
|
|
_ => return Err(bad(C, "kind must be range, wiggly or expression")),
|
|
};
|
|
let n = sels.groups().filter(|g| g.match_id == m).count() + 1;
|
|
let g = build::text_selector(&mut ids, &kind, &format!("{label} {n}")).ok_or_else(|| bad(C, "unknown selector kind"))?;
|
|
let uid = g.uid;
|
|
sels.children.push(g.into());
|
|
proj.next_id = next;
|
|
Ok(uid)
|
|
})?;
|
|
Ok(json!({"selector": uid, "animator": auid}))
|
|
}
|
|
|
|
/// Show / hide Z components and X / Y rotation on every animator.
|
|
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 {
|
|
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() };
|
|
}
|
|
}
|
|
}
|
|
|
|
fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "layer.enablePerChar3D";
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).map(per_char_on).unwrap_or(false);
|
|
let on = b_p(p, "enabled").unwrap_or(if p.get("toggle").and_then(Value::as_bool) == Some(true) { !cur } else { true });
|
|
s.edit(if on { "Enable Per-character 3D" } else { "Disable Per-character 3D" }, None, |proj, _| {
|
|
let mut next = proj.next_id;
|
|
let mut ids = Ids(&mut next);
|
|
let l = layer_mut(proj, cid, lid)?;
|
|
if !matches!(l.source, LayerSource::Text) {
|
|
return Err(bad(C, "not a text layer"));
|
|
}
|
|
if l.props.prop("text/perChar3d").is_none() {
|
|
let mut pc = ids.prop("perChar3d", "Per-character 3D", KV::Bool(false)).with_ui(effectcraft_project::ParamUi::Hidden);
|
|
pc.static_only = true;
|
|
if let Some(t) = l.props.sub_mut("text") {
|
|
t.children.insert(1, pc.into());
|
|
}
|
|
}
|
|
if let Some(pr) = l.props.prop_mut("text/perChar3d") {
|
|
pr.value = KV::Bool(on);
|
|
}
|
|
// Per-character 3D makes the layer 3D (as in AE).
|
|
if on {
|
|
l.switches.three_d = true;
|
|
}
|
|
set_animators_3d(&mut ids, l, on);
|
|
proj.next_id = next;
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"perChar3d": on}))
|
|
}
|
|
|
|
/// JSON → property value shaped like `like`.
|
|
fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
|
Some(match v {
|
|
Value::Bool(b) => match like {
|
|
KV::Bool(_) => KV::Bool(*b),
|
|
_ => KV::Scalar(if *b { 1.0 } else { 0.0 }),
|
|
},
|
|
Value::Number(n) => {
|
|
let x = n.as_f64()?;
|
|
match like {
|
|
KV::Enum(_) => KV::Enum(x.max(0.0) as u32),
|
|
KV::Bool(_) => KV::Bool(x != 0.0),
|
|
_ => KV::Scalar(x),
|
|
}
|
|
}
|
|
Value::Array(a) => {
|
|
let c: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
|
like.with_components(&c)
|
|
}
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
fn set_path(g: &mut PropGroup, path: &str, v: &Value, keys: Option<&Value>, at: Tick, ease: bool) {
|
|
let Some(pr) = g.prop_mut(path) else { return };
|
|
if let Some(val) = json_value(v, &pr.value) {
|
|
pr.value = val;
|
|
}
|
|
let Some(Value::Array(list)) = keys else { return };
|
|
let mut ks = Vec::new();
|
|
for k in list {
|
|
let (Some(t), Some(val)) = (k.get(0).and_then(Value::as_f64), k.get(1).and_then(|x| json_value(x, &pr.value))) else { continue };
|
|
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
|
|
if pr.hold_only {
|
|
key = key.hold();
|
|
} else if ease {
|
|
key = key.eased();
|
|
}
|
|
ks.push(key);
|
|
}
|
|
if !ks.is_empty() {
|
|
pr.value = ks[0].value.clone();
|
|
pr.keys = ks;
|
|
}
|
|
}
|
|
|
|
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "layer.applyTextPreset";
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
|
|
let preset = presets()
|
|
.into_iter()
|
|
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
|
|
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
|
|
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
|
|
let now = s.time();
|
|
let uids = s.edit(&format!("Apply Text Preset {name}"), None, |proj, _| {
|
|
let mut next = proj.next_id;
|
|
let mut ids = Ids(&mut next);
|
|
let l = layer_mut(proj, cid, lid)?;
|
|
let at = l.layer_time(now);
|
|
let three = per_char_on(l);
|
|
let anims_json = preset.get("animators").and_then(Value::as_array).cloned().unwrap_or_default();
|
|
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
|
|
let mut uids = Vec::new();
|
|
for a in &anims_json {
|
|
let aname = a.get("name").and_then(Value::as_str).unwrap_or("Animator");
|
|
let mut g = build::text_animator(&mut ids, aname, vec![]);
|
|
let ease = a.get("ease").and_then(Value::as_bool).unwrap_or(false);
|
|
if let Some(Value::Object(props)) = a.get("properties") {
|
|
for (m, v) in props {
|
|
add_props(&mut ids, &mut g, m, three);
|
|
let keys = a.get("keys").and_then(|k| k.get(m));
|
|
if let Some(pg) = g.sub_mut("properties") {
|
|
set_path(pg, m, v, keys, at, ease);
|
|
}
|
|
}
|
|
}
|
|
for (i, sj) in a.get("selectors").and_then(Value::as_array).cloned().unwrap_or_default().iter().enumerate() {
|
|
let kind = sj.get("kind").and_then(Value::as_str).unwrap_or("range");
|
|
let Some(sels) = g.sub_mut("selectors") else { continue };
|
|
if !(i == 0 && kind == "range") {
|
|
let label = match kind {
|
|
"wiggly" => "Wiggly Selector 1",
|
|
"expression" => "Expression Selector 1",
|
|
_ => "Range Selector 2",
|
|
};
|
|
if let Some(sg) = build::text_selector(&mut ids, kind, label) {
|
|
sels.children.push(sg.into());
|
|
}
|
|
}
|
|
let Some(Node::Group(sel)) = sels.children.last_mut() else { continue };
|
|
let ease = sj.get("ease").and_then(Value::as_bool).unwrap_or(false);
|
|
if let Some(Value::Object(set)) = sj.get("set") {
|
|
for (path, v) in set {
|
|
set_path(sel, path, v, None, at, ease);
|
|
}
|
|
}
|
|
if let Some(Value::Object(keys)) = sj.get("keys") {
|
|
for (path, list) in keys {
|
|
let cur = sel.prop(path).map(|p| p.value.clone());
|
|
if let Some(cur) = cur {
|
|
let v = serde_json::to_value(cur.components().first().copied().unwrap_or(0.0)).unwrap_or_default();
|
|
set_path(sel, path, &v, Some(list), at, ease);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
uids.push(g.uid);
|
|
anims.children.push(g.into());
|
|
}
|
|
proj.next_id = next;
|
|
Ok(uids)
|
|
})?;
|
|
Ok(json!({"animators": uids}))
|
|
}
|
|
|
|
fn list_presets(_: &mut Session, _: &Value) -> Result<Value> {
|
|
Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect()))
|
|
}
|
|
|
|
/// Animation ▸ Animate Text ▸ Variable Font Axes: the axes of the layer's font, or (with `axis`)
|
|
/// a new animator — or a property on `animator` — that offsets that axis (user units, weighted
|
|
/// by the selectors, added to the axis default).
|
|
fn font_axes(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "text.animatorFontAxes";
|
|
let (cid, lid) = layer_p(s, p, C)?;
|
|
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
|
let Some(KV::Text(doc)) = layer.props.prop("text/sourceText").map(|pr| pr.value_at(layer.layer_time(s.time()))) else {
|
|
return Err(bad(C, "not a text layer"));
|
|
};
|
|
let face = effectcraft_text::layout_doc(&doc).glyphs.first().map(|g| g.face).unwrap_or_else(|| effectcraft_text::resolve(&doc.font, &doc.style).face);
|
|
let axes = effectcraft_text::variable::font_axes(face);
|
|
let list = || json!(axes.iter().map(|a| json!({"tag": a.tag, "name": a.name, "min": a.min, "default": a.default, "max": a.max})).collect::<Vec<_>>());
|
|
let Some(want) = str_p(p, "axis") else { return Ok(json!({"font": doc.font, "axes": list()})) };
|
|
if axes.is_empty() {
|
|
return Err(bad(C, format!("{} {} has no variation axes (not a variable font)", doc.font, doc.style)));
|
|
}
|
|
let axis = axes
|
|
.iter()
|
|
.find(|a| a.tag == want || a.name.eq_ignore_ascii_case(want))
|
|
.ok_or_else(|| bad(C, format!("no axis `{want}`; the font has {}", list())))?
|
|
.clone();
|
|
let target = match p.get("animator") {
|
|
Some(_) => Some(animator_uid(s, &layer, p, C)?),
|
|
None => None,
|
|
};
|
|
let uid = s.edit("Add Variable Font Axis", None, |proj, _| {
|
|
let mut next = proj.next_id;
|
|
let mut ids = Ids(&mut next);
|
|
let prop = ids
|
|
.prop(&format!("{}{}", effectcraft_render::text::AXIS_PREFIX, axis.tag.trim_end()), &axis.name, KV::Scalar(0.0))
|
|
.with_ui(effectcraft_project::ParamUi::Number);
|
|
let l = layer_mut(proj, cid, lid)?;
|
|
let uid = match target {
|
|
Some(a) => {
|
|
let g = l.props.find_group_mut(a).and_then(|g| g.sub_mut("properties")).ok_or_else(|| bad(C, "no such animator"))?;
|
|
g.children.push(prop.into());
|
|
a
|
|
}
|
|
None => {
|
|
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
|
|
let name = format!("Animator {}", anims.children.len() + 1);
|
|
let g = build::text_animator(&mut ids, &name, vec![prop]);
|
|
let uid = g.uid;
|
|
anims.children.push(g.into());
|
|
uid
|
|
}
|
|
};
|
|
proj.next_id = next;
|
|
Ok(uid)
|
|
})?;
|
|
Ok(json!({"animator": uid, "axis": axis.tag}))
|
|
}
|
|
|
|
fn list_kinds(_: &mut Session, _: &Value) -> Result<Value> {
|
|
Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect()))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"layer.addTextAnimator",
|
|
"Animate Text",
|
|
[],
|
|
None,
|
|
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|perChar3d], name?}",
|
|
has_text_layer,
|
|
add_animator
|
|
),
|
|
cmd!(
|
|
"layer.addTextAnimatorProperty",
|
|
"Add Property",
|
|
[],
|
|
None,
|
|
"{layer?, animator?: uid|index, property: (see layer.addTextAnimator)}",
|
|
has_animator,
|
|
add_animator_prop
|
|
),
|
|
cmd!(
|
|
"layer.addTextSelector",
|
|
"Add Text Selector",
|
|
[],
|
|
None,
|
|
"{layer?, animator?: uid|index, kind: range|wiggly|expression}",
|
|
has_animator,
|
|
add_selector
|
|
),
|
|
cmd!("layer.enablePerChar3D", "Enable Per-character 3D", [], None, "{layer?, enabled?: bool, toggle?: bool}", has_text_layer, enable_per_char),
|
|
cmd!(
|
|
"layer.applyTextPreset",
|
|
"Apply Text Animation Preset",
|
|
[],
|
|
None,
|
|
"{layer?, preset: typewriter|fadeUpCharacters|bounceInWords|trackingIn|scramble|blurIn|jitter|dropInLines}",
|
|
has_text_layer,
|
|
apply_preset
|
|
),
|
|
cmd!(
|
|
"text.animatorFontAxes",
|
|
"Variable Font Axes",
|
|
["Animation", "Animate Text"],
|
|
None,
|
|
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}",
|
|
has_text_layer,
|
|
font_axes
|
|
),
|
|
query!("text.presets", "Text Animation Presets", "{}", list_presets),
|
|
query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds),
|
|
]
|
|
}
|