Skip to main content

zvidlib_core/
timeline.rs

1use crate::{Error, ErrorKind, Result};
2use std::cmp::Ordering;
3
4/// A normalized signed rational number with a positive denominator.
5#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
6pub struct Rational {
7    numerator: i64,
8    denominator: i64,
9}
10
11impl Rational {
12    pub fn new(numerator: i64, denominator: i64) -> Result<Self> {
13        Self::from_wide(i128::from(numerator), i128::from(denominator))
14    }
15
16    pub const fn numerator(self) -> i64 {
17        self.numerator
18    }
19
20    pub const fn denominator(self) -> i64 {
21        self.denominator
22    }
23
24    pub const fn is_positive(self) -> bool {
25        self.numerator > 0
26    }
27
28    pub fn checked_add(self, other: Self) -> Result<Self> {
29        let numerator = i128::from(self.numerator) * i128::from(other.denominator)
30            + i128::from(other.numerator) * i128::from(self.denominator);
31        let denominator = i128::from(self.denominator) * i128::from(other.denominator);
32        Self::from_wide(numerator, denominator)
33    }
34
35    pub fn checked_sub(self, other: Self) -> Result<Self> {
36        let numerator = i128::from(self.numerator) * i128::from(other.denominator)
37            - i128::from(other.numerator) * i128::from(self.denominator);
38        let denominator = i128::from(self.denominator) * i128::from(other.denominator);
39        Self::from_wide(numerator, denominator)
40    }
41
42    pub fn checked_mul(self, other: Self) -> Result<Self> {
43        Self::from_wide(
44            i128::from(self.numerator) * i128::from(other.numerator),
45            i128::from(self.denominator) * i128::from(other.denominator),
46        )
47    }
48
49    pub fn checked_div(self, other: Self) -> Result<Self> {
50        if other.numerator == 0 {
51            return Err(invalid_timeline("cannot divide by zero"));
52        }
53        Self::from_wide(
54            i128::from(self.numerator) * i128::from(other.denominator),
55            i128::from(self.denominator) * i128::from(other.numerator),
56        )
57    }
58
59    fn from_wide(mut numerator: i128, mut denominator: i128) -> Result<Self> {
60        if denominator == 0 {
61            return Err(invalid_timeline("a rational denominator cannot be zero"));
62        }
63        if numerator == 0 {
64            return Ok(Self {
65                numerator: 0,
66                denominator: 1,
67            });
68        }
69        if denominator < 0 {
70            numerator = numerator
71                .checked_neg()
72                .ok_or_else(|| timeline_overflow("normalizing a rational"))?;
73            denominator = denominator
74                .checked_neg()
75                .ok_or_else(|| timeline_overflow("normalizing a rational"))?;
76        }
77
78        let divisor = gcd(numerator.unsigned_abs(), denominator.unsigned_abs());
79        let numerator = numerator / divisor as i128;
80        let denominator = denominator / divisor as i128;
81        Ok(Self {
82            numerator: i64::try_from(numerator)
83                .map_err(|_| timeline_overflow("storing a rational numerator"))?,
84            denominator: i64::try_from(denominator)
85                .map_err(|_| timeline_overflow("storing a rational denominator"))?,
86        })
87    }
88}
89
90impl Ord for Rational {
91    fn cmp(&self, other: &Self) -> Ordering {
92        (i128::from(self.numerator) * i128::from(other.denominator))
93            .cmp(&(i128::from(other.numerator) * i128::from(self.denominator)))
94    }
95}
96
97impl PartialOrd for Rational {
98    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
99        Some(self.cmp(other))
100    }
101}
102
103/// A zero-based presentation-order video frame index.
104#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
105pub struct FrameIndex(pub u64);
106
107/// A positive number of frames per second.
108#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
109pub struct FrameRate(Rational);
110
111impl FrameRate {
112    pub fn new(numerator: u32, denominator: u32) -> Result<Self> {
113        let value = Rational::new(i64::from(numerator), i64::from(denominator))?;
114        if !value.is_positive() {
115            return Err(invalid_timeline("a frame rate must be positive"));
116        }
117        Ok(Self(value))
118    }
119
120    pub const fn as_rational(self) -> Rational {
121        self.0
122    }
123}
124
125/// A half-open range in an audio track's sample clock.
126#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
127pub struct SampleRange {
128    pub start: u64,
129    pub end: u64,
130}
131
132impl SampleRange {
133    pub fn new(start: u64, end: u64) -> Result<Self> {
134        if end < start {
135            return Err(invalid_timeline(
136                "a sample range cannot end before it starts",
137            ));
138        }
139        Ok(Self { start, end })
140    }
141
142    pub const fn len(self) -> u64 {
143        self.end - self.start
144    }
145
146    pub const fn is_empty(self) -> bool {
147        self.start == self.end
148    }
149}
150
151/// A constant-frame-rate video timeline aligned to an audio sample clock.
152#[derive(Clone, Copy, Debug, Eq, PartialEq)]
153pub struct Timeline {
154    frame_rate: FrameRate,
155    audio_sample_rate: u32,
156}
157
158impl Timeline {
159    pub fn new(frame_rate: FrameRate, audio_sample_rate: u32) -> Result<Self> {
160        if audio_sample_rate == 0 {
161            return Err(invalid_timeline("an audio sample rate must be positive"));
162        }
163        Ok(Self {
164            frame_rate,
165            audio_sample_rate,
166        })
167    }
168
169    pub const fn frame_rate(self) -> FrameRate {
170        self.frame_rate
171    }
172
173    pub const fn audio_sample_rate(self) -> u32 {
174        self.audio_sample_rate
175    }
176
177    /// Returns the exact half-open audio interval covered by a video frame.
178    ///
179    /// Boundaries use floor rounding. Applying the same rule to both sides
180    /// guarantees that adjacent frame requests neither duplicate nor lose a
181    /// sample, including fractional rates such as 30000/1001.
182    pub fn audio_interval_for_frame(self, frame: FrameIndex) -> Result<SampleRange> {
183        let next_frame = frame
184            .0
185            .checked_add(1)
186            .ok_or_else(|| timeline_overflow("advancing a frame index"))?;
187        let rate = self.frame_rate.as_rational();
188        let numerator = u128::try_from(rate.numerator())
189            .map_err(|_| invalid_timeline("a frame rate must be positive"))?;
190        let denominator = u128::try_from(rate.denominator())
191            .map_err(|_| invalid_timeline("a frame-rate denominator must be positive"))?;
192        let samples = u128::from(self.audio_sample_rate);
193
194        let boundary = |index: u64| -> Result<u64> {
195            let value = u128::from(index)
196                .checked_mul(samples)
197                .and_then(|value| value.checked_mul(denominator))
198                .ok_or_else(|| timeline_overflow("mapping a frame to audio samples"))?
199                / numerator;
200            u64::try_from(value).map_err(|_| timeline_overflow("storing an audio sample boundary"))
201        };
202
203        SampleRange::new(boundary(frame.0)?, boundary(next_frame)?)
204    }
205
206    /// Returns the presentation frame containing an audio sample boundary.
207    ///
208    /// This is the inverse of [`Self::audio_interval_for_frame`] using floor
209    /// rounding, so playback driven by an audio clock never selects a future
210    /// frame.
211    pub fn frame_for_audio_sample(self, sample: u64) -> Result<FrameIndex> {
212        let rate = self.frame_rate.as_rational();
213        let numerator = u128::try_from(rate.numerator())
214            .map_err(|_| invalid_timeline("a frame rate must be positive"))?;
215        let denominator = u128::try_from(rate.denominator())
216            .map_err(|_| invalid_timeline("a frame-rate denominator must be positive"))?;
217        let divisor = u128::from(self.audio_sample_rate)
218            .checked_mul(denominator)
219            .ok_or_else(|| timeline_overflow("mapping an audio sample to a frame"))?;
220        let frame = u128::from(sample)
221            .checked_add(1)
222            .and_then(|value| value.checked_mul(numerator))
223            .and_then(|value| value.checked_sub(1))
224            .ok_or_else(|| timeline_overflow("mapping an audio sample to a frame"))?
225            / divisor;
226        Ok(FrameIndex(
227            u64::try_from(frame).map_err(|_| timeline_overflow("storing a frame index"))?,
228        ))
229    }
230}
231
232fn gcd(mut left: u128, mut right: u128) -> u128 {
233    while right != 0 {
234        let remainder = left % right;
235        left = right;
236        right = remainder;
237    }
238    left
239}
240
241fn invalid_timeline(message: &str) -> Error {
242    Error::new(ErrorKind::InvalidInput, message)
243}
244
245fn timeline_overflow(operation: &str) -> Error {
246    Error::new(
247        ErrorKind::ResourceLimit,
248        format!("timeline overflow while {operation}"),
249    )
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn rational_normalizes_sign_and_common_factors() {
258        assert_eq!(Rational::new(6, -8), Rational::new(-3, 4));
259        assert_eq!(Rational::new(0, -9), Rational::new(0, 1));
260    }
261
262    #[test]
263    fn rational_rejects_zero_denominator() {
264        let error = Rational::new(1, 0).unwrap_err();
265        assert_eq!(error.kind(), ErrorKind::InvalidInput);
266    }
267
268    #[test]
269    fn rational_arithmetic_is_checked_and_exact() {
270        let half = Rational::new(1, 2).unwrap();
271        let third = Rational::new(1, 3).unwrap();
272        assert_eq!(
273            half.checked_add(third).unwrap(),
274            Rational::new(5, 6).unwrap()
275        );
276        assert_eq!(
277            half.checked_sub(third).unwrap(),
278            Rational::new(1, 6).unwrap()
279        );
280        assert_eq!(
281            half.checked_mul(third).unwrap(),
282            Rational::new(1, 6).unwrap()
283        );
284        assert_eq!(
285            half.checked_div(third).unwrap(),
286            Rational::new(3, 2).unwrap()
287        );
288    }
289
290    #[test]
291    fn integral_frame_rate_maps_to_integral_sample_ranges() {
292        let timeline = Timeline::new(FrameRate::new(30, 1).unwrap(), 44_100).unwrap();
293        assert_eq!(
294            timeline.audio_interval_for_frame(FrameIndex(5)).unwrap(),
295            SampleRange {
296                start: 7_350,
297                end: 8_820
298            }
299        );
300    }
301
302    #[test]
303    fn fractional_frame_rate_has_contiguous_sample_ranges() {
304        let timeline = Timeline::new(FrameRate::new(30_000, 1_001).unwrap(), 48_000).unwrap();
305        let first = timeline.audio_interval_for_frame(FrameIndex(0)).unwrap();
306        let second = timeline.audio_interval_for_frame(FrameIndex(1)).unwrap();
307        let third = timeline.audio_interval_for_frame(FrameIndex(2)).unwrap();
308        assert_eq!(
309            first,
310            SampleRange {
311                start: 0,
312                end: 1_601
313            }
314        );
315        assert_eq!(first.end, second.start);
316        assert_eq!(second.end, third.start);
317        assert_eq!(third.end, 4_804);
318    }
319
320    #[test]
321    fn maximum_frame_index_reports_overflow() {
322        let timeline = Timeline::new(FrameRate::new(24, 1).unwrap(), 48_000).unwrap();
323        let error = timeline
324            .audio_interval_for_frame(FrameIndex(u64::MAX))
325            .unwrap_err();
326        assert_eq!(error.kind(), ErrorKind::ResourceLimit);
327    }
328
329    #[test]
330    fn audio_clock_mapping_selects_the_containing_frame() {
331        let timeline = Timeline::new(FrameRate::new(30_000, 1_001).unwrap(), 48_000).unwrap();
332        assert_eq!(timeline.frame_for_audio_sample(0).unwrap(), FrameIndex(0));
333        assert_eq!(
334            timeline.frame_for_audio_sample(1_600).unwrap(),
335            FrameIndex(0)
336        );
337        assert_eq!(
338            timeline.frame_for_audio_sample(1_601).unwrap(),
339            FrameIndex(1)
340        );
341    }
342}