Skip to main content

motion_core/
time.rs

1//! Signed integer-tick time and rational frame rates, independent of rendering.
2
3use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
4
5/// Flicks per second, exactly covering common video and audio sample rates.
6pub const TICKS_PER_SECOND: i64 = 705_600_000;
7
8/// A signed time point or duration, stored as integer flicks.
9///
10/// Comparisons and frame conversion use integers. Floating-point seconds are
11/// intended for animation mathematics, not interval membership checks.
12/// Arithmetic panics on overflow, independently of the build profile.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct Time(i64);
15
16/// A duration uses the same representation and arithmetic as a time point.
17pub type Duration = Time;
18
19impl Time {
20    /// The time origin and an empty duration.
21    pub const ZERO: Self = Self(0);
22
23    /// Constructs time without losing integer tick precision.
24    pub const fn from_ticks(ticks: i64) -> Self {
25        Self(ticks)
26    }
27
28    /// Rounds seconds to the nearest tick; halfway cases round away from zero.
29    ///
30    /// # Panics
31    /// Panics for nonfinite input or a rounded result outside the signed range.
32    pub fn from_seconds(seconds: f64) -> Self {
33        Self::rounded_ticks(seconds * TICKS_PER_SECOND as f64)
34    }
35
36    /// Returns the first tick at or after the exact rational frame boundary.
37    ///
38    /// Common integer and NTSC rates have exact tick boundaries. Other rates
39    /// round toward positive infinity by less than one tick, including before
40    /// zero. Compute each boundary directly; multiplying a rounded frame duration
41    /// would accumulate drift. Frame indices round-trip through [`Self::to_frame`]
42    /// when the rate does not exceed one frame per tick.
43    ///
44    /// # Panics
45    /// Panics if the boundary cannot be represented as signed ticks.
46    pub fn from_frame(frame: i64, rate: FrameRate) -> Self {
47        Self::checked_from_frame(frame, rate).expect("frame time exceeds the signed tick range")
48    }
49
50    /// Converts a frame boundary with the same ceiling rule as from_frame.
51    /// Returns None instead of panicking when signed ticks would overflow.
52    pub fn checked_from_frame(frame: i64, rate: FrameRate) -> Option<Self> {
53        let numerator =
54            i128::from(frame) * i128::from(TICKS_PER_SECOND) * i128::from(rate.denominator);
55        let denominator = i128::from(rate.numerator);
56        let ticks =
57            numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0);
58        i64::try_from(ticks).ok().map(Self)
59    }
60
61    /// Returns the containing frame, rounding toward negative infinity.
62    ///
63    /// # Panics
64    /// Panics if the frame index is outside the signed 64-bit range.
65    pub fn to_frame(self, rate: FrameRate) -> i64 {
66        self.checked_to_frame(rate)
67            .expect("frame index exceeds the signed 64-bit range")
68    }
69
70    /// Finds the containing frame using integer floor division.
71    /// Returns None instead of panicking when the frame index would overflow.
72    pub fn checked_to_frame(self, rate: FrameRate) -> Option<i64> {
73        let numerator = i128::from(self.0) * i128::from(rate.numerator);
74        let denominator = i128::from(TICKS_PER_SECOND) * i128::from(rate.denominator);
75        i64::try_from(numerator.div_euclid(denominator)).ok()
76    }
77
78    /// Converts to floating-point seconds for animation mathematics.
79    pub fn as_seconds(self) -> f64 {
80        self.0 as f64 / TICKS_PER_SECOND as f64
81    }
82
83    /// Returns the exact signed tick count.
84    pub const fn ticks(self) -> i64 {
85        self.0
86    }
87
88    /// Multiplies ticks exactly, returning None on signed overflow.
89    pub fn checked_mul(self, factor: i64) -> Option<Self> {
90        self.0.checked_mul(factor).map(Self)
91    }
92
93    fn rounded_ticks(ticks: f64) -> Self {
94        let rounded = ticks.round();
95        assert!(
96            rounded.is_finite() && rounded >= i64::MIN as f64 && rounded < -(i64::MIN as f64),
97            "time must be finite and fit the signed tick range"
98        );
99        Self(rounded as i64)
100    }
101}
102
103impl Add for Time {
104    type Output = Self;
105
106    fn add(self, other: Self) -> Self {
107        Self(self.0.checked_add(other.0).expect("time addition overflow"))
108    }
109}
110
111impl Sub for Time {
112    type Output = Self;
113
114    fn sub(self, other: Self) -> Self {
115        Self(
116            self.0
117                .checked_sub(other.0)
118                .expect("time subtraction overflow"),
119        )
120    }
121}
122
123impl AddAssign for Time {
124    fn add_assign(&mut self, other: Self) {
125        *self = *self + other;
126    }
127}
128
129impl SubAssign for Time {
130    fn sub_assign(&mut self, other: Self) {
131        *self = *self - other;
132    }
133}
134
135impl Neg for Time {
136    type Output = Self;
137
138    fn neg(self) -> Self {
139        Self(self.0.checked_neg().expect("time negation overflow"))
140    }
141}
142
143impl Mul<f64> for Time {
144    type Output = Self;
145
146    /// Scales time and rounds to the nearest tick, away from zero at ties.
147    fn mul(self, factor: f64) -> Self {
148        match factor {
149            1.0 => self,
150            -1.0 => -self,
151            _ => Self::rounded_ticks(self.0 as f64 * factor),
152        }
153    }
154}
155
156macro_rules! integer_time_mul {
157    ($($integer:ty),+ $(,)?) => {$ (
158        impl Mul<$integer> for Time {
159            type Output = Time;
160            fn mul(self, factor: $integer) -> Time {
161                Time(i64::try_from(i128::from(self.0) * factor as i128).expect("time multiplication overflow"))
162            }
163        }
164        impl Mul<Time> for $integer {
165            type Output = Time;
166            fn mul(self, time: Time) -> Time { time * self }
167        }
168    )+};
169}
170
171integer_time_mul!(i32, i64, u32, u64, usize);
172
173impl Mul<Time> for f64 {
174    type Output = Time;
175
176    fn mul(self, time: Time) -> Time {
177        time * self
178    }
179}
180
181impl Div<f64> for Time {
182    type Output = Self;
183
184    /// Divides time; a nonfinite or zero divisor is rejected.
185    fn div(self, divisor: f64) -> Self {
186        assert!(
187            divisor.is_finite() && divisor != 0.0,
188            "time divisor must be finite and nonzero"
189        );
190        match divisor {
191            1.0 => self,
192            -1.0 => -self,
193            _ => Self::rounded_ticks(self.0 as f64 / divisor),
194        }
195    }
196}
197
198impl Div for Time {
199    type Output = f64;
200
201    /// Returns a dimensionless ratio; a zero duration is rejected.
202    fn div(self, duration: Self) -> f64 {
203        assert!(duration.0 != 0, "time ratio requires a nonzero duration");
204        self.0 as f64 / duration.0 as f64
205    }
206}
207
208/// A positive rational number of frames per second, stored in reduced form.
209#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
210pub struct FrameRate {
211    numerator: u32,
212    denominator: u32,
213}
214
215impl FrameRate {
216    /// Film rate of exactly 24000/1001 frames per second.
217    pub const NTSC_FILM: Self = Self::new(24_000, 1_001);
218
219    /// NTSC rate of exactly 30000/1001 frames per second.
220    pub const NTSC: Self = Self::new(30_000, 1_001);
221
222    /// Double NTSC rate of exactly 60000/1001 frames per second.
223    pub const NTSC_DOUBLE: Self = Self::new(60_000, 1_001);
224
225    /// Constructs and reduces a rational frame rate.
226    ///
227    /// # Panics
228    /// Panics if either component is zero.
229    pub const fn new(numerator: u32, denominator: u32) -> Self {
230        assert!(numerator != 0, "frame-rate numerator must be nonzero");
231        assert!(denominator != 0, "frame-rate denominator must be nonzero");
232        let mut common = numerator;
233        let mut remainder = denominator;
234        while remainder != 0 {
235            let next = common % remainder;
236            common = remainder;
237            remainder = next;
238        }
239        Self {
240            numerator: numerator / common,
241            denominator: denominator / common,
242        }
243    }
244
245    /// Returns the numerator of the reduced rate.
246    pub const fn numerator(self) -> u32 {
247        self.numerator
248    }
249
250    /// Returns the denominator of the reduced rate.
251    pub const fn denominator(self) -> u32 {
252        self.denominator
253    }
254
255    /// Returns the first frame's duration, rounded up if not tick-exact.
256    /// Use [`Time::from_frame`] for later boundaries to avoid accumulated rounding.
257    pub fn frame_duration(self) -> Duration {
258        Time::from_frame(1, self)
259    }
260}
261
262impl From<u32> for FrameRate {
263    fn from(frames_per_second: u32) -> Self {
264        Self::new(frames_per_second, 1)
265    }
266}
267
268/// Readable signed time literals, such as `2.seconds()` and `1.5.seconds()`.
269///
270/// Floating-point literals round to the nearest tick, away from zero at ties.
271/// Nonfinite or out-of-range values panic. Integer literals use exact arithmetic
272/// and panic on overflow, independently of the build profile.
273pub trait TimeLiteral {
274    /// Converts seconds to signed tick time.
275    fn seconds(self) -> Time;
276
277    /// Converts milliseconds to signed tick time.
278    fn milliseconds(self) -> Time;
279}
280
281impl TimeLiteral for i64 {
282    fn seconds(self) -> Time {
283        Time(
284            self.checked_mul(TICKS_PER_SECOND)
285                .expect("seconds overflow"),
286        )
287    }
288
289    fn milliseconds(self) -> Time {
290        Time(
291            self.checked_mul(TICKS_PER_SECOND / 1_000)
292                .expect("milliseconds overflow"),
293        )
294    }
295}
296
297impl TimeLiteral for i32 {
298    fn seconds(self) -> Time {
299        i64::from(self).seconds()
300    }
301
302    fn milliseconds(self) -> Time {
303        i64::from(self).milliseconds()
304    }
305}
306
307macro_rules! unsigned_time_literal {
308    ($($integer:ty),+ $(,)?) => {$ (
309        impl TimeLiteral for $integer {
310            fn seconds(self) -> Time { i64::try_from(self).expect("seconds overflow").seconds() }
311            fn milliseconds(self) -> Time { i64::try_from(self).expect("milliseconds overflow").milliseconds() }
312        }
313    )+};
314}
315
316unsigned_time_literal!(u32, u64, usize);
317
318impl TimeLiteral for f64 {
319    fn seconds(self) -> Time {
320        Time::from_seconds(self)
321    }
322
323    fn milliseconds(self) -> Time {
324        Time::rounded_ticks(self * (TICKS_PER_SECOND / 1_000) as f64)
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn unsigned_literals_and_integer_multipliers_preserve_large_tick_precision() {
334        assert_eq!(2_u32.seconds(), 2_i64.seconds());
335        assert_eq!(3_usize.milliseconds(), 3_i64.milliseconds());
336        assert_eq!(4_u64.seconds(), 4_i64.seconds());
337        let large = Time::from_ticks((1_i64 << 53) + 1);
338        assert_eq!((large * 3_i64).ticks(), ((1_i64 << 53) + 1) * 3);
339        assert_eq!((3_u32 * large).ticks(), (large * 3_i64).ticks());
340        assert_eq!((Time::from_ticks(-2) * 4_u64).ticks(), -8);
341        assert_eq!(Time::from_ticks(i64::MAX).checked_mul(2), None);
342        assert!(std::panic::catch_unwind(|| Time::from_ticks(i64::MAX) * 2_u32).is_err());
343        assert!(std::panic::catch_unwind(|| u64::MAX.seconds()).is_err());
344    }
345
346    #[test]
347    fn standard_video_and_audio_intervals_are_exact() {
348        for numerator in [24, 25, 30, 48, 50, 60, 90, 100, 120] {
349            let rate = FrameRate::from(numerator);
350            assert_eq!(
351                rate.frame_duration().ticks() * i64::from(numerator),
352                TICKS_PER_SECOND
353            );
354        }
355        for rate in [
356            FrameRate::NTSC_FILM,
357            FrameRate::NTSC,
358            FrameRate::NTSC_DOUBLE,
359        ] {
360            assert_eq!(
361                i128::from(rate.frame_duration().ticks()) * i128::from(rate.numerator()),
362                i128::from(TICKS_PER_SECOND) * i128::from(rate.denominator())
363            );
364        }
365        for sample_rate in [44_100, 48_000, 96_000] {
366            assert_eq!(TICKS_PER_SECOND % sample_rate, 0);
367        }
368        assert_eq!(FrameRate::new(60_000, 2_002), FrameRate::NTSC);
369        assert_eq!(FrameRate::new(120, 2), FrameRate::from(60));
370    }
371
372    #[test]
373    fn signed_frame_boundaries_round_trip_without_drift() {
374        for rate in [
375            FrameRate::from(24),
376            FrameRate::from(60),
377            FrameRate::NTSC,
378            FrameRate::NTSC_FILM,
379            FrameRate::new(11, 1),
380            FrameRate::new(123_457, 1_001),
381        ] {
382            for frame in [-1_000_000, -101, -1, 0, 1, 101, 1_000_000] {
383                let time = Time::from_frame(frame, rate);
384                assert_eq!(time.to_frame(rate), frame);
385                assert_eq!((time - Time::from_ticks(1)).to_frame(rate), frame - 1);
386            }
387        }
388        let rate = FrameRate::from(11);
389        assert_eq!(Time::from_frame(11, rate), 1.seconds());
390        assert_ne!(rate.frame_duration() * 11.0, 1.seconds());
391        assert_eq!(Time::from_ticks(-1).to_frame(FrameRate::from(30)), -1);
392        let tiny_time = Time::from_frame(i64::MAX, FrameRate::new(u32::MAX, 1));
393        assert!(tiny_time.ticks() > 0);
394    }
395
396    #[test]
397    fn checked_frame_conversions_preserve_rounding_and_reject_overflow() {
398        for rate in [
399            FrameRate::NTSC,
400            FrameRate::new(11, 1),
401            FrameRate::new(123_457, 1_001),
402        ] {
403            for frame in [-1_000_000, -1, 0, 1, 1_000_000] {
404                let time = Time::checked_from_frame(frame, rate).unwrap();
405                assert_eq!(time, Time::from_frame(frame, rate));
406                assert_eq!(time.checked_to_frame(rate), Some(frame));
407                assert_eq!(
408                    (time - Time::from_ticks(1)).checked_to_frame(rate),
409                    Some(frame - 1)
410                );
411            }
412        }
413        assert_eq!(Time::checked_from_frame(i64::MAX, FrameRate::from(1)), None);
414        assert_eq!(Time::checked_from_frame(i64::MIN, FrameRate::from(1)), None);
415        assert_eq!(
416            Time::from_ticks(i64::MAX).checked_to_frame(FrameRate::new(u32::MAX, 1)),
417            None
418        );
419        assert_eq!(
420            Time::from_ticks(i64::MIN).checked_to_frame(FrameRate::new(u32::MAX, 1)),
421            None
422        );
423    }
424
425    #[test]
426    fn literals_and_arithmetic_share_signed_tick_time() {
427        let duration: Duration = 1.5.seconds();
428        assert_eq!(duration, 1.seconds() + 500.milliseconds());
429        assert_eq!((-250).milliseconds(), -250.milliseconds());
430        assert_eq!(Time::from_seconds(1.0 / 1024.0).ticks(), 689_063);
431        assert_eq!(Time::from_seconds(-1.0 / 1024.0).ticks(), -689_063);
432        assert_eq!((1.0 / 128.0).milliseconds().ticks(), 5_513);
433        assert_eq!(2.0 * duration, 3.seconds());
434        assert_eq!(duration / 2.0, 750.milliseconds());
435        assert_eq!(duration / 500.milliseconds(), 3.0);
436        assert_eq!(duration.as_seconds(), 1.5);
437        let mut time = Time::ZERO;
438        time += duration;
439        time -= 2.seconds();
440        assert_eq!(time, -500.milliseconds());
441        for ticks in [i64::MIN, i64::MAX] {
442            assert_eq!(Time::from_ticks(ticks) * 1.0, Time::from_ticks(ticks));
443            assert_eq!(Time::from_ticks(ticks) / 1.0, Time::from_ticks(ticks));
444        }
445    }
446
447    #[test]
448    fn invalid_inputs_and_overflow_do_not_saturate_or_wrap() {
449        for seconds in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX] {
450            assert!(std::panic::catch_unwind(|| Time::from_seconds(seconds)).is_err());
451        }
452        for components in [(0, 1), (1, 0), (0, 0)] {
453            assert!(
454                std::panic::catch_unwind(|| FrameRate::new(components.0, components.1)).is_err()
455            );
456        }
457        let maximum = Time::from_ticks(i64::MAX);
458        let minimum = Time::from_ticks(i64::MIN);
459        assert!(std::panic::catch_unwind(|| maximum + Time::from_ticks(1)).is_err());
460        assert!(std::panic::catch_unwind(|| minimum - Time::from_ticks(1)).is_err());
461        assert!(std::panic::catch_unwind(|| -minimum).is_err());
462        assert!(std::panic::catch_unwind(|| maximum * 2.0).is_err());
463        assert!(std::panic::catch_unwind(|| 1.seconds() * f64::NAN).is_err());
464        assert!(std::panic::catch_unwind(|| 1.seconds() / 0.0).is_err());
465        assert!(std::panic::catch_unwind(|| Time::ZERO / Time::ZERO).is_err());
466        assert!(std::panic::catch_unwind(|| i64::MAX.seconds()).is_err());
467        assert!(std::panic::catch_unwind(|| i64::MAX.milliseconds()).is_err());
468        assert!(
469            std::panic::catch_unwind(|| Time::from_frame(i64::MAX, FrameRate::from(1))).is_err()
470        );
471        assert!(
472            std::panic::catch_unwind(|| maximum.to_frame(FrameRate::new(u32::MAX, 1))).is_err()
473        );
474    }
475}