Merge branch 'worktree-agent-ac3707915045f047d'
This commit is contained in:
commit
b85dc6757b
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,7 @@ dependencies = [
|
|||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-render",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
@ -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<Value> {
|
|||
|
||||
// ---------------------------------------------------------------- text animators
|
||||
|
||||
/// Animation ▸ Add Text Selector ▸ Range / Wiggly / Expression (see `layer.addTextSelector`).
|
||||
fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Uid> = 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<Value> {
|
||||
|
|
@ -392,7 +364,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -816,32 +816,6 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.addTextAnimator")?;
|
||||
let props: Vec<String> = match p.get("properties").or(p.get("property")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::String(x)) => vec![x.clone()],
|
||||
_ => vec!["opacity".into()],
|
||||
};
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,12 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// 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"], "{}"),
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
||||
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// (id, name) of every preset.
|
||||
pub fn preset_names() -> Vec<(String, String)> {
|
||||
presets().iter().filter_map(|p| Some((p.get("id")?.as_str()?.to_string(), p.get("name")?.as_str()?.to_string()))).collect()
|
||||
}
|
||||
|
||||
/// Enabled when a text layer is selected.
|
||||
fn has_text_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let comp = s.active_comp().ok_or("no composition is open")?;
|
||||
if s.state.selected_layers.iter().any(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a text layer first".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Enabled when a selected text layer has an animator.
|
||||
fn has_animator(s: &Session) -> std::result::Result<(), String> {
|
||||
has_text_layer(s)?;
|
||||
let comp = s.active_comp().ok_or("no composition is open")?;
|
||||
let any =
|
||||
s.state.selected_layers.iter().filter_map(|id| comp.layer(*id)).any(|l| l.props.group("text/animators").is_some_and(|a| a.groups().next().is_some()));
|
||||
if any { Ok(()) } else { Err("add a text animator first".into()) }
|
||||
}
|
||||
|
||||
fn per_char_on(l: &Layer) -> bool {
|
||||
l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool())
|
||||
}
|
||||
|
||||
/// The animator a command targets: `animator` (uid or 1-based index), else the animator holding
|
||||
/// a selected property, else the last one.
|
||||
fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
|
||||
let anims = l.props.group("text/animators").ok_or_else(|| bad(cmd, "not a text layer"))?;
|
||||
let list: Vec<&PropGroup> = anims.groups().collect();
|
||||
if let Some(v) = p.get("animator") {
|
||||
let n = v.as_u64().ok_or_else(|| bad(cmd, "`animator` must be a uid or 1-based index"))?;
|
||||
if let Some(g) = list.iter().find(|g| g.uid == n) {
|
||||
return Ok(g.uid);
|
||||
}
|
||||
return list.get((n as usize).wrapping_sub(1)).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no animator {n}")));
|
||||
}
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
if *lid != l.id {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = list.iter().find(|g| g.uid == *uid || g.find(*uid).is_some() || g.find_group(*uid).is_some()) {
|
||||
return Ok(g.uid);
|
||||
}
|
||||
}
|
||||
list.last().map(|g| g.uid).ok_or_else(|| bad(cmd, "the layer has no text animators"))
|
||||
}
|
||||
|
||||
/// Add the properties of `kind` to an animator's Properties group (skipping ones it has).
|
||||
fn add_props(ids: &mut Ids, anim: &mut PropGroup, kind: &str, three_d: bool) -> usize {
|
||||
let list = build::text_anim_props(ids, kind, three_d);
|
||||
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<String> {
|
||||
match p.get("properties").or(p.get("property")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::String(x)) => vec![x.clone()],
|
||||
_ => vec!["opacity".into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.addTextAnimator";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let kinds = kinds_p(p);
|
||||
if kinds.iter().any(|k| k == "perChar3d") {
|
||||
return enable_per_char(s, &json!({"layer": lid.0, "comp": cid.0}));
|
||||
}
|
||||
let uid = s.edit("Add Text Animator", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let three = per_char_on(l);
|
||||
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1));
|
||||
let mut g = build::text_animator(&mut ids, &name, vec![]);
|
||||
let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum();
|
||||
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<Value> {
|
||||
const C: &str = "layer.addTextAnimatorProperty";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let kinds = kinds_p(p);
|
||||
if let Some(k) = kinds.iter().find(|k| !build::TEXT_ANIMATOR_KINDS.iter().any(|(id, _)| id == k)) {
|
||||
return Err(bad(C, format!("unknown animator property {k}")));
|
||||
}
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
||||
let auid = animator_uid(s, &layer, p, C)?;
|
||||
s.edit("Add Text Animator Property", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let three = per_char_on(l);
|
||||
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
|
||||
for k in &kinds {
|
||||
add_props(&mut ids, anim, k, three);
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"animator": auid}))
|
||||
}
|
||||
|
||||
pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.addTextSelector";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let kind = str_p(p, "kind").unwrap_or("range").to_ascii_lowercase();
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
||||
let auid = animator_uid(s, &layer, p, C)?;
|
||||
let uid = s.edit("Add Text Selector", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
|
||||
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<Value> {
|
||||
const C: &str = "layer.enablePerChar3D";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).map(per_char_on).unwrap_or(false);
|
||||
let on = b_p(p, "enabled").unwrap_or(if p.get("toggle").and_then(Value::as_bool) == Some(true) { !cur } else { true });
|
||||
s.edit(if on { "Enable Per-character 3D" } else { "Disable Per-character 3D" }, None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
if !matches!(l.source, LayerSource::Text) {
|
||||
return Err(bad(C, "not a text layer"));
|
||||
}
|
||||
if l.props.prop("text/perChar3d").is_none() {
|
||||
let mut pc = ids.prop("perChar3d", "Per-character 3D", KV::Bool(false)).with_ui(effectcraft_project::ParamUi::Hidden);
|
||||
pc.static_only = true;
|
||||
if let Some(t) = l.props.sub_mut("text") {
|
||||
t.children.insert(1, pc.into());
|
||||
}
|
||||
}
|
||||
if let Some(pr) = l.props.prop_mut("text/perChar3d") {
|
||||
pr.value = KV::Bool(on);
|
||||
}
|
||||
// Per-character 3D makes the layer 3D (as in AE).
|
||||
if on {
|
||||
l.switches.three_d = true;
|
||||
}
|
||||
set_animators_3d(&mut ids, l, on);
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"perChar3d": on}))
|
||||
}
|
||||
|
||||
/// JSON → property value shaped like `like`.
|
||||
fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
Some(match v {
|
||||
Value::Bool(b) => match like {
|
||||
KV::Bool(_) => KV::Bool(*b),
|
||||
_ => KV::Scalar(if *b { 1.0 } else { 0.0 }),
|
||||
},
|
||||
Value::Number(n) => {
|
||||
let x = n.as_f64()?;
|
||||
match like {
|
||||
KV::Enum(_) => KV::Enum(x.max(0.0) as u32),
|
||||
KV::Bool(_) => KV::Bool(x != 0.0),
|
||||
_ => KV::Scalar(x),
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
let c: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
like.with_components(&c)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_path(g: &mut PropGroup, path: &str, v: &Value, keys: Option<&Value>, at: Tick, ease: bool) {
|
||||
let Some(pr) = g.prop_mut(path) else { return };
|
||||
if let Some(val) = json_value(v, &pr.value) {
|
||||
pr.value = val;
|
||||
}
|
||||
let Some(Value::Array(list)) = keys else { return };
|
||||
let mut ks = Vec::new();
|
||||
for k in list {
|
||||
let (Some(t), Some(val)) = (k.get(0).and_then(Value::as_f64), k.get(1).and_then(|x| json_value(x, &pr.value))) else { continue };
|
||||
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
|
||||
if pr.hold_only {
|
||||
key = key.hold();
|
||||
} else if ease {
|
||||
key = key.eased();
|
||||
}
|
||||
ks.push(key);
|
||||
}
|
||||
if !ks.is_empty() {
|
||||
pr.value = ks[0].value.clone();
|
||||
pr.keys = ks;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.applyTextPreset";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
|
||||
let preset = presets()
|
||||
.into_iter()
|
||||
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
|
||||
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
|
||||
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
|
||||
let now = s.time();
|
||||
let uids = s.edit(&format!("Apply Text Preset {name}"), None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let at = l.layer_time(now);
|
||||
let three = per_char_on(l);
|
||||
let anims_json = preset.get("animators").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
|
||||
let mut uids = Vec::new();
|
||||
for a in &anims_json {
|
||||
let aname = a.get("name").and_then(Value::as_str).unwrap_or("Animator");
|
||||
let mut g = build::text_animator(&mut ids, aname, vec![]);
|
||||
let ease = a.get("ease").and_then(Value::as_bool).unwrap_or(false);
|
||||
if let Some(Value::Object(props)) = a.get("properties") {
|
||||
for (m, v) in props {
|
||||
add_props(&mut ids, &mut g, m, three);
|
||||
let keys = a.get("keys").and_then(|k| k.get(m));
|
||||
if let Some(pg) = g.sub_mut("properties") {
|
||||
set_path(pg, m, v, keys, at, ease);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, sj) in a.get("selectors").and_then(Value::as_array).cloned().unwrap_or_default().iter().enumerate() {
|
||||
let kind = sj.get("kind").and_then(Value::as_str).unwrap_or("range");
|
||||
let Some(sels) = g.sub_mut("selectors") else { continue };
|
||||
if !(i == 0 && kind == "range") {
|
||||
let label = match kind {
|
||||
"wiggly" => "Wiggly Selector 1",
|
||||
"expression" => "Expression Selector 1",
|
||||
_ => "Range Selector 2",
|
||||
};
|
||||
if let Some(sg) = build::text_selector(&mut ids, kind, label) {
|
||||
sels.children.push(sg.into());
|
||||
}
|
||||
}
|
||||
let Some(Node::Group(sel)) = sels.children.last_mut() else { continue };
|
||||
let ease = sj.get("ease").and_then(Value::as_bool).unwrap_or(false);
|
||||
if let Some(Value::Object(set)) = sj.get("set") {
|
||||
for (path, v) in set {
|
||||
set_path(sel, path, v, None, at, ease);
|
||||
}
|
||||
}
|
||||
if let Some(Value::Object(keys)) = sj.get("keys") {
|
||||
for (path, list) in keys {
|
||||
let cur = sel.prop(path).map(|p| p.value.clone());
|
||||
if let Some(cur) = cur {
|
||||
let v = serde_json::to_value(cur.components().first().copied().unwrap_or(0.0)).unwrap_or_default();
|
||||
set_path(sel, path, &v, Some(list), at, ease);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
uids.push(g.uid);
|
||||
anims.children.push(g.into());
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(uids)
|
||||
})?;
|
||||
Ok(json!({"animators": uids}))
|
||||
}
|
||||
|
||||
fn list_presets(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect()))
|
||||
}
|
||||
|
||||
fn list_kinds(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect()))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"layer.addTextAnimator",
|
||||
"Animate Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|perChar3d], name?}",
|
||||
has_text_layer,
|
||||
add_animator
|
||||
),
|
||||
cmd!(
|
||||
"layer.addTextAnimatorProperty",
|
||||
"Add Property",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index, property: (see layer.addTextAnimator)}",
|
||||
has_animator,
|
||||
add_animator_prop
|
||||
),
|
||||
cmd!(
|
||||
"layer.addTextSelector",
|
||||
"Add Text Selector",
|
||||
[],
|
||||
None,
|
||||
"{layer?, animator?: uid|index, kind: range|wiggly|expression}",
|
||||
has_animator,
|
||||
add_selector
|
||||
),
|
||||
cmd!("layer.enablePerChar3D", "Enable Per-character 3D", [], None, "{layer?, enabled?: bool, toggle?: bool}", has_text_layer, enable_per_char),
|
||||
cmd!(
|
||||
"layer.applyTextPreset",
|
||||
"Apply Text Animation Preset",
|
||||
[],
|
||||
None,
|
||||
"{layer?, preset: typewriter|fadeUpCharacters|bounceInWords|trackingIn|scramble|blurIn|jitter|dropInLines}",
|
||||
has_text_layer,
|
||||
apply_preset
|
||||
),
|
||||
query!("text.presets", "Text Animation Presets", "{}", list_presets),
|
||||
query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds),
|
||||
]
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<String> = 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<String> = 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");
|
||||
}
|
||||
|
|
@ -25,3 +25,4 @@ boa_engine = { workspace = true, features = ["js"] }
|
|||
[dev-dependencies]
|
||||
effectcraft-color = { workspace = true }
|
||||
effectcraft-effects = { workspace = true }
|
||||
effectcraft-text = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
||||
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<runtime::Out, String> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>()
|
||||
};
|
||||
// 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]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Property>) -> 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<Property> {
|
||||
/// 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<PropGroup> {
|
||||
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<Property> {
|
||||
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<Property> {
|
||||
let mut v = text_anim_props(ids, kind, false);
|
||||
v.pop()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shape layer contents
|
||||
|
||||
pub fn contents(ids: &mut Ids) -> PropGroup {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Value, String>;
|
||||
/// 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)]
|
||||
|
|
|
|||
|
|
@ -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<dyn Fn(usize, usize, [f64; 3]) -> [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<f64>, 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<char> {
|
||||
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<TextDoc> {
|
||||
let text = layer.props.sub("text")?;
|
||||
|
|
@ -182,108 +386,427 @@ pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option<TextDoc> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<PlacedGlyph>,
|
||||
/// 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<TextGeom> {
|
||||
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<Option<char>> = 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<char> = (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<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
|
||||
let x2: Vec<CharXf> = 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<usize, [f64; 4]> = 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<f64> = (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<Pass> {
|
||||
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<usize>) {
|
||||
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<kurbo::Rect> = 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;
|
||||
|
|
|
|||
|
|
@ -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<f64> {
|
||||
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::<f64>(), 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::<Vec<_>>();
|
||||
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<BezPath> = 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<f64> = 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<f64> = 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<f64> = 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<kurbo::Rect> = 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]);
|
||||
}
|
||||
|
|
@ -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<Vec<f32>>,
|
||||
matte: Option<Arc<Vec<f32>>>,
|
||||
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<Item<'a>> {
|
||||
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<Item<'a>> {
|
||||
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<Buf>,
|
||||
local: Mat4,
|
||||
) -> Option<Item<'a>> {
|
||||
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<Vec<f32>> {
|
||||
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Arc<Vec<f32>>> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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<Point>,
|
||||
/// Cumulative length at each point.
|
||||
cum: Vec<f64>,
|
||||
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<Point> = 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<f64> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<usize> {
|
||||
let mut p: Vec<usize> = (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<f64> = (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<f64> = (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<f64> = (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<f64> = (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::<Vec<_>>());
|
||||
assert_eq!(a, random_order(20, 7));
|
||||
assert_ne!(a, random_order(20, 8));
|
||||
assert_ne!(a, (0..20).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> = 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<String> = 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<String> = 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u64>, f: &dyn Fn(&Property) -> boo
|
|||
fn push_group(rows: &mut Vec<Row>, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet<u64>) {
|
||||
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<u64>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<String> = 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<Vec<String>> = 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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue