mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
co-authored by
Claude Opus 5.5
parent
83b134cdc8
commit
8ea876914d
@@ -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,
|
||||
|
||||
@@ -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])));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user