Skip to main content

ff_filter/animation/
track.rs

1use std::time::Duration;
2
3use super::{Easing, Keyframe, Lerp};
4
5/// A sorted collection of keyframes with interpolated `value_at(t)` lookup.
6///
7/// Keyframes are kept in ascending timestamp order at all times.  The easing
8/// used for each interval is taken from the **preceding** keyframe's `easing`
9/// field; the last keyframe's easing is never read.
10///
11/// # Panics
12///
13/// `value_at` panics if the track is empty.  Always push at least one keyframe
14/// before querying.
15#[derive(Debug, Clone)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[cfg_attr(
18    feature = "serde",
19    serde(bound(
20        serialize = "T: serde::Serialize",
21        deserialize = "T: serde::Deserialize<'de>",
22    ))
23)]
24pub struct AnimationTrack<T: Lerp> {
25    keyframes: Vec<Keyframe<T>>,
26}
27
28impl<T: Lerp> AnimationTrack<T> {
29    /// Creates an empty track.
30    pub fn new() -> Self {
31        Self {
32            keyframes: Vec::new(),
33        }
34    }
35
36    /// Inserts a keyframe, maintaining timestamp-sorted order.
37    ///
38    /// If a keyframe at the same timestamp already exists it is replaced.
39    #[must_use]
40    pub fn push(mut self, kf: Keyframe<T>) -> Self {
41        let pos = self
42            .keyframes
43            .partition_point(|k| k.timestamp < kf.timestamp);
44        if self
45            .keyframes
46            .get(pos)
47            .is_some_and(|k| k.timestamp == kf.timestamp)
48        {
49            self.keyframes[pos] = kf;
50        } else {
51            self.keyframes.insert(pos, kf);
52        }
53        self
54    }
55
56    /// Returns the interpolated value at time `t`.
57    ///
58    /// - Before the first keyframe: returns the first value (hold).
59    /// - After the last keyframe: returns the last value (hold).
60    /// - Between two keyframes: uses the preceding keyframe's `easing`.
61    ///
62    /// # Panics
63    ///
64    /// Panics if the track is empty.
65    pub fn value_at(&self, t: Duration) -> T {
66        let len = self.keyframes.len();
67        // pos = number of keyframes with timestamp < t
68        let pos = self.keyframes.partition_point(|k| k.timestamp <= t);
69
70        if pos == 0 {
71            // Before or exactly at the first keyframe.
72            return self.keyframes[0].value.clone();
73        }
74        if pos >= len {
75            // After or exactly at the last keyframe.
76            return self.keyframes[len - 1].value.clone();
77        }
78
79        let a = &self.keyframes[pos - 1];
80        let b = &self.keyframes[pos];
81
82        let span = b
83            .timestamp
84            .checked_sub(a.timestamp)
85            .map_or(0.0, |d| d.as_secs_f64());
86        let elapsed = t.checked_sub(a.timestamp).map_or(0.0, |d| d.as_secs_f64());
87        let norm_t = if span > 0.0 { elapsed / span } else { 1.0 };
88
89        let u = a.easing.apply(norm_t);
90        T::lerp(&a.value, &b.value, u)
91    }
92
93    /// Returns all keyframes in sorted (ascending-timestamp) order.
94    pub fn keyframes(&self) -> &[Keyframe<T>] {
95        &self.keyframes
96    }
97
98    /// Returns the number of keyframes in the track.
99    pub fn len(&self) -> usize {
100        self.keyframes.len()
101    }
102
103    /// Returns `true` if the track has no keyframes.
104    pub fn is_empty(&self) -> bool {
105        self.keyframes.is_empty()
106    }
107}
108
109impl AnimationTrack<f64> {
110    /// Creates a two-keyframe track that ramps linearly (or with `easing`) from
111    /// `from` to `to` between `start` and `end`.
112    ///
113    /// - Before `start`: value is held at `from`.
114    /// - Between `start` and `end`: value is interpolated using `easing`.
115    /// - After `end`: value is held at `to`.
116    ///
117    /// This is the common-case shorthand for a volume fade, opacity ramp, or
118    /// position sweep.  Equivalent to:
119    ///
120    /// ```
121    /// # use std::time::Duration;
122    /// # use ff_filter::animation::{AnimationTrack, Easing, Keyframe};
123    /// AnimationTrack::new()
124    ///     .push(Keyframe::new(Duration::ZERO, 0.0_f64, Easing::Linear))
125    ///     .push(Keyframe::new(Duration::from_secs(2), 1.0_f64, Easing::Linear));
126    /// ```
127    pub fn fade(from: f64, to: f64, start: Duration, end: Duration, easing: Easing) -> Self {
128        Self::new()
129            .push(Keyframe::new(start, from, easing))
130            .push(Keyframe::new(end, to, Easing::Linear))
131    }
132}
133
134impl<T: Lerp> Default for AnimationTrack<T> {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140// ── Tests ─────────────────────────────────────────────────────────────────────
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::animation::Easing;
146
147    fn kf(ms: u64, v: f64) -> Keyframe<f64> {
148        Keyframe::new(Duration::from_millis(ms), v, Easing::Linear)
149    }
150
151    #[test]
152    fn animation_track_should_return_first_value_before_first_keyframe() {
153        let track = AnimationTrack::new()
154            .push(kf(500, 10.0))
155            .push(kf(1000, 20.0));
156
157        let v = track.value_at(Duration::from_millis(0));
158        assert!((v - 10.0).abs() < f64::EPSILON, "expected 10.0, got {v}");
159
160        let v2 = track.value_at(Duration::from_millis(499));
161        assert!((v2 - 10.0).abs() < f64::EPSILON, "expected 10.0, got {v2}");
162    }
163
164    #[test]
165    fn animation_track_should_return_last_value_after_last_keyframe() {
166        let track = AnimationTrack::new().push(kf(0, 0.0)).push(kf(1000, 50.0));
167
168        let v = track.value_at(Duration::from_millis(1000));
169        assert!((v - 50.0).abs() < f64::EPSILON, "expected 50.0, got {v}");
170
171        let v2 = track.value_at(Duration::from_millis(9999));
172        assert!((v2 - 50.0).abs() < f64::EPSILON, "expected 50.0, got {v2}");
173    }
174
175    #[test]
176    fn animation_track_should_interpolate_between_keyframes() {
177        // 0 ms → 0.0, 1000 ms → 1.0, linear easing.
178        let track = AnimationTrack::new().push(kf(0, 0.0)).push(kf(1000, 1.0));
179
180        let v = track.value_at(Duration::from_millis(500));
181        assert!((v - 0.5).abs() < 1e-9, "expected 0.5 at midpoint, got {v}");
182
183        let v2 = track.value_at(Duration::from_millis(250));
184        assert!(
185            (v2 - 0.25).abs() < 1e-9,
186            "expected 0.25 at quarter-point, got {v2}"
187        );
188    }
189
190    #[test]
191    fn fade_shorthand_should_produce_linear_ramp() {
192        // fade(0.0, 1.0, 0 ms, 2000 ms, Linear) must interpolate linearly.
193        let track = AnimationTrack::fade(
194            0.0,
195            1.0,
196            Duration::ZERO,
197            Duration::from_secs(2),
198            Easing::Linear,
199        );
200
201        assert_eq!(track.len(), 2, "fade must produce exactly 2 keyframes");
202
203        let mid = track.value_at(Duration::from_secs(1));
204        assert!(
205            (mid - 0.5).abs() < 1e-9,
206            "expected 0.5 at midpoint (1 s), got {mid}"
207        );
208
209        let quarter = track.value_at(Duration::from_millis(500));
210        assert!(
211            (quarter - 0.25).abs() < 1e-9,
212            "expected 0.25 at quarter-point (500 ms), got {quarter}"
213        );
214    }
215
216    #[test]
217    fn fade_shorthand_should_hold_before_start_and_after_end() {
218        let track = AnimationTrack::fade(
219            10.0,
220            20.0,
221            Duration::from_secs(1),
222            Duration::from_secs(3),
223            Easing::Linear,
224        );
225
226        // Before start — held at `from`.
227        let before = track.value_at(Duration::ZERO);
228        assert!(
229            (before - 10.0).abs() < f64::EPSILON,
230            "expected 10.0 before start, got {before}"
231        );
232        let at_start = track.value_at(Duration::from_millis(999));
233        assert!(
234            (at_start - 10.0).abs() < f64::EPSILON,
235            "expected 10.0 just before start, got {at_start}"
236        );
237
238        // After end — held at `to`.
239        let after = track.value_at(Duration::from_secs(3));
240        assert!(
241            (after - 20.0).abs() < f64::EPSILON,
242            "expected 20.0 at end, got {after}"
243        );
244        let long_after = track.value_at(Duration::from_secs(9999));
245        assert!(
246            (long_after - 20.0).abs() < f64::EPSILON,
247            "expected 20.0 long after end, got {long_after}"
248        );
249    }
250}