Skip to main content

postproject_core/
time.rs

1//! Exact rational time values shared by media structures and adapters.
2
3use std::{cmp::Ordering, fmt, str::FromStr};
4
5use crate::{Error, ErrorKind, Result};
6
7/// A positive, normalized rational rate.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct RationalRate {
10    numerator: u32,
11    denominator: u32,
12}
13
14impl RationalRate {
15    /// Creates a reduced positive rate.
16    ///
17    /// # Errors
18    ///
19    /// Returns [`ErrorKind::InvalidArgument`] when either component is zero.
20    pub fn new(numerator: u32, denominator: u32) -> Result<Self> {
21        if numerator == 0 || denominator == 0 {
22            return Err(Error::new(
23                ErrorKind::InvalidArgument,
24                "rational rate components must be greater than zero",
25            ));
26        }
27        let divisor = greatest_common_divisor(numerator, denominator);
28        Ok(Self {
29            numerator: numerator / divisor,
30            denominator: denominator / divisor,
31        })
32    }
33
34    /// Returns the reduced numerator.
35    #[must_use]
36    pub const fn numerator(self) -> u32 {
37        self.numerator
38    }
39
40    /// Returns the reduced denominator.
41    #[must_use]
42    pub const fn denominator(self) -> u32 {
43        self.denominator
44    }
45}
46
47impl fmt::Display for RationalRate {
48    /// Formats the normalized rate as `numerator/denominator`.
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(formatter, "{}/{}", self.numerator, self.denominator)
51    }
52}
53
54impl FromStr for RationalRate {
55    type Err = Error;
56
57    /// Parses the canonical `numerator/denominator` representation.
58    fn from_str(value: &str) -> Result<Self> {
59        let (numerator, denominator) = value.split_once('/').ok_or_else(|| {
60            Error::new(
61                ErrorKind::InvalidArgument,
62                "rational rate must use numerator/denominator syntax",
63            )
64        })?;
65        let numerator = numerator.parse::<u32>().map_err(|_| {
66            Error::new(
67                ErrorKind::InvalidArgument,
68                "rational rate numerator must be an unsigned integer",
69            )
70        })?;
71        let denominator = denominator.parse::<u32>().map_err(|_| {
72            Error::new(
73                ErrorKind::InvalidArgument,
74                "rational rate denominator must be an unsigned integer",
75            )
76        })?;
77        Self::new(numerator, denominator)
78    }
79}
80
81/// An exact integer value measured at a rational rate.
82#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
83pub struct RationalTime {
84    value: i64,
85    rate: RationalRate,
86}
87
88impl RationalTime {
89    /// Creates a rational time value.
90    #[must_use]
91    pub const fn new(value: i64, rate: RationalRate) -> Self {
92        Self { value, rate }
93    }
94
95    /// Returns the integer value at this time's rate.
96    #[must_use]
97    pub const fn value(self) -> i64 {
98        self.value
99    }
100
101    /// Returns the rate used to interpret the value.
102    #[must_use]
103    pub const fn rate(self) -> RationalRate {
104        self.rate
105    }
106
107    /// Compares two time values exactly without floating-point conversion.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`ErrorKind::InvalidArgument`] if checked cross multiplication
112    /// exceeds the internal comparison range.
113    pub fn checked_cmp(self, other: Self) -> Result<Ordering> {
114        let left = comparison_product(self.value, self.rate.denominator, other.rate.numerator)?;
115        let right = comparison_product(other.value, other.rate.denominator, self.rate.numerator)?;
116        Ok(left.cmp(&right))
117    }
118
119    /// Returns whether two values denote the same exact time.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error under the same conditions as [`Self::checked_cmp`].
124    pub fn equivalent(self, other: Self) -> Result<bool> {
125        Ok(self.checked_cmp(other)? == Ordering::Equal)
126    }
127
128    /// Converts this value to `rate` when the result is an exact integer.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`ErrorKind::InvalidArgument`] if the target rate cannot
133    /// represent the time exactly or the converted value exceeds [`i64`].
134    pub fn rescaled_to(self, rate: RationalRate) -> Result<Self> {
135        let numerator = i128::from(self.value)
136            .checked_mul(i128::from(rate.numerator))
137            .and_then(|value| value.checked_mul(i128::from(self.rate.denominator)))
138            .ok_or_else(arithmetic_overflow)?;
139        let denominator = i128::from(self.rate.numerator)
140            .checked_mul(i128::from(rate.denominator))
141            .ok_or_else(arithmetic_overflow)?;
142        if numerator % denominator != 0 {
143            return Err(Error::new(
144                ErrorKind::InvalidArgument,
145                "rational time is not exactly representable at the target rate",
146            ));
147        }
148        let value = i64::try_from(numerator / denominator).map_err(|_| arithmetic_overflow())?;
149        Ok(Self::new(value, rate))
150    }
151}
152
153impl fmt::Display for RationalTime {
154    /// Formats the value and normalized rate as `value@numerator/denominator`.
155    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(formatter, "{}@{}", self.value, self.rate)
157    }
158}
159
160impl FromStr for RationalTime {
161    type Err = Error;
162
163    /// Parses the canonical `value@numerator/denominator` representation.
164    fn from_str(value: &str) -> Result<Self> {
165        let (value, rate) = value.split_once('@').ok_or_else(|| {
166            Error::new(
167                ErrorKind::InvalidArgument,
168                "rational time must use value@numerator/denominator syntax",
169            )
170        })?;
171        let value = value.parse::<i64>().map_err(|_| {
172            Error::new(
173                ErrorKind::InvalidArgument,
174                "rational time value must be a signed integer",
175            )
176        })?;
177        Ok(Self::new(value, rate.parse()?))
178    }
179}
180
181/// A half-open time range with a non-negative duration.
182#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
183pub struct TimeRange {
184    start: RationalTime,
185    duration: RationalTime,
186}
187
188impl TimeRange {
189    /// Creates a time range whose values use one rate.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`ErrorKind::InvalidArgument`] when the duration is negative or
194    /// uses a different rate from the start.
195    pub fn new(start: RationalTime, duration: RationalTime) -> Result<Self> {
196        if duration.value() < 0 {
197            return Err(Error::new(
198                ErrorKind::InvalidArgument,
199                "time range duration must not be negative",
200            ));
201        }
202        if start.rate() != duration.rate() {
203            return Err(Error::new(
204                ErrorKind::InvalidArgument,
205                "time range start and duration must use the same rate",
206            ));
207        }
208        Ok(Self { start, duration })
209    }
210
211    /// Returns the inclusive start time.
212    #[must_use]
213    pub const fn start(self) -> RationalTime {
214        self.start
215    }
216
217    /// Returns the non-negative duration.
218    #[must_use]
219    pub const fn duration(self) -> RationalTime {
220        self.duration
221    }
222
223    /// Returns the exclusive end, checking integer overflow.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`ErrorKind::InvalidArgument`] when start plus duration exceeds
228    /// the range of [`i64`].
229    pub fn end_exclusive(self) -> Result<RationalTime> {
230        let value = self
231            .start
232            .value()
233            .checked_add(self.duration.value())
234            .ok_or_else(arithmetic_overflow)?;
235        Ok(RationalTime::new(value, self.start.rate()))
236    }
237}
238
239impl fmt::Display for TimeRange {
240    /// Formats the range as `start+duration` using canonical rational times.
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        write!(formatter, "{}+{}", self.start, self.duration)
243    }
244}
245
246impl FromStr for TimeRange {
247    type Err = Error;
248
249    /// Parses the canonical `start+duration` representation.
250    fn from_str(value: &str) -> Result<Self> {
251        let (start, duration) = value.split_once('+').ok_or_else(|| {
252            Error::new(
253                ErrorKind::InvalidArgument,
254                "time range must use start+duration syntax",
255            )
256        })?;
257        Self::new(start.parse()?, duration.parse()?)
258    }
259}
260
261fn comparison_product(value: i64, denominator: u32, other_numerator: u32) -> Result<i128> {
262    i128::from(value)
263        .checked_mul(i128::from(denominator))
264        .and_then(|product| product.checked_mul(i128::from(other_numerator)))
265        .ok_or_else(arithmetic_overflow)
266}
267
268fn arithmetic_overflow() -> Error {
269    Error::new(
270        ErrorKind::InvalidArgument,
271        "rational time arithmetic overflow",
272    )
273}
274
275const fn greatest_common_divisor(mut left: u32, mut right: u32) -> u32 {
276    while right != 0 {
277        let remainder = left % right;
278        left = right;
279        right = remainder;
280    }
281    left
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn rate_is_positive_and_normalized() {
290        let rate = RationalRate::new(60_000, 2_002).expect("valid rate");
291
292        assert_eq!(rate.numerator(), 30_000);
293        assert_eq!(rate.denominator(), 1_001);
294        assert!(RationalRate::new(0, 1).is_err());
295        assert!(RationalRate::new(24, 0).is_err());
296    }
297
298    #[test]
299    fn range_requires_one_rate_and_non_negative_duration() {
300        let film = RationalRate::new(24, 1).expect("valid rate");
301        let video = RationalRate::new(25, 1).expect("valid rate");
302        let start = RationalTime::new(100, film);
303
304        assert!(TimeRange::new(start, RationalTime::new(48, film)).is_ok());
305        assert!(TimeRange::new(start, RationalTime::new(-1, film)).is_err());
306        assert!(TimeRange::new(start, RationalTime::new(50, video)).is_err());
307    }
308
309    #[test]
310    fn comparison_and_rescaling_are_exact_across_rates() {
311        let film = RationalRate::new(24, 1).expect("valid rate");
312        let video = RationalRate::new(48, 1).expect("valid rate");
313        let one_second_film = RationalTime::new(24, film);
314        let one_second_video = RationalTime::new(48, video);
315
316        assert!(one_second_film.equivalent(one_second_video).unwrap());
317        assert_eq!(
318            one_second_film.rescaled_to(video).unwrap(),
319            one_second_video
320        );
321        assert!(RationalTime::new(1, film).rescaled_to(video).is_ok());
322        assert!(RationalTime::new(1, video).rescaled_to(film).is_err());
323    }
324
325    #[test]
326    fn checked_time_operations_reject_i64_overflow() {
327        let rate = RationalRate::new(1, 1).expect("valid rate");
328        let faster = RationalRate::new(u32::MAX, 1).expect("valid rate");
329
330        assert!(
331            RationalTime::new(i64::MAX, rate)
332                .rescaled_to(faster)
333                .is_err()
334        );
335        let range = TimeRange::new(
336            RationalTime::new(i64::MAX, rate),
337            RationalTime::new(1, rate),
338        )
339        .expect("valid range");
340        assert!(range.end_exclusive().is_err());
341    }
342
343    #[test]
344    fn canonical_text_round_trips_common_rational_rates() {
345        for text in ["24/1", "25/1", "30000/1001", "60000/1001"] {
346            let rate: RationalRate = text.parse().expect("parse common rate");
347            assert_eq!(rate.to_string(), text);
348        }
349
350        let time: RationalTime = "-1001@30000/1001".parse().expect("parse time");
351        assert_eq!(time.to_string(), "-1001@30000/1001");
352        let range: TimeRange = "-1001@30000/1001+2002@30000/1001"
353            .parse()
354            .expect("parse range");
355        assert_eq!(range.to_string(), "-1001@30000/1001+2002@30000/1001");
356    }
357
358    #[test]
359    fn canonical_text_rejects_ambiguous_or_invalid_values() {
360        assert!("23.976".parse::<RationalRate>().is_err());
361        assert!("1@0/1".parse::<RationalTime>().is_err());
362        assert!("1@24/1+-1@24/1".parse::<TimeRange>().is_err());
363        assert!("1@24/1+1@25/1".parse::<TimeRange>().is_err());
364    }
365}