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
+1 -1
View File
@@ -77,7 +77,7 @@
"name": "Tracking In",
"properties": { "tracking": 0, "opacity": 100 },
"keys": {
"tracking": [[0, 400], [1.6, 0]],
"tracking": [[0, 40], [1.6, 0]],
"opacity": [[0, 0], [0.8, 100]]
},
"ease": true,
+2 -2
View File
@@ -227,8 +227,8 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec<Json> {
props.insert("s".into(), k(json!([p, p, 100.0])));
}
if (s.tracking - base.tracking).abs() > 1e-9 {
// Lottie tracking is in 1/1000 em like the Character panel's.
props.insert("t".into(), k(json!(s.tracking - base.tracking)));
// An animator's tracking is in pixels; the Character panel's in 1/1000 em.
props.insert("t".into(), k(json!((s.tracking - base.tracking) / 1000.0 * s.size)));
}
if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 {
props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0])));
+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);
}
+7
View File
@@ -71,6 +71,10 @@ impl Mode {
pub fn combine(self, acc: f64, v: f64) -> f64 {
let r = match self {
Mode::Add => acc + v,
// Subtracting more than is selected leaves nothing, not a negative selection (After
// Effects 2026: a Subtract selector over unselected characters leaves their
// Tracking at 0, EXPORTER read-back t8).
Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0),
Mode::Subtract => acc - v,
Mode::Intersect => acc * v,
Mode::Min => acc.min(v),
@@ -352,6 +356,9 @@ mod tests {
// 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);
// Subtracting from what is not selected leaves 0, not a negative selection (AE t8).
assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0);
assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0);
assert_eq!(Mode::from_index(5), Mode::Difference);
}
+12
View File
@@ -90,6 +90,18 @@ Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_star
AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in
left-to-right text with the same font), so a 300 px box fits a word more per line.
## Text animator tracking
Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in
pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring
characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector
over characters the first selector leaves out keeps them at 0 instead of tracking them negatively.
With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector
over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text
preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie export writes a style run's
tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`,
`modes_combine`.
## Still unmeasured
Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames`