Skip to main content

motion_core/animation/
keyframes.rs

1//! Finite tick-addressed keyframe tracks with explicit interpolation and holds.
2
3use super::{Easing, FiniteAnimation, Signal};
4use crate::{Duration, Interpolate, Time, ValidationError};
5
6#[derive(Clone, Debug)]
7struct Keyframe<Value> {
8    at: Time,
9    value: Value,
10    easing: Option<Easing>,
11}
12
13/// A nonempty track starting at zero, with absolute destinations and relative holds.
14///
15/// Exact timestamps return stored values. Intervals use the arriving point's
16/// easing, while holds clone their value without invoking interpolation. Sampling
17/// is stateless and uses integer ticks for interval selection before f64 progress.
18///
19/// This is the canonical [`crate::Keyframes`] type.
20///
21/// ```
22/// use motion_core::prelude::*;
23/// use motion_core::{
24///     DisplayList, RasterLimits, ResourceStore, ValidatedDisplayList, MAX_PATH_VERBS,
25///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
26/// };
27/// let track = Keyframes::new(0.0_f64)
28///     .to(1.seconds(), 10.0, Easing::QuadraticIn)
29///     .hold(500.milliseconds())
30///     .to(2.5.seconds(), 20.0, Easing::Linear);
31/// assert_eq!(track.sample(500.milliseconds().after_origin()), 2.5);
32/// assert_eq!(track.sample(1250.milliseconds().after_origin()), 10.0);
33/// assert_eq!(track.sample(2.seconds().after_origin()), 15.0);
34/// assert_eq!(track.duration(), 2.5.seconds());
35/// ```
36#[derive(Clone, Debug)]
37pub struct Keyframes<Value> {
38    points: Vec<Keyframe<Value>>,
39}
40
41impl<Value> Keyframes<Value> {
42    /// Starts with a value at zero. Without further points the finite duration
43    /// is zero and every sample returns this value.
44    pub fn new(value: Value) -> Self {
45        Self {
46            points: vec![Keyframe {
47                at: Time::ZERO,
48                value,
49                easing: None,
50            }],
51        }
52    }
53
54    /// Appends a destination at an absolute local time, not an added duration.
55    /// The easing controls the segment arriving at this value.
56    ///
57    /// # Panics
58    /// Panics unless `at` is strictly later than the previous point, including
59    /// any appended hold. Equal and descending times are not sorted or converted
60    /// into instantaneous jumps.
61    pub fn to(self, at: Duration, value: Value, easing: Easing) -> Self {
62        self.try_to(at, value, easing)
63            .unwrap_or_else(|error| panic!("{}", error.message))
64    }
65
66    /// Appends a destination like [`Self::to`], reporting non-increasing times.
67    pub fn try_to(
68        mut self,
69        at: Duration,
70        value: Value,
71        easing: Easing,
72    ) -> Result<Self, ValidationError> {
73        let at = at
74            .checked_offset()
75            .map(|offset| Time::ZERO + offset)
76            .filter(|at| *at > self.last().at)
77            .ok_or_else(|| {
78                ValidationError::invalid(
79                    "keyframes",
80                    "at",
81                    "keyframe times must be strictly increasing",
82                )
83            })?;
84        self.points.push(Keyframe {
85            at,
86            value,
87            easing: Some(easing),
88        });
89        Ok(self)
90    }
91
92    fn last(&self) -> &Keyframe<Value> {
93        self.points
94            .last()
95            .expect("keyframes contain an initial value")
96    }
97}
98
99impl<Value: Clone> Keyframes<Value> {
100    /// Holds the last value for an additional duration, without interpolation.
101    /// Zero is a no-op, leaving the last timestamp unchanged.
102    ///
103    /// # Panics
104    /// Panics on signed tick overflow; see [`Self::try_hold`].
105    pub fn hold(self, duration: Duration) -> Self {
106        self.try_hold(duration)
107            .unwrap_or_else(|error| panic!("{}", error.message))
108    }
109
110    /// Holds like [`Self::hold`], reporting tick overflow.
111    pub fn try_hold(mut self, duration: Duration) -> Result<Self, ValidationError> {
112        if duration == Duration::ZERO {
113            return Ok(self);
114        }
115        let last = self.last();
116        let at = last.at.checked_add(duration).ok_or_else(|| {
117            ValidationError::invalid(
118                "keyframes",
119                "hold",
120                "keyframe hold overflows the tick range",
121            )
122        })?;
123        let point = Keyframe {
124            at,
125            value: last.value.clone(),
126            easing: None,
127        };
128        self.points.push(point);
129        Ok(self)
130    }
131}
132
133impl<Value: Interpolate + Send + Sync> Signal<Value> for Keyframes<Value> {
134    fn sample(&self, time: Time) -> Value {
135        let upper = self.points.partition_point(|point| point.at <= time);
136        if upper == 0 {
137            return self.points[0].value.clone();
138        }
139        let left = &self.points[upper - 1];
140        if upper == self.points.len() || time == left.at {
141            return left.value.clone();
142        }
143        let right = &self.points[upper];
144        let Some(easing) = &right.easing else {
145            return left.value.clone();
146        };
147        let duration = right.at - left.at;
148        let progress = easing.apply((time - left.at).ticks() as f64 / duration.ticks() as f64);
149        left.value.interpolate(&right.value, progress)
150    }
151}
152
153impl<Value: Interpolate + Send + Sync> FiniteAnimation<Value> for Keyframes<Value> {
154    fn duration(&self) -> Duration {
155        self.last().at.since_origin().magnitude()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::{Point, TimeLiteral, TimeOffset, Tween};
163
164    #[test]
165    fn absolute_destinations_relative_holds_and_arriving_easing_are_seekable() {
166        let track = Keyframes::new(0.0_f64)
167            .to(1.seconds(), 10.0, Easing::QuadraticIn)
168            .hold(500.milliseconds())
169            .to(2.5.seconds(), 20.0, Easing::Linear);
170        assert_eq!(track.duration(), 2.5.seconds());
171        for (milliseconds, expected) in [
172            (2_000, 15.0),
173            (0, 0.0),
174            (500, 2.5),
175            (1_000, 10.0),
176            (1_250, 10.0),
177            (1_500, 10.0),
178            (2_500, 20.0),
179            (500, 2.5),
180        ] {
181            assert_eq!(
182                track.sample(milliseconds.milliseconds().after_origin()),
183                expected
184            );
185        }
186        assert_eq!(track.sample(Time::from_ticks(i64::MIN)), 0.0);
187        assert_eq!(track.sample(Time::from_ticks(i64::MAX)), 20.0);
188    }
189
190    #[test]
191    fn progress_and_adjacent_ticks_survive_large_absolute_timestamps() {
192        let track =
193            Keyframes::new(0.0_f64).to(Duration::from_ticks(10_000_000_000), 1.0, Easing::Linear);
194        let progress = track.sample(Time::from_ticks(5_000_000_001));
195        assert_eq!(progress, 0.500_000_000_1);
196        assert_ne!(progress, f64::from(progress as f32));
197        let start = Duration::from_ticks(1_u64 << 53);
198        let end = start + Duration::from_ticks(3);
199        let track = Keyframes::new(0.0_f64)
200            .hold(start)
201            .to(end, 3.0, Easing::Linear)
202            .hold(Duration::from_ticks(i64::MAX as u64) - end);
203        assert_eq!(track.duration(), Duration::from_ticks(i64::MAX as u64));
204        assert_eq!(track.sample(start.after_origin()), 0.0);
205        assert_eq!(
206            track.sample((start + Duration::from_ticks(1)).after_origin()),
207            1.0
208        );
209        assert_eq!(
210            track.sample((start + Duration::from_ticks(2)).after_origin()),
211            2.0
212        );
213        assert_eq!(track.sample(end.after_origin()), 3.0);
214        assert_eq!(track.sample(Time::from_ticks(i64::MAX)), 3.0);
215        let last_tick = Keyframes::new(2.0_f64)
216            .hold(Duration::from_ticks(i64::MAX as u64 - 1))
217            .to(Duration::from_ticks(i64::MAX as u64), 7.0, Easing::Linear);
218        assert_eq!(last_tick.sample(Time::from_ticks(i64::MAX - 1)), 2.0);
219        assert_eq!(last_tick.sample(Time::from_ticks(i64::MAX)), 7.0);
220    }
221
222    #[test]
223    fn holds_and_exact_points_never_call_value_interpolation() {
224        #[derive(Clone, Debug, PartialEq)]
225        struct ExactOnly(u32);
226
227        impl Interpolate for ExactOnly {
228            fn interpolate(&self, _target: &Self, _progress: f64) -> Self {
229                panic!("hold or exact timestamp must return the stored value");
230            }
231        }
232        let track = Keyframes::new(ExactOnly(1))
233            .to(Duration::from_ticks(10), ExactOnly(2), Easing::QuadraticOut)
234            .hold(Duration::from_ticks(5));
235        for (ticks, expected) in [
236            (i64::MIN, 1),
237            (0, 1),
238            (10, 2),
239            (12, 2),
240            (15, 2),
241            (i64::MAX, 2),
242        ] {
243            assert_eq!(track.sample(Time::from_ticks(ticks)), ExactOnly(expected));
244        }
245        let constant = Keyframes::new(ExactOnly(3));
246        assert_eq!(constant.duration(), Duration::ZERO);
247        assert_eq!(constant.sample(Time::ZERO), ExactOnly(3));
248        assert_eq!(constant.sample(Time::from_ticks(i64::MAX)), ExactOnly(3));
249    }
250
251    #[test]
252    fn invalid_order_and_hold_overflow_fail_during_building() {
253        assert!(
254            std::panic::catch_unwind(|| {
255                Keyframes::new(0.0_f64).to(Duration::ZERO, 1.0, Easing::Linear)
256            })
257            .is_err()
258        );
259
260        fn track() -> Keyframes<f64> {
261            Keyframes::new(0.0_f64).hold(Duration::from_ticks(10))
262        }
263        for ticks in [9, 10] {
264            assert!(
265                std::panic::catch_unwind(|| {
266                    track().to(Duration::from_ticks(ticks), 1.0, Easing::Linear)
267                })
268                .is_err()
269            );
270        }
271        let unchanged = track().hold(Duration::ZERO);
272        assert_eq!(unchanged.points.len(), 2);
273        assert_eq!(unchanged.duration(), Duration::from_ticks(10));
274        assert!(
275            std::panic::catch_unwind(|| {
276                Keyframes::new(0.0_f64)
277                    .hold(Duration::from_ticks(i64::MAX as u64))
278                    .hold(Duration::from_ticks(1))
279            })
280            .is_err()
281        );
282    }
283
284    #[test]
285    fn tracks_use_existing_combinators_dynamic_dispatch_and_local_frames() -> crate::Result {
286        let track = crate::Keyframes::new(0.0_f64)
287            .to(1.seconds(), 10.0, Easing::Linear)
288            .hold(500.milliseconds())
289            .to(2.seconds(), 20.0, Easing::Linear);
290        let cache = crate::SceneCache::new(0);
291        let local = crate::SceneContext::new(
292            1500.milliseconds().after_origin(),
293            crate::fps!(60),
294            crate::Size::new(64.0, 64.0),
295            &cache,
296        )
297        .shifted(TimeOffset::from_seconds(1.0));
298        assert_eq!(local.sample(&track), 5.0);
299        let boxed = track
300            .clone()
301            .map(|value| Point::new(value, value * 2.0))
302            .boxed();
303        assert_eq!(local.sample(boxed.as_ref()), Point::new(5.0, 10.0));
304        assert_eq!(
305            track
306                .clone()
307                .reverse()
308                .sample(500.milliseconds().after_origin()),
309            10.0
310        );
311        let repeated = track.clone().repeat(2).delay(250.milliseconds());
312        assert_eq!(repeated.duration(), 4.25.seconds());
313        assert_eq!(repeated.sample(2.25.seconds().after_origin()), 0.0);
314        assert_eq!(repeated.sample(4.25.seconds().after_origin()), 20.0);
315        let joined = track.then(Tween::new(20.0_f64, 40.0, 1.seconds()));
316        assert_eq!(joined.duration(), 3.seconds());
317        assert_eq!(joined.sample(2.5.seconds().after_origin()), 30.0);
318        assert_eq!(
319            std::thread::spawn(move || boxed.sample(1.25.seconds().after_origin()))
320                .join()
321                .unwrap(),
322            Point::new(10.0, 20.0)
323        );
324        Ok(())
325    }
326
327    #[test]
328    fn moving_segments_match_double_precision_tweens_including_overshoot() {
329        for easing in [
330            Easing::Linear,
331            Easing::QuadraticIn,
332            Easing::QuadraticOut,
333            Easing::QuadraticInOut,
334            Easing::CubicBezier {
335                x1: 0.3,
336                y1: 1.8,
337                x2: 0.7,
338                y2: 1.8,
339            },
340        ] {
341            let track = Keyframes::new(10.0_f64).hold(300.milliseconds()).to(
342                2.3.seconds(),
343                30.0,
344                easing.clone(),
345            );
346            let tween = Tween::new(10.0_f64, 30.0, 2.seconds()).easing(easing);
347            for milliseconds in [1_000, 0, 1, 500, 1_999, 2_000] {
348                let time = milliseconds.milliseconds();
349                assert_eq!(
350                    track.sample((300.milliseconds() + time).after_origin()),
351                    tween.sample(time.after_origin())
352                );
353            }
354        }
355    }
356}