Files
effectcraft/crates/render/src/text/tests.rs
T
Brandon ThomasandClaude Opus 5.5 6baff9f5fe Integration fixes for the community batch
- Footage sources: the cancellable wrapper (#784) forwards frame_with (#752 float curves)
  and problem (#887); problem's default lives on the trait.
- Media: a called-off decode (#784) is not a footage problem (#887).
- Effect Controls rename (#953): Escape cancels (egui drops focus on Escape the same
  frame), the Rename item is translated; regression test.
- i18n (#873): drop the /out-i18n/ ignore rule and the note about an uncommitted script.
- Tests: text range selector caps (#926) re-borrow the layer; the RAM preview test (#793)
  sets the comp's own Resolution (#462).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:27:59 -04:00

553 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 range_start_end_and_offset_are_capped() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
let a = animator(&mut p, &mut l, &["opacity"], &["range"]);
set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0));
// End 300% is capped at 100% (Offset -150% at -100%): the range is -100%..0% and selects nothing.
// Uncapped it would be -150%..150% and select everything.
set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(300.0));
set(&mut l, "text/animators/#1/selectors/#1/offset", Value::Scalar(-150.0));
p.comp_mut(cid).unwrap().layers.push(l);
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
// Start -200% is capped at 0%: with Offset 100% the range is 100%..150% and selects nothing.
// Uncapped it would be -100%..150% and select everything.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(-200.0));
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0));
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(100.0));
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(0.0));
assert_eq!(selection(&p, cid, 0.0, a), vec![1.0, 1.0, 0.0, 0.0]);
}
#[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, false);
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false, 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'));
}
/// Without a Line Anchor, tracking grows each line from where its paragraph's alignment pins it:
/// left text grows right, centred text around its centre, right text to the left (#146).
#[test]
fn tracking_grows_from_each_paragraphs_alignment() {
use effectcraft_keyframe::{Justify, ParaStyle};
let tracked = |d: TextDoc, amount: f64| {
let (mut p, cid, comp) = setup(600, 300);
let mut l = text_layer(&mut p, &comp, d);
animator(&mut p, &mut l, &["tracking"], &["range"]);
set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount));
p.comp_mut(cid).unwrap().layers.push(l);
glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])
};
// 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after.
for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] {
let d = TextDoc { justify, ..doc("ABCD", 40.0) };
let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0)));
let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1];
assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]);
}
// Each paragraph by its own alignment: a left paragraph above a right-aligned one.
let mut d = doc("ABCD\nABCD", 40.0);
let left = d.base_para();
d.set_paras(vec![left.clone(), ParaStyle { justify: Justify::Right, ..left }]);
let lines = |paths: Vec<(BezPath, CharXf)>| -> [kurbo::Rect; 2] {
let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0);
[bounds_of(&a), bounds_of(&b)]
};
let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0)));
assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0);
assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1);
}
#[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, feather: Vec::new() };
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]);
}
#[test]
fn caret_follows_animators_and_path_text() {
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
let a = animator(&mut p, &mut l, &["position"], &["range"]);
set(&mut l, &format!("text/animators/#{a}/properties/position"), Value::Vec3([0.0, 50.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
assert_eq!(maps.len(), 5);
for (ci, m) in maps.iter().enumerate() {
let (top, _) = lay.caret(ci);
let q = m.apply(vec2(top.x, top.y));
assert!((q.x - top.x).abs() < 1e-6 && (q.y - top.y - 50.0).abs() < 1e-6, "caret {ci}: {top:?} → {q:?}");
}
// On a vertical path (x = 100, running down) the caret turns with the characters and sits
// on the path.
let (mut p, cid, comp) = setup(400, 400);
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
set(&mut l, "text/pathOptions/path", Value::Enum(1));
p.comp_mut(cid).unwrap().layers.push(l);
let c = ctx(&p, cid, 0.0);
let ly = &p.comp(cid).unwrap().layers[0];
let lay = layout_doc(&source_text(&c, ly).unwrap());
let maps = caret_maps(&c, ly, lay.chars);
let (t, b) = lay.caret(2);
let (qt, qb) = (maps[2].apply(vec2(t.x, t.y)), maps[2].apply(vec2(b.x, b.y)));
assert!((qt.y - qb.y).abs() < 1.0 && (qt.x - qb.x).abs() > 20.0, "caret across the path: {qt:?} {qb:?}");
assert!(qb.x < 100.5 && qt.x > 100.0, "baseline end on the path, top to its right: {qt:?} {qb:?}");
let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::<Vec<_>>();
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
}
/// Glyphs that touch or overlap are drawn as one outline, as After Effects draws a line of text:
/// no light seam where their anti-aliased edges meet (the crossbars of "ff", #415).
#[test]
fn touching_glyphs_join_without_a_seam() {
use kurbo::Shape;
let glyph = |x0: f64, x1: f64, fill: [f32; 4], reversed: bool| {
let path = kurbo::Rect::new(x0, 4.0, x1, 16.0).to_path(0.1);
PlacedGlyph {
local: if reversed { path.reverse_subpaths() } else { path },
m: Mat4::IDENTITY,
xf: CharXf::default(),
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
apply_fill: true,
char_index: 0,
rest: [0.0; 2],
}
};
let draw = |glyphs: Vec<PlacedGlyph>, blend: BlendMode| {
let geom = TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend };
let mut img = crate::Image::new(24, 20);
draw_glyphs(&geom, &mut img, &Mat3::IDENTITY, 1.0, None);
img
};
let (white, red) = ([1.0; 4], [1.0, 0.0, 0.0, 1.0]);
// The shared edge runs through the middle of pixel column 10.
let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Normal).get(10, 10);
assert!(seam[3] > 0.999 && seam[0] > 0.999, "touching glyphs leave no seam: {seam:?}");
// Outlines wound the other way (another font, a mirrored character) still add up.
let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, true)], BlendMode::Normal).get(10, 10);
assert!(seam[3] > 0.999, "opposite windings join too: {seam:?}");
let overlap = draw(vec![glyph(4.0, 12.0, white, false), glyph(8.0, 17.0, white, true)], BlendMode::Normal).get(10, 10);
assert!(overlap[3] > 0.999, "and their overlap stays filled: {overlap:?}");
// Characters of other colours, or blended with each other, are still drawn one by one.
let two = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, red, false)], BlendMode::Normal).get(10, 10);
assert!((two[3] - 0.75).abs() < 0.02, "{two:?}");
let multiply = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Multiply).get(10, 10);
assert!(multiply[3] < 0.9, "{multiply:?}");
}