Skip to main content

alpha2_eval/
animation.rs

1use crate::EvaluatedVisual;
2use alpha2_model::{Animated, EffectValue, Keyframe, Vec2, VisualProperties};
3use alpha2_time::MediaTime;
4
5/// Evaluates visual properties at a timestamp: a clip's at timeline time,
6/// a transform node's at composition time.
7///
8/// An overshooting timing curve can carry a value past its keyframes. Opacity
9/// is clamped to its domain, `0..=1`; position, scale, and rotation have no
10/// bounds and keep the overshoot, as far as a finite `f32` goes.
11#[must_use]
12pub fn sample_visual(properties: &VisualProperties, time: MediaTime) -> EvaluatedVisual {
13    EvaluatedVisual {
14        position: evaluate_vec2(&properties.position, time),
15        anchor: properties.anchor,
16        scale: evaluate_vec2(&properties.scale, time),
17        rotation_degrees: evaluate_scalar(&properties.rotation_degrees, time),
18        opacity: evaluate_scalar(&properties.opacity, time).clamp(0.0, 1.0),
19    }
20}
21
22fn evaluate_vec2(animation: &Animated<Vec2>, time: MediaTime) -> Vec2 {
23    match animation {
24        Animated::Constant { value } => *value,
25        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
26            Segment::Between(left, right, amount) => Vec2::new(
27                interpolate_f32(left.value.x, right.value.x, amount),
28                interpolate_f32(left.value.y, right.value.y, amount),
29            ),
30            Segment::At(keyframe) => keyframe.value,
31            Segment::Empty => Vec2::default(),
32        },
33    }
34}
35
36/// Samples one animated two-dimensional value with the project's keyframe
37/// semantics; see [`sample_scalar`].
38#[must_use]
39pub fn sample_vec2(animation: &Animated<Vec2>, time: MediaTime) -> Vec2 {
40    evaluate_vec2(animation, time)
41}
42
43/// Samples one animated scalar with the project's keyframe semantics.
44///
45/// Values outside the keyframe sequence clamp to its first or last value, and a
46/// [`alpha2_model::Interpolation::Hold`] segment keeps its left value until the
47/// next keyframe. Values are interpolated numerically, so a rotation from 0 to
48/// 720 degrees turns twice. Audio mixing calls this at control points fixed
49/// by the project sample, and on every sample around a keyframe, so that
50/// automation does not depend on the host block size.
51#[must_use]
52pub fn sample_scalar(animation: &Animated<f32>, time: MediaTime) -> f32 {
53    evaluate_scalar(animation, time)
54}
55
56fn evaluate_scalar(animation: &Animated<f32>, time: MediaTime) -> f32 {
57    match animation {
58        Animated::Constant { value } => *value,
59        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
60            Segment::Between(left, right, amount) => {
61                interpolate_f32(left.value, right.value, amount)
62            }
63            Segment::At(keyframe) => keyframe.value,
64            Segment::Empty => 0.0,
65        },
66    }
67}
68
69/// One effect parameter's value at a timeline timestamp.
70#[derive(Copy, Clone, Debug, PartialEq)]
71pub struct EffectParamValue {
72    /// The value at this timestamp.
73    pub value: EffectValue,
74    /// Whether `value` lies between two keyframes rather than being an
75    /// authored value. A timing curve may carry an interpolated value past
76    /// the parameter's range, and a renderer clamps it there; an authored
77    /// value outside the range is an error.
78    pub interpolated: bool,
79}
80
81/// Samples one effect parameter with the project's keyframe semantics.
82///
83/// `Float` and `Vec2` interpolate component-wise. A `Color` (straight,
84/// sRGB-encoded) interpolates premultiplied, as CSS colors do, so a fade from
85/// transparent to red does not pass through a dark fringe; it is clamped to
86/// `0..=1` after an overshooting curve. `Enum` and `Bool` keyframes hold
87/// (validation rejects any other interpolation for them).
88#[must_use]
89pub fn sample_effect_param(animation: &Animated<EffectValue>, time: MediaTime) -> EffectParamValue {
90    let authored = |value: EffectValue| EffectParamValue {
91        value,
92        interpolated: false,
93    };
94    match animation {
95        Animated::Constant { value } => authored(*value),
96        Animated::Keyframes { keyframes } => match segment(keyframes, time) {
97            Segment::Between(left, right, amount) => {
98                interpolate_value(left.value, right.value, amount).map_or_else(
99                    || authored(left.value),
100                    |value| EffectParamValue {
101                        value,
102                        interpolated: true,
103                    },
104                )
105            }
106            Segment::At(keyframe) => authored(keyframe.value),
107            Segment::Empty => authored(EffectValue::Float(0.0)),
108        },
109    }
110}
111
112fn interpolate_value(start: EffectValue, end: EffectValue, amount: f64) -> Option<EffectValue> {
113    let lerp = |a: f32, b: f32| interpolate_f32(a, b, amount);
114    match (start, end) {
115        (EffectValue::Float(a), EffectValue::Float(b)) => Some(EffectValue::Float(lerp(a, b))),
116        (EffectValue::Vec2(a), EffectValue::Vec2(b)) => {
117            Some(EffectValue::Vec2([lerp(a[0], b[0]), lerp(a[1], b[1])]))
118        }
119        (EffectValue::Vec4(a), EffectValue::Vec4(b)) => {
120            Some(EffectValue::Vec4(std::array::from_fn(|index| {
121                lerp(a[index], b[index])
122            })))
123        }
124        (EffectValue::Color(a), EffectValue::Color(b)) => {
125            Some(EffectValue::Color(interpolate_color(a, b, amount)))
126        }
127        _ => None,
128    }
129}
130
131/// Interpolates two straight colors premultiplied and returns the result
132/// straight, clamped to `0..=1`.
133#[allow(clippy::cast_possible_truncation)]
134fn interpolate_color(start: [f32; 4], end: [f32; 4], amount: f64) -> [f32; 4] {
135    let (start_alpha, end_alpha) = (f64::from(start[3]), f64::from(end[3]));
136    let alpha = (start_alpha + (end_alpha - start_alpha) * amount).clamp(0.0, 1.0);
137    let mut color = [0.0; 4];
138    for index in 0..3 {
139        let from = f64::from(start[index]) * start_alpha;
140        let to = f64::from(end[index]) * end_alpha;
141        let premultiplied = from + (to - from) * amount;
142        // Fully transparent has no color; keep the straight interpolation so the value stays
143        // continuous.
144        let straight = if alpha > 0.0 {
145            premultiplied / alpha
146        } else {
147            f64::from(start[index]) + (f64::from(end[index]) - f64::from(start[index])) * amount
148        };
149        color[index] = straight.clamp(0.0, 1.0) as f32;
150    }
151    color[3] = alpha as f32;
152    color
153}
154
155/// Where a timestamp falls in a keyframe sequence.
156enum Segment<'a, T> {
157    /// Before the first, after the last, or inside a held segment.
158    At(&'a Keyframe<T>),
159    /// Inside an interpolated segment, this fraction of the way to the right.
160    Between(&'a Keyframe<T>, &'a Keyframe<T>, f64),
161    /// No keyframes; validation rejects this.
162    Empty,
163}
164
165fn segment<T>(keyframes: &[Keyframe<T>], time: MediaTime) -> Segment<'_, T> {
166    let next = keyframes.partition_point(|keyframe| keyframe.time <= time);
167    if next == 0 {
168        return keyframes.first().map_or(Segment::Empty, Segment::At);
169    }
170    let left = &keyframes[next - 1];
171    let Some(right) = keyframes.get(next) else {
172        return Segment::At(left);
173    };
174    // On a keyframe the value is the authored one, whatever the curve.
175    if time == left.time {
176        return Segment::At(left);
177    }
178    let progress = interpolation_amount(left.time, right.time, time);
179    match left.interpolation.amount(progress) {
180        Some(amount) => Segment::Between(left, right, amount),
181        None => Segment::At(left),
182    }
183}
184
185#[allow(clippy::cast_precision_loss)]
186fn interpolation_amount(start: MediaTime, end: MediaTime, time: MediaTime) -> f64 {
187    (i128::from(time.ticks()) - i128::from(start.ticks())) as f64
188        / (i128::from(end.ticks()) - i128::from(start.ticks())) as f64
189}
190
191/// The value `amount` of the way from `start` to `end`, held to the finite
192/// `f32` values: an overshooting curve can carry it past what an `f32` holds.
193#[allow(clippy::cast_possible_truncation)]
194fn interpolate_f32(start: f32, end: f32, amount: f64) -> f32 {
195    (f64::from(start) + (f64::from(end) - f64::from(start)) * amount)
196        .clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32
197}