Skip to main content

alpha2_eval/
animation.rs

1use crate::EvaluatedVisual;
2use alpha2_model::{
3    Animated, AnimatedPoint, EffectValue, GradientStop, Insets, Keyframe, LayoutLength,
4    LayoutOffset, LayoutSize, Paint, Path, PointKeyframe, Vec2, VisualProperties,
5};
6use alpha2_time::MediaTime;
7
8/// Evaluates visual properties at a timestamp. `aspect` is the canvas width over height, used
9/// to measure motion along spatial curves.
10///
11/// Opacity is clamped to `0..=1`; position, scale and rotation keep curve overshoot.
12#[must_use]
13pub fn sample_visual(
14    properties: &VisualProperties,
15    time: MediaTime,
16    aspect: f64,
17) -> EvaluatedVisual {
18    EvaluatedVisual {
19        position: sample_point(&properties.position, time, aspect),
20        anchor: properties.anchor,
21        scale: evaluate_vec2(&properties.scale, time),
22        rotation_degrees: evaluate_scalar(&properties.rotation_degrees, time),
23        opacity: evaluate_scalar(&properties.opacity, time).clamp(0.0, 1.0),
24    }
25}
26
27fn evaluate_vec2(animation: &Animated<Vec2>, time: MediaTime) -> Vec2 {
28    match animation {
29        Animated::Constant { value } => *value,
30        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
31            Segment::Between(left, right, amount) => Vec2::new(
32                interpolate_f32(left.value.x, right.value.x, amount),
33                interpolate_f32(left.value.y, right.value.y, amount),
34            ),
35            Segment::At(keyframe) => keyframe.value,
36            Segment::Empty => Vec2::default(),
37        },
38    }
39}
40
41/// Samples an animated 2D value like [`sample_scalar`]. Use [`sample_point`] for positions that
42/// may follow spatial curves.
43#[must_use]
44pub fn sample_vec2(animation: &Animated<Vec2>, time: MediaTime) -> Vec2 {
45    evaluate_vec2(animation, time)
46}
47
48/// Samples one animated scalar with the project's keyframe semantics.
49///
50/// Times outside the keyframes clamp to the first or last value, and a
51/// [`alpha2_model::Interpolation::Hold`] segment keeps its left value. Interpolation is numeric,
52/// so a rotation from 0 to 720 degrees turns twice.
53#[must_use]
54pub fn sample_scalar(animation: &Animated<f32>, time: MediaTime) -> f32 {
55    evaluate_scalar(animation, time)
56}
57
58fn evaluate_scalar(animation: &Animated<f32>, time: MediaTime) -> f32 {
59    match animation {
60        Animated::Constant { value } => *value,
61        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
62            Segment::Between(left, right, amount) => {
63                interpolate_f32(left.value, right.value, amount)
64            }
65            Segment::At(keyframe) => keyframe.value,
66            Segment::Empty => 0.0,
67        },
68    }
69}
70
71/// Samples a position that may move along spatial curves.
72///
73/// With both facing tangents zero the value moves in a straight line like an `Animated<Vec2>`.
74/// Otherwise it follows the cubic Bezier, measured in a space `aspect` wide per unit high, and the
75/// eased amount is the fraction of arc length travelled. Overshoot continues along the end
76/// tangent.
77#[must_use]
78pub fn sample_point(animation: &AnimatedPoint, time: MediaTime, aspect: f64) -> Vec2 {
79    match animation {
80        AnimatedPoint::Constant { value } => *value,
81        AnimatedPoint::Keyframes { keyframes } => match point_segment(keyframes, time) {
82            PointSegment::At(value) => value,
83            PointSegment::Between(left, right, amount) => {
84                if left.out_tangent.is_zero() && right.in_tangent.is_zero() {
85                    return Vec2::new(
86                        interpolate_f32(left.value.x, right.value.x, amount),
87                        interpolate_f32(left.value.y, right.value.y, amount),
88                    );
89                }
90                let aspect = if aspect.is_finite() && aspect > 0.0 {
91                    aspect
92                } else {
93                    1.0
94                };
95                let point = |value: Vec2| {
96                    alpha2_geometry::kurbo::Point::new(
97                        f64::from(value.x) * aspect,
98                        f64::from(value.y),
99                    )
100                };
101                let vector = |value: Vec2| {
102                    alpha2_geometry::kurbo::Vec2::new(
103                        f64::from(value.x) * aspect,
104                        f64::from(value.y),
105                    )
106                };
107                let at = alpha2_geometry::spatial::point_along(
108                    point(left.value),
109                    vector(left.out_tangent),
110                    vector(right.in_tangent),
111                    point(right.value),
112                    amount,
113                );
114                Vec2::new(finite_f32(at.x / aspect), finite_f32(at.y))
115            }
116        },
117    }
118}
119
120enum PointSegment<'a> {
121    At(Vec2),
122    Between(&'a PointKeyframe, &'a PointKeyframe, f64),
123}
124
125fn point_segment(keyframes: &[PointKeyframe], time: MediaTime) -> PointSegment<'_> {
126    let next = keyframes.partition_point(|keyframe| keyframe.time <= time);
127    if next == 0 {
128        return PointSegment::At(
129            keyframes
130                .first()
131                .map_or_else(Vec2::default, |key| key.value),
132        );
133    }
134    let left = &keyframes[next - 1];
135    let Some(right) = keyframes.get(next) else {
136        return PointSegment::At(left.value);
137    };
138    if time == left.time {
139        return PointSegment::At(left.value);
140    }
141    let progress = interpolation_amount(left.time, right.time, time);
142    match left.interpolation.amount(progress) {
143        Some(amount) => PointSegment::Between(left, right, amount),
144        None => PointSegment::At(left.value),
145    }
146}
147
148/// Nearest finite `f32`; NaN becomes zero.
149#[allow(clippy::cast_possible_truncation)]
150fn finite_f32(value: f64) -> f32 {
151    if value.is_nan() {
152        0.0
153    } else {
154        value.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32
155    }
156}
157
158/// Samples an animated path, interpolating points and tangents linearly in `f64`. Mismatched
159/// keyframe structures hold the left path.
160#[must_use]
161pub fn sample_path(animation: &Animated<Path>, time: MediaTime) -> alpha2_geometry::EvaluatedPath {
162    use alpha2_geometry::kurbo::{Point, Vec2 as Vector};
163    use alpha2_geometry::{EvaluatedPath, EvaluatedSubpath, EvaluatedVertex};
164    match animation {
165        Animated::Constant { value } => EvaluatedPath::from(value),
166        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
167            Segment::At(keyframe) => EvaluatedPath::from(&keyframe.value),
168            Segment::Empty => EvaluatedPath::default(),
169            Segment::Between(left, right, amount) => {
170                let structure = |path: &Path| {
171                    path.subpaths
172                        .iter()
173                        .map(|subpath| (subpath.closed, subpath.vertices.len()))
174                        .collect::<Vec<_>>()
175                };
176                if structure(&left.value) != structure(&right.value) {
177                    return EvaluatedPath::from(&left.value);
178                }
179                let lerp = |a: f32, b: f32| f64::from(a) + (f64::from(b) - f64::from(a)) * amount;
180                let point = |a: Vec2, b: Vec2| Point::new(lerp(a.x, b.x), lerp(a.y, b.y));
181                let vector = |a: Vec2, b: Vec2| Vector::new(lerp(a.x, b.x), lerp(a.y, b.y));
182                EvaluatedPath {
183                    subpaths: left
184                        .value
185                        .subpaths
186                        .iter()
187                        .zip(&right.value.subpaths)
188                        .map(|(from, to)| EvaluatedSubpath {
189                            closed: from.closed,
190                            vertices: from
191                                .vertices
192                                .iter()
193                                .zip(&to.vertices)
194                                .map(|(a, b)| EvaluatedVertex {
195                                    point: point(a.point, b.point),
196                                    in_tangent: vector(a.in_tangent, b.in_tangent),
197                                    out_tangent: vector(a.out_tangent, b.out_tangent),
198                                })
199                                .collect(),
200                        })
201                        .collect(),
202                }
203            }
204        },
205    }
206}
207
208/// Samples an animated paint. Colors interpolate premultiplied; gradient geometry and stop
209/// offsets interpolate numerically, then offsets are kept ordered in `0..=1` and radii positive.
210/// Mismatched keyframes hold the left paint.
211#[must_use]
212pub fn sample_paint(animation: &Animated<Paint>, time: MediaTime) -> Paint {
213    match animation {
214        Animated::Constant { value } => value.clone(),
215        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
216            Segment::At(keyframe) => keyframe.value.clone(),
217            Segment::Empty => Paint::Solid {
218                color: alpha2_model::Rgba::new(0.0, 0.0, 0.0, 0.0),
219            },
220            Segment::Between(left, right, amount) => {
221                interpolate_paint(&left.value, &right.value, amount)
222                    .unwrap_or_else(|| left.value.clone())
223            }
224        },
225    }
226}
227
228fn interpolate_paint(from: &Paint, to: &Paint, amount: f64) -> Option<Paint> {
229    let lerp = |a: f32, b: f32| interpolate_f32(a, b, amount);
230    let point = |a: Vec2, b: Vec2| Vec2::new(lerp(a.x, b.x), lerp(a.y, b.y));
231    let color = |a: alpha2_model::Rgba, b: alpha2_model::Rgba| {
232        let [red, green, blue, alpha] = interpolate_color(
233            [a.red, a.green, a.blue, a.alpha],
234            [b.red, b.green, b.blue, b.alpha],
235            amount,
236        );
237        alpha2_model::Rgba::new(red, green, blue, alpha)
238    };
239    let stops = |a: &[GradientStop], b: &[GradientStop]| -> Option<Vec<GradientStop>> {
240        if a.len() != b.len() {
241            return None;
242        }
243        let mut previous = 0.0_f32;
244        Some(
245            a.iter()
246                .zip(b)
247                .map(|(a, b)| {
248                    let offset = lerp(a.offset, b.offset).clamp(previous, 1.0);
249                    previous = offset;
250                    GradientStop {
251                        offset,
252                        color: color(a.color, b.color),
253                    }
254                })
255                .collect(),
256        )
257    };
258    Some(match (from, to) {
259        (Paint::Solid { color: a }, Paint::Solid { color: b }) => Paint::Solid {
260            color: color(*a, *b),
261        },
262        (
263            Paint::LinearGradient {
264                start: s0,
265                end: e0,
266                stops: a,
267                spread,
268            },
269            Paint::LinearGradient {
270                start: s1,
271                end: e1,
272                stops: b,
273                spread: other,
274            },
275        ) if spread == other => Paint::LinearGradient {
276            start: point(*s0, *s1),
277            end: point(*e0, *e1),
278            stops: stops(a, b)?,
279            spread: *spread,
280        },
281        (
282            Paint::RadialGradient {
283                center: c0,
284                radius: r0,
285                stops: a,
286                spread,
287            },
288            Paint::RadialGradient {
289                center: c1,
290                radius: r1,
291                stops: b,
292                spread: other,
293            },
294        ) if spread == other => Paint::RadialGradient {
295            center: point(*c0, *c1),
296            radius: lerp(*r0, *r1).max(f32::MIN_POSITIVE),
297            stops: stops(a, b)?,
298            spread: *spread,
299        },
300        (
301            Paint::ConicGradient {
302                center: c0,
303                angle_degrees: a0,
304                stops: a,
305            },
306            Paint::ConicGradient {
307                center: c1,
308                angle_degrees: a1,
309                stops: b,
310            },
311        ) => Paint::ConicGradient {
312            center: point(*c0, *c1),
313            angle_degrees: lerp(*a0, *a1),
314            stops: stops(a, b)?,
315        },
316        _ => return None,
317    })
318}
319
320/// One effect parameter's value at a timeline timestamp.
321#[derive(Copy, Clone, Debug, PartialEq)]
322pub struct EffectParamValue {
323    /// The sampled value.
324    pub value: EffectValue,
325    /// Whether `value` is interpolated rather than authored. Renderers clamp interpolated values
326    /// to the parameter range; an authored out-of-range value is an error.
327    pub interpolated: bool,
328}
329
330/// Samples one effect parameter with the project's keyframe semantics.
331///
332/// `Float` and `Vec2` interpolate component-wise. A straight sRGB `Color` interpolates
333/// premultiplied, as CSS does, to avoid a dark fringe when fading from transparent, then clamps
334/// to `0..=1`. `Enum` and `Bool` hold.
335#[must_use]
336pub fn sample_effect_param(animation: &Animated<EffectValue>, time: MediaTime) -> EffectParamValue {
337    let authored = |value: EffectValue| EffectParamValue {
338        value,
339        interpolated: false,
340    };
341    match animation {
342        Animated::Constant { value } => authored(*value),
343        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
344            Segment::Between(left, right, amount) => {
345                interpolate_value(left.value, right.value, amount).map_or_else(
346                    || authored(left.value),
347                    |value| EffectParamValue {
348                        value,
349                        interpolated: true,
350                    },
351                )
352            }
353            Segment::At(keyframe) => authored(keyframe.value),
354            Segment::Empty => authored(EffectValue::Float(0.0)),
355        },
356    }
357}
358
359fn interpolate_value(start: EffectValue, end: EffectValue, amount: f64) -> Option<EffectValue> {
360    let lerp = |a: f32, b: f32| interpolate_f32(a, b, amount);
361    match (start, end) {
362        (EffectValue::Float(a), EffectValue::Float(b)) => Some(EffectValue::Float(lerp(a, b))),
363        (EffectValue::Vec2(a), EffectValue::Vec2(b)) => {
364            Some(EffectValue::Vec2([lerp(a[0], b[0]), lerp(a[1], b[1])]))
365        }
366        (EffectValue::Vec4(a), EffectValue::Vec4(b)) => {
367            Some(EffectValue::Vec4(std::array::from_fn(|index| {
368                lerp(a[index], b[index])
369            })))
370        }
371        (EffectValue::Color(a), EffectValue::Color(b)) => {
372            Some(EffectValue::Color(interpolate_color(a, b, amount)))
373        }
374        _ => None,
375    }
376}
377
378/// Samples an animated color, interpolated premultiplied.
379#[must_use]
380pub fn sample_rgba(
381    animation: &Animated<alpha2_model::Rgba>,
382    time: MediaTime,
383) -> alpha2_model::Rgba {
384    match animation {
385        Animated::Constant { value } => *value,
386        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
387            Segment::At(keyframe) => keyframe.value,
388            Segment::Empty => alpha2_model::Rgba::new(0.0, 0.0, 0.0, 0.0),
389            Segment::Between(left, right, amount) => {
390                let (a, b) = (left.value, right.value);
391                let [red, green, blue, alpha] = interpolate_color(
392                    [a.red, a.green, a.blue, a.alpha],
393                    [b.red, b.green, b.blue, b.alpha],
394                    amount,
395                );
396                alpha2_model::Rgba::new(red, green, blue, alpha)
397            }
398        },
399    }
400}
401
402/// Interpolates straight colors premultiplied; returns straight, clamped to `0..=1`.
403#[allow(clippy::cast_possible_truncation)]
404fn interpolate_color(start: [f32; 4], end: [f32; 4], amount: f64) -> [f32; 4] {
405    let (start_alpha, end_alpha) = (f64::from(start[3]), f64::from(end[3]));
406    let alpha = (start_alpha + (end_alpha - start_alpha) * amount).clamp(0.0, 1.0);
407    let mut color = [0.0; 4];
408    for index in 0..3 {
409        let from = f64::from(start[index]) * start_alpha;
410        let to = f64::from(end[index]) * end_alpha;
411        let premultiplied = from + (to - from) * amount;
412        // Fully transparent has no color; keep straight interpolation so the value stays continuous.
413        let straight = if alpha > 0.0 {
414            premultiplied / alpha
415        } else {
416            f64::from(start[index]) + (f64::from(end[index]) - f64::from(start[index])) * amount
417        };
418        color[index] = straight.clamp(0.0, 1.0) as f32;
419    }
420    color[3] = alpha as f32;
421    color
422}
423
424enum Segment<'a, T> {
425    /// Before the first, after the last, or inside a held segment.
426    At(&'a Keyframe<T>),
427    /// Inside an interpolated segment, this fraction of the way to the right.
428    Between(&'a Keyframe<T>, &'a Keyframe<T>, f64),
429    /// No keyframes; validation rejects this.
430    Empty,
431}
432
433fn segment<T>(keyframes: &[Keyframe<T>], time: MediaTime) -> Segment<'_, T> {
434    let next = keyframes.partition_point(|keyframe| keyframe.time <= time);
435    if next == 0 {
436        return keyframes.first().map_or(Segment::Empty, Segment::At);
437    }
438    let left = &keyframes[next - 1];
439    let Some(right) = keyframes.get(next) else {
440        return Segment::At(left);
441    };
442    // On a keyframe the value is the authored one, whatever the curve.
443    if time == left.time {
444        return Segment::At(left);
445    }
446    let progress = interpolation_amount(left.time, right.time, time);
447    match left.interpolation.amount(progress) {
448        Some(amount) => Segment::Between(left, right, amount),
449        None => Segment::At(left),
450    }
451}
452
453#[allow(clippy::cast_precision_loss)]
454fn interpolation_amount(start: MediaTime, end: MediaTime, time: MediaTime) -> f64 {
455    (i128::from(time.ticks()) - i128::from(start.ticks())) as f64
456        / (i128::from(end.ticks()) - i128::from(start.ticks())) as f64
457}
458
459/// Linear interpolation clamped to finite `f32`, since overshoot can exceed its range.
460#[allow(clippy::cast_possible_truncation)]
461fn interpolate_f32(start: f32, end: f32, amount: f64) -> f32 {
462    (f64::from(start) + (f64::from(end) - f64::from(start)) * amount)
463        .clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32
464}
465
466/// Samples per-side lengths like `sample_size`.
467#[must_use]
468pub fn sample_insets(animation: &Animated<Insets>, time: MediaTime) -> [f32; 4] {
469    match animation {
470        Animated::Constant { value } => value.sides().map(LayoutSize::value),
471        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
472            Segment::Between(left, right, amount) => {
473                let (from, to) = (left.value.sides(), right.value.sides());
474                std::array::from_fn(|side| {
475                    interpolate_f32(from[side].value(), to[side].value(), amount)
476                })
477            }
478            Segment::At(keyframe) => keyframe.value.sides().map(LayoutSize::value),
479            Segment::Empty => [0.0; 4],
480        },
481    }
482}
483
484/// Samples a layout size like [`sample_scalar`]. Plan sizes are fractions.
485#[must_use]
486pub fn sample_size(animation: &Animated<LayoutSize>, time: MediaTime) -> f32 {
487    match animation {
488        Animated::Constant { value } => value.value(),
489        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
490            Segment::Between(left, right, amount) => {
491                interpolate_f32(left.value.value(), right.value.value(), amount)
492            }
493            Segment::At(keyframe) => keyframe.value.value(),
494            Segment::Empty => 0.0,
495        },
496    }
497}
498
499/// Samples a layout offset per axis like [`sample_size`].
500#[must_use]
501pub fn sample_offset(animation: &Animated<LayoutOffset>, time: MediaTime) -> Vec2 {
502    let vector = |offset: &LayoutOffset| Vec2::new(offset.x.value(), offset.y.value());
503    match animation {
504        Animated::Constant { value } => vector(value),
505        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
506            Segment::Between(left, right, amount) => Vec2::new(
507                interpolate_f32(left.value.x.value(), right.value.x.value(), amount),
508                interpolate_f32(left.value.y.value(), right.value.y.value(), amount),
509            ),
510            Segment::At(keyframe) => vector(&keyframe.value),
511            Segment::Empty => Vec2::default(),
512        },
513    }
514}
515
516/// Samples a layout length. Mismatched keyframe kinds keep the left kind.
517#[must_use]
518pub fn sample_length(animation: &Animated<LayoutLength>, time: MediaTime) -> LayoutLength {
519    match animation {
520        Animated::Constant { value } => *value,
521        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
522            Segment::Between(left, right, amount) => left.value.with_value(interpolate_f32(
523                left.value.value(),
524                right.value.value(),
525                amount,
526            )),
527            Segment::At(keyframe) => keyframe.value,
528            Segment::Empty => LayoutLength::Fraction(0.0),
529        },
530    }
531}
532
533/// Samples a non-interpolating value: the last keyframe at or before `time`, else the first.
534#[must_use]
535pub fn sample_hold<T: Clone + Default>(animation: &Animated<T>, time: MediaTime) -> T {
536    match animation {
537        Animated::Constant { value } => value.clone(),
538        Animated::Keyframes { keyframes } => {
539            let next = keyframes.partition_point(|keyframe| keyframe.time <= time);
540            keyframes
541                .get(next.saturating_sub(1))
542                .map(|keyframe| keyframe.value.clone())
543                .unwrap_or_default()
544        }
545    }
546}