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    /// Compiles this track into an `FFmpeg` expression in terms of `var` (typically
134    /// `"t"`, the frame PTS in seconds) that reproduces [`value_at`](Self::value_at):
135    /// the value is held before the first and after the last keyframe, and
136    /// piecewise-interpolated (with per-segment easing) in between.
137    ///
138    /// For self-animating `eval=frame` filters (`scale`, `rotate`): the same
139    /// expression is placed in both the preview and export graphs, so the value
140    /// animates identically without `send_command`. Commas are `\,`-escaped for use in
141    /// a filter argument. `Easing::Bezier` falls back to linear in the expression.
142    pub fn to_ffmpeg_expr(&self, var: &str) -> String {
143        let ks = &self.keyframes;
144        match ks.len() {
145            0 => return "0".to_string(),
146            1 => return format!("{:.6}", ks[0].value),
147            _ => {}
148        }
149        // Innermost branch: hold the last value once `var` passes the last keyframe.
150        let mut expr = format!("{:.6}", ks[ks.len() - 1].value);
151        // Wrap each segment from last to first: if(lt(var, t_next), seg, rest).
152        for i in (0..ks.len() - 1).rev() {
153            let (a, b) = (&ks[i], &ks[i + 1]);
154            let t1 = b.timestamp.as_secs_f64();
155            let seg = segment_expr(
156                var,
157                a.timestamp.as_secs_f64(),
158                t1,
159                a.value,
160                b.value,
161                &a.easing,
162            );
163            expr = format!("if(lt({var}\\,{t1:.6})\\,{seg}\\,{expr})");
164        }
165        // Hold the first value before the first keyframe.
166        let t0 = ks[0].timestamp.as_secs_f64();
167        format!("if(lt({var}\\,{t0:.6})\\,{:.6}\\,{expr})", ks[0].value)
168    }
169}
170
171/// `FFmpeg` expression for one eased segment `[t0, t1)`:
172/// `v0 + (v1-v0)*ease((var-t0)/(t1-t0))`. Commas are `\,`-escaped for a filter arg.
173/// The easing formulas mirror [`Easing::apply`]; `Bezier` falls back to linear.
174fn segment_expr(var: &str, t0: f64, t1: f64, v0: f64, v1: f64, easing: &Easing) -> String {
175    let dt = (t1 - t0).max(1e-9);
176    // Normalised progress u = (var - t0) / (t1 - t0).
177    let u = format!("(({var}-{t0:.6})/{dt:.6})");
178    let eased = match easing {
179        // Hold: stay at v0 for the whole segment; the next `lt` guard jumps to v1.
180        Easing::Hold => "0".to_string(),
181        Easing::Linear | Easing::Bezier { .. } => u,
182        Easing::EaseIn => format!("pow({u}\\,3)"),
183        Easing::EaseOut => format!("(1-pow((1-{u})\\,3))"),
184        Easing::EaseInOut => format!("(3*pow({u}\\,2)-2*pow({u}\\,3))"),
185    };
186    format!("({v0:.6}+({v1:.6}-{v0:.6})*{eased})")
187}
188
189impl<T: Lerp> Default for AnimationTrack<T> {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::animation::Easing;
199
200    fn kf(ms: u64, v: f64) -> Keyframe<f64> {
201        Keyframe::new(Duration::from_millis(ms), v, Easing::Linear)
202    }
203
204    #[test]
205    fn animation_track_should_return_first_value_before_first_keyframe() {
206        let track = AnimationTrack::new()
207            .push(kf(500, 10.0))
208            .push(kf(1000, 20.0));
209
210        let v = track.value_at(Duration::from_millis(0));
211        assert!((v - 10.0).abs() < f64::EPSILON, "expected 10.0, got {v}");
212
213        let v2 = track.value_at(Duration::from_millis(499));
214        assert!((v2 - 10.0).abs() < f64::EPSILON, "expected 10.0, got {v2}");
215    }
216
217    #[test]
218    fn animation_track_should_return_last_value_after_last_keyframe() {
219        let track = AnimationTrack::new().push(kf(0, 0.0)).push(kf(1000, 50.0));
220
221        let v = track.value_at(Duration::from_millis(1000));
222        assert!((v - 50.0).abs() < f64::EPSILON, "expected 50.0, got {v}");
223
224        let v2 = track.value_at(Duration::from_millis(9999));
225        assert!((v2 - 50.0).abs() < f64::EPSILON, "expected 50.0, got {v2}");
226    }
227
228    #[test]
229    fn animation_track_should_interpolate_between_keyframes() {
230        // 0 ms → 0.0, 1000 ms → 1.0, linear easing.
231        let track = AnimationTrack::new().push(kf(0, 0.0)).push(kf(1000, 1.0));
232
233        let v = track.value_at(Duration::from_millis(500));
234        assert!((v - 0.5).abs() < 1e-9, "expected 0.5 at midpoint, got {v}");
235
236        let v2 = track.value_at(Duration::from_millis(250));
237        assert!(
238            (v2 - 0.25).abs() < 1e-9,
239            "expected 0.25 at quarter-point, got {v2}"
240        );
241    }
242
243    #[test]
244    fn fade_shorthand_should_produce_linear_ramp() {
245        // fade(0.0, 1.0, 0 ms, 2000 ms, Linear) must interpolate linearly.
246        let track = AnimationTrack::fade(
247            0.0,
248            1.0,
249            Duration::ZERO,
250            Duration::from_secs(2),
251            Easing::Linear,
252        );
253
254        assert_eq!(track.len(), 2, "fade must produce exactly 2 keyframes");
255
256        let mid = track.value_at(Duration::from_secs(1));
257        assert!(
258            (mid - 0.5).abs() < 1e-9,
259            "expected 0.5 at midpoint (1 s), got {mid}"
260        );
261
262        let quarter = track.value_at(Duration::from_millis(500));
263        assert!(
264            (quarter - 0.25).abs() < 1e-9,
265            "expected 0.25 at quarter-point (500 ms), got {quarter}"
266        );
267    }
268
269    #[test]
270    fn fade_shorthand_should_hold_before_start_and_after_end() {
271        let track = AnimationTrack::fade(
272            10.0,
273            20.0,
274            Duration::from_secs(1),
275            Duration::from_secs(3),
276            Easing::Linear,
277        );
278
279        // Before start — held at `from`.
280        let before = track.value_at(Duration::ZERO);
281        assert!(
282            (before - 10.0).abs() < f64::EPSILON,
283            "expected 10.0 before start, got {before}"
284        );
285        let at_start = track.value_at(Duration::from_millis(999));
286        assert!(
287            (at_start - 10.0).abs() < f64::EPSILON,
288            "expected 10.0 just before start, got {at_start}"
289        );
290
291        // After end — held at `to`.
292        let after = track.value_at(Duration::from_secs(3));
293        assert!(
294            (after - 20.0).abs() < f64::EPSILON,
295            "expected 20.0 at end, got {after}"
296        );
297        let long_after = track.value_at(Duration::from_secs(9999));
298        assert!(
299            (long_after - 20.0).abs() < f64::EPSILON,
300            "expected 20.0 long after end, got {long_after}"
301        );
302    }
303
304    #[test]
305    fn to_ffmpeg_expr_single_keyframe_is_constant() {
306        let track = AnimationTrack::new().push(Keyframe::new(Duration::ZERO, 42.0, Easing::Linear));
307        assert_eq!(track.to_ffmpeg_expr("t"), "42.000000");
308    }
309
310    #[test]
311    fn to_ffmpeg_expr_two_keyframes_guards_and_interpolates() {
312        let track = AnimationTrack::new()
313            .push(Keyframe::new(Duration::ZERO, 0.0, Easing::Linear))
314            .push(Keyframe::new(Duration::from_secs(2), 100.0, Easing::Linear));
315        let e = track.to_ffmpeg_expr("t");
316        // Before-first guard, segment-boundary guard (commas escaped), and end value.
317        assert!(
318            e.contains("if(lt(t\\,0.000000)"),
319            "before-first guard missing: {e}"
320        );
321        assert!(
322            e.contains("if(lt(t\\,2.000000)"),
323            "segment guard missing: {e}"
324        );
325        assert!(e.contains("100.000000"), "end value missing: {e}");
326        // Linear segment references normalised progress over the 2s span.
327        assert!(e.contains("/2.000000"), "span normalisation missing: {e}");
328    }
329
330    #[test]
331    fn to_ffmpeg_expr_ease_in_uses_cubic() {
332        let track = AnimationTrack::new()
333            .push(Keyframe::new(Duration::ZERO, 0.0, Easing::EaseIn))
334            .push(Keyframe::new(Duration::from_secs(1), 1.0, Easing::Linear));
335        assert!(
336            track.to_ffmpeg_expr("t").contains("pow("),
337            "ease-in should use pow()"
338        );
339    }
340}