diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 58394d2b..fb8f22e8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -34,6 +34,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall - UI icons drawn in code: `crates/ui-egui/src/icons.rs`. - Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`. +- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter, + Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`). ## First-party brand marks diff --git a/Cargo.lock b/Cargo.lock index 1fe51f3d..8519ae6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1239,6 +1239,7 @@ dependencies = [ "effectcraft-path", "effectcraft-project", "effectcraft-render", + "effectcraft-text", "effectcraft-time", ] diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json new file mode 100644 index 00000000..20e14226 --- /dev/null +++ b/crates/engine/presets/text_animators.json @@ -0,0 +1,167 @@ +{ + "format": "effectcraft.textAnimatorPresets/1", + "notes": "Original EffectCraft text animator presets (MIT OR Apache-2.0). Key times are seconds from the current time; paths are relative to the selector.", + "presets": [ + { + "id": "typewriter", + "name": "Typewriter", + "description": "Characters appear one at a time, hard-edged.", + "animators": [ + { + "name": "Typewriter", + "properties": { "opacity": 0 }, + "selectors": [ + { + "kind": "range", + "set": { "advanced/smoothness": 0 }, + "keys": { "start": [[0, 0], [2, 100]] } + } + ] + } + ] + }, + { + "id": "fadeUpCharacters", + "name": "Fade Up Characters", + "description": "Characters fade in while rising into place, left to right.", + "animators": [ + { + "name": "Fade Up", + "properties": { "opacity": 0, "position": [0, 40, 0] }, + "selectors": [ + { + "kind": "range", + "set": { "advanced/shape": 1, "advanced/easeHigh": 50, "advanced/easeLow": 50 }, + "keys": { "offset": [[0, -100], [1.5, 100]] }, + "ease": true + } + ] + } + ] + }, + { + "id": "bounceInWords", + "name": "Bounce In Words", + "description": "Words scale up from nothing with an overshoot, one after another.", + "animators": [ + { + "name": "Bounce Scale", + "properties": { "scale": [0, 0, 100], "opacity": 0 }, + "selectors": [ + { + "kind": "range", + "set": { "advanced/basedOn": 2, "advanced/shape": 1 }, + "keys": { "offset": [[0, -100], [1.2, 100]] } + } + ] + }, + { + "name": "Bounce Overshoot", + "properties": { "scale": [130, 130, 100] }, + "selectors": [ + { + "kind": "range", + "set": { "advanced/basedOn": 2, "advanced/shape": 3, "end": 35 }, + "keys": { "offset": [[0.15, -35], [1.5, 100]] } + } + ] + } + ] + }, + { + "id": "trackingIn", + "name": "Tracking In", + "description": "Widely spaced text tightens up while fading in.", + "animators": [ + { + "name": "Tracking In", + "properties": { "tracking": 0, "opacity": 100 }, + "keys": { + "tracking": [[0, 400], [1.6, 0]], + "opacity": [[0, 0], [0.8, 100]] + }, + "ease": true, + "selectors": [] + } + ] + }, + { + "id": "scramble", + "name": "Scramble", + "description": "Letters cycle through random characters before settling, left to right.", + "animators": [ + { + "name": "Scramble", + "properties": { "characterOffset": 12 }, + "selectors": [ + { + "kind": "range", + "set": { "advanced/randomize": true, "advanced/smoothness": 0 }, + "keys": { "start": [[0, 0], [2, 100]] } + }, + { + "kind": "wiggly", + "set": { "wigglesPerSecond": 8, "correlation": 0 } + } + ] + } + ] + }, + { + "id": "blurIn", + "name": "Blur In", + "description": "Characters come into focus and fade in, left to right.", + "animators": [ + { + "name": "Blur In", + "properties": { "blur": [40, 40], "opacity": 0, "scale": [140, 140, 100] }, + "selectors": [ + { + "kind": "range", + "set": { "advanced/shape": 1, "advanced/easeLow": 60 }, + "keys": { "offset": [[0, -100], [1.4, 100]] }, + "ease": true + } + ] + } + ] + }, + { + "id": "jitter", + "name": "Jitter", + "description": "Characters wobble randomly in position and rotation, continuously.", + "animators": [ + { + "name": "Jitter", + "properties": { "position": [0, 18, 0], "rotation": 12 }, + "selectors": [ + { "kind": "range" }, + { + "kind": "wiggly", + "set": { "wigglesPerSecond": 3, "correlation": 20 } + } + ] + } + ] + }, + { + "id": "dropInLines", + "name": "Drop In Lines", + "description": "Lines drop from above with a slight rotation, one after another.", + "animators": [ + { + "name": "Drop In", + "properties": { "position": [0, -120, 0], "rotation": -8, "opacity": 0 }, + "selectors": [ + { + "kind": "range", + "set": { "advanced/basedOn": 3, "advanced/shape": 1, "advanced/easeHigh": 80 }, + "keys": { "offset": [[0, -100], [1.4, 100]] }, + "ease": true + } + ] + } + ] + } + ] +} diff --git a/crates/engine/presets/text_animators.json.attribution b/crates/engine/presets/text_animators.json.attribution new file mode 100644 index 00000000..f895aae6 --- /dev/null +++ b/crates/engine/presets/text_animators.json.attribution @@ -0,0 +1,7 @@ +asset: text_animators.json +title: EffectCraft text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter, Drop In Lines) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-02 by EffectCraft agent (M9.6) +notes: Animator/selector settings written from scratch for EffectCraft; not derived from any Adobe preset (.ffx). diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index 69a9ec31..a6390504 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -5,12 +5,11 @@ use std::collections::BTreeMap; use effectcraft_keyframe::{Interp, Keyframe, Value as KV}; -use effectcraft_project::build::{self, Ids}; 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, layer_p, layers_p, match_path_of, selected_leaf_props, str_p}; +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). @@ -257,36 +256,9 @@ fn exponential_scale(s: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- text animators +/// Animation ▸ Add Text Selector ▸ Range / Wiggly / Expression (see `layer.addTextSelector`). fn add_selector(s: &mut Session, p: &Value) -> Result { - let kind = str_p(p, "kind").unwrap_or("range"); - if kind != "range" { - return Err(bad("text.addSelector", "only Range selectors are available")); - } - let (cid, lid) = layer_p(s, p, "text.addSelector")?; - // Target: a selected animator (or anything inside one), else the last animator. - let sel: Vec = s.state.selected_props.iter().filter(|(l, _)| *l == lid).map(|(_, u)| *u).collect(); - let uid = s.edit("Add Text Selector", None, |proj, _| { - let mut next = proj.next_id; - let l = layer_mut(proj, cid, lid)?; - let anims = l.props.group_mut("text/animators").ok_or_else(|| bad("text.addSelector", "not a text layer"))?; - if anims.children.is_empty() { - return Err(bad("text.addSelector", "the layer has no text animators (use Animate Text first)")); - } - let idx = anims - .children - .iter() - .position(|c| c.as_group().is_some_and(|g| sel.contains(&g.uid) || sel.iter().any(|u| g.find(*u).is_some() || g.find_group(*u).is_some()))) - .unwrap_or(anims.children.len() - 1); - let Some(Node::Group(anim)) = anims.children.get_mut(idx) else { return Err(bad("text.addSelector", "no animator")) }; - let sels = anim.sub_mut("selectors").ok_or_else(|| bad("text.addSelector", "animator has no selectors group"))?; - let mut ids = Ids(&mut next); - let g = build::range_selector(&mut ids, &format!("Range Selector {}", sels.children.len() + 1)); - let uid = g.uid; - sels.children.push(g.into()); - proj.next_id = next; - Ok(uid) - })?; - Ok(json!({"selector": uid})) + super::text_anim::add_selector(s, p) } fn remove_all_animators(s: &mut Session, p: &Value) -> Result { @@ -392,7 +364,7 @@ pub fn specs() -> Vec { has_keys, exponential_scale ), - cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, kind: range}", has_text_layer, add_selector), + 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), ] diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 5d9e6a38..94605428 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -816,32 +816,6 @@ fn set_text(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -fn add_animator(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.addTextAnimator")?; - let props: Vec = 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()], - }; - let uid = s.edit("Add Text Animator", None, |proj, _| { - let mut next = proj.next_id; - let mut ids = Ids(&mut next); - let list: Vec<_> = props.iter().filter_map(|k| build::text_anim_prop(&mut ids, k)).collect(); - if list.is_empty() { - return Err(bad("layer.addTextAnimator", "unknown animator property")); - } - let l = layer_mut(proj, cid, lid)?; - let anims = l.props.group_mut("text/animators").ok_or_else(|| bad("layer.addTextAnimator", "not a text layer"))?; - let name = format!("Animator {}", anims.children.len() + 1); - let g = build::text_animator(&mut ids, &name, list); - let uid = g.uid; - anims.children.push(g.into()); - proj.next_id = next; - Ok(uid) - })?; - Ok(json!({"animator": uid})) -} - fn transform_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let op = str_p(p, "op").unwrap_or("reset").to_string(); @@ -1019,15 +993,6 @@ pub fn specs() -> Vec { has_layers, set_text ), - cmd!( - "layer.addTextAnimator", - "Animate Text", - [], - None, - "{layer?, properties: [position|scale|rotation|opacity|fillColor|tracking|…]}", - has_layers, - add_animator - ), cmd!( "layer.transform", "Transform", diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1c59d69f..6301e440 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -21,6 +21,7 @@ mod query; mod render_queue; mod stubs; mod styles; +mod text_anim; mod three_d; mod time; mod view; @@ -84,6 +85,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layer::specs()); v.extend(styles::specs()); v.extend(three_d::specs()); + v.extend(text_anim::specs()); v.extend(layer_time::specs()); v.extend(prop::specs()); v.extend(anim::specs()); @@ -105,6 +107,11 @@ pub fn command_specs() -> &'static [CommandSpec] { }) } +/// (id, name) of the text animation presets. +pub fn text_preset_list() -> Vec<(String, String)> { + text_anim::preset_names() +} + pub fn find(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 40e323ed..b8b97575 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -80,12 +80,7 @@ pub fn specs() -> Vec { // Keyframes / text / tracking. stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"), stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"), - stub!("text.perCharacter3d", "Enable Per-character 3D", ["Animation", "Animate Text"], "{}"), - stub!("text.animatorLineAnchor", "Line Anchor", ["Animation", "Animate Text"], "{}"), - stub!("text.animatorCharacterValue", "Character Value", ["Animation", "Animate Text"], "{}"), stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"), - stub!("text.addWigglySelector", "Wiggly", ["Animation", "Add Text Selector"], "{}"), - stub!("text.addExpressionSelector", "Expression", ["Animation", "Add Text Selector"], "{}"), stub!("track.camera", "Track Camera", ["Animation"], "{}"), stub!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], "{}"), stub!("track.motion", "Track Motion", ["Animation"], "{}"), diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs new file mode 100644 index 00000000..73f34c5e --- /dev/null +++ b/crates/engine/src/commands/text_anim.rs @@ -0,0 +1,420 @@ +//! 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 { + serde_json::from_str::(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 { + 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); + let props = match anim.sub_mut("properties") { + Some(p) => p, + None => { + let g = ids.group("properties", "Properties"); + anim.children.push(g.into()); + anim.sub_mut("properties").expect("just added") + } + }; + 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 { + 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 { + 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(); + if added == 0 { + 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 { + 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 { + 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"))?; + let sels = match anim.sub_mut("selectors") { + Some(g) => g, + None => { + let g = ids.group("selectors", "Selectors"); + anim.children.insert(0, g.into()); + anim.sub_mut("selectors").expect("just added") + } + }; + 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 { + 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 { + 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 = 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 { + 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 { + Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect())) +} + +fn list_kinds(_: &mut Session, _: &Value) -> Result { + Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect())) +} + +pub fn specs() -> Vec { + 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 + ), + query!("text.presets", "Text Animation Presets", "{}", list_presets), + query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds), + ] +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 6be2e12b..9d1a059b 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -383,7 +383,7 @@ pub fn demo_project() -> Project { { let mut ids = Ids(&mut next); let pos = build::text_anim_prop(&mut ids, "position").map(|mut pr| { - pr.value = Value::Vec2([0.0, 90.0]); + pr.value = Value::Vec3([0.0, 90.0, 0.0]); pr }); let op = build::text_anim_prop(&mut ids, "opacity").map(|mut pr| { @@ -391,7 +391,7 @@ pub fn demo_project() -> Project { pr }); let sc = build::text_anim_prop(&mut ids, "scale").map(|mut pr| { - pr.value = Value::Vec2([60.0, 60.0]); + pr.value = Value::Vec3([60.0, 60.0, 100.0]); pr }); let mut a = build::text_animator(&mut ids, "Animator 1", [pos, op, sc].into_iter().flatten().collect()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6770a5bc..5195fe72 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -458,9 +458,16 @@ mod tests_menu_cmds; #[cfg(test)] mod tests_styles; #[cfg(test)] +mod tests_text; +#[cfg(test)] mod tests_timeline; /// Font families available to text layers (bundled + scanned system fonts). +/// Text animation presets: (id, name), for menus. +pub fn text_presets() -> Vec<(String, String)> { + commands::text_preset_list() +} + pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index cac33580..8965da25 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -712,6 +712,15 @@ Animation Save Animation Preset... | anim.savePreset Apply Animation Preset... | anim.applyPreset Browse Presets... | anim.browsePresets + Text Animation Presets + Typewriter | layer.applyTextPreset {"preset":"typewriter"} + Fade Up Characters | layer.applyTextPreset {"preset":"fadeUpCharacters"} + Bounce In Words | layer.applyTextPreset {"preset":"bounceInWords"} + Tracking In | layer.applyTextPreset {"preset":"trackingIn"} + Scramble | layer.applyTextPreset {"preset":"scramble"} + Blur In | layer.applyTextPreset {"preset":"blurIn"} + Jitter | layer.applyTextPreset {"preset":"jitter"} + Drop In Lines | layer.applyTextPreset {"preset":"dropInLines"} --- Add Keyframe | anim.addKeyframe Toggle Hold Keyframe | keys.toggleHold @@ -734,7 +743,7 @@ Animation Time-Reverse Keyframes | keys.timeReverse --- Animate Text - Enable Per-character 3D | text.perCharacter3d + Enable Per-character 3D | layer.enablePerChar3D {"toggle":true} --- Anchor Point | layer.addTextAnimator {"properties":["anchor"]} Position | layer.addTextAnimator {"properties":["position"]} @@ -743,25 +752,35 @@ Animation Rotation | layer.addTextAnimator {"properties":["rotation"]} Opacity | layer.addTextAnimator {"properties":["opacity"]} --- - All Transform Properties | layer.addTextAnimator {"properties":["anchor","position","scale","skew","rotation","opacity"]} + All Transform Properties | layer.addTextAnimator {"properties":["transformAll"]} --- - Fill Color | layer.addTextAnimator {"properties":["fillColor"]} - Stroke Color | layer.addTextAnimator {"properties":["strokeColor"]} + Fill Color + RGB | layer.addTextAnimator {"properties":["fillColor"]} + Hue | layer.addTextAnimator {"properties":["fillHue"]} + Saturation | layer.addTextAnimator {"properties":["fillSaturation"]} + Brightness | layer.addTextAnimator {"properties":["fillBrightness"]} + Opacity | layer.addTextAnimator {"properties":["fillOpacity"]} + Stroke Color + RGB | layer.addTextAnimator {"properties":["strokeColor"]} + Hue | layer.addTextAnimator {"properties":["strokeHue"]} + Saturation | layer.addTextAnimator {"properties":["strokeSaturation"]} + Brightness | layer.addTextAnimator {"properties":["strokeBrightness"]} + Opacity | layer.addTextAnimator {"properties":["strokeOpacity"]} Stroke Width | layer.addTextAnimator {"properties":["strokeWidth"]} --- Tracking | layer.addTextAnimator {"properties":["tracking"]} - Line Anchor | text.animatorLineAnchor + Line Anchor | layer.addTextAnimator {"properties":["lineAnchor"]} Line Spacing | layer.addTextAnimator {"properties":["lineSpacing"]} --- Character Offset | layer.addTextAnimator {"properties":["characterOffset"]} - Character Value | text.animatorCharacterValue + Character Value | layer.addTextAnimator {"properties":["characterValue"]} --- Blur | layer.addTextAnimator {"properties":["blur"]} Variable Font Axes | text.animatorFontAxes Add Text Selector Range | text.addSelector {"kind":"range"} - Wiggly | text.addWigglySelector - Expression | text.addExpressionSelector + Wiggly | text.addSelector {"kind":"wiggly"} + Expression | text.addSelector {"kind":"expression"} Remove All Text Animators | text.removeAllAnimators --- Add Expression | prop.setExpression diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 095e55af..71007654 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -310,8 +310,10 @@ fn animation_menu_keys_presets_text() { s.execute("text.addSelector", json!({"kind": "range"})).unwrap(); let anim = layer(&s, t).props.group("text/animators").unwrap().groups().next().unwrap().clone(); assert_eq!(anim.sub("selectors").unwrap().children.len(), 2); - assert_eq!(anim.sub("properties").unwrap().children.len(), 6); - assert!(s.execute("text.addSelector", json!({"kind": "wiggly"})).is_err()); + // Skew brings Skew Axis along. + assert_eq!(anim.sub("properties").unwrap().children.len(), 7); + s.execute("text.addSelector", json!({"kind": "wiggly"})).unwrap(); + assert!(s.execute("text.addSelector", json!({"kind": "other"})).is_err()); s.execute("text.removeAllAnimators", json!({})).unwrap(); assert!(layer(&s, t).props.group("text/animators").unwrap().children.is_empty()); s.execute("edit.undo", json!({})).unwrap(); diff --git a/crates/engine/src/tests_text.rs b/crates/engine/src/tests_text.rs new file mode 100644 index 00000000..7b965006 --- /dev/null +++ b/crates/engine/src/tests_text.rs @@ -0,0 +1,188 @@ +//! Text animation commands: animators, properties, selectors, per-character 3D, presets (with +//! undo/redo and serde round-trips). + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Node, Project, PropGroup}; +use serde_json::json; + +use crate::Session; + +fn session() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap(); + let t = s.execute("layer.newText", json!({"text": "Hello World", "size": 60})).unwrap()["layer"].as_u64().unwrap(); + (s, t) +} + +fn text_group(s: &Session) -> PropGroup { + s.active_comp().unwrap().layers[0].props.sub("text").unwrap().clone() +} + +fn matches(g: &PropGroup) -> Vec { + g.children.iter().map(|c| c.match_id().to_string()).collect() +} + +#[test] +fn animate_text_adds_animators_with_companion_properties() { + let (mut s, t) = session(); + let r = s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["skew", "tracking", "characterOffset"]})).unwrap(); + assert!(r["animator"].as_u64().is_some()); + let tg = text_group(&s); + let a = tg.group("animators/#1").unwrap(); + assert_eq!( + matches(a.sub("properties").unwrap()), + ["skew", "skewAxis", "trackingType", "tracking", "characterAlignment", "characterRange", "characterOffset"] + ); + assert_eq!(matches(a.sub("selectors").unwrap()), ["rangeSelector"]); + // Every Animate entry is accepted. + for (k, _) in effectcraft_project::build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-") { + s.execute("layer.addTextAnimator", json!({"layer": t, "property": k})).unwrap_or_else(|e| panic!("{k}: {e}")); + } + assert!(s.execute("layer.addTextAnimator", json!({"layer": t, "property": "nope"})).is_err()); + // All Transform Properties. + s.execute("layer.addTextAnimator", json!({"layer": t, "property": "transformAll"})).unwrap(); + let tg = text_group(&s); + let last = tg.sub("animators").unwrap().groups().last().unwrap().clone(); + assert_eq!(matches(last.sub("properties").unwrap()), ["anchor", "position", "scale", "skew", "skewAxis", "rotation", "opacity"]); + // Rendering with every property present works. + let cid = s.active_comp_id().unwrap(); + let _ = s.render(cid, s.time(), Default::default()); +} + +#[test] +fn add_property_and_selectors_with_undo_redo() { + let (mut s, t) = session(); + s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap(); + s.execute("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 1, "property": "fillHue"})).unwrap(); + assert!(s.execute("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 1, "property": "bogus"})).is_err()); + for kind in ["wiggly", "expression", "range"] { + s.execute("layer.addTextSelector", json!({"layer": t, "kind": kind})).unwrap(); + } + assert!(s.execute("layer.addTextSelector", json!({"layer": t, "kind": "other"})).is_err()); + let tg = text_group(&s); + let a = tg.group("animators/#1").unwrap(); + assert_eq!(matches(a.sub("properties").unwrap()), ["position", "fillHue"]); + let sels = a.sub("selectors").unwrap(); + assert_eq!(matches(sels), ["rangeSelector", "wigglySelector", "expressionSelector", "rangeSelector"]); + let names: Vec<&str> = sels.children.iter().map(Node::name).collect(); + assert_eq!(names, ["Range Selector 1", "Wiggly Selector 1", "Expression Selector 1", "Range Selector 2"]); + // Expression selector carries the default amount expression (and no Mode); wiggly defaults to + // Intersect. + let ex = sels.group("expressionSelector").unwrap(); + assert!(ex.get("amount").unwrap().has_expression()); + assert!(ex.get("mode").is_none()); + assert_eq!(sels.group("wigglySelector").unwrap().get("mode").unwrap().value, KV::Enum(2)); + let cid = s.active_comp_id().unwrap(); + let a1 = s.render(cid, s.time(), Default::default()); + let a2 = s.render(cid, s.time(), Default::default()); + assert_eq!(a1.data, a2.data, "wiggly text renders deterministically"); + // Undo the three selector additions and the property, redo them. + for _ in 0..3 { + s.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(matches(text_group(&s).group("animators/#1/selectors").unwrap()), ["rangeSelector"]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matches(text_group(&s).group("animators/#1/properties").unwrap()), ["position"]); + for _ in 0..4 { + s.execute("edit.redo", json!({})).unwrap(); + } + assert_eq!(text_group(&s).group("animators/#1/selectors").unwrap().children.len(), 4); +} + +#[test] +fn per_character_3d_toggles_layer_and_dimensions() { + let (mut s, t) = session(); + s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["position", "rotation"]})).unwrap(); + s.execute("layer.enablePerChar3D", json!({"layer": t})).unwrap(); + let l = s.active_comp().unwrap().layers[0].clone(); + assert!(l.switches.three_d); + assert_eq!(l.props.prop("text/perChar3d").unwrap().value, KV::Bool(true)); + let props = l.props.group("text/animators/#1/properties").unwrap(); + assert_eq!(props.get("position").unwrap().shown_dims, 3); + assert_eq!(matches(props), ["position", "rotationX", "rotationY", "rotation"]); + assert_eq!(props.get("rotation").unwrap().name, "Z Rotation"); + // New animators get 3D properties too. + s.execute("layer.addTextAnimator", json!({"layer": t, "property": "rotation"})).unwrap(); + assert_eq!(matches(text_group(&s).group("animators/#2/properties").unwrap()), ["rotationX", "rotationY", "rotation"]); + // Renders through the 3D path. + s.execute("prop.set", json!({"layer": t, "path": "text/animators/#1/properties/rotationY", "value": 45})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, s.time(), Default::default()); + assert!(img.data.iter().any(|p| p[3] > 0.5)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let l = s.active_comp().unwrap().layers[0].clone(); + assert!(!l.switches.three_d); + assert_eq!(matches(l.props.group("text/animators/#1/properties").unwrap()), ["position", "rotation"]); + // Disable removes X / Y rotation again. + s.execute("edit.redo", json!({})).unwrap(); + s.execute("layer.enablePerChar3D", json!({"layer": t, "enabled": false})).unwrap(); + let l = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(matches(l.props.group("text/animators/#1/properties").unwrap()), ["position", "rotation"]); + assert_eq!(l.props.prop("text/perChar3d").unwrap().value, KV::Bool(false)); +} + +#[test] +fn presets_apply_render_and_round_trip() { + let (mut s, t) = session(); + let list = s.execute("text.presets", json!({})).unwrap(); + let ids: Vec = list.as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap().to_string()).collect(); + assert!(ids.len() >= 5 && ids.len() <= 8, "{ids:?}"); + let cid = s.active_comp_id().unwrap(); + for id in &ids { + let before = s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len(); + let r = s.execute("layer.applyTextPreset", json!({"layer": t, "preset": id})).unwrap(); + assert!(!r["animators"].as_array().unwrap().is_empty(), "{id}"); + let after = s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len(); + assert!(after > before, "{id}"); + for secs in [0.0, 0.7, 3.0] { + let _ = s.render(cid, effectcraft_time::Tick::from_seconds_f64(secs), effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() }); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len(), before, "{id}"); + } + // Typewriter: the start keyframes reveal the text over time. + s.execute("layer.applyTextPreset", json!({"layer": t, "preset": "Typewriter"})).unwrap(); + let opts = effectcraft_render::RenderOpts { scale: 0.5, ..Default::default() }; + let cov = |img: &effectcraft_render::Image| img.data.iter().filter(|p| p[3] > 0.5).count(); + let a = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(0.0), opts)); + let b = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(1.0), opts)); + let c = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(3.0), opts)); + assert!(a < b && b < c, "{a} {b} {c}"); + // Every selector kind and More / Path Options survive a save/load round trip. + s.execute("layer.addTextSelector", json!({"layer": t, "kind": "wiggly"})).unwrap(); + s.execute("layer.addTextSelector", json!({"layer": t, "kind": "expression"})).unwrap(); + s.execute("layer.enablePerChar3D", json!({"layer": t})).unwrap(); + let json = s.project.to_json(); + let back = Project::from_json(&json).unwrap(); + assert_eq!(*s.project, back); + let tg = text_group(&s); + for m in ["pathOptions", "moreOptions"] { + assert!(tg.sub(m).is_some(), "{m}"); + } + assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string())); +} + +#[test] +fn menus_have_text_animation_entries() { + let (s, _) = session(); + let spec = crate::commands::find("layer.enablePerChar3D").unwrap(); + assert!(spec.menu.is_empty(), "placed by the engine menu tree"); + let entries = crate::menus::entries(); + let has = |path: &[&str], cmd: &str| { + let (label, parent) = path.split_last().unwrap(); + entries.iter().any(|(p, e)| p.iter().map(String::as_str).eq(parent.iter().copied()) && e.label == *label && e.command == cmd) + }; + assert!(has(&["Animation", "Animate Text", "Enable Per-character 3D"], "layer.enablePerChar3D")); + assert!(has(&["Animation", "Animate Text", "Fill Color", "Hue"], "layer.addTextAnimator")); + assert!(has(&["Animation", "Add Text Selector", "Wiggly"], "text.addSelector")); + assert!(has(&["Animation", "Text Animation Presets", "Typewriter"], "layer.applyTextPreset")); + // Every preset in the menu exists. + let ids: Vec = crate::text_presets().into_iter().map(|p| p.0).collect(); + for (_, e) in entries.iter().filter(|(_, e)| e.command == "layer.applyTextPreset") { + assert!(ids.iter().any(|i| e.params["preset"] == i.as_str()), "{:?}", e.params); + } + assert!(s.is_enabled("layer.enablePerChar3D")); + assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator"); +} diff --git a/crates/expr/Cargo.toml b/crates/expr/Cargo.toml index 92dc1d81..613162e1 100644 --- a/crates/expr/Cargo.toml +++ b/crates/expr/Cargo.toml @@ -25,3 +25,4 @@ boa_engine = { workspace = true, features = ["js"] } [dev-dependencies] effectcraft-color = { workspace = true } effectcraft-effects = { workspace = true } +effectcraft-text = { workspace = true } diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 48049cf3..323e3c7a 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -53,6 +53,31 @@ impl ExprHost for Expressions { None => Ok(value.clone()), } } + + fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> { + let Some(e) = prop.expr.as_ref().filter(|_| prop.has_expression()) else { + let v = prop.value_at(layer.layer_time(ctx.time)).components(); + return Ok([v.first().copied().unwrap_or(100.0), v.get(1).copied().unwrap_or(100.0), v.get(2).copied().unwrap_or(100.0)]); + }; + let value = prop.value_at(layer.layer_time(ctx.time)); + let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, [index as f64 + 1.0, total as f64], selector)?; + let num = |o: &runtime::Out| match o { + runtime::Out::Num(x) => Some(*x), + runtime::Out::Bool(b) => Some(if *b { 100.0 } else { 0.0 }), + _ => None, + }; + let r = match &out { + runtime::Out::Arr(items) => { + let g = |i: usize| items.get(i).and_then(num).or_else(|| items.first().and_then(num)); + [g(0), g(1), g(2)] + } + o => [num(o); 3], + }; + match r { + [Some(a), Some(b), Some(c)] if a.is_finite() && b.is_finite() && c.is_finite() => Ok([a, b, c]), + _ => Err("Error: expression selector amount must be a number or an array of numbers".into()), + } + } } /// Pops the evaluation frame on every exit path. @@ -66,6 +91,23 @@ impl Drop for FrameGuard { /// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed /// `value`. Other properties' expressions are evaluated through `host`. pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result { + let out = evaluate_out(host, ctx, layer, prop, value, text, [1.0, 1.0], [100.0; 3])?; + runtime::to_value(&out, value) +} + +/// Run an expression and return the raw script result. `text_sel` / `selector` are the text +/// Expression Selector's `[textIndex, textTotal]` and `selectorValue`. +#[allow(clippy::too_many_arguments)] +fn evaluate_out( + host: &dyn ExprHost, + ctx: &EvalCtx, + layer: &Layer, + prop: &Property, + value: &Value, + text: &str, + text_sel: [f64; 2], + selector: [f64; 3], +) -> Result { let depth = FRAMES.with(|f| f.borrow().len()); if depth >= MAX_DEPTH { return Err("Error: expressions reference each other too deeply (circular reference?)".into()); @@ -84,6 +126,8 @@ pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Proper uid: prop.uid, index: ctx.comp.index_of(layer.id).unwrap_or(0) as f64, frame_duration: ctx.comp.frame_duration().seconds(), + text_sel, + selector, }; // Answer the requests nearly every expression makes up front. let mut frame = Frame::default(); @@ -95,7 +139,7 @@ pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Proper let _guard = FrameGuard; for _ in 0..MAX_PASSES { match runtime::run(text, &begin) { - Run::Done(r) => return r.and_then(|o| runtime::to_value(&o, value)), + Run::Done(r) => return r, Run::Pending => { let misses = FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| std::mem::take(&mut fr.misses)).unwrap_or_default()); for m in misses { diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index eb9dfb14..1e3a61ab 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -11,13 +11,20 @@ var __S = null; var __rc = 0; var __seed = 0; var __timeless = false; +// Text Expression Selector inputs (1, 1, [100, 100, 100] outside a selector). +var textIndex = 1; +var textTotal = 1; +var selectorValue = [100, 100, 100]; // Request ops (must match runtime.rs). var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9; -function __begin(t, v, c, l, path, uid, idx, fd) { +function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { time = t; value = v; + textIndex = ti; + textTotal = tt; + selectorValue = sv; __S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {} }; __rc = 0; __seed = 0; diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index b00e6324..65177476 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -49,6 +49,9 @@ pub struct Begin<'a> { pub uid: u64, pub index: f64, pub frame_duration: f64, + /// Text Expression Selector `[textIndex, textTotal]` and `selectorValue` (percent). + pub text_sel: [f64; 2], + pub selector: [f64; 3], } /// Outcome of one script run. @@ -228,6 +231,9 @@ impl Runtime { JsValue::from(b.uid as f64), JsValue::from(b.index), JsValue::from(b.frame_duration), + JsValue::from(b.text_sel[0]), + JsValue::from(b.text_sel[1]), + to_js(&crate::host::Resp::List(b.selector.iter().map(|x| crate::host::Resp::Num(*x)).collect()), ctx), ]; if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) { return Run::Done(Err(format_error(&e.to_string(), line_count(text)))); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 779797a0..8ff451bd 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -942,3 +942,58 @@ fn error_messages_point_at_the_users_line() { // An unfinished last line is reported on that line, not on the hidden wrapper. assert!(f.err("5 +").starts_with("Error at line 1: SyntaxError")); } + +/// Text Expression Selector: Amount evaluated per unit with textIndex / textTotal / +/// selectorValue. +#[test] +fn text_expression_selector_values() { + let mut p = Project::default(); + let comp = Comp::new(400, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = build::layer(&mut p, &comp, "T", LayerSource::Text, (400, 200), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "ABCD".into(), size: 40.0, ..Default::default() })); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let props = build::text_anim_props(&mut ids, "opacity", false); + let mut a = build::text_animator(&mut ids, "Animator 1", props); + let sel = build::expression_selector(&mut ids, "Expression Selector 1"); + a.sub_mut("selectors").unwrap().children.push(sel.into()); + p.next_id = next; + l.props.group_mut("text/animators").unwrap().children.push(a.into()); + let lid = l.id; + p.comp_mut(cid).unwrap().layers.push(l); + let sel_of = |p: &Project| { + let c = p.comp(cid).unwrap(); + let l = c.layer(lid).unwrap(); + let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions) }; + let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); + let lay = effectcraft_text::layout_doc(&doc); + let anim = l.props.group("text/animators/#1").unwrap(); + effectcraft_render::text::animator_selection(&ctx, l, anim, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect::>() + }; + // Default: selectorValue * textIndex / textTotal (selectorValue = the full range selector). + assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.75, 1.0]); + let set_expr = |p: &mut Project, text: &str| { + let l = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap(); + l.props.prop_mut("text/animators/#1/selectors/#2/amount").unwrap().expr = Some(Expression { text: text.into(), enabled: true }); + }; + // Range selector covering the first half → selectorValue 0 for the second half. + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(50.0); + assert_eq!(sel_of(&p), vec![0.25, 0.5, 0.0, 0.0]); + // Plain numbers, conditionals on textIndex (1-based) and arrays all work. + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.prop_mut("text/animators/#1/selectors/#1/end").unwrap().value = Value::Scalar(100.0); + set_expr(&mut p, "textIndex % 2 == 0 ? 100 : 0"); + assert_eq!(sel_of(&p), vec![0.0, 1.0, 0.0, 1.0]); + set_expr(&mut p, "[50, 0, 0]"); + assert_eq!(sel_of(&p), vec![0.5; 4]); + // The result replaces the selection so far (which it reads as selectorValue). + set_expr(&mut p, "selectorValue - textIndex / textTotal * 100"); + assert_eq!(sel_of(&p), vec![0.75, 0.5, 0.25, 0.0]); + // Errors fall back to the static amount (100%). + set_expr(&mut p, "nope("); + assert_eq!(sel_of(&p), vec![1.0; 4]); + // Outside a selector the expression still evaluates (textIndex = textTotal = 1). + set_expr(&mut p, build::EXPRESSION_SELECTOR_DEFAULT); + let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap(); + assert_eq!(v, Value::Vec3([100.0; 3])); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 0e582de9..d491efac 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -78,21 +78,77 @@ pub fn mask(ids: &mut Ids, name: &str, path: ShapePath, mode: MaskMode, color: [ .with(ids.prop("expansion", "Mask Expansion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) } -pub fn text(ids: &mut Ids, doc: TextDoc) -> PropGroup { - let mut st = ids.prop("sourceText", "Source Text", Value::Text(Box::new(doc))).with_ui(ParamUi::Text); - st.hold_only = true; - let path_opts = ids.group("pathOptions", "Path Options").with(ids.prop("path", "Path", Value::Enum(0)).with_ui(popup(&["None"]))); - let more = ids - .group("moreOptions", "More Options") - .with(ids.prop("anchorGrouping", "Anchor Point Grouping", Value::Enum(0)).with_ui(popup(&["Character", "Word", "Line", "All"]))) +/// Inter-Character Blending modes (blend mode names, see `BlendMode::from_name`). +pub const INTER_CHAR_BLEND_MODES: &[&str] = &[ + "Normal", + "Darken", + "Multiply", + "Color Burn", + "Linear Burn", + "Darker Color", + "Add", + "Lighten", + "Screen", + "Color Dodge", + "Lighter Color", + "Overlay", + "Soft Light", + "Hard Light", + "Linear Light", + "Vivid Light", + "Pin Light", + "Hard Mix", + "Difference", + "Exclusion", + "Subtract", + "Divide", + "Hue", + "Saturation", + "Color", + "Luminosity", +]; + +/// A non-keyframable header-like property (no stopwatch, as in AE's selector popups). +fn fixed(mut p: Property) -> Property { + p.static_only = true; + p +} + +/// Path Options group (Path popup: 0 = None, k = mask k). +pub fn path_options(ids: &mut Ids) -> PropGroup { + let mut path = ids.prop("path", "Path", Value::Enum(0)).with_ui(popup(&["None"])); + path.static_only = true; + ids.group("pathOptions", "Path Options") + .with(path) + .with(ids.prop("reversePath", "Reverse Path", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("perpendicular", "Perpendicular To Path", Value::Bool(true)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("forceAlignment", "Force Alignment", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("firstMargin", "First Margin", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) + .with(ids.prop("lastMargin", "Last Margin", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) +} + +/// More Options group. +pub fn more_options(ids: &mut Ids) -> PropGroup { + ids.group("moreOptions", "More Options") + .with(fixed(ids.prop("anchorGrouping", "Anchor Point Grouping", Value::Enum(0)).with_ui(popup(&["Character", "Word", "Line", "All"])))) .with(ids.prop("groupingAlignment", "Grouping Alignment", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent)) - .with(ids.prop("fillStroke", "Fill & Stroke", Value::Enum(0)).with_ui(popup(&[ + .with(fixed(ids.prop("fillStroke", "Fill & Stroke", Value::Enum(0)).with_ui(popup(&[ "Per Character Palette", "All Fills Over All Strokes", "All Strokes Over All Fills", - ]))) - .with(ids.prop("interCharBlend", "Inter-Character Blending", Value::Enum(0)).with_ui(popup(&["Normal", "Multiply", "Screen", "Overlay"]))); - ids.group("text", "Text").with(st).with(path_opts).with(more).with(ids.group("animators", "Animators")) + ])))) + .with(fixed(ids.prop("interCharBlend", "Inter-Character Blending", Value::Enum(0)).with_ui(popup(INTER_CHAR_BLEND_MODES)))) +} + +pub fn text(ids: &mut Ids, doc: TextDoc) -> PropGroup { + let mut st = ids.prop("sourceText", "Source Text", Value::Text(Box::new(doc))).with_ui(ParamUi::Text); + st.hold_only = true; + // Animation ▸ Animate Text ▸ Enable Per-character 3D (layer-level flag, not keyframable). + let mut pc3 = ids.prop("perChar3d", "Per-character 3D", Value::Bool(false)).with_ui(ParamUi::Hidden); + pc3.static_only = true; + let path_opts = path_options(ids); + let more = more_options(ids); + ids.group("text", "Text").with(st).with(pc3).with(path_opts).with(more).with(ids.group("animators", "Animators")) } /// A text animator with one range selector and the given properties. @@ -108,20 +164,23 @@ pub fn text_animator(ids: &mut Ids, name: &str, props: Vec) -> PropGro g.with(sels).with(pg) } +const BASED_ON: &[&str] = &["Characters", "Characters Excluding Spaces", "Words", "Lines"]; +const SEL_MODES: &[&str] = &["Add", "Subtract", "Intersect", "Min", "Max", "Difference"]; + pub fn range_selector(ids: &mut Ids, name: &str) -> PropGroup { let mut g = ids.group("rangeSelector", name); g.kind = GroupKind::Indexed; let adv = ids .group("advanced", "Advanced") - .with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Percentage", "Index"]))) - .with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(&["Characters", "Characters Excluding Spaces", "Words", "Lines"]))) - .with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&["Add", "Subtract", "Intersect", "Min", "Max", "Difference"]))) - .with(ids.prop("amount", "Amount", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) - .with(ids.prop("shape", "Shape", Value::Enum(0)).with_ui(popup(&["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"]))) - .with(ids.prop("smoothness", "Smoothness", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) - .with(ids.prop("easeHigh", "Ease High", Value::Scalar(0.0)).with_ui(ParamUi::Percent)) - .with(ids.prop("easeLow", "Ease Low", Value::Scalar(0.0)).with_ui(ParamUi::Percent)) - .with(ids.prop("randomize", "Randomize Order", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(fixed(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Percentage", "Index"])))) + .with(fixed(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))) + .with(fixed(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(SEL_MODES)))) + .with(ids.prop("amount", "Amount", Value::Scalar(100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0))) + .with(fixed(ids.prop("shape", "Shape", Value::Enum(0)).with_ui(popup(&["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"])))) + .with(ids.prop("smoothness", "Smoothness", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0))) + .with(ids.prop("easeHigh", "Ease High", Value::Scalar(0.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0))) + .with(ids.prop("easeLow", "Ease Low", Value::Scalar(0.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0))) + .with(fixed(ids.prop("randomize", "Randomize Order", Value::Bool(false)).with_ui(ParamUi::Checkbox))) .with(ids.prop("randomSeed", "Random Seed", Value::Scalar(0.0))); g.with(ids.prop("start", "Start", Value::Scalar(0.0)).with_ui(ParamUi::Percent)) .with(ids.prop("end", "End", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) @@ -129,26 +188,151 @@ pub fn range_selector(ids: &mut Ids, name: &str) -> PropGroup { .with(adv) } -/// Text animator properties by id (Animate ▸ …). -pub fn text_anim_prop(ids: &mut Ids, kind: &str) -> Option { +/// Wiggly Selector (Mode defaults to Intersect, so it varies the selectors above it). +pub fn wiggly_selector(ids: &mut Ids, name: &str) -> PropGroup { + let mut g = ids.group("wigglySelector", name); + g.kind = GroupKind::Indexed; + g.with(fixed(ids.prop("mode", "Mode", Value::Enum(2)).with_ui(popup(SEL_MODES)))) + .with(ids.prop("maxAmount", "Max Amount", Value::Scalar(100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0))) + .with(ids.prop("minAmount", "Min Amount", Value::Scalar(-100.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 0))) + .with(fixed(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))) + .with(ids.prop("wigglesPerSecond", "Wiggles/Second", Value::Scalar(2.0)).with_ui(slider(0.0, 1000.0, 0.0, 10.0, 1))) + .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0))) + .with(ids.prop("temporalPhase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("lockDimensions", "Lock Dimensions", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("randomSeed", "Random Seed", Value::Scalar(0.0))) +} + +/// Default Expression Selector amount: a ramp across the units. +pub const EXPRESSION_SELECTOR_DEFAULT: &str = "selectorValue * textIndex / textTotal"; + +/// Expression Selector: Amount is computed per unit by its expression (`textIndex`, +/// `textTotal`, `selectorValue` = the selection of the selectors above it) and replaces that +/// selection (there is no Mode: the expression combines with `selectorValue` itself). +pub fn expression_selector(ids: &mut Ids, name: &str) -> PropGroup { + let mut g = ids.group("expressionSelector", name); + g.kind = GroupKind::Indexed; + let mut amount = ids.prop("amount", "Amount", Value::Vec3([100.0, 100.0, 100.0])).with_ui(ParamUi::Percent); + amount.expr = Some(crate::props::Expression { text: EXPRESSION_SELECTOR_DEFAULT.into(), enabled: true }); + g.with(fixed(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(BASED_ON)))).with(amount) +} + +/// A selector by kind (`range`, `wiggly`, `expression`). +pub fn text_selector(ids: &mut Ids, kind: &str, name: &str) -> Option { Some(match kind { - "anchor" => ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point), - "position" => ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point), - "scale" => ids.prop("scale", "Scale", Value::Vec2([100.0, 100.0])).with_ui(ParamUi::Percent), - "skew" => ids.prop("skew", "Skew", Value::Scalar(0.0)), - "rotation" => ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle), - "opacity" => ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(ParamUi::Percent), - "fillColor" => ids.prop("fillColor", "Fill Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color), - "strokeColor" => ids.prop("strokeColor", "Stroke Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color), - "strokeWidth" => ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels), - "tracking" => ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0)), - "lineSpacing" => ids.prop("lineSpacing", "Line Spacing", Value::Vec2([0.0, 0.0])), - "characterOffset" => ids.prop("characterOffset", "Character Offset", Value::Scalar(0.0)), - "blur" => ids.prop("blur", "Blur", Value::Vec2([0.0, 0.0])), + "range" => range_selector(ids, name), + "wiggly" => wiggly_selector(ids, name), + "expression" => expression_selector(ids, name), _ => return None, }) } +/// Animation ▸ Animate Text entries: (`layer.addTextAnimator` property kind, menu label); +/// `"-"` is a separator. +pub const TEXT_ANIMATOR_KINDS: &[(&str, &str)] = &[ + ("anchor", "Anchor Point"), + ("position", "Position"), + ("scale", "Scale"), + ("skew", "Skew"), + ("rotation", "Rotation"), + ("opacity", "Opacity"), + ("transformAll", "All Transform Properties"), + ("-", "-"), + ("lineAnchor", "Line Anchor"), + ("lineSpacing", "Line Spacing"), + ("characterOffset", "Character Offset"), + ("characterValue", "Character Value"), + ("blur", "Blur"), + ("-", "-"), + ("fillColor", "Fill Color: RGB"), + ("fillHue", "Fill Color: Hue"), + ("fillSaturation", "Fill Color: Saturation"), + ("fillBrightness", "Fill Color: Brightness"), + ("fillOpacity", "Fill Color: Opacity"), + ("strokeColor", "Stroke Color: RGB"), + ("strokeHue", "Stroke Color: Hue"), + ("strokeSaturation", "Stroke Color: Saturation"), + ("strokeBrightness", "Stroke Color: Brightness"), + ("strokeOpacity", "Stroke Color: Opacity"), + ("strokeWidth", "Stroke Width"), + ("tracking", "Tracking"), +]; + +fn vec3_prop(ids: &mut Ids, m: &str, name: &str, v: [f64; 3], ui: ParamUi, three_d: bool) -> Property { + let mut p = ids.prop(m, name, Value::Vec3(v)).with_ui(ui); + p.shown_dims = if three_d { 3 } else { 2 }; + p +} + +fn pct_slider(ids: &mut Ids, m: &str, name: &str, v: f64, min: f64) -> Property { + ids.prop(m, name, Value::Scalar(v)).with_ui(slider(min, 100.0, min, 100.0, 0)) +} + +/// The properties one Animate / Add ▸ Property entry adds (AE adds companions: Skew Axis with +/// Skew, Tracking Type with Tracking, Character Alignment / Range with Character Offset, X / Y +/// Rotation with Rotation when per-character 3D is on). `three_d` shows Z components. +pub fn text_anim_props(ids: &mut Ids, kind: &str, three_d: bool) -> Vec { + match kind { + "anchor" => vec![vec3_prop(ids, "anchor", "Anchor Point", [0.0; 3], ParamUi::Point, three_d)], + "position" => vec![vec3_prop(ids, "position", "Position", [0.0; 3], ParamUi::Point, three_d)], + "scale" => vec![vec3_prop(ids, "scale", "Scale", [100.0; 3], ParamUi::Percent, three_d)], + "skew" => vec![ + ids.prop("skew", "Skew", Value::Scalar(0.0)).with_ui(slider(-85.0, 85.0, -70.0, 70.0, 1)), + ids.prop("skewAxis", "Skew Axis", Value::Scalar(0.0)).with_ui(ParamUi::Angle), + ], + "rotation" => { + if three_d { + vec![ + ids.prop("rotationX", "X Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle), + ids.prop("rotationY", "Y Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle), + ids.prop("rotation", "Z Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle), + ] + } else { + vec![ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)] + } + } + "opacity" => vec![pct_slider(ids, "opacity", "Opacity", 100.0, 0.0)], + "transformAll" => ["anchor", "position", "scale", "skew", "rotation", "opacity"].iter().flat_map(|k| text_anim_props(ids, k, three_d)).collect(), + "lineAnchor" => vec![ids.prop("lineAnchor", "Line Anchor", Value::Scalar(0.0)).with_ui(ParamUi::Percent)], + "lineSpacing" => vec![ids.prop("lineSpacing", "Line Spacing", Value::Vec2([0.0, 0.0]))], + "characterOffset" => vec![char_alignment(ids), char_range(ids), ids.prop("characterOffset", "Character Offset", Value::Scalar(0.0))], + "characterValue" => vec![char_range(ids), ids.prop("characterValue", "Character Value", Value::Scalar(0.0))], + "blur" => vec![ids.prop("blur", "Blur", Value::Vec2([0.0, 0.0]))], + "fillColor" => vec![ids.prop("fillColor", "Fill Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)], + "fillHue" => vec![ids.prop("fillHue", "Fill Hue", Value::Scalar(0.0)).with_ui(ParamUi::Angle)], + "fillSaturation" => vec![pct_slider(ids, "fillSaturation", "Fill Saturation", 0.0, -100.0)], + "fillBrightness" => vec![pct_slider(ids, "fillBrightness", "Fill Brightness", 0.0, -100.0)], + "fillOpacity" => vec![pct_slider(ids, "fillOpacity", "Fill Opacity", 100.0, 0.0)], + "strokeColor" => vec![ids.prop("strokeColor", "Stroke Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)], + "strokeHue" => vec![ids.prop("strokeHue", "Stroke Hue", Value::Scalar(0.0)).with_ui(ParamUi::Angle)], + "strokeSaturation" => vec![pct_slider(ids, "strokeSaturation", "Stroke Saturation", 0.0, -100.0)], + "strokeBrightness" => vec![pct_slider(ids, "strokeBrightness", "Stroke Brightness", 0.0, -100.0)], + "strokeOpacity" => vec![pct_slider(ids, "strokeOpacity", "Stroke Opacity", 100.0, 0.0)], + "strokeWidth" => vec![ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)], + "tracking" => { + let tt = fixed(ids.prop("trackingType", "Tracking Type", Value::Enum(0)).with_ui(popup(&["Before & After", "Before", "After"]))); + vec![tt, ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0))] + } + _ => vec![], + } +} + +fn char_alignment(ids: &mut Ids) -> Property { + fixed(ids.prop("characterAlignment", "Character Alignment", Value::Enum(1)).with_ui(popup(&["Left or Top", "Center", "Right or Bottom", "Adjust Kerning"]))) +} + +fn char_range(ids: &mut Ids) -> Property { + fixed(ids.prop("characterRange", "Character Range", Value::Enum(0)).with_ui(popup(&["Preserve Case & Digits", "Full Unicode"]))) +} + +/// The main property of an animator kind (the last of [`text_anim_props`]; kept for callers +/// that add one property). +pub fn text_anim_prop(ids: &mut Ids, kind: &str) -> Option { + let mut v = text_anim_props(ids, kind, false); + v.pop() +} + // ---------------------------------------------------------------- shape layer contents pub fn contents(ids: &mut Ids) -> PropGroup { diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index e1cdd73e..fa721f06 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -172,6 +172,13 @@ fn time_dependent(layer: &Layer) -> bool { { return group_has(c, "wiggle"); } + // Wiggly selectors move with time; Expression selectors evaluate per character (their + // property value alone doesn't capture the result). + if matches!(layer.source, LayerSource::Text) + && let Some(t) = layer.props.sub("text") + { + return group_has(t, "wigglySelector") || group_has(t, "expressionSelector"); + } false } diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 7271d36a..eefe77c3 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -12,6 +12,20 @@ use effectcraft_time::Tick; pub trait ExprHost: Send + Sync { /// Evaluate the expression of `prop` (on `layer`, comp time `t`), given the keyframed value. fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result; + /// Evaluate a text Expression Selector's Amount expression (on `prop`) for one unit: + /// `textIndex` (1-based), `textTotal` and `selectorValue` (percent, per dimension) are + /// defined. Returns the amount in percent per dimension. + fn eval_text_selector( + &self, + _ctx: &EvalCtx, + _layer: &Layer, + _prop: &Property, + _index: usize, + _total: usize, + _selector: [f64; 3], + ) -> Result<[f64; 3], String> { + Err("no expression engine".into()) + } } #[derive(Clone, Copy)] diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 09735733..165ea298 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1,95 +1,213 @@ -//! Text layers: Source Text layout + text animators (range selectors) → per-character outlines. +//! Text layers: Source Text layout → text animators (Range / Wiggly / Expression selectors, +//! combined by mode) → per-character transforms and paint → outlines, optionally placed on a mask +//! path (Path Options) and drawn with More Options (anchor point grouping, fill & stroke order, +//! inter-character blending). Per-character 3D characters are handed to the 3D compositor as +//! separate planes (see [`per_char_planes`]). +use effectcraft_color::BlendMode; use effectcraft_effects::Buf; -use effectcraft_geom::{Mat3, vec2}; -use effectcraft_keyframe::{TextDoc, Value}; +use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3}; +use effectcraft_keyframe::{Justify, TextDoc, Value}; use effectcraft_path::{BezPath, FillRule, StrokeStyle}; use effectcraft_project::{Layer, PropGroup}; use effectcraft_raster::Image; -use effectcraft_text::{TextLayout, layout_doc}; +use effectcraft_text::path_text::{self, PathMeasure}; +use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly}; +use effectcraft_text::{CharGlyph, TextLayout, char_glyph, layout_doc}; use crate::eval::EvalCtx; -/// Evaluated per-character animation state. +/// Evaluated per-character animation state (sums of every animator, weighted by selection). #[derive(Clone, Copy, Debug)] pub struct CharXf { - pub offset: [f64; 2], - pub anchor: [f64; 2], - pub scale: [f64; 2], + pub offset: [f64; 3], + pub anchor: [f64; 3], + pub scale: [f64; 3], + /// Z rotation (2D rotation). pub rotation: f64, + pub rotation_x: f64, + pub rotation_y: f64, pub skew: f64, + pub skew_axis: f64, pub opacity: f64, pub fill: Option<[f32; 4]>, pub fill_k: f32, + /// Hue (degrees), saturation and brightness (percent) offsets. + pub fill_hsb: [f64; 3], + pub fill_opacity: f64, pub stroke: Option<[f32; 4]>, pub stroke_k: f32, + pub stroke_hsb: [f64; 3], + pub stroke_opacity: f64, pub stroke_width: f64, - pub tracking: f64, + /// Tracking (1/1000 em) added before / after the character. + pub track_before: f64, + pub track_after: f64, + /// Line Anchor (percent) and the selection weight it was set with. + pub line_anchor: f64, + pub line_anchor_k: f64, + pub line_spacing: [f64; 2], + pub char_offset: f64, + /// Character Value (code point) and its selection. + pub char_value: Option<(f64, f64)>, + pub char_range: u32, + pub char_align: u32, + pub blur: [f64; 2], } impl Default for CharXf { fn default() -> Self { CharXf { - offset: [0.0; 2], - anchor: [0.0; 2], - scale: [100.0; 2], + offset: [0.0; 3], + anchor: [0.0; 3], + scale: [100.0; 3], rotation: 0.0, + rotation_x: 0.0, + rotation_y: 0.0, skew: 0.0, + skew_axis: 0.0, opacity: 100.0, fill: None, fill_k: 0.0, + fill_hsb: [0.0; 3], + fill_opacity: 100.0, stroke: None, stroke_k: 0.0, + stroke_hsb: [0.0; 3], + stroke_opacity: 100.0, stroke_width: 0.0, - tracking: 0.0, + track_before: 0.0, + track_after: 0.0, + line_anchor: 0.0, + line_anchor_k: 0.0, + line_spacing: [0.0; 2], + char_offset: 0.0, + char_value: None, + char_range: 0, + char_align: 1, + blur: [0.0; 2], } } } -fn shape_value(shape: u32, f: f64) -> f64 { - let f = f.clamp(0.0, 1.0); - match shape { - 1 => f, // ramp up - 2 => 1.0 - f, // ramp down - 3 => 1.0 - (2.0 * f - 1.0).abs(), // triangle - 4 => (1.0 - (2.0 * f - 1.0).powi(2)).sqrt(), // round - 5 => { - let t = 1.0 - (2.0 * f - 1.0).abs(); - t * t * (3.0 - 2.0 * t) - } // smooth - _ => 1.0, +/// The unit a glyph belongs to for a selector's Based On, and the unit count. +fn unit_of(based: BasedOn, g: &CharGlyph, lay: &TextLayout) -> (usize, usize) { + match based { + BasedOn::Characters => (g.char_index, lay.chars), + BasedOn::CharactersExcludingSpaces => (g.char_index_no_space.min(lay.chars_no_space.saturating_sub(1)), lay.chars_no_space), + BasedOn::Words => (g.word_index, lay.words), + BasedOn::Lines => (g.line_index, lay.lines), } } -/// Selection amount (0..1) of unit `i` of `n` for a range selector. -fn range_amount(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, i: usize, n: usize) -> f64 { +fn unit_count(based: BasedOn, lay: &TextLayout) -> usize { + match based { + BasedOn::Characters => lay.chars, + BasedOn::CharactersExcludingSpaces => lay.chars_no_space, + BasedOn::Words => lay.words, + BasedOn::Lines => lay.lines, + } +} + +fn layer_seed(layer: &Layer, seed: f64) -> u64 { + (seed as i64 as u64) ^ layer.id.0.wrapping_mul(0x9E37_79B9) +} + +/// Range Selector parameters at the context time (fractions of `n` units). +fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Range { let adv = sel.sub("advanced"); + let af = |m: &str, d: f64| adv.map(|a| ctx.f(layer, a, m, d)).unwrap_or(d); let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false); - let shape = adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0); - let amount = adv.map(|a| ctx.f(layer, a, "amount", 100.0)).unwrap_or(100.0) / 100.0; - let n = n.max(1) as f64; - let (mut s, mut e, o) = if units_index { - (ctx.f(layer, sel, "start", 0.0) / n, ctx.f(layer, sel, "end", n) / n, ctx.f(layer, sel, "offset", 0.0) / n) - } else { - (ctx.f(layer, sel, "start", 0.0) / 100.0, ctx.f(layer, sel, "end", 100.0) / 100.0, ctx.f(layer, sel, "offset", 0.0) / 100.0) - }; - if s > e { - std::mem::swap(&mut s, &mut e); + let div = if units_index { n.max(1) as f64 } else { 100.0 }; + Range { + start: ctx.f(layer, sel, "start", 0.0) / div, + end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div, + offset: ctx.f(layer, sel, "offset", 0.0) / div, + amount: af("amount", 100.0) / 100.0, + shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)), + smoothness: af("smoothness", 100.0) / 100.0, + ease_high: af("easeHigh", 0.0) / 100.0, + ease_low: af("easeLow", 0.0) / 100.0, } - s += o; - e += o; - let (a, b) = (i as f64 / n, (i as f64 + 1.0) / n); - let v = if shape == 0 { - // Square: the fraction of this unit covered by the range (smooth partial selection). - ((b.min(e) - a.max(s)) / (b - a)).clamp(0.0, 1.0) - } else { - if e - s <= 1e-9 { - return 0.0; +} + +/// Selection of every glyph by an animator's selectors, per dimension. +pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> { + let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default(); + if sels.is_empty() { + // No selectors: every character is fully affected. + return vec![[1.0; 3]; lay.glyphs.len()]; + } + let mut acc: Vec<[f64; 3]> = vec![[0.0; 3]; lay.glyphs.len()]; + let lt = layer.layer_time(ctx.time).seconds(); + for (si, sel) in sels.iter().enumerate() { + let (mode, based) = match sel.match_id.as_str() { + "rangeSelector" => { + let adv = sel.sub("advanced"); + (adv.map(|a| ctx.e(layer, a, "mode")).unwrap_or(0), adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0)) + } + "wigglySelector" => (ctx.e(layer, sel, "mode"), ctx.e(layer, sel, "basedOn")), + _ => (0, ctx.e(layer, sel, "basedOn")), + }; + let mode = Mode::from_index(mode); + let based = BasedOn::from_index(based); + let n = unit_count(based, lay); + let values: Box [f64; 3]> = match sel.match_id.as_str() { + "rangeSelector" => { + let r = range_params(ctx, layer, sel, n); + let adv = sel.sub("advanced"); + let perm = + adv.filter(|a| ctx.b(layer, a, "randomize")).map(|a| selectors::random_order(n, layer_seed(layer, ctx.f(layer, a, "randomSeed", 0.0)))); + Box::new(move |i, n, _| { + let i = perm.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(i); + [selectors::range_value(&r, i, n); 3] + }) + } + "wigglySelector" => { + let w = Wiggly { + max: ctx.f(layer, sel, "maxAmount", 100.0) / 100.0, + min: ctx.f(layer, sel, "minAmount", -100.0) / 100.0, + wiggles_per_second: ctx.f(layer, sel, "wigglesPerSecond", 2.0), + correlation: ctx.f(layer, sel, "correlation", 50.0) / 100.0, + temporal_phase: ctx.f(layer, sel, "temporalPhase", 0.0), + spatial_phase: ctx.f(layer, sel, "spatialPhase", 0.0), + lock_dimensions: ctx.b(layer, sel, "lockDimensions"), + seed: layer_seed(layer, ctx.f(layer, sel, "randomSeed", 0.0)), + }; + Box::new(move |i, _, _| [0, 1, 2].map(|d| selectors::wiggly_value(&w, lt, i, d))) + } + "expressionSelector" => { + let Some(prop) = sel.get("amount") else { continue }; + let stat = ctx.v3(layer, sel, "amount", [100.0; 3]); + Box::new(move |i, n, prev| { + let pv = prev.map(|x| x * 100.0); + // Without an expression engine: the static amount scales the selection. + let fallback = [0, 1, 2].map(|d| stat[d] * prev[d]); + let r = match ctx.expr.filter(|_| prop.has_expression()) { + Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(fallback), + None => fallback, + }; + r.map(|x| (x / 100.0).clamp(-1.0, 1.0)) + }) + } + _ => continue, + }; + // The Expression Selector's result replaces the selection (it reads it as selectorValue). + let replace = sel.match_id == "expressionSelector"; + let mut memo: std::collections::HashMap<(usize, [u64; 3]), [f64; 3]> = std::collections::HashMap::new(); + for (gi, g) in lay.glyphs.iter().enumerate() { + let (i, n) = unit_of(based, g, lay); + let prev = match si { + 0 if replace => [1.0; 3], + 0 => [mode.initial(); 3], + _ => acc[gi], + }; + let key = (i, prev.map(f64::to_bits)); + let v = *memo.entry(key).or_insert_with(|| values(i, n, prev)); + acc[gi] = if replace { v } else { [0, 1, 2].map(|d| mode.combine(prev[d], v[d])) }; } - let c = (a + b) * 0.5; - if c < s || c > e { 0.0 } else { shape_value(shape, (c - s) / (e - s)) } - }; - v * amount + } + acc } /// Evaluate animators for every glyph. @@ -98,80 +216,166 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex let Some(anims) = text.sub("animators") else { return out }; for anim in anims.groups().filter(|g| g.enabled) { let Some(props) = anim.sub("properties") else { continue }; - let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default(); - let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p)); - let pos = get("position").map(|v| v.as_vec2()); - let anchor = get("anchor").map(|v| v.as_vec2()); - let scale = get("scale").map(|v| v.as_vec2()); - let rot = get("rotation").map(|v| v.as_f64()); - let skew = get("skew").map(|v| v.as_f64()); - let op = get("opacity").map(|v| v.as_f64()); - let fill = get("fillColor").map(|v| v.as_color()); - let stroke = get("strokeColor").map(|v| v.as_color()); - let sw = get("strokeWidth").map(|v| v.as_f64()); - let tracking = get("tracking").map(|v| v.as_f64()); - for (gi, g) in lay.glyphs.iter().enumerate() { - let mut k = 1.0; - for sel in &sels { - let based = sel.sub("advanced").map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0); - let (i, n) = match based { - 1 => { - if g.is_space { - k = 0.0; - continue; - } - (g.char_index_no_space, lay.chars_no_space) - } - 2 => (g.word_index, lay.words), - 3 => (g.line_index, lay.lines), - _ => (g.char_index, lay.chars), - }; - k *= range_amount(ctx, layer, sel, i, n); - } - if k <= 0.0 { + let sel = animator_selection(ctx, layer, anim, lay); + let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p).components()); + let get1 = |m: &str| props.get(m).map(|p| ctx.value(layer, p).as_f64()); + let c3 = |v: Vec, d: f64| [v.first().copied().unwrap_or(d), v.get(1).copied().unwrap_or(d), v.get(2).copied().unwrap_or(d)]; + let pos = get("position").map(|v| c3(v, 0.0)); + let anchor = get("anchor").map(|v| c3(v, 0.0)); + let scale = get("scale").map(|v| c3(v, 100.0)); + let rot = get1("rotation"); + let rx = get1("rotationX"); + let ry = get1("rotationY"); + let skew = get1("skew"); + let skew_axis = get1("skewAxis"); + let op = get1("opacity"); + let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color()); + let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color()); + let fill_hsb = [get1("fillHue"), get1("fillSaturation"), get1("fillBrightness")]; + let stroke_hsb = [get1("strokeHue"), get1("strokeSaturation"), get1("strokeBrightness")]; + let fill_op = get1("fillOpacity"); + let stroke_op = get1("strokeOpacity"); + let sw = get1("strokeWidth"); + let tracking = get1("tracking"); + let tracking_type = props.get("trackingType").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(0); + let line_anchor = get1("lineAnchor"); + let line_spacing = props.get("lineSpacing").map(|p| ctx.value(layer, p).as_vec2()); + let char_offset = get1("characterOffset"); + let char_value = get1("characterValue"); + let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum()); + let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum()); + let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2()); + for (gi, k3) in sel.iter().enumerate() { + let k = k3[0]; + if k3.iter().all(|x| *x == 0.0) { continue; } let c = &mut out[gi]; - if let Some(p) = pos { - c.offset[0] += p[0] * k; - c.offset[1] += p[1] * k; - } - if let Some(a) = anchor { - c.anchor[0] += a[0] * k; - c.anchor[1] += a[1] * k; - } - if let Some(s) = scale { - c.scale[0] *= 1.0 + (s[0] / 100.0 - 1.0) * k; - c.scale[1] *= 1.0 + (s[1] / 100.0 - 1.0) * k; + for d in 0..3 { + if let Some(p) = pos { + c.offset[d] += p[d] * k3[d]; + } + if let Some(a) = anchor { + c.anchor[d] += a[d] * k3[d]; + } + if let Some(s) = scale { + c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d]; + } } if let Some(r) = rot { c.rotation += r * k; } + if let Some(r) = rx { + c.rotation_x += r * k; + } + if let Some(r) = ry { + c.rotation_y += r * k; + } if let Some(s) = skew { c.skew += s * k; } + if let Some(a) = skew_axis { + c.skew_axis = a; + } if let Some(o) = op { c.opacity *= 1.0 + (o / 100.0 - 1.0) * k; } if let Some(f) = fill { c.fill = Some(f); - c.fill_k = (c.fill_k + k as f32).min(1.0); + c.fill_k = (c.fill_k + k as f32).clamp(0.0, 1.0); } if let Some(f) = stroke { c.stroke = Some(f); - c.stroke_k = (c.stroke_k + k as f32).min(1.0); + c.stroke_k = (c.stroke_k + k as f32).clamp(0.0, 1.0); + } + for d in 0..3 { + if let Some(v) = fill_hsb[d] { + c.fill_hsb[d] += v * k; + } + if let Some(v) = stroke_hsb[d] { + c.stroke_hsb[d] += v * k; + } + } + if let Some(o) = fill_op { + c.fill_opacity *= 1.0 + (o / 100.0 - 1.0) * k; + } + if let Some(o) = stroke_op { + c.stroke_opacity *= 1.0 + (o / 100.0 - 1.0) * k; } if let Some(w) = sw { c.stroke_width += w * k; } if let Some(t) = tracking { - c.tracking += t * k; + let t = t * k; + match tracking_type { + 1 => c.track_before += t, + 2 => c.track_after += t, + _ => { + c.track_before += t * 0.5; + c.track_after += t * 0.5; + } + } + } + if let Some(a) = line_anchor + && k.abs() >= c.line_anchor_k + { + c.line_anchor = a; + c.line_anchor_k = k.abs(); + } + if let Some(l) = line_spacing { + c.line_spacing[0] += l[0] * k; + c.line_spacing[1] += l[1] * k; + } + if let Some(o) = char_offset { + c.char_offset += o * k; + } + if let Some(v) = char_value { + c.char_value = Some((v, k)); + } + if let Some(r) = char_range { + c.char_range = r; + } + if let Some(a) = char_align { + c.char_align = a; + } + if let Some(b) = blur { + c.blur[0] += (b[0] * k3[0]).abs(); + c.blur[1] += (b[1] * k3[1]).abs(); } } } out } +/// Character Offset / Character Value substitution (None = unchanged). +pub fn substitute(ch: char, x: &CharXf) -> Option { + if ch.is_whitespace() { + return None; + } + let mut code = ch as u32 as f64; + if let Some((v, k)) = x.char_value { + code += (v - code) * k; + } + let off = x.char_offset.round() as i64; + let base = code.round().max(0.0) as u32; + let out = if x.char_range == 1 { + char::from_u32((base as i64 + off).max(0) as u32) + } else { + let c = char::from_u32(base)?; + let wrap = |lo: char, n: i64| char::from_u32((lo as i64 + ((c as i64 - lo as i64 + off).rem_euclid(n))) as u32); + if c.is_ascii_lowercase() { + wrap('a', 26) + } else if c.is_ascii_uppercase() { + wrap('A', 26) + } else if c.is_ascii_digit() { + wrap('0', 10) + } else { + Some(c) + } + }?; + (out != ch).then_some(out) +} + /// The Source Text value at the current time. pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option { let text = layer.props.sub("text")?; @@ -182,108 +386,427 @@ pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option { } } -/// Outlines of all glyphs in layer space with their paint (for text → shapes and hit testing). -pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> { - let Some(doc) = source_text(ctx, layer) else { return vec![] }; - let Some(text) = layer.props.sub("text") else { return vec![] }; - let lay = layout_doc(&doc); - let xfs = char_transforms(ctx, layer, text, &lay); - let mut track = 0.0; - let mut out = Vec::with_capacity(lay.glyphs.len()); - let mut last_line = usize::MAX; - for (g, x) in lay.glyphs.iter().zip(&xfs) { - if g.line_index != last_line { - track = 0.0; - last_line = g.line_index; - } - let pivot = [g.origin.x + g.advance / 2.0 + track, g.origin.y]; - track += x.tracking / 1000.0 * doc.size; - if g.is_space || g.path.elements().is_empty() { - continue; - } - let m = Mat3::translate(vec2(pivot[0] + x.offset[0], pivot[1] + x.offset[1])) - * Mat3::rotate_deg(x.rotation) - * Mat3::skew_deg(-x.skew, 0.0) - * Mat3::scale(vec2(x.scale[0] / 100.0, x.scale[1] / 100.0)) - * Mat3::translate(vec2(-g.advance / 2.0 - x.anchor[0], -x.anchor[1])); - out.push((effectcraft_path::transform(std::slice::from_ref(&g.path), &m).remove(0), *x)); - } - out +/// Per-character 3D is on for this layer (and the layer is 3D). +pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool { + layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d")) } -fn lerp4(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] { - [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k, a[3] + (b[3] - a[3]) * k] +/// One drawn character. +#[derive(Clone, Debug)] +pub struct PlacedGlyph { + /// Outline in character space (the anchor pivot at the origin). + pub local: BezPath, + /// Character space → layer space. + pub m: Mat4, + pub xf: CharXf, + pub fill: [f32; 4], + pub stroke: [f32; 4], + pub stroke_width: f64, +} + +impl PlacedGlyph { + /// Character space → layer space, flattened to 2D (ignores Z). + pub fn m2(&self) -> Mat3 { + let m = &self.m.0; + Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]) + } + /// Outline in layer space (2D). + pub fn path(&self) -> BezPath { + effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0) + } +} + +/// Laid-out, animated text of a layer at the context time. +pub struct TextGeom { + pub doc: TextDoc, + pub glyphs: Vec, + /// Fill & Stroke: 0 per character palette, 1 all fills over all strokes, 2 all strokes over + /// all fills. + pub fill_stroke: u32, + pub blend: BlendMode, +} + +fn adjust_color(base: [f32; 4], rgb: Option<[f32; 4]>, k: f32, hsb: [f64; 3], opacity: f64) -> [f32; 4] { + let mut c = match rgb { + Some(f) => [base[0] + (f[0] - base[0]) * k, base[1] + (f[1] - base[1]) * k, base[2] + (f[2] - base[2]) * k, base[3] + (f[3] - base[3]) * k], + None => base, + }; + if hsb != [0.0; 3] { + let (h, s, v) = effectcraft_color::rgb_to_hsv(c[0], c[1], c[2]); + let h = h + (hsb[0] / 360.0) as f32; + let s = (s + (hsb[1] / 100.0) as f32).clamp(0.0, 1.0); + let v = (v + (hsb[2] / 100.0) as f32).clamp(0.0, 1.0); + let (r, g, b) = effectcraft_color::hsv_to_rgb(h, s, v); + c = [r, g, b, c[3]]; + } + c[3] *= (opacity / 100.0).clamp(0.0, 1.0) as f32; + c +} + +/// The mask path chosen in Path Options (layer space), measured. +fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeasure, bool, bool, f64, f64)> { + let po = text.sub("pathOptions")?; + let k = ctx.e(layer, po, "path") as usize; + if k == 0 { + return None; + } + let mask = layer.masks()?.groups().nth(k - 1)?; + let Some(Value::Path(sp)) = ctx.group_value(layer, mask, "path") else { return None }; + let bp = effectcraft_path::to_kurbo(&sp); + let pm = PathMeasure::new(&bp, ctx.b(layer, po, "reversePath")); + if pm.is_empty() { + return None; + } + let perpendicular = po.get("perpendicular").map(|p| ctx.value(layer, p).as_bool()).unwrap_or(true); + Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0))) +} + +/// Lay out and animate a text layer. +pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { + let doc = source_text(ctx, layer)?; + let text = layer.props.sub("text")?; + let lay = layout_doc(&doc); + let xfs = char_transforms(ctx, layer, text, &lay); + let three = per_char_3d(ctx, layer); + let more = text.sub("moreOptions"); + let grouping = more.map(|m| ctx.e(layer, m, "anchorGrouping")).unwrap_or(0); + let galign = more.map(|m| ctx.v2(layer, m, "groupingAlignment", [0.0; 2])).unwrap_or([0.0; 2]); + let fill_stroke = more.map(|m| ctx.e(layer, m, "fillStroke")).unwrap_or(0); + let blend_idx = more.map(|m| ctx.e(layer, m, "interCharBlend")).unwrap_or(0) as usize; + let blend = effectcraft_project::build::INTER_CHAR_BLEND_MODES.get(blend_idx).and_then(|n| BlendMode::from_name(n)).unwrap_or_default(); + + // Character substitutions (Character Offset / Value). Adjust Kerning re-lays out the text. + let mut subs: Vec> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect(); + let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3); + let (lay, xfs) = if relayout { + let src: Vec = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect(); + let mut chars = src.clone(); + for (g, s) in lay.glyphs.iter().zip(&subs) { + if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) { + *slot = *c; + } + } + let d2 = TextDoc { text: chars.into_iter().collect(), all_caps: false, ..doc.clone() }; + let l2 = layout_doc(&d2); + let by_char: std::collections::HashMap = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect(); + let x2: Vec = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect(); + subs = vec![None; l2.glyphs.len()]; + (l2, x2) + } else { + (lay, xfs) + }; + + // Tracking (before / after each character) and Line Anchor, per line. + let n = lay.glyphs.len(); + let mut shift = vec![0.0f64; n]; + let mut li_start = 0; + while li_start < n { + let line = lay.glyphs[li_start].line_index; + let mut li_end = li_start; + while li_end < n && lay.glyphs[li_end].line_index == line { + li_end += 1; + } + let em = doc.size / 1000.0; + let mut pen = 0.0; + let (mut la, mut la_k) = (0.0, 0.0); + for gi in li_start..li_end { + let x = &xfs[gi]; + pen += x.track_before * em; + shift[gi] = pen; + pen += x.track_after * em; + if x.line_anchor_k > la_k { + la = x.line_anchor; + la_k = x.line_anchor_k; + } + } + let back = pen * la / 100.0; + for s in &mut shift[li_start..li_end] { + *s -= back; + } + li_start = li_end; + } + + // Glyph origins after tracking / line spacing, and pivots by anchor point grouping. + let origin = |gi: usize| -> [f64; 2] { + let g = &lay.glyphs[gi]; + let x = &xfs[gi]; + [g.origin.x + shift[gi] + x.line_spacing[0] * g.line_index as f64, g.origin.y + x.line_spacing[1] * g.line_index as f64] + }; + let group_key = |g: &CharGlyph| -> usize { + match grouping { + 1 => g.word_index * 4096 + g.line_index, + 2 => g.line_index, + 3 => 0, + _ => usize::MAX, + } + }; + let mut extents: std::collections::HashMap = std::collections::HashMap::new(); + if grouping != 0 { + for gi in 0..n { + let g = &lay.glyphs[gi]; + if g.is_space && grouping == 1 { + continue; + } + let o = origin(gi); + let e = extents.entry(group_key(g)).or_insert([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]); + e[0] = e[0].min(o[0]); + e[2] = e[2].max(o[0] + g.advance); + e[1] = e[1].min(o[1]); + e[3] = e[3].max(o[1]); + } + } + + // Path Options. + let on_path = text_path(ctx, layer, text); + let align = match doc.justify { + Justify::Center | Justify::JustifyLastCenter => 1, + Justify::Right | Justify::JustifyLastRight => 2, + _ => 0, + }; + let first_baseline = lay.line_boxes.first().map(|b| b[1]).unwrap_or(0.0); + let mut arc = vec![0.0f64; n]; + if let Some((pm, _, force, fm, lm)) = &on_path { + let mut i = 0; + while i < n { + let line = lay.glyphs[i].line_index; + let mut j = i; + while j < n && lay.glyphs[j].line_index == line { + j += 1; + } + let xs: Vec = (i..j).map(|gi| origin(gi)[0] + lay.glyphs[gi].advance / 2.0).collect(); + let lb = lay.line_boxes.get(line).copied().unwrap_or([0.0; 4]); + let pos = path_text::arc_positions(&xs, [lb[0], lb[0] + lb[2]], align, pm.length(), *fm, *lm, *force); + arc[i..j].copy_from_slice(&pos); + i = j; + } + } + + let base_stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 }; + let mut glyphs = Vec::with_capacity(n); + for gi in 0..n { + let g = &lay.glyphs[gi]; + let mut x = xfs[gi]; + if g.is_space { + continue; + } + if !three { + x.offset[2] = 0.0; + x.anchor[2] = 0.0; + x.scale[2] = 100.0; + x.rotation_x = 0.0; + x.rotation_y = 0.0; + } + let o = origin(gi); + // Outline (substituted characters aligned in the original slot). + let (outline, adv) = match subs[gi] { + Some(c) => { + let (p, a) = char_glyph(&doc, c); + let dx = match x.char_align { + 0 => 0.0, + 2 => g.advance - a, + _ => (g.advance - a) / 2.0, + }; + (kurbo::Affine::translate((dx, 0.0)) * p, g.advance) + } + None => (g.path.clone(), g.advance), + }; + if outline.elements().is_empty() { + continue; + } + let pivot = match extents.get(&group_key(g)) { + Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * doc.size], + _ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * doc.size], + }; + // Outline relative to the pivot. + let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; + let char_m = Mat4::rotate_z(x.rotation) + * Mat4::rotate_y(x.rotation_y) + * Mat4::rotate_x(x.rotation_x) + * Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis)) + * Mat4::scale(Vec3::from(x.scale) / 100.0) + * Mat4::translate(-Vec3::from(x.anchor)); + let m = match &on_path { + Some((pm, perp, ..)) => { + // Position X slides along the path, Y moves off it. + let char_center = o[0] + adv / 2.0; + let s = arc[gi] + (pivot[0] - char_center) + x.offset[0]; + let (p, ang) = path_text::place(pm, s, pivot[1] - first_baseline + x.offset[1], *perp); + Mat4::translate(vec3(p.x, p.y, x.offset[2])) * Mat4::rotate_z(ang) * char_m + } + None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m, + }; + let fill = adjust_color(doc.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity); + let stroke = adjust_color(doc.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity); + glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0) }); + } + Some(TextGeom { doc, glyphs, fill_stroke, blend }) +} + +/// Outlines of all glyphs in layer space with their animation state (for text → shapes, bounds +/// and hit testing). +pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> { + text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default() +} + +/// One paint pass of a glyph: fill (false) or stroke (true). +type Pass = (usize, bool); + +fn passes(geom: &TextGeom) -> Vec { + let doc = &geom.doc; + let n = geom.glyphs.len(); + let fill_on = doc.apply_fill; + let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0; + let mut v = Vec::with_capacity(n * 2); + match geom.fill_stroke { + 1 => { + v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true))); + v.extend((0..n).filter(|_| fill_on).map(|i| (i, false))); + } + 2 => { + v.extend((0..n).filter(|_| fill_on).map(|i| (i, false))); + v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true))); + } + _ => { + for i in 0..n { + let f = fill_on.then_some((i, false)); + let s = has_stroke(i).then_some((i, true)); + if doc.stroke_over_fill { + v.extend(f); + v.extend(s); + } else { + v.extend(s); + v.extend(f); + } + } + } + } + v +} + +/// Rasterize one pass of a glyph (path already in target pixels via `m`) into a premultiplied +/// patch over the target rect, blurred when the character has Blur. +fn raster_pass(g: &PlacedGlyph, stroke: bool, path: &BezPath, m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image { + let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32); + let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m; + let cov = if stroke { + let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + effectcraft_path::stroke_coverage(std::slice::from_ref(path), &st, &mm, w, h) + } else { + effectcraft_path::fill_coverage(std::slice::from_ref(path), &mm, w, h, FillRule::NonZero) + }; + let col = if stroke { g.stroke } else { g.fill }; + let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; + let a = col[3] * op; + let mut img = Image::new(w, h); + for (px, c) in img.data.iter_mut().zip(&cov.data) { + let k = c * a; + if k > 0.0 { + *px = [col[0] * k, col[1] * k, col[2] * k, k]; + } + } + if blur_px[0] > 0.05 || blur_px[1] > 0.05 { + img = effectcraft_raster::gaussian_blur(&img, blur_px[0] / 2.0, blur_px[1] / 2.0, false); + } + img +} + +fn blit(dst: &mut Image, src: &Image, at: [i64; 2], mode: BlendMode) { + for y in 0..src.height as i64 { + let ty = y + at[1]; + if ty < 0 || ty >= dst.height as i64 { + continue; + } + for x in 0..src.width as i64 { + let tx = x + at[0]; + if tx < 0 || tx >= dst.width as i64 { + continue; + } + let s = src.data[(y * src.width as i64 + x) as usize]; + if s[3] <= 0.0 && s[0] == 0.0 && s[1] == 0.0 && s[2] == 0.0 { + continue; + } + let d = &mut dst.data[(ty * dst.width as i64 + tx) as usize]; + *d = effectcraft_color::blend_pixel(mode, *d, s, 0.5); + } + } +} + +/// Draw glyphs (paths mapped by `to_px` from layer space into `img`). +fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option) { + let (w, h) = (img.width as i64, img.height as i64); + for (gi, stroke) in passes(geom) { + if only.is_some_and(|o| o != gi) { + continue; + } + let g = &geom.glyphs[gi]; + if g.xf.opacity <= 0.0 { + continue; + } + let m = *to_px * if only.is_some() { Mat3::IDENTITY } else { g.m2() }; + let path = &g.local; + let Some(b) = effectcraft_path::bounds(std::slice::from_ref(path)) else { continue }; + let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s]; + let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; + let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1)); + let rect = [ + ((r.x0 - pad).floor() as i64).max(0), + ((r.y0 - pad).floor() as i64).max(0), + ((r.x1 + pad).ceil() as i64).min(w), + ((r.y1 + pad).ceil() as i64).min(h), + ]; + if rect[2] <= rect[0] || rect[3] <= rect[1] { + continue; + } + let patch = raster_pass(g, stroke, path, &m, rect, blur); + blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend }); + } } /// Render a text layer into a layer-space buffer at scale `s`. pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { - let Some(doc) = source_text(ctx, layer) else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } }; - let glyphs = glyph_paths(ctx, layer); - let stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 }; + let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s }; + let Some(geom) = text_geom(ctx, layer) else { return empty() }; let mut bounds: Option = None; - for (p, x) in &glyphs { - if let Some(b) = effectcraft_path::bounds(std::slice::from_ref(p)) { - let b = b.inflate(stroke_w + x.stroke_width + 2.0, stroke_w + x.stroke_width + 2.0); + for g in &geom.glyphs { + if let Some(b) = effectcraft_path::bounds(&[g.path()]) { + let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let b = b.inflate(pad, pad); bounds = Some(bounds.map_or(b, |a| a.union(b))); } } - let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } }; + let Some(b) = bounds else { return empty() }; let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; let mut img = Image::new(w, h); let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); - for (p, x) in &glyphs { - let op = (x.opacity / 100.0).clamp(0.0, 1.0) as f32; - if op <= 0.0 { - continue; - } - let Some(gb) = effectcraft_path::bounds(std::slice::from_ref(p)) else { continue }; - let sw = stroke_w + x.stroke_width; - let gb = gb.inflate(sw + 1.0, sw + 1.0); - let x0 = ((gb.x0 * s + offset[0]).floor() as i64).max(0); - let y0 = ((gb.y0 * s + offset[1]).floor() as i64).max(0); - let x1 = ((gb.x1 * s + offset[0]).ceil() as i64).min(w as i64); - let y1 = ((gb.y1 * s + offset[1]).ceil() as i64).min(h as i64); - if x1 <= x0 || y1 <= y0 { - continue; - } - let (gw, gh) = ((x1 - x0) as u32, (y1 - y0) as u32); - let m = Mat3::translate(vec2(-x0 as f64, -y0 as f64)) * base; - let fill_col = x.fill.map(|f| lerp4(doc.fill, f, x.fill_k)).unwrap_or(doc.fill); - let stroke_col = x.stroke.map(|f| lerp4(doc.stroke, f, x.stroke_k)).unwrap_or(doc.stroke); - let mut layers: Vec<(effectcraft_raster::Mask, [f32; 4])> = Vec::new(); - let fill_cov = doc.apply_fill.then(|| (effectcraft_path::fill_coverage(std::slice::from_ref(p), &m, gw, gh, FillRule::NonZero), fill_col)); - let stroke_cov = (sw > 0.0).then(|| { - let st = StrokeStyle { width: sw, join: effectcraft_path::Join::Round, ..Default::default() }; - (effectcraft_path::stroke_coverage(std::slice::from_ref(p), &st, &m, gw, gh), stroke_col) - }); - if doc.stroke_over_fill { - layers.extend(fill_cov); - layers.extend(stroke_cov); - } else { - layers.extend(stroke_cov); - layers.extend(fill_cov); - } - for (cov, col) in layers { - for yy in 0..gh as usize { - let row = (y0 as usize + yy) * w as usize + x0 as usize; - for xx in 0..gw as usize { - let c = cov.data[yy * gw as usize + xx]; - if c <= 0.0 { - continue; - } - let a = c * op * col[3]; - let px = &mut img.data[row + xx]; - let k = 1.0 - a; - px[0] = col[0] * a + px[0] * k; - px[1] = col[1] * a + px[1] * k; - px[2] = col[2] * a + px[2] * k; - px[3] = a + px[3] * k; - } - } - } - } + draw_glyphs(&geom, &mut img, &base, s, None); Buf { img, offset, scale: s } } + +/// Per-character 3D: each character as its own plane — (buffer in character space, character +/// space → layer space matrix). +pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)> { + let Some(geom) = text_geom(ctx, layer) else { return vec![] }; + let mut out = Vec::with_capacity(geom.glyphs.len()); + for (gi, g) in geom.glyphs.iter().enumerate() { + if g.xf.opacity <= 0.0 { + continue; + } + let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue }; + let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let b = b.inflate(pad, pad); + let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096); + let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096); + let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let mut img = Image::new(w, h); + let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); + draw_glyphs(&geom, &mut img, &base, s, Some(gi)); + out.push((Buf { img, offset, scale: s }, g.m)); + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs new file mode 100644 index 00000000..ab334067 --- /dev/null +++ b/crates/render/src/text/tests.rs @@ -0,0 +1,418 @@ +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, PropGroup}; +use effectcraft_text::layout_doc; +use effectcraft_time::{FrameRate, Tick}; + +use super::*; + +fn setup(w: u32, h: u32) -> (Project, ItemId, Comp) { + let mut p = Project::default(); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(4.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +fn text_layer(p: &mut Project, comp: &Comp, doc: TextDoc) -> Layer { + let mut l = build::layer(p, comp, "Text", LayerSource::Text, (comp.width, comp.height), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc)); + l +} + +/// Add an animator with `kinds` and selectors (`range`/`wiggly`/`expression`); returns its index. +fn animator(p: &mut Project, l: &mut Layer, kinds: &[&str], sels: &[&str]) -> usize { + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let props = kinds.iter().flat_map(|k| build::text_anim_props(&mut ids, k, false)).collect(); + let mut g = build::text_animator(&mut ids, "Animator", props); + let sg = g.sub_mut("selectors").unwrap(); + for (i, k) in sels.iter().enumerate() { + if i == 0 && *k == "range" { + continue; + } + if i == 0 { + sg.children.clear(); + } + sg.children.push(build::text_selector(&mut ids, k, k).unwrap().into()); + } + p.next_id = next; + let anims = l.props.group_mut("text/animators").unwrap(); + anims.children.push(g.into()); + anims.children.len() +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; +} + +fn ctx<'a>(p: &'a Project, cid: ItemId, t: f64) -> EvalCtx<'a> { + let comp = p.comp(cid).unwrap(); + EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(t)) +} + +fn selection(p: &Project, cid: ItemId, t: f64, anim: usize) -> Vec { + let c = ctx(p, cid, t); + let l = &p.comp(cid).unwrap().layers[0]; + let doc = source_text(&c, l).unwrap(); + let lay = layout_doc(&doc); + let a = l.props.group(&format!("text/animators/#{anim}")).unwrap(); + animator_selection(&c, l, a, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect() +} + +fn doc(text: &str, size: f64) -> TextDoc { + TextDoc { text: text.into(), size, ..Default::default() } +} + +#[test] +fn range_based_on_words_and_lines() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("AB CD\nEF", 30.0)); + let a = animator(&mut p, &mut l, &["opacity"], &["range"]); + set(&mut l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(2)); + set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0)); + set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(34.0)); + p.comp_mut(cid).unwrap().layers.push(l); + // Glyphs: A B ␠ C D E F (the newline has no glyph). Word 1 only. + let s = selection(&p, cid, 0.0, a); + assert_eq!(s, vec![1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}"); + // Lines: the first half = line 1. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(3)); + set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0)); + let s = selection(&p, cid, 0.0, a); + assert_eq!(s, vec![1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0], "{s:?}"); + // Characters excluding spaces: 6 units, first half = A B C. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(1)); + let s = selection(&p, cid, 0.0, a); + assert_eq!(s[0..2], [1.0, 1.0]); + assert_eq!(s[3], 1.0); + assert_eq!(s[4..], [0.0, 0.0, 0.0]); + // Index units: start 1, end 3 characters. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(0)); + set(l, "text/animators/#1/selectors/#1/advanced/units", Value::Enum(1)); + set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(1.0)); + set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(3.0)); + let s = selection(&p, cid, 0.0, a); + assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}"); +} + +#[test] +fn selector_modes_and_randomize() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCDEFGH", 30.0)); + let a = animator(&mut p, &mut l, &["opacity"], &["range", "range"]); + let s1 = "text/animators/#1/selectors/#1"; + let s2 = "text/animators/#1/selectors/#2"; + set(&mut l, &format!("{s1}/end"), Value::Scalar(50.0)); + set(&mut l, &format!("{s2}/end"), Value::Scalar(25.0)); + set(&mut l, &format!("{s2}/advanced/mode"), Value::Enum(1)); // subtract + p.comp_mut(cid).unwrap().layers.push(l); + assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]); + let modes = [ + (2, vec![1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), + (4, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]), + (5, vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]), + ]; + for (m, want) in modes { + set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s2}/advanced/mode"), Value::Enum(m)); + assert_eq!(selection(&p, cid, 0.0, a), want, "mode {m}"); + } + // A lone Subtract selector inverts. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + if let Some(Node::Group(g)) = l.props.group_mut("text/animators/#1/selectors").map(|g| &mut g.children[1]) { + g.enabled = false; + } + set(l, &format!("{s1}/advanced/mode"), Value::Enum(1)); + assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); + // Randomize Order: still half selected, but not the first half; seed changes it. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, &format!("{s1}/advanced/mode"), Value::Enum(0)); + set(l, &format!("{s1}/advanced/randomize"), Value::Bool(true)); + let r1 = selection(&p, cid, 0.0, a); + assert_eq!(r1.iter().sum::(), 4.0); + assert_ne!(r1, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]); + assert_eq!(r1, selection(&p, cid, 0.0, a)); + set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s1}/advanced/randomSeed"), Value::Scalar(5.0)); + assert_ne!(selection(&p, cid, 0.0, a), r1); +} + +#[test] +fn wiggly_selector_is_deterministic_and_time_dependent() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("WIGGLE", 40.0)); + animator(&mut p, &mut l, &["position"], &["range", "wiggly"]); + set(&mut l, "text/animators/#1/properties/position", Value::Vec3([0.0, 50.0, 0.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let l = &p.comp(cid).unwrap().layers[0]; + let a = glyph_paths(&ctx(&p, cid, 0.5), l); + let b = glyph_paths(&ctx(&p, cid, 0.5), l); + let c = glyph_paths(&ctx(&p, cid, 1.3), l); + let ys = |v: &[(BezPath, CharXf)]| v.iter().map(|g| g.1.offset[1]).collect::>(); + assert_eq!(ys(&a), ys(&b)); + assert_ne!(ys(&a), ys(&c)); + // Within ±50 and not all identical across characters (correlation 50%). + assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9)); + assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6)); + // The layer cache keys a wiggly text layer by time. + let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false); + let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false); + assert_ne!(k1, k2); + // Renders identically twice. + let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5); + let r2 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5); + assert_eq!(r1.data, r2.data); +} + +fn bounds_of(paths: &[(BezPath, CharXf)]) -> kurbo::Rect { + let v: Vec = paths.iter().map(|p| p.0.clone()).collect(); + effectcraft_path::bounds(&v).unwrap() +} + +#[test] +fn tracking_line_anchor_and_character_offset() { + let (mut p, cid, comp) = setup(600, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0)); + animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]); + p.comp_mut(cid).unwrap().layers.push(l); + let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0)); + let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); + // 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after). + assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); + assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); + // Line Anchor 100%: tracking grows to the left. + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/lineAnchor", Value::Scalar(100.0)); + let w2 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); + assert!((w2.x1 - w0.x1 + 10.0).abs() < 1.0, "{} {}", w0.x1, w2.x1); + + // Character Offset 1: "ABCD" draws as "BCDE". + let (mut p, cid, comp) = setup(600, 200); + let mut l = text_layer(&mut p, &comp, doc("AZ9", 40.0)); + animator(&mut p, &mut l, &["characterOffset"], &["range"]); + set(&mut l, "text/animators/#1/properties/characterOffset", Value::Scalar(1.0)); + set(&mut l, "text/animators/#1/properties/characterAlignment", Value::Enum(3)); + p.comp_mut(cid).unwrap().layers.push(l); + let got = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); + let (mut q, qid, qc) = setup(600, 200); + let l2 = text_layer(&mut q, &qc, doc("BA0", 40.0)); + q.comp_mut(qid).unwrap().layers.push(l2); + let want = bounds_of(&glyph_paths(&ctx(&q, qid, 0.0), &q.comp(qid).unwrap().layers[0])); + assert!((got.width() - want.width()).abs() < 0.01 && (got.height() - want.height()).abs() < 0.01, "{got:?} {want:?}"); + assert_eq!(substitute('z', &CharXf { char_offset: 2.0, ..Default::default() }), Some('b')); + assert_eq!(substitute('-', &CharXf { char_offset: 2.0, ..Default::default() }), None); + assert_eq!(substitute('-', &CharXf { char_offset: 2.0, char_range: 1, ..Default::default() }), Some('/')); + assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B')); +} + +#[test] +fn anchor_grouping_line_rotates_around_line_centre() { + let (mut p, cid, comp) = setup(600, 300); + let mut l = text_layer(&mut p, &comp, doc("HHHH", 40.0)); + animator(&mut p, &mut l, &["rotation"], &["range"]); + set(&mut l, "text/animators/#1/properties/rotation", Value::Scalar(90.0)); + p.comp_mut(cid).unwrap().layers.push(l); + let per_char = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + let cx: Vec = per_char.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect(); + assert!(cx.windows(2).all(|w| w[1] > w[0] + 10.0), "characters rotate in place: {cx:?}"); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/anchorGrouping", Value::Enum(2)); + let line = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + let cx: Vec = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect(); + assert!(cx.windows(2).all(|w| (w[1] - w[0]).abs() < 1.0), "the line turns as one: {cx:?}"); + let cy: Vec = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect(); + assert!(cy.windows(2).all(|w| w[1] > w[0] + 10.0), "{cy:?}"); +} + +fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) { + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed }; + let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]); + p.next_id = next; + let masks = l.props.group_mut("masks").unwrap(); + masks.children.push(g.into()); +} + +#[test] +fn path_text_follows_a_mask() { + let (mut p, cid, comp) = setup(400, 400); + let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0)); + // A vertical path going down at x = 100. + mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false); + set(&mut l, "text/pathOptions/path", Value::Enum(1)); + set(&mut l, "text/pathOptions/firstMargin", Value::Scalar(40.0)); + p.comp_mut(cid).unwrap().layers.push(l); + let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + assert_eq!(g.len(), 4); + let bs: Vec = g.iter().map(|x| effectcraft_path::bounds(std::slice::from_ref(&x.0)).unwrap()).collect(); + // Perpendicular: glyphs stand on the path, rotated 90° (to the right of x = 100), in order down. + for b in &bs { + assert!(b.x0 > 99.0 && b.x1 < 100.0 + 30.0, "{b:?}"); + assert!(b.height() < 30.0); + } + assert!(bs.windows(2).all(|w| w[1].center().y > w[0].center().y)); + assert!((bs[0].y0 - 60.0).abs() < 4.0, "first margin 40 from the start at y = 20: {:?}", bs[0]); + // Reverse Path: starts from the bottom. + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/reversePath", Value::Bool(true)); + let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap(); + assert!(b0.center().y > 300.0 && b0.x1 < 101.0, "{b0:?}"); + // Not perpendicular: glyphs stay upright. + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/perpendicular", Value::Bool(false)); + let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap(); + assert!(b0.height() > b0.width(), "{b0:?}"); + // Force alignment spreads to the margins. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/pathOptions/reversePath", Value::Bool(false)); + set(l, "text/pathOptions/forceAlignment", Value::Bool(true)); + set(l, "text/pathOptions/firstMargin", Value::Scalar(0.0)); + let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + let first = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap().center().y; + let last = effectcraft_path::bounds(std::slice::from_ref(&g[3].0)).unwrap().center().y; + assert!(first < 40.0 && last > 360.0, "{first} {last}"); +} + +fn alpha_bbox(img: &crate::Image) -> Option<[i64; 4]> { + let mut b: Option<[i64; 4]> = None; + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.2 { + b = Some(match b { + None => [x, y, x, y], + Some(r) => [r[0].min(x), r[1].min(y), r[2].max(x), r[3].max(y)], + }); + } + } + } + b +} + +#[test] +fn render_key_pixels_fill_hue_blur_and_fill_stroke_order() { + let (mut p, cid, comp) = setup(200, 100); + let mut l = text_layer(&mut p, &comp, TextDoc { text: "I".into(), size: 90.0, fill: [1.0, 0.0, 0.0, 1.0], style: "Bold".into(), ..Default::default() }); + set(&mut l, "transform/position", Value::Vec3([90.0, 80.0, 0.0])); + animator(&mut p, &mut l, &["fillHue"], &["range"]); + set(&mut l, "text/animators/#1/properties/fillHue", Value::Scalar(120.0)); + p.comp_mut(cid).unwrap().layers.push(l); + let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + let b = alpha_bbox(&img).unwrap(); + let c = img.get((b[0] + b[2]) / 2, (b[1] + b[3]) / 2); + assert!(c[1] > 0.9 && c[0] < 0.1, "hue +120° turns red green: {c:?}"); + // Fill Opacity 0 hides the fill. + let mut l = p.comp(cid).unwrap().layers[0].clone(); + animator(&mut p, &mut l, &["fillOpacity"], &["range"]); + set(&mut l, "text/animators/#2/properties/fillOpacity", Value::Scalar(0.0)); + p.comp_mut(cid).unwrap().layers[0] = l; + let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(alpha_bbox(&img).is_none()); + p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.pop(); + // Blur spreads the glyph. + let sharp = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap(); + let mut l = p.comp(cid).unwrap().layers[0].clone(); + animator(&mut p, &mut l, &["blur"], &["range"]); + set(&mut l, "text/animators/#2/properties/blur", Value::Vec2([20.0, 0.0])); + p.comp_mut(cid).unwrap().layers[0] = l; + let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + let soft = img.get(sharp[0] - 4, (sharp[1] + sharp[3]) / 2); + assert!(soft[3] > 0.05, "blur reaches outside the sharp edge: {soft:?}"); + let core = img.get((sharp[0] + sharp[2]) / 2, (sharp[1] + sharp[3]) / 2); + assert!(core[3] < 0.99, "and softens the core: {core:?}"); +} + +#[test] +fn fill_stroke_modes_order_layers() { + // Two overlapping characters ("AA" with negative tracking), stroke on. + let (mut p, cid, comp) = setup(300, 150); + let d = TextDoc { + text: "OO".into(), + size: 100.0, + tracking: -400.0, + fill: [1.0, 0.0, 0.0, 1.0], + stroke: [0.0, 0.0, 1.0, 1.0], + apply_stroke: true, + stroke_width: 6.0, + stroke_over_fill: false, + ..Default::default() + }; + let mut l = text_layer(&mut p, &comp, d); + set(&mut l, "transform/position", Value::Vec3([60.0, 110.0, 0.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let c = ctx(&p, cid, 0.0); + let geom = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap(); + assert_eq!(geom.glyphs.len(), 2); + let order = |fs: u32| { + let mut g = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap(); + g.fill_stroke = fs; + passes(&g) + }; + assert_eq!(order(1), vec![(0, true), (1, true), (0, false), (1, false)]); + assert_eq!(order(2), vec![(0, false), (1, false), (0, true), (1, true)]); + assert_eq!(order(0), vec![(0, true), (0, false), (1, true), (1, false)]); + // Inter-character blending: Multiply darkens where the characters overlap. + let normal = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/interCharBlend", Value::Enum(2)); + let multiply = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + assert_ne!(normal.data, multiply.data); +} + +#[test] +fn per_character_3d_projects_characters() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, TextDoc { text: "MM".into(), size: 80.0, justify: effectcraft_keyframe::Justify::Center, ..Default::default() }); + set(&mut l, "transform/position", Value::Vec3([200.0, 130.0, 0.0])); + l.switches.three_d = true; + animator(&mut p, &mut l, &["position", "rotation"], &["range"]); + p.comp_mut(cid).unwrap().layers.push(l); + let flat = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap(); + // Per-character 3D off: Z position is ignored. + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/position", Value::Vec3([0.0, 0.0, -300.0])); + let off = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap(); + assert_eq!(flat, off); + // On: characters come towards the camera and get bigger. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let t = l.props.sub_mut("text").unwrap(); + t.get_mut("perChar3d").unwrap().value = Value::Bool(true); + let near = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap(); + assert!(near[2] - near[0] > (flat[2] - flat[0]) + 20, "{flat:?} {near:?}"); + assert_eq!(per_char_planes(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0], 1.0).len(), 2); + // Y rotation 90° turns every character edge-on: nothing visible. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/properties/position", Value::Vec3([0.0; 3])); + let mut next = p.next_id; + let ry = build::text_anim_props(&mut Ids(&mut next), "rotation", true).remove(1); + p.next_id = next; + let props: &mut PropGroup = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap(); + props.children.push(ry.into()); + props.get_mut("rotationY").unwrap().value = Value::Scalar(90.0); + let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + let covered = |img: &crate::Image| img.data.iter().filter(|p| p[3] > 0.2).count(); + let vis = covered(&img); + let flat_px = { + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap().get_mut("rotationY").unwrap().value = Value::Scalar(0.0); + covered(&crate::render_frame(&q, cid, Tick::ZERO, 1.0)) + }; + assert!(vis * 10 < flat_px, "edge-on characters are thin slivers: {vis} of {flat_px}"); + // 45°: narrower than flat but visible. + let props = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap(); + props.get_mut("rotationY").unwrap().value = Value::Scalar(60.0); + let b = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap(); + assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}"); +} + +#[test] +fn keyframed_selector_animates() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0)); + animator(&mut p, &mut l, &["opacity"], &["range"]); + l.props.prop_mut("text/animators/#1/selectors/#1/start").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))]; + p.comp_mut(cid).unwrap().layers.push(l); + assert_eq!(selection(&p, cid, 0.0, 1), vec![1.0; 4]); + assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]); + assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 388dab74..53023c50 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -54,7 +54,7 @@ pub(crate) struct Item<'a> { /// Drawn (false for "Casts Shadows: Only" and shadow-only casters outside the run). draw: bool, /// Screen-space track matte factor (output size). - matte: Option>, + matte: Option>>, preserve: bool, /// World → layer (for shadow rays) and the buffer's layer-space bounds. winv: Mat4, @@ -76,6 +76,11 @@ fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); let full = proj * world; let h = full.plane_to_mat3(); + // Edge-on planes project to a line (singular homography): nothing to draw. + let col = |j: usize| (h.0[0][j].powi(2) + h.0[1][j].powi(2) + h.0[2][j].powi(2)).sqrt(); + if h.determinant().abs() <= 1e-10 * col(0) * col(1) * col(2) { + return None; + } let hinv = h.inverse()?; let cv = cam.view * world; let depth = [cv.0[2][0], cv.0[2][1], cv.0[2][3]]; @@ -162,14 +167,40 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { - let mut buf = r.layer_buf(ctx, layer)?; + let buf = r.layer_buf(ctx, layer)?; + prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY) +} + +/// Prepare a layer as planes: one per character for per-character 3D text, else one. +pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { + if crate::text::per_char_3d(ctx, layer) { + return crate::text::per_char_planes(ctx, layer, r.opts.scale) + .into_iter() + .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, Arc::new(buf), m)) + .collect(); + } + prepare(r, ctx, layer, order, out, in_run).into_iter().collect() +} + +/// Prepare a plane showing `buf`, whose space maps into the layer through `local`. +#[allow(clippy::too_many_arguments)] +fn prepare_with<'a>( + r: &Renderer, + ctx: &EvalCtx<'a>, + layer: &'a Layer, + order: usize, + out: (u32, u32), + in_run: bool, + mut buf: Arc, + local: Mat4, +) -> Option> { if buf.img.is_empty() { return None; } let comp = (ctx.comp.width as f64, ctx.comp.height as f64); let s = r.opts.scale; let cam = camera_for(r, ctx); - let world = ctx.world_matrix(layer); + let world = ctx.world_matrix(layer) * local; // Depth of field: blur the layer by its circle of confusion at its centre. if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { let b = buf_bounds(&buf); @@ -203,7 +234,7 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, ord let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, sub.world_matrix(layer), &buf, comp, s, out) + geo(&c, sub.world_matrix(layer) * local, &buf, comp, s, out) }) .collect() }; @@ -234,7 +265,7 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, ord } /// Screen-space track matte factor for a layer, or None. -fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option> { +fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option>> { let tm = layer.track_matte?; let m = ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id)?; let mut img = Image::new(out.0, out.1); @@ -245,15 +276,16 @@ fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Opt solo.track_matte = None; solo.preserve_transparency = false; solo.blend_mode = BlendMode::Normal; - if let Some(it) = prepare(r, ctx, &solo, 0, out, true) { + let its = prepare_all(r, ctx, &solo, 0, out, true); + if !its.is_empty() { let lights = scene_lights(ctx); - composite(&mut img, &[it], &lights, &[], m.id.0 as u32); + composite(&mut img, &its, &lights, &[], m.id.0 as u32); } } else if let Some(mb) = r.layer_buf(ctx, m) { r.place(ctx, m, &mb, &mut img, BlendMode::Normal, ctx.opacity(m) as f32); } } - Some( + Some(Arc::new( img.data .par_iter() .map(|q| { @@ -266,7 +298,7 @@ fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Opt .clamp(0.0, 1.0) }) .collect(), - ) + )) } /// Lights used for shading: none in Draft 3D (which also turns off shadows and depth of field). @@ -292,10 +324,15 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut .par_iter() .enumerate() .filter(|(_, l)| ctx.opacity(l) > 0.0) - .filter_map(|(i, l)| { - let mut it = prepare(r, ctx, l, i, out, true)?; - it.matte = matte_for(r, ctx, l, out); - Some(it) + .flat_map_iter(|(i, l)| { + let mut its = prepare_all(r, ctx, l, i, out, true); + if !its.is_empty() { + let m = matte_for(r, ctx, l, out); + for it in &mut its { + it.matte = m.clone(); + } + } + its }) .collect(); // Shadow casters outside this run (other 3D layers of the comp). @@ -308,7 +345,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut .par_iter() .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) .filter(|l| Material::of(ctx, l).casts_shadows != 0) - .filter_map(|l| prepare(r, ctx, l, 0, out, false)) + .flat_map_iter(|l| prepare_all(r, ctx, l, 0, out, false)) .collect(); } items.sort_by_key(|i| i.order); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 039ce7b9..ab77f44f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -7,6 +7,8 @@ pub mod fonts; pub mod layout; +pub mod path_text; +pub mod selectors; pub mod sfnt; use std::collections::HashMap; @@ -84,6 +86,8 @@ pub struct CharGlyph { pub word_index: usize, pub line_index: usize, pub is_space: bool, + /// The source character (after All Caps). + pub ch: char, pub synth_bold: bool, pub size: f64, } @@ -106,26 +110,7 @@ pub struct TextLayout { /// `box_pos`. pub fn layout_doc(doc: &TextDoc) -> TextLayout { let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }; - let style = TextStyle { - family: doc.font.clone(), - style: doc.style.clone(), - size: doc.size as f32, - tracking: doc.tracking as f32, - baseline_shift: doc.baseline_shift as f32, - faux_bold: doc.faux_bold, - faux_italic: doc.faux_italic, - caps: if doc.small_caps { Caps::Small } else { Caps::Normal }, - ..Default::default() - }; - let align = match doc.justify { - Justify::Left | Justify::JustifyLastLeft => Align::Left, - Justify::Center | Justify::JustifyLastCenter => Align::Center, - Justify::Right | Justify::JustifyLastRight => Align::Right, - Justify::JustifyAll => Align::Justify, - }; - let natural = doc.size * 1.2; - let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0); - let para = ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None }; + let (style, para) = styles(doc); let lay = layout_text(&text, &style, ¶); let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0); // Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0. @@ -190,6 +175,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { word_index: word_of_char.get(ci).copied().unwrap_or(0), line_index: li, is_space: ch.is_whitespace(), + ch, synth_bold: g.synth_bold, size: g.size as f64, }); @@ -201,6 +187,46 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { out } +/// Character and paragraph styles of a Source Text value. +fn styles(doc: &TextDoc) -> (TextStyle, ParagraphStyle) { + let style = TextStyle { + family: doc.font.clone(), + style: doc.style.clone(), + size: doc.size as f32, + tracking: doc.tracking as f32, + baseline_shift: doc.baseline_shift as f32, + faux_bold: doc.faux_bold, + faux_italic: doc.faux_italic, + caps: if doc.small_caps { Caps::Small } else { Caps::Normal }, + ..Default::default() + }; + let align = match doc.justify { + Justify::Left | Justify::JustifyLastLeft => Align::Left, + Justify::Center | Justify::JustifyLastCenter => Align::Center, + Justify::Right | Justify::JustifyLastRight => Align::Right, + Justify::JustifyAll => Align::Justify, + }; + let natural = doc.size * 1.2; + let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0); + (style, ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None }) +} + +/// Outline (origin on the baseline) and advance of a single character in a Source Text's +/// style: used by Character Offset / Character Value substitutions. +pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) { + let (style, _) = styles(doc); + let lay = layout_text(&ch.to_string(), &style, &ParagraphStyle::default()); + let hs = doc.h_scale / 100.0; + let vs = doc.v_scale / 100.0; + let Some(g) = lay.glyphs.first() else { return (BezPath::new(), 0.0) }; + let mut path = glyph_outline(g); + if hs != 1.0 || vs != 1.0 { + path = Affine::scale_non_uniform(hs, vs) * path; + } + let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0) * hs; + (Affine::translate((-(g.x as f64) * hs, 0.0)) * path, adv) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/text/src/path_text.rs b/crates/text/src/path_text.rs new file mode 100644 index 00000000..5c2b4338 --- /dev/null +++ b/crates/text/src/path_text.rs @@ -0,0 +1,181 @@ +//! Text on a path: arc-length parameterisation of a (mask) path and character placement along it +//! (Path Options: Reverse Path, Perpendicular To Path, Force Alignment, First / Last Margin). + +use kurbo::{BezPath, PathEl, Point, Vec2}; + +/// A flattened path measured by arc length. +#[derive(Clone, Debug, Default)] +pub struct PathMeasure { + pts: Vec, + /// Cumulative length at each point. + cum: Vec, + pub closed: bool, +} + +impl PathMeasure { + /// Measure the first subpath of `path` (reversed when `reverse`). + pub fn new(path: &BezPath, reverse: bool) -> PathMeasure { + let mut pts: Vec = Vec::new(); + let mut closed = false; + let mut started = false; + let mut done = false; + kurbo::flatten(path.iter(), 0.05, |el| { + if done { + return; + } + match el { + PathEl::MoveTo(p) => { + if started { + done = true; + } else { + started = true; + pts.push(p); + } + } + PathEl::LineTo(p) => { + if pts.last().is_none_or(|q| q.distance(p) > 1e-9) { + pts.push(p); + } + } + PathEl::ClosePath => { + closed = true; + if let (Some(&a), Some(&b)) = (pts.first(), pts.last()) + && a.distance(b) > 1e-9 + { + pts.push(a); + } + done = true; + } + _ => {} + } + }); + if reverse { + pts.reverse(); + } + let mut cum = Vec::with_capacity(pts.len()); + let mut acc = 0.0; + for (i, p) in pts.iter().enumerate() { + if i > 0 { + acc += pts[i - 1].distance(*p); + } + cum.push(acc); + } + PathMeasure { pts, cum, closed } + } + + pub fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + pub fn is_empty(&self) -> bool { + self.pts.len() < 2 || self.length() <= 1e-9 + } + + /// Point and unit tangent at arc length `s`. Closed paths wrap; open paths continue straight + /// along the end tangents beyond either end. + pub fn at(&self, s: f64) -> (Point, Vec2) { + if self.is_empty() { + return (self.pts.first().copied().unwrap_or(Point::ZERO), Vec2::new(1.0, 0.0)); + } + let len = self.length(); + let s = if self.closed { s.rem_euclid(len) } else { s }; + let k = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { + Ok(i) => i.min(self.pts.len() - 2), + Err(i) => i.clamp(1, self.pts.len() - 1) - 1, + }; + let (a, b) = (self.pts[k], self.pts[k + 1]); + let seg = self.cum[k + 1] - self.cum[k]; + let d = b - a; + let tan = if seg > 0.0 { d / seg } else { Vec2::new(1.0, 0.0) }; + (a + tan * (s - self.cum[k]), tan) + } +} + +/// Where a character pivot lands on the path: the point and the tangent angle in degrees. +pub fn place(pm: &PathMeasure, s: f64, baseline_offset: f64, perpendicular: bool) -> (Point, f64) { + let (p, t) = pm.at(s); + // y down: the normal pointing "below" the baseline is the tangent turned clockwise. + let n = Vec2::new(-t.y, t.x); + let angle = if perpendicular { t.y.atan2(t.x).to_degrees() } else { 0.0 }; + let off = if perpendicular { n * baseline_offset } else { Vec2::new(0.0, baseline_offset) }; + (p + off, angle) +} + +/// Arc-length positions for character pivots of one line, given each pivot's x along the +/// line, the line's [x0, x1] extent and alignment (0 = left, 1 = centre, 2 = right). +pub fn arc_positions(xs: &[f64], line: [f64; 2], align: u8, length: f64, first_margin: f64, last_margin: f64, force: bool) -> Vec { + if xs.is_empty() { + return vec![]; + } + if force { + let (a, b) = (first_margin, length - last_margin); + let (x0, x1) = (xs[0], xs[xs.len() - 1]); + if (x1 - x0).abs() < 1e-9 { + return vec![a; xs.len()]; + } + return xs.iter().map(|x| a + (b - a) * (x - x0) / (x1 - x0)).collect(); + } + match align { + 1 => { + let mid = (first_margin + length - last_margin) * 0.5; + let c = (line[0] + line[1]) * 0.5; + xs.iter().map(|x| mid + x - c).collect() + } + 2 => xs.iter().map(|x| length - last_margin - (line[1] - x)).collect(), + _ => xs.iter().map(|x| first_margin + x - line[0]).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn measures_a_line_and_a_circle() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((100.0, 0.0)); + p.line_to((100.0, 50.0)); + let m = PathMeasure::new(&p, false); + assert!((m.length() - 150.0).abs() < 1e-9); + let (q, t) = m.at(120.0); + assert!((q.x - 100.0).abs() < 1e-9 && (q.y - 20.0).abs() < 1e-9); + assert!((t.y - 1.0).abs() < 1e-9); + // Past the end: straight along the end tangent. + let (q, _) = m.at(160.0); + assert!((q.y - 60.0).abs() < 1e-9); + let r = PathMeasure::new(&p, true); + assert!((r.at(0.0).0.y - 50.0).abs() < 1e-9); + + let c = kurbo::Circle::new((0.0, 0.0), 100.0); + let path = kurbo::Shape::to_path(&c, 0.01); + let m = PathMeasure::new(&path, false); + assert!(m.closed); + assert!((m.length() - 2.0 * std::f64::consts::PI * 100.0).abs() < 0.5, "{}", m.length()); + // Every point is on the circle; wraps. + for k in 0..10 { + let (q, _) = m.at(k as f64 * 97.0); + assert!((q.to_vec2().hypot() - 100.0).abs() < 0.05); + } + } + + #[test] + fn placement_perpendicular_and_margins() { + let mut p = BezPath::new(); + p.move_to((0.0, 100.0)); + p.line_to((0.0, 0.0)); // straight up + let m = PathMeasure::new(&p, false); + let (q, a) = place(&m, 30.0, 0.0, true); + assert!((q.y - 70.0).abs() < 1e-9 && (a + 90.0).abs() < 1e-9); + // Baseline offset below the path (y down) turns with the path: to the right of travel... up = -y → normal = (1, 0). + let (q, _) = place(&m, 30.0, 10.0, true); + assert!((q.x - 10.0).abs() < 1e-9, "{q:?}"); + let (q, a) = place(&m, 30.0, 10.0, false); + assert!(a == 0.0 && (q.y - 80.0).abs() < 1e-9); + let xs = [0.0, 10.0, 20.0]; + assert_eq!(arc_positions(&xs, [0.0, 20.0], 0, 100.0, 5.0, 0.0, false), vec![5.0, 15.0, 25.0]); + assert_eq!(arc_positions(&xs, [0.0, 20.0], 2, 100.0, 0.0, 10.0, false), vec![70.0, 80.0, 90.0]); + assert_eq!(arc_positions(&xs, [0.0, 20.0], 1, 100.0, 0.0, 0.0, false), vec![40.0, 50.0, 60.0]); + assert_eq!(arc_positions(&xs, [0.0, 20.0], 0, 100.0, 10.0, 10.0, true), vec![10.0, 50.0, 90.0]); + } +} diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs new file mode 100644 index 00000000..e3a42246 --- /dev/null +++ b/crates/text/src/selectors.rs @@ -0,0 +1,396 @@ +//! Text selector maths: how much each character / word / line is affected by an animator. +//! +//! Pure functions shared by the renderer and tests. Selection values are fractions (1 = fully +//! selected; negative values invert the animated property's effect, as with a negative Amount). +//! Behaviour follows After Effects' public documentation of the Range, Wiggly and Expression +//! selectors; the formulas themselves are our own. +//! +//! * **Range**: Start / End / Offset over `n` units, Shape (Square with Smoothness, Ramp Up / +//! Down, Triangle, Round, Smooth), Ease High / Low, Amount, Randomize Order. +//! * **Wiggly**: smooth seeded noise in time (Wiggles/Second, Temporal Phase) and across units +//! (Correlation, Spatial Phase), mapped to Min..Max Amount, per dimension unless Lock Dimensions. +//! * **Modes** combine each selector with the selectors above it. + +/// Range selector Shape. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Shape { + #[default] + Square, + RampUp, + RampDown, + Triangle, + Round, + Smooth, +} + +impl Shape { + pub fn from_index(i: u32) -> Shape { + match i { + 1 => Shape::RampUp, + 2 => Shape::RampDown, + 3 => Shape::Triangle, + 4 => Shape::Round, + 5 => Shape::Smooth, + _ => Shape::Square, + } + } +} + +/// How a selector combines with the selectors above it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Mode { + #[default] + Add, + Subtract, + Intersect, + Min, + Max, + Difference, +} + +impl Mode { + pub fn from_index(i: u32) -> Mode { + match i { + 1 => Mode::Subtract, + 2 => Mode::Intersect, + 3 => Mode::Min, + 4 => Mode::Max, + 5 => Mode::Difference, + _ => Mode::Add, + } + } + /// Selection before the first selector: nothing for Add / Max / Difference, everything for + /// Subtract / Intersect / Min (so a lone Subtract selector inverts its range). + pub fn initial(self) -> f64 { + match self { + Mode::Subtract | Mode::Intersect | Mode::Min => 1.0, + Mode::Add | Mode::Max | Mode::Difference => 0.0, + } + } + /// Combine the selection so far (`acc`) with this selector's value `v`. + pub fn combine(self, acc: f64, v: f64) -> f64 { + let r = match self { + Mode::Add => acc + v, + Mode::Subtract => acc - v, + Mode::Intersect => acc * v, + Mode::Min => acc.min(v), + Mode::Max => acc.max(v), + Mode::Difference => (acc - v).abs(), + }; + r.clamp(-1.0, 1.0) + } +} + +/// What a selector counts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BasedOn { + #[default] + Characters, + CharactersExcludingSpaces, + Words, + Lines, +} + +impl BasedOn { + pub fn from_index(i: u32) -> BasedOn { + match i { + 1 => BasedOn::CharactersExcludingSpaces, + 2 => BasedOn::Words, + 3 => BasedOn::Lines, + _ => BasedOn::Characters, + } + } +} + +/// Evaluated Range Selector parameters (fractions of the unit count, not percent). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Range { + pub start: f64, + pub end: f64, + pub offset: f64, + /// -1..1. + pub amount: f64, + pub shape: Shape, + /// 0..1 (Square only): width of the soft edge in units. + pub smoothness: f64, + /// -1..1. + pub ease_high: f64, + pub ease_low: f64, +} + +impl Default for Range { + fn default() -> Self { + Range { start: 0.0, end: 1.0, offset: 0.0, amount: 1.0, shape: Shape::Square, smoothness: 1.0, ease_high: 0.0, ease_low: 0.0 } + } +} + +/// Shape profile over the range (`f` = 0 at Start, 1 at End). +pub fn shape_value(shape: Shape, f: f64) -> f64 { + match shape { + Shape::Square => { + if (0.0..=1.0).contains(&f) { + 1.0 + } else { + 0.0 + } + } + // Ramps hold their end values outside the range (cascades animate Offset across it). + Shape::RampUp => f.clamp(0.0, 1.0), + Shape::RampDown => 1.0 - f.clamp(0.0, 1.0), + _ if !(0.0..=1.0).contains(&f) => 0.0, + Shape::Triangle => 1.0 - (2.0 * f - 1.0).abs(), + Shape::Round => (1.0 - (2.0 * f - 1.0).powi(2)).max(0.0).sqrt(), + Shape::Smooth => { + let t = 1.0 - (2.0 * f - 1.0).abs(); + t * t * (3.0 - 2.0 * t) + } + } +} + +/// Ease High / Low remap of a selection value `v` (0..1): positive values slow the change near +/// the fully selected (high) or unselected (low) end, negative values make it more abrupt. A cubic +/// Bezier `y(t)` with control values `(1 - low)/3` and `1 - (1 - high)/3`; monotonic for -1..1. +pub fn ease(v: f64, high: f64, low: f64) -> f64 { + if high == 0.0 && low == 0.0 { + return v; + } + let t = v.clamp(0.0, 1.0); + let p1 = (1.0 - low.clamp(-1.0, 1.0)) / 3.0; + let p2 = 1.0 - (1.0 - high.clamp(-1.0, 1.0)) / 3.0; + let u = 1.0 - t; + 3.0 * u * u * t * p1 + 3.0 * u * t * t * p2 + t * t * t +} + +/// Selection of unit `i` of `n` (0-based, after any random reordering) by a range selector. +pub fn range_value(r: &Range, i: usize, n: usize) -> f64 { + let n = n.max(1) as f64; + let (mut s, mut e) = (r.start, r.end); + if s > e { + std::mem::swap(&mut s, &mut e); + } + s += r.offset; + e += r.offset; + let unit = 1.0 / n; + let c = (i as f64 + 0.5) * unit; + let v = match r.shape { + Shape::Square => { + let w = r.smoothness.clamp(0.0, 1.0) * unit; + let edge = |d: f64| { + if w > 1e-12 { + (d / w + 0.5).clamp(0.0, 1.0) + } else if d >= 0.0 { + 1.0 + } else { + 0.0 + } + }; + if e - s <= 1e-12 { 0.0 } else { edge(c - s).min(edge(e - c)) } + } + shape => { + if e - s <= 1e-12 { + // Degenerate range: ramps step at the point, others select nothing. + match shape { + Shape::RampUp => f64::from(u8::from(c >= s)), + Shape::RampDown => f64::from(u8::from(c < s)), + _ => 0.0, + } + } else { + ease(shape_value(shape, (c - s) / (e - s)), r.ease_high, r.ease_low) + } + } + }; + v * r.amount +} + +/// SplitMix64 step (deterministic hashing for seeds and noise lattices). +pub fn splitmix(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +fn hash3(a: u64, b: u64, c: u64) -> u64 { + splitmix(splitmix(splitmix(a) ^ b.wrapping_mul(0x632B_E59B_D9B4_E019)) ^ c.wrapping_mul(0x8CB9_2BA7_2F3D_8DD7)) +} + +/// Randomize Order: a seeded permutation `p` with `p[i]` = the position unit `i` takes. +pub fn random_order(n: usize, seed: u64) -> Vec { + let mut p: Vec = (0..n).collect(); + let mut s = splitmix(seed ^ 0x5EED); + for i in (1..n).rev() { + s = splitmix(s); + let j = (s % (i as u64 + 1)) as usize; + p.swap(i, j); + } + p +} + +/// Smooth 1D value noise in -1..1 (quintic interpolation of seeded lattice values). +pub fn noise1(x: f64, seed: u64, channel: u64) -> f64 { + let i = x.floor(); + let f = x - i; + let lat = |k: f64| { + let h = hash3(seed, channel, k as i64 as u64); + (h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + }; + let a = lat(i); + let b = lat(i + 1.0); + let t = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + a + (b - a) * t +} + +/// Evaluated Wiggly Selector parameters. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Wiggly { + /// -1..1. + pub max: f64, + pub min: f64, + pub wiggles_per_second: f64, + /// 0..1: 1 = every unit moves together. + pub correlation: f64, + /// Degrees (one cycle = 360). + pub temporal_phase: f64, + pub spatial_phase: f64, + pub lock_dimensions: bool, + pub seed: u64, +} + +impl Default for Wiggly { + fn default() -> Self { + Wiggly { max: 1.0, min: -1.0, wiggles_per_second: 2.0, correlation: 0.5, temporal_phase: 0.0, spatial_phase: 0.0, lock_dimensions: false, seed: 0 } + } +} + +/// Wiggly selection of unit `i` at layer time `t` seconds for dimension `dim` (0..3). +pub fn wiggly_value(w: &Wiggly, t: f64, i: usize, dim: usize) -> f64 { + let ch = if w.lock_dimensions { 0 } else { dim as u64 }; + let tau = t * w.wiggles_per_second + w.temporal_phase / 360.0; + let shared = noise1(tau, w.seed, ch * 2); + // Each unit's own channel; Spatial Phase slides the pattern along the units. + let along = i as f64 * 0.61 + w.spatial_phase / 360.0; + let own = 0.5 * noise1(tau + along, w.seed ^ 0xA5A5, ch * 2 + 1) + 0.5 * noise1(tau * 1.37 + along * 1.7, w.seed ^ (i as u64 + 1).wrapping_mul(0x9E37), ch); + let c = w.correlation.clamp(0.0, 1.0); + let v = (c * shared + (1.0 - c) * own.clamp(-1.0, 1.0)).clamp(-1.0, 1.0); + w.min + (v + 1.0) * 0.5 * (w.max - w.min) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(start: f64, end: f64, shape: Shape) -> Range { + Range { start, end, shape, ..Default::default() } + } + + #[test] + fn square_partial_coverage_and_smoothness() { + // 4 units, range 0..0.375: units 0 fully, 1 half (smoothness 100%), 2 none. + let rg = r(0.0, 0.375, Shape::Square); + assert!((range_value(&rg, 0, 4) - 1.0).abs() < 1e-9); + assert!((range_value(&rg, 1, 4) - 0.5).abs() < 1e-9); + assert_eq!(range_value(&rg, 2, 4), 0.0); + // Smoothness 0: hard edge at the unit centre. + let hard = Range { smoothness: 0.0, ..rg }; + assert_eq!(range_value(&hard, 1, 4), 1.0); + let hard2 = Range { end: 0.3, ..hard }; + assert_eq!(range_value(&hard2, 1, 4), 0.0); + // Everything selected by default. + assert!((0..7).all(|i| (range_value(&Range::default(), i, 7) - 1.0).abs() < 1e-9)); + } + + #[test] + fn shapes_profiles() { + let n = 5; + let up: Vec = (0..n).map(|i| range_value(&r(0.0, 1.0, Shape::RampUp), i, n)).collect(); + assert!(up.windows(2).all(|w| w[1] > w[0])); + assert!((up[0] - 0.1).abs() < 1e-9 && (up[4] - 0.9).abs() < 1e-9); + let down: Vec = (0..n).map(|i| range_value(&r(0.0, 1.0, Shape::RampDown), i, n)).collect(); + assert!((down[0] - 0.9).abs() < 1e-9); + let tri: Vec = (0..n).map(|i| range_value(&r(0.0, 1.0, Shape::Triangle), i, n)).collect(); + assert!((tri[2] - 1.0).abs() < 1e-9 && (tri[0] - tri[4]).abs() < 1e-9 && tri[0] < 0.5); + let round = range_value(&r(0.0, 1.0, Shape::Round), 0, n); + assert!(round > tri[0], "round bulges above triangle"); + let smooth = range_value(&r(0.0, 1.0, Shape::Smooth), 0, n); + assert!(smooth < tri[0], "smooth eases below triangle"); + // Ramp Up holds after the end, ramp down before the start; others are 0 outside. + assert_eq!(range_value(&r(0.0, 0.2, Shape::RampUp), 4, n), 1.0); + assert_eq!(range_value(&r(0.8, 1.0, Shape::RampDown), 0, n), 1.0); + assert_eq!(range_value(&r(0.0, 0.2, Shape::Triangle), 4, n), 0.0); + // Offset moves the range. + let off = Range { offset: 0.6, ..r(0.0, 0.2, Shape::Square) }; + assert_eq!(range_value(&off, 0, n), 0.0); + assert!((range_value(&off, 3, n) - 1.0).abs() < 1e-9); + // Amount scales (and may invert). + let neg = Range { amount: -0.5, ..Range::default() }; + assert!((range_value(&neg, 2, n) + 0.5).abs() < 1e-9); + } + + #[test] + fn ease_high_low() { + assert_eq!(ease(0.3, 0.0, 0.0), 0.3); + assert!((ease(0.0, 1.0, 1.0)).abs() < 1e-12 && (ease(1.0, 1.0, 1.0) - 1.0).abs() < 1e-12); + // Ease Low 100% flattens the start, Ease High 100% the top. + assert!(ease(0.1, 0.0, 1.0) < 0.1); + assert!(ease(0.9, 1.0, 0.0) > 0.9); + // Negative makes it abrupt. + assert!(ease(0.1, 0.0, -1.0) > 0.1); + // Monotonic. + let v: Vec = (0..=20).map(|i| ease(i as f64 / 20.0, -1.0, 1.0)).collect(); + assert!(v.windows(2).all(|w| w[1] >= w[0])); + } + + #[test] + fn modes_combine() { + assert_eq!(Mode::Add.combine(0.5, 0.75), 1.0); + assert_eq!(Mode::Subtract.combine(1.0, 0.25), 0.75); + assert_eq!(Mode::Intersect.combine(0.5, 0.5), 0.25); + assert_eq!(Mode::Min.combine(0.3, 0.6), 0.3); + assert_eq!(Mode::Max.combine(0.3, 0.6), 0.6); + assert_eq!(Mode::Difference.combine(0.3, 1.0), 0.7); + // A lone Subtract inverts. + assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0); + assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0); + assert_eq!(Mode::from_index(5), Mode::Difference); + } + + #[test] + fn randomize_is_a_seeded_permutation() { + let a = random_order(20, 7); + let mut s = a.clone(); + s.sort_unstable(); + assert_eq!(s, (0..20).collect::>()); + assert_eq!(a, random_order(20, 7)); + assert_ne!(a, random_order(20, 8)); + assert_ne!(a, (0..20).collect::>()); + } + + #[test] + fn wiggly_is_deterministic_smooth_and_bounded() { + let w = Wiggly { seed: 3, ..Default::default() }; + let a = wiggly_value(&w, 1.234, 5, 0); + assert_eq!(a, wiggly_value(&w, 1.234, 5, 0)); + for i in 0..50 { + let v = wiggly_value(&w, i as f64 * 0.137, i % 7, i % 3); + assert!((-1.0..=1.0).contains(&v)); + } + // Smooth in time. + let d = (wiggly_value(&w, 1.0, 2, 0) - wiggly_value(&w, 1.001, 2, 0)).abs(); + assert!(d < 0.02, "{d}"); + // Correlation 100%: all units identical; 0%: they differ. + let c1 = Wiggly { correlation: 1.0, ..w }; + assert_eq!(wiggly_value(&c1, 0.7, 0, 0), wiggly_value(&c1, 0.7, 9, 0)); + let c0 = Wiggly { correlation: 0.0, ..w }; + assert_ne!(wiggly_value(&c0, 0.7, 0, 0), wiggly_value(&c0, 0.7, 9, 0)); + // Lock Dimensions: same value in every dimension. + let lk = Wiggly { lock_dimensions: true, ..w }; + assert_eq!(wiggly_value(&lk, 0.7, 3, 0), wiggly_value(&lk, 0.7, 3, 1)); + assert_ne!(wiggly_value(&w, 0.7, 3, 0), wiggly_value(&w, 0.7, 3, 1)); + // Min/Max map the range. + let pos = Wiggly { min: 0.0, max: 1.0, ..w }; + assert!((0..30).all(|i| wiggly_value(&pos, i as f64 * 0.3, i, 0) >= 0.0)); + // Changes over time. + assert_ne!(wiggly_value(&w, 0.0, 1, 0), wiggly_value(&w, 0.8, 1, 0)); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 7e41c82d..cb963580 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -24,22 +24,15 @@ type Actions = Vec<(String, serde_json::Value)>; const ROW_H: f32 = 22.0; const PAD: f32 = 12.0; -/// Text Animation › Add Animator entries: (menu label, `layer.addTextAnimator` property). -const ANIMATORS: &[(&str, &str)] = &[ - ("Anchor Point", "anchor"), - ("Position", "position"), - ("Scale", "scale"), - ("Skew", "skew"), - ("Rotation", "rotation"), - ("Opacity", "opacity"), - ("Fill Color", "fillColor"), - ("Stroke Color", "strokeColor"), - ("Stroke Width", "strokeWidth"), - ("Tracking", "tracking"), - ("Line Spacing", "lineSpacing"), - ("Character Offset", "characterOffset"), - ("Blur", "blur"), -]; +/// Text Animation › Add Animator entries: (menu label, `layer.addTextAnimator` property; `""` +/// for separators). The engine's list (`build::TEXT_ANIMATOR_KINDS`) after Enable Per-character 3D. +fn animator_entries(per_char: bool) -> Vec<(String, &'static str)> { + let mut v = vec![((if per_char { "Disable Per-character 3D" } else { "Enable Per-character 3D" }).to_string(), "perChar3d"), ("-".to_string(), "")]; + for (k, l) in effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS { + v.push((l.to_string(), if *k == "-" { "" } else { k })); + } + v +} /// AE's seven Paragraph alignment buttons: (justify, `layer.setText` key). const ALIGNS: [(Justify, &str); 7] = [ @@ -134,9 +127,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.clicked() { widgets::open_popup(ui, pop); } - let labels: Vec = ANIMATORS.iter().map(|(l, _)| l.to_string()).collect(); + let per_char = layer.props.prop("text/perChar3d").is_some_and(|q| q.value.as_bool()); + let entries = animator_entries(per_char); + let labels: Vec = entries.iter().map(|(l, _)| l.clone()).collect(); if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None) { - actions.push(("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": ANIMATORS[i].1}))); + match entries[i].1 { + "" => {} + "perChar3d" => actions.push(("layer.enablePerChar3D".into(), json!({"layer": layer.id.0, "enabled": !per_char}))), + k => actions.push(("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k}))), + } + } + // Our own presets (original work, see presets/text_animators.json). + let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84.0, 24.0)); + let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click()); + p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); + p.text(pr.center(), Align2::CENTER_CENTER, "Presets", Tokens::ui(12.0), t.text); + app.auto.add("properties.textPresets", pr, "Text Animation Presets"); + let ppop = egui::Id::new("props-text-presets-pop"); + if presp.clicked() { + widgets::open_popup(ui, ppop); + } + let presets = effectcraft_engine::text_presets(); + let names: Vec = presets.iter().map(|(_, n)| n.clone()).collect(); + if let Some(i) = widgets::popup_menu(ui, ppop, pr.left_bottom(), &names, None) { + actions.push(("layer.applyTextPreset".into(), json!({"layer": layer.id.0, "preset": presets[i].0}))); } y += 24.0; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d2ae6ed3..62b0a5a7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -190,6 +190,14 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool { if matches!(p.ui, ParamUi::Hidden) { return false; } + // Path Options show only the Path popup until a mask path is chosen. + if let Some(po) = layer.props.group("text/pathOptions") + && p.match_id != "path" + && po.get(&p.match_id).is_some_and(|q| q.uid == p.uid) + && po.get("path").is_some_and(|q| q.value.as_enum() == 0 && q.keys.is_empty()) + { + return false; + } if p.three_d_only && !layer.is_3d() { return false; } @@ -393,6 +401,10 @@ fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> boo fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { for c in &g.children { match c { + // Text animators list their selectors and properties directly (as in AE). + Node::Group(sg) if g.match_id == "animator" && matches!(sg.match_id.as_str(), "selectors" | "properties") => { + push_group(rows, l, sg, depth, open); + } Node::Group(sg) => { let o = open.contains(&sg.uid); let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled); @@ -1021,6 +1033,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); } lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); + text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); // Mask mode + inverted inline. if let Some(g) = layer.props.find_group(*uid) && let GroupKind::Mask { mode, inverted, .. } = g.kind @@ -1517,6 +1530,77 @@ fn walk_groups(g: &PropGroup, out: &mut Vec) { } } +/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus). +#[allow(clippy::too_many_arguments)] +fn text_anim_popups( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + uid: u64, + x: f32, + cy: f32, + actions: &mut Vec<(String, serde_json::Value)>, +) { + use effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS; + if !matches!(layer.source, LayerSource::Text) { + return; + } + let Some(g) = layer.props.find_group(uid) else { return }; + let t = app.tokens; + let (label, key) = match g.match_id.as_str() { + "text" if layer.props.sub("text").is_some_and(|tg| tg.uid == uid) => ("Animate:", "animate"), + "animator" => ("Add:", "add"), + _ => return, + }; + p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); + let br = Rect::from_center_size(pos2(x + 6.0 + if key == "add" { 34.0 } else { 62.0 }, cy), vec2(16.0, 16.0)); + let resp = ui.interact(br, egui::Id::new(("tl-textanim", uid)), Sense::click()).on_hover_text(if key == "add" { + "Add property or selector" + } else { + "Animate text" + }); + icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.group.{uid}.{key}"), br, label); + let pop = egui::Id::new(("tl-textanim-pop", uid)); + if resp.clicked() { + widgets::open_popup(ui, pop); + } + let per_char = layer.props.prop("text/perChar3d").is_some_and(|q| q.value.as_bool()); + let mut opts: Vec = Vec::new(); + let mut acts: Vec<(String, serde_json::Value)> = Vec::new(); + if key == "animate" { + opts.push(if per_char { "Disable Per-character 3D" } else { "Enable Per-character 3D" }.into()); + acts.push(("layer.enablePerChar3D".into(), json!({"layer": layer.id.0, "enabled": !per_char}))); + opts.push("-".into()); + acts.push((String::new(), json!(null))); + for (k, l) in TEXT_ANIMATOR_KINDS { + opts.push(l.to_string()); + acts.push(if *k == "-" { (String::new(), json!(null)) } else { ("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k})) }); + } + } else { + for (kind, l) in [("range", "Selector: Range"), ("wiggly", "Selector: Wiggly"), ("expression", "Selector: Expression")] { + opts.push(l.into()); + acts.push(("layer.addTextSelector".into(), json!({"layer": layer.id.0, "animator": uid, "kind": kind}))); + } + opts.push("-".into()); + acts.push((String::new(), json!(null))); + for (k, l) in TEXT_ANIMATOR_KINDS { + opts.push(if *k == "-" { "-".into() } else { format!("Property: {l}") }); + acts.push(if *k == "-" { + (String::new(), json!(null)) + } else { + ("layer.addTextAnimatorProperty".into(), json!({"layer": layer.id.0, "animator": uid, "property": k})) + }); + } + } + if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None) + && let Some((id, params)) = acts.get(i).filter(|a| !a.0.is_empty()) + { + actions.push((id.clone(), params.clone())); + } +} + fn kind_name(k: MatteKind) -> &'static str { match k { MatteKind::Alpha => "alpha", @@ -1695,7 +1779,14 @@ fn value_editor( app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); } Value::Enum(i) => { - if let ParamUi::Popup { options } = &prop.ui { + // Text Path Options ▸ Path lists the layer's masks. + let mask_opts: Option> = layer + .props + .prop("text/pathOptions/path") + .filter(|q| q.uid == uid) + .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect()); + let ui_opts = mask_opts.map(|options| ParamUi::Popup { options }); + if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) { let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0)); let label = options.get(*i as usize).cloned().unwrap_or_default(); let pop = egui::Id::new(("epop", uid));