Skip to main content

alpha2_layout/
text_animation.rs

1//! Text animators applied to a laid-out text: each cluster's selection and resulting state.
2//!
3//! Selection follows After Effects' text animators. A Range selector counts units
4//! (characters, non-space characters, words or lines) and shapes its range at the ends. A
5//! Timing selector picks the characters of runs by the run's time against the current time.
6//! A Class selector picks the characters of runs that carry a class. Only `+ - * /` and
7//! `sqrt` enter the selection, so native and wasm agree bit for bit.
8
9use alpha2_eval::{SampledAnimator, SampledSelector};
10use alpha2_model::{RangeShape, RangeUnits, RunState, TextRun, TextUnit};
11use alpha2_text::{ClusterState, ColorMix, TextLayout};
12use alpha2_time::MediaTime;
13
14/// The selection in `[0, 1]` that `selector` gives each cluster of `layout`, at `time` for a
15/// Timing selector. `runs` is empty for a plain string.
16#[must_use]
17pub fn selection(
18    selector: &SampledSelector,
19    layout: &TextLayout,
20    runs: &[TextRun],
21    time: MediaTime,
22) -> Vec<f32> {
23    match selector {
24        SampledSelector::Range {
25            based_on,
26            units,
27            start,
28            end,
29            offset,
30            shape,
31            amount,
32        } => {
33            let (unit_of, count) = units_of(layout, *based_on);
34            #[allow(clippy::cast_precision_loss)]
35            let count_f = count as f32;
36            let (mut s, mut e) = match units {
37                RangeUnits::Percent => (
38                    (start + offset) / 100.0 * count_f,
39                    (end + offset) / 100.0 * count_f,
40                ),
41                RangeUnits::Index => (start + offset, end + offset),
42            };
43            if s > e {
44                std::mem::swap(&mut s, &mut e);
45            }
46            unit_of
47                .iter()
48                .map(|unit| unit.map_or(0.0, |unit| range_value(*shape, unit, s, e) * amount))
49                .collect()
50        }
51        SampledSelector::Timing { state, ramp, sweep } => {
52            timing(layout, runs, time, *state, *ramp, *sweep)
53        }
54        SampledSelector::Class { class } => {
55            let run_of = run_of_clusters(layout, runs);
56            run_of
57                .iter()
58                .map(|run| {
59                    let selected =
60                        run.is_some_and(|run| runs[run].class.iter().any(|name| name == class));
61                    if selected { 1.0 } else { 0.0 }
62                })
63                .collect()
64        }
65    }
66}
67
68/// Each cluster's unit index under `based_on` (`None` for a cluster in no unit, such as a
69/// space under `words`), and the unit count.
70fn units_of(layout: &TextLayout, based_on: TextUnit) -> (Vec<Option<u32>>, u32) {
71    let clusters = &layout.clusters;
72    match based_on {
73        TextUnit::Characters => (
74            (0..u32::try_from(clusters.len()).unwrap_or(u32::MAX))
75                .map(Some)
76                .collect(),
77            u32::try_from(clusters.len()).unwrap_or(u32::MAX),
78        ),
79        TextUnit::CharactersExcludingSpaces => {
80            let mut next = 0;
81            let units = clusters
82                .iter()
83                .map(|cluster| {
84                    (!cluster.is_space).then(|| {
85                        next += 1;
86                        next - 1
87                    })
88                })
89                .collect();
90            (units, next)
91        }
92        TextUnit::Words => {
93            let count = clusters
94                .iter()
95                .filter_map(|cluster| cluster.word)
96                .max()
97                .map_or(0, |last| last + 1);
98            (clusters.iter().map(|cluster| cluster.word).collect(), count)
99        }
100        TextUnit::Lines => (
101            clusters.iter().map(|cluster| Some(cluster.line)).collect(),
102            u32::try_from(layout.lines.len()).unwrap_or(u32::MAX),
103        ),
104    }
105}
106
107/// The value `shape` gives unit `unit` (covering `[unit, unit + 1)`) of a range from `s` to
108/// `e` in unit space, `s <= e`.
109#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
110fn range_value(shape: RangeShape, unit: u32, s: f32, e: f32) -> f32 {
111    #[allow(clippy::float_cmp)]
112    if e == s {
113        return 0.0;
114    }
115    let i = unit as f32;
116    let ramp = || (i + 0.5 - s) / (e - s);
117    let triangle = |u: f32| {
118        if (0.0..=1.0).contains(&u) {
119            1.0 - (2.0 * u - 1.0).abs()
120        } else {
121            0.0
122        }
123    };
124    match shape {
125        RangeShape::Square => (e.min(i + 1.0) - s.max(i)).clamp(0.0, 1.0),
126        RangeShape::RampUp => ramp().clamp(0.0, 1.0),
127        RangeShape::RampDown => 1.0 - ramp().clamp(0.0, 1.0),
128        RangeShape::Triangle => triangle(ramp()),
129        RangeShape::Round => {
130            let u = ramp();
131            if (0.0..=1.0).contains(&u) {
132                let x = 2.0 * u - 1.0;
133                (1.0 - x * x).max(0.0).sqrt()
134            } else {
135                0.0
136            }
137        }
138        RangeShape::Smooth => {
139            let t = triangle(ramp());
140            t * t * (3.0 - 2.0 * t)
141        }
142    }
143}
144
145/// The run each cluster belongs to, by the run holding its first byte; `None` without runs.
146fn run_of_clusters(layout: &TextLayout, runs: &[TextRun]) -> Vec<Option<usize>> {
147    let mut ends = Vec::with_capacity(runs.len());
148    let mut end = 0;
149    for run in runs {
150        end += run.text.len();
151        ends.push(end);
152    }
153    layout
154        .clusters
155        .iter()
156        .map(|cluster| {
157            let run = ends.partition_point(|&end| end <= cluster.bytes.start);
158            (run < runs.len()).then_some(run)
159        })
160        .collect()
161}
162
163/// A Timing selector's selection of each cluster at `time`.
164#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
165fn timing(
166    layout: &TextLayout,
167    runs: &[TextRun],
168    time: MediaTime,
169    state: RunState,
170    ramp: MediaTime,
171    sweep: bool,
172) -> Vec<f32> {
173    let run_of = run_of_clusters(layout, runs);
174    let ramp = ramp.ticks();
175    // How far past `at` the time is, faded over the ramp: a step without one.
176    let reached = |at: i64| -> f32 {
177        let past = time.ticks().saturating_sub(at);
178        if ramp <= 0 {
179            if past >= 0 { 1.0 } else { 0.0 }
180        } else {
181            (past as f64 / ramp as f64).clamp(0.0, 1.0) as f32
182        }
183    };
184    // Each run's clusters in order, for a sweep's share of the run.
185    let mut place = vec![(0_usize, 0_usize); run_of.len()];
186    let mut counts = vec![0_usize; runs.len()];
187    for (cluster, run) in run_of.iter().enumerate() {
188        if let Some(run) = run {
189            place[cluster].0 = counts[*run];
190            counts[*run] += 1;
191        }
192    }
193    for (cluster, run) in run_of.iter().enumerate() {
194        if let Some(run) = run {
195            place[cluster].1 = counts[*run];
196        }
197    }
198    run_of
199        .iter()
200        .zip(place)
201        .map(|(run, (index, count))| {
202            let Some(span) = run.and_then(|run| runs[run].time) else {
203                return 0.0;
204            };
205            let (start, end) = (span.start.ticks(), span.end.ticks());
206            let (a, b) = if sweep && count > 0 {
207                let length = i128::from(end - start);
208                let share = |at: usize| {
209                    i64::try_from(i128::from(start) + length * at as i128 / count as i128)
210                        .unwrap_or(end)
211                };
212                (share(index), share(index + 1))
213            } else {
214                (start, end)
215            };
216            let (begun, ended) = (reached(a), reached(b));
217            match state {
218                RunState::Upcoming => 1.0 - begun,
219                RunState::Active => begun * (1.0 - ended),
220                RunState::Spoken => ended,
221            }
222        })
223        .collect()
224}
225
226/// Each cluster's state under `animators` at `time`, in pixels of a `font_size`-pixel font,
227/// and a hash of the states. The hash is 0 when every state is identity, so the text draws as
228/// it would without animators. `base_fill` and `base_stroke` are where color mixes start.
229#[must_use]
230pub fn cluster_states(
231    layout: &TextLayout,
232    runs: &[TextRun],
233    animators: &[SampledAnimator],
234    time: MediaTime,
235    font_size: f32,
236) -> (Vec<ClusterState>, u64) {
237    let mut states = vec![ClusterState::IDENTITY; layout.clusters.len()];
238    // Rotation is summed in degrees and turned into radians once.
239    let mut degrees = vec![0.0_f32; states.len()];
240    for animator in animators {
241        let weights = selection(&animator.selector, layout, runs, time);
242        for ((state, degrees), weight) in states.iter_mut().zip(&mut degrees).zip(weights) {
243            if weight <= 0.0 {
244                continue;
245            }
246            if let Some([x, y]) = animator.offset {
247                state.offset[0] += weight * x * font_size;
248                state.offset[1] += weight * y * font_size;
249            }
250            if let Some(scale) = animator.scale {
251                for (axis, target) in state.scale.iter_mut().zip(scale) {
252                    *axis *= 1.0 + weight * (target - 1.0);
253                }
254            }
255            if let Some(rotation) = animator.rotation_degrees {
256                *degrees += weight * rotation;
257            }
258            if let Some(opacity) = animator.opacity {
259                state.opacity *= 1.0 + weight * (opacity - 1.0);
260            }
261            if let Some(target) = animator.fill {
262                state.fill = Some(mix_toward(state.fill, target, weight));
263            }
264            if let Some(target) = animator.stroke {
265                state.stroke = Some(mix_toward(state.stroke, target, weight));
266            }
267            if let Some(tracking) = animator.tracking {
268                state.tracking += weight * tracking * font_size;
269            }
270        }
271    }
272    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
273    let mut feed = |value: f32| {
274        for byte in value.to_bits().to_le_bytes() {
275            hash = (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3);
276        }
277    };
278    let mut identity = true;
279    for (state, degrees) in states.iter_mut().zip(degrees) {
280        state.rotation = degrees.to_radians();
281        state.opacity = state.opacity.clamp(0.0, 1.0);
282        identity &= state.is_identity();
283        let mixes = [state.fill, state.stroke].map(|mix| {
284            mix.map_or([1.0, 0.0, 0.0, 0.0, 0.0], |mix| {
285                [mix.keep, mix.add[0], mix.add[1], mix.add[2], mix.add[3]]
286            })
287        });
288        for value in state
289            .offset
290            .into_iter()
291            .chain(state.scale)
292            .chain([state.rotation, state.opacity, state.tracking])
293            .chain(mixes.into_iter().flatten())
294        {
295            feed(value);
296        }
297    }
298    (states, if identity { 0 } else { hash.max(1) })
299}
300
301/// `mix` (or the color unchanged) mixed toward the straight sRGB `target` by `weight`, in
302/// premultiplied linear light.
303fn mix_toward(mix: Option<ColorMix>, target: [f32; 4], weight: f32) -> ColorMix {
304    let mix = mix.unwrap_or(ColorMix {
305        keep: 1.0,
306        add: [0.0; 4],
307    });
308    let target = premultiplied_linear(target);
309    ColorMix {
310        keep: mix.keep * (1.0 - weight),
311        add: std::array::from_fn(|channel| {
312            mix.add[channel] * (1.0 - weight) + target[channel] * weight
313        }),
314    }
315}
316
317/// Straight sRGB to premultiplied linear light, as paint stops convert.
318fn premultiplied_linear(color: [f32; 4]) -> [f32; 4] {
319    let alpha = color[3].clamp(0.0, 1.0);
320    let linear = |value: f32| {
321        let value = value.clamp(0.0, 1.0);
322        if value >= 1.0 {
323            1.0
324        } else if value <= 0.040_45 {
325            value / 12.92
326        } else {
327            libm::powf((value + 0.055) / 1.055, 2.4)
328        }
329    };
330    [
331        linear(color[0]) * alpha,
332        linear(color[1]) * alpha,
333        linear(color[2]) * alpha,
334        alpha,
335    ]
336}
337
338#[cfg(test)]
339#[path = "text_animation_tests.rs"]
340mod tests;