Text animators: Tracking Amount in pixels, Subtract selectors stop at 0, as After Effects

A text animator's Tracking Amount is in pixels in After Effects 2026, not in
1/1000 em like the Character panel's tracking: Amount 30 on every character
adds 30 px between neighbours (half before, half after each) at 80 px and at
40 px. And a Subtract range selector over characters the first selector
leaves out keeps them at 0 instead of selecting them negatively. With both,
the EXPORTER's t8 layer D (Tracking 30 on characters 2-4 by index, Subtract
over the last 20 %) puts every glyph where AE does (it ended 88 px short,
1553 vs 1641; now 1641).

The "Tracking In" text preset animates 40 -> 0 px (was 400 -> 0 thousandths
of an em, about the same at 100 px text); Lottie export writes a style run's
tracking (1/1000 em) as pixels for its tracking animator, which Lottie
players add in pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:41:15 +02:00
co-authored by Claude Opus 5.5
parent 83b134cdc8
commit 8ea876914d
6 changed files with 55 additions and 11 deletions
+5 -3
View File
@@ -639,10 +639,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0);
for gi in li_start..li_end {
let x = &xfs[gi];
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
pen += x.track_before * em;
// An animator's Tracking Amount is in pixels, whatever the font size (After Effects
// 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike
// the Character panel's tracking in 1/1000 em.
pen += x.track_before;
shift[gi] = pen;
pen += x.track_after * em + vadv[gi];
pen += x.track_after + vadv[gi];
if x.line_anchor_k > la_k {
la = x.line_anchor;
la_k = x.line_anchor_k;
+28 -5
View File
@@ -202,9 +202,9 @@ fn tracking_line_anchor_and_character_offset() {
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));
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.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).
// Tracking Amount 20 (pixels, as After Effects): 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.
@@ -231,6 +231,29 @@ fn tracking_line_anchor_and_character_offset() {
assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B'));
}
/// After Effects 2026: a text animator's Tracking Amount is in pixels: Amount 30 on every
/// character of an 80 px and of a 40 px text adds 30 px between neighbouring characters (half
/// before, half after each one).
#[test]
fn animator_tracking_is_in_pixels_as_in_after_effects() {
for size in [80.0, 40.0] {
let x_of = |amount: f64| {
let (mut p, cid, comp) = setup(900, 200);
let mut l = text_layer(&mut p, &comp, doc("HHHH", size));
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);
let paths = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
paths.iter().map(|g| bounds_of(std::slice::from_ref(g)).x0).collect::<Vec<f64>>()
};
let (a, b) = (x_of(0.0), x_of(30.0));
for i in 1..a.len() {
let gap = (b[i] - b[i - 1]) - (a[i] - a[i - 1]);
assert!((gap - 30.0).abs() < 0.5, "{size} px: neighbour gap grows by {gap}");
}
}
}
/// 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]
@@ -244,10 +267,10 @@ fn tracking_grows_from_each_paragraphs_alignment() {
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.
// Tracking Amount 20 (pixels): 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 (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.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]);
}
@@ -259,7 +282,7 @@ fn tracking_grows_from_each_paragraphs_alignment() {
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)));
let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.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);
}