From 7537eea85cbf6323ea635fff8b27b679528651aa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:24:05 -0400 Subject: [PATCH] M13.5: Animate Text > Variable Font Axes Text: text.animatorFontAxes (replaces the stub) lists the layer font's variation axes (skrifa) or adds an animator property that offsets one axis (weighted by the selectors, from the axis default); the renderer redraws each character's outline at its design-space position (cached per location). The Timeline's Animate: menu lists the font's axes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/stubs.rs | 1 - crates/engine/src/commands/text_anim.rs | 63 ++++++++++++ crates/engine/src/tests_tail.rs | 43 ++++++++ crates/render/src/text.rs | 47 ++++++++- crates/text/src/lib.rs | 23 ++++- crates/text/src/variable.rs | 125 ++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 13 +++ 7 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 crates/text/src/variable.rs diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 49160762..aba46a76 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -46,7 +46,6 @@ pub fn specs() -> Vec { // Keyframes / text / tracking. stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"), stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"), - stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"), stub!("track.camera", "Track Camera", ["Animation"], "{}"), // Workspaces and panels that don't exist yet. stub!("window.unavailablePanel", "Panel", [], "{panel}"), diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 51367da9..fefb8d08 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -372,6 +372,60 @@ fn list_presets(_: &mut Session, _: &Value) -> Result { Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect())) } +/// Animation ▸ Animate Text ▸ Variable Font Axes: the axes of the layer's font, or (with `axis`) +/// a new animator — or a property on `animator` — that offsets that axis (user units, weighted +/// by the selectors, added to the axis default). +fn font_axes(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.animatorFontAxes"; + let (cid, lid) = layer_p(s, p, C)?; + let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; + let Some(KV::Text(doc)) = layer.props.prop("text/sourceText").map(|pr| pr.value_at(layer.layer_time(s.time()))) else { + return Err(bad(C, "not a text layer")); + }; + let face = effectcraft_text::layout_doc(&doc).glyphs.first().map(|g| g.face).unwrap_or_else(|| effectcraft_text::resolve(&doc.font, &doc.style).face); + let axes = effectcraft_text::variable::font_axes(face); + let list = || json!(axes.iter().map(|a| json!({"tag": a.tag, "name": a.name, "min": a.min, "default": a.default, "max": a.max})).collect::>()); + let Some(want) = str_p(p, "axis") else { return Ok(json!({"font": doc.font, "axes": list()})) }; + if axes.is_empty() { + return Err(bad(C, format!("{} {} has no variation axes (not a variable font)", doc.font, doc.style))); + } + let axis = axes + .iter() + .find(|a| a.tag == want || a.name.eq_ignore_ascii_case(want)) + .ok_or_else(|| bad(C, format!("no axis `{want}`; the font has {}", list())))? + .clone(); + let target = match p.get("animator") { + Some(_) => Some(animator_uid(s, &layer, p, C)?), + None => None, + }; + let uid = s.edit("Add Variable Font Axis", None, |proj, _| { + let mut next = proj.next_id; + let mut ids = Ids(&mut next); + let prop = ids + .prop(&format!("{}{}", effectcraft_render::text::AXIS_PREFIX, axis.tag.trim_end()), &axis.name, KV::Scalar(0.0)) + .with_ui(effectcraft_project::ParamUi::Number); + let l = layer_mut(proj, cid, lid)?; + let uid = match target { + Some(a) => { + let g = l.props.find_group_mut(a).and_then(|g| g.sub_mut("properties")).ok_or_else(|| bad(C, "no such animator"))?; + g.children.push(prop.into()); + a + } + None => { + let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?; + let name = format!("Animator {}", anims.children.len() + 1); + let g = build::text_animator(&mut ids, &name, vec![prop]); + let uid = g.uid; + anims.children.push(g.into()); + uid + } + }; + proj.next_id = next; + Ok(uid) + })?; + Ok(json!({"animator": uid, "axis": axis.tag})) +} + fn list_kinds(_: &mut Session, _: &Value) -> Result { Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect())) } @@ -415,6 +469,15 @@ pub fn specs() -> Vec { has_text_layer, apply_preset ), + cmd!( + "text.animatorFontAxes", + "Variable Font Axes", + ["Animation", "Animate Text"], + None, + "{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}", + has_text_layer, + font_axes + ), query!("text.presets", "Text Animation Presets", "{}", list_presets), query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds), ] diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 4bd2bc5a..da6c6c3a 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -283,6 +283,49 @@ fn tate_chu_yoko_via_set_text_with_undo_and_serde() { assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().vertical_roman_upright); } +#[test] +fn variable_font_axes_animator() { + let mut s = comp(400, 200); + let t = s.execute("layer.newText", json!({"text": "HH", "size": 60})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [t]})).unwrap(); + assert!(s.is_enabled("text.animatorFontAxes")); + // Inter (bundled) is static. + assert_eq!(s.execute("text.animatorFontAxes", json!({})).unwrap()["axes"], json!([])); + assert!(s.execute("text.animatorFontAxes", json!({"axis": "wght"})).is_err()); + let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else { + eprintln!("no variable font installed: animation check skipped"); + return; + }; + let info = effectcraft_text::fonts::face(face).info.clone(); + s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap(); + let axis = axes.iter().find(|a| a.max > a.min).unwrap().clone(); + let listed = s.execute("text.animatorFontAxes", json!({})).unwrap(); + if listed["axes"].as_array().is_none_or(|a| a.is_empty()) { + eprintln!("the variable face isn't reachable by family/style: skipped"); + return; + } + let tag = listed["axes"][0]["tag"].as_str().unwrap().to_string(); + let r = s.execute("text.animatorFontAxes", json!({"axis": tag})).unwrap(); + let anim = r["animator"].as_u64().unwrap(); + let area = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time()); + let l = comp.layer(LayerId(t)).unwrap(); + effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::() + }; + let before = area(&s); + let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone(); + let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid; + let range = (axis.max - axis.min) as f64; + s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap(); + let after = area(&s); + assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag); + s.execute("edit.undo", json!({})).unwrap(); + assert!((area(&s) - before).abs() < 1e-6); + let _ = info; +} + #[test] fn camera_focus_commands_undo_and_serde() { let mut s = comp(400, 300); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index a5399dc6..ca37a951 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -53,8 +53,33 @@ pub struct CharXf { pub char_range: u32, pub char_align: u32, pub blur: [f64; 2], + /// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used. + pub axes: [([u8; 4], f64); 4], + pub n_axes: u8, } +impl CharXf { + /// The variable-font axis offsets that move the outline. + pub fn axis_deltas(&self) -> Vec<(String, f32)> { + self.axes[..self.n_axes as usize] + .iter() + .filter(|(_, d)| d.abs() > 1e-6) + .map(|(t, d)| (String::from_utf8_lossy(t).trim_end().to_string(), *d as f32)) + .collect() + } + fn add_axis(&mut self, tag: [u8; 4], d: f64) { + if let Some(a) = self.axes[..self.n_axes as usize].iter_mut().find(|a| a.0 == tag) { + a.1 += d; + } else if (self.n_axes as usize) < self.axes.len() { + self.axes[self.n_axes as usize] = (tag, d); + self.n_axes += 1; + } + } +} + +/// Animator property match id of a Variable Font Axes property (`axis_wght`). +pub const AXIS_PREFIX: &str = "axis_"; + impl Default for CharXf { fn default() -> Self { CharXf { @@ -86,6 +111,8 @@ impl Default for CharXf { char_range: 0, char_align: 1, blur: [0.0; 2], + axes: [([0; 4], 0.0); 4], + n_axes: 0, } } } @@ -245,6 +272,17 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex 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()); + let axes: Vec<([u8; 4], f64)> = props + .props() + .filter_map(|p| { + let t = p.match_id.strip_prefix(AXIS_PREFIX)?; + let mut tag = [b' '; 4]; + for (i, c) in t.bytes().take(4).enumerate() { + tag[i] = c; + } + Some((tag, ctx.value(layer, p).as_f64())) + }) + .collect(); for (gi, k3) in sel.iter().enumerate() { let k = k3[0]; if k3.iter().all(|x| *x == 0.0) { @@ -342,6 +380,9 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex c.blur[0] += (b[0] * k3[0]).abs(); c.blur[1] += (b[1] * k3[1]).abs(); } + for (tag, v) in &axes { + c.add_axis(*tag, v * k); + } } } out @@ -641,7 +682,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; (kurbo::Affine::translate((dx, 0.0)) * p, g.advance) } - None => (g.path.clone(), g.advance), + // Variable Font Axes: the outline redrawn at the animated design-space position. + None => match x.axis_deltas() { + d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), g.advance), + _ => (g.path.clone(), g.advance), + }, }; if outline.elements().is_empty() { continue; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 229b7636..26a659bd 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -10,6 +10,7 @@ pub mod layout; pub mod path_text; pub mod selectors; pub mod sfnt; +pub mod variable; pub use kurbo; @@ -24,7 +25,7 @@ use skrifa::instance::{LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; use skrifa::{GlyphId, MetadataProvider}; -struct Pen(BezPath); +pub(crate) struct Pen(pub(crate) BezPath); impl OutlinePen for Pen { fn move_to(&mut self, x: f32, y: f32) { @@ -70,10 +71,15 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { /// horizontal / vertical scale and faux italic. pub fn glyph_outline(g: &Glyph) -> BezPath { let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() }; + glyph_affine(g) * (*units).clone() +} + +/// Font units → the glyph's outline in pixels (size, horizontal / vertical scale, faux italic). +pub fn glyph_affine(g: &Glyph) -> Affine { let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64; let slant = if g.synth_italic { 0.21 } else { 0.0 }; let (hs, vs) = (g.h_scale as f64, g.v_scale as f64); - Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone() + Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) } /// One character of laid-out text. @@ -97,6 +103,10 @@ pub struct CharGlyph { pub size: f64, /// Index into [`TextLayout::styles`]. pub run: usize, + /// Face and glyph id, and font units → `path` (for variable-font redraws). + pub face: FaceId, + pub gid: u32, + pub outline_xf: Affine, } #[derive(Clone, Debug, Default)] @@ -266,6 +276,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { let st = styles.get(g.run); let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src }; let mut path = glyph_outline(g); + let mut outline_xf = glyph_affine(g); let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width); let advance = (next_x - g.x) as f64; let mut origin = to_layer * Point::new(g.x as f64 - shift, g.y as f64); @@ -293,10 +304,13 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { // Keep the character upright around its centre. let c = Point::new(advance / 2.0, -0.35 * g.size as f64); let rc = ROT90 * c; - path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path; + let t = Affine::translate((rc.x - c.x, rc.y - c.y)); + path = t * path; + outline_xf = t * outline_xf; } else { // Roman characters turn on their side with the column. path = ROT90 * path; + outline_xf = ROT90 * outline_xf; } } if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) { @@ -318,6 +332,9 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { synth_bold: g.synth_bold, size: g.size as f64, run: g.run, + face: g.face, + gid: g.id, + outline_xf, }); } } diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs new file mode 100644 index 00000000..95fdec18 --- /dev/null +++ b/crates/text/src/variable.rs @@ -0,0 +1,125 @@ +//! Variable fonts: a face's variation axes and glyph outlines at a point of its design space +//! (Animate Text ▸ Variable Font Axes). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use kurbo::BezPath; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::DrawSettings; +use skrifa::{GlyphId, MetadataProvider, Tag}; + +use crate::fonts::{self, FaceId}; +use crate::{CharGlyph, Pen}; + +/// One variation axis of a face, in user units. +#[derive(Clone, Debug, PartialEq)] +pub struct FontAxis { + /// Four-letter tag (`wght`, `wdth`, `opsz`, `slnt`, `ital` or a custom one). + pub tag: String, + pub name: String, + pub min: f32, + pub default: f32, + pub max: f32, +} + +/// The variation axes of a face (empty for static fonts). +pub fn font_axes(face: FaceId) -> Vec { + let f = fonts::face(face); + let Some(font) = f.font() else { return Vec::new() }; + font.axes() + .iter() + .map(|a| { + let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string(); + let name = font.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone()); + FontAxis { tag, name, min: a.min_value(), default: a.default_value(), max: a.max_value() } + }) + .collect() +} + +/// A tag string as a [`Tag`] (padded with spaces, at most four characters). +fn tag_of(s: &str) -> Tag { + let mut b = [b' '; 4]; + for (i, c) in s.bytes().take(4).enumerate() { + b[i] = c; + } + Tag::new(&b) +} + +type Key = (FaceId, u32, Vec<(String, i32)>); + +/// A glyph's outline in font units at the given axis values (user units, unspecified axes at +/// their defaults). +pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Option> { + if coords.is_empty() { + return crate::outline_units(face, gid); + } + static C: OnceLock>>>> = OnceLock::new(); + let c = C.get_or_init(Default::default); + // Quantise to 1/100 of a unit so animation frames reuse outlines. + let key: Key = (face, gid, coords.iter().map(|(t, v)| (t.clone(), (v * 100.0).round() as i32)).collect()); + if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return v.clone(); + } + let f = fonts::face(face); + let v = f.font().and_then(|font| { + let loc = font.axes().location(coords.iter().map(|(t, v)| (tag_of(t), *v))); + let g = font.outline_glyphs().get(GlyphId::new(gid))?; + let mut pen = Pen(BezPath::new()); + g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::from(&loc)), &mut pen).ok()?; + Some(Arc::new(pen.0)) + }); + let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 20_000 { + m.clear(); + } + m.insert(key, v.clone()); + v +} + +/// A laid-out character's outline (same placement as [`CharGlyph::path`]) with its face's axes +/// moved by `deltas` (tag, user-unit offset from the axis default). +pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option { + let axes = font_axes(g.face); + let coords: Vec<(String, f32)> = + deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (a.default + d).clamp(a.min, a.max)))).collect(); + if coords.is_empty() { + return None; + } + let units = outline_units_at(g.face, g.gid, &coords)?; + Some(g.outline_xf * (*units).clone()) +} + +/// The first installed face with variation axes (system fonts are scanned), for tests and +/// demos: (face, its axes). +pub fn find_variable_face() -> Option<(FaceId, Vec)> { + fonts::scan_system(); + (0..fonts::all_faces().len()).map(|f| (f, font_axes(f))).find(|(_, a)| a.iter().any(|x| x.max > x.min)) +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn static_fonts_have_no_axes_and_variable_outlines_move() { + let inter = crate::resolve("Inter", "Regular").face; + assert!(font_axes(inter).is_empty()); + // A variable system font, when one is installed (macOS ships several): its outline + // changes along an axis. Skipped on machines without one. + let Some((face, axes)) = find_variable_face() else { + eprintln!("no variable font installed: outline check skipped"); + return; + }; + let a = axes.iter().find(|x| x.max > x.min).unwrap(); + let gid = fonts::face(face).glyph('H').or_else(|| fonts::face(face).glyph('A')).unwrap_or(1); + let lo = outline_units_at(face, gid, &[(a.tag.clone(), a.min)]).unwrap(); + let hi = outline_units_at(face, gid, &[(a.tag.clone(), a.max)]).unwrap(); + assert_ne!(lo.area(), hi.area(), "{} {}..{}", a.tag, a.min, a.max); + // The default location is the static outline. + let d = outline_units_at(face, gid, &[(a.tag.clone(), a.default)]).unwrap(); + let s = crate::outline_units(face, gid).unwrap(); + assert!((d.area() - s.area()).abs() < 1e-3); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f9f6cd9c..46f0097a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2139,6 +2139,19 @@ fn text_anim_popups( opts.push(l.to_string()); acts.push(if *k == "-" { (String::new(), json!(null)) } else { ("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k})) }); } + // Variable Font Axes of the layer's font. + if let Some(effectcraft_engine::keyframe::Value::Text(doc)) = layer.props.prop("text/sourceText").map(|p| p.value.clone()) { + let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face; + let axes = effectcraft_engine::text::variable::font_axes(face); + if !axes.is_empty() { + opts.push("-".into()); + acts.push((String::new(), json!(null))); + } + for a in axes { + opts.push(format!("Variable Font Axes: {}", a.name)); + acts.push(("text.animatorFontAxes".into(), json!({"layer": layer.id.0, "axis": a.tag}))); + } + } } else { for (kind, l) in [("range", "Selector: Range"), ("wiggly", "Selector: Wiggly"), ("expression", "Selector: Expression")] { opts.push(l.into());