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