diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 19c17d53f..683da82ee 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -343,7 +343,10 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex } angles.apply(c, k); if let Some(o) = op { - c.opacity *= 1.0 + (o / 100.0 - 1.0) * k; + // Blend toward this animator's value, so a later animator can show characters an + // earlier one hid (Lottie files rely on it: Opacity 0 % on every character, then + // animators at 100 % that sweep in). With one animator this is the same as before. + c.opacity += (o - c.opacity) * k; } if let Some(f) = fill { c.fill = Some(f); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 503ea097c..0280115a5 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -548,6 +548,22 @@ fn keyframed_selector_animates() { assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]); } +#[test] +fn a_later_animator_opacity_shows_characters_an_earlier_one_hid() { + 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"]); + set(&mut l, "text/animators/#1/properties/opacity", Value::Scalar(0.0)); + animator(&mut p, &mut l, &["opacity"], &["range"]); + set(&mut l, "text/animators/#2/properties/opacity", Value::Scalar(60.0)); + set(&mut l, "text/animators/#2/selectors/#1/end", Value::Scalar(50.0)); + p.comp_mut(cid).unwrap().layers.push(l); + let glyphs = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + let opacity: Vec = glyphs.iter().map(|g| (g.1.opacity * 1000.0).round() / 1000.0).collect(); + // The second animator selects "AB" only: they come back at 60 %, "CD" stay hidden. + assert_eq!(opacity, [60.0, 60.0, 0.0, 0.0]); +} + #[test] fn caret_follows_animators_and_path_text() { let (mut p, cid, comp) = setup(400, 400);