Skip to main content

use_media_timestamp/
lib.rs

1#![forbid(unsafe_code)]
2//! Primitive media timestamp helpers.
3//!
4//! These helpers keep timestamps explicit and frame conversion deterministic.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use use_media_timestamp::{MediaTimestamp, clamp_timestamp, timestamp_from_frame};
10//!
11//! let timestamp = timestamp_from_frame(60, 30.0).unwrap();
12//!
13//! assert_eq!(timestamp, MediaTimestamp::new(2.0).unwrap());
14//! assert_eq!(timestamp.frame_index(30.0).unwrap(), 60);
15//! assert_eq!(clamp_timestamp(12.0, 10.0).unwrap(), 10.0);
16//! ```
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct MediaTimestamp {
20    seconds: f64,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MediaTimestampError {
25    InvalidSeconds,
26    InvalidDuration,
27    InvalidFps,
28}
29
30fn validate_seconds(seconds: f64) -> Result<f64, MediaTimestampError> {
31    if !seconds.is_finite() || seconds < 0.0 {
32        Err(MediaTimestampError::InvalidSeconds)
33    } else {
34        Ok(seconds)
35    }
36}
37
38fn validate_duration(duration_seconds: f64) -> Result<f64, MediaTimestampError> {
39    if !duration_seconds.is_finite() || duration_seconds < 0.0 {
40        Err(MediaTimestampError::InvalidDuration)
41    } else {
42        Ok(duration_seconds)
43    }
44}
45
46fn validate_fps(fps: f64) -> Result<f64, MediaTimestampError> {
47    if !fps.is_finite() || fps <= 0.0 {
48        Err(MediaTimestampError::InvalidFps)
49    } else {
50        Ok(fps)
51    }
52}
53
54impl MediaTimestamp {
55    pub fn new(seconds: f64) -> Result<Self, MediaTimestampError> {
56        Ok(Self {
57            seconds: validate_seconds(seconds)?,
58        })
59    }
60
61    #[must_use]
62    pub fn seconds(&self) -> f64 {
63        self.seconds
64    }
65
66    #[must_use]
67    pub fn millis(&self) -> u64 {
68        (self.seconds * 1_000.0).round() as u64
69    }
70
71    pub fn frame_index(&self, fps: f64) -> Result<u64, MediaTimestampError> {
72        frame_index_at_time(self.seconds, fps)
73    }
74}
75
76pub fn timestamp_from_frame(
77    frame_index: u64,
78    fps: f64,
79) -> Result<MediaTimestamp, MediaTimestampError> {
80    MediaTimestamp::new(frame_index as f64 / validate_fps(fps)?)
81}
82
83pub fn frame_index_at_time(seconds: f64, fps: f64) -> Result<u64, MediaTimestampError> {
84    let seconds = validate_seconds(seconds)?;
85    let fps = validate_fps(fps)?;
86    Ok((seconds * fps).floor() as u64)
87}
88
89pub fn clamp_timestamp(seconds: f64, duration_seconds: f64) -> Result<f64, MediaTimestampError> {
90    let seconds = validate_seconds(seconds)?;
91    let duration_seconds = validate_duration(duration_seconds)?;
92    Ok(seconds.min(duration_seconds))
93}
94
95pub fn format_timestamp(seconds: f64) -> Result<String, MediaTimestampError> {
96    let total_millis = (validate_seconds(seconds)? * 1_000.0).round() as u64;
97    let hours = total_millis / 3_600_000;
98    let minutes = (total_millis % 3_600_000) / 60_000;
99    let seconds = (total_millis % 60_000) / 1_000;
100    let millis = total_millis % 1_000;
101
102    Ok(format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}"))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::{
108        MediaTimestamp, MediaTimestampError, clamp_timestamp, format_timestamp,
109        frame_index_at_time, timestamp_from_frame,
110    };
111
112    #[test]
113    fn converts_between_frames_and_timestamps() {
114        let timestamp = timestamp_from_frame(60, 30.0).unwrap();
115
116        assert_eq!(timestamp, MediaTimestamp::new(2.0).unwrap());
117        assert_eq!(timestamp.seconds(), 2.0);
118        assert_eq!(timestamp.millis(), 2_000);
119        assert_eq!(timestamp.frame_index(30.0).unwrap(), 60);
120        assert_eq!(frame_index_at_time(2.5, 24.0).unwrap(), 60);
121    }
122
123    #[test]
124    fn clamps_and_formats_timestamps() {
125        assert_eq!(clamp_timestamp(12.0, 10.0).unwrap(), 10.0);
126        assert_eq!(clamp_timestamp(8.0, 10.0).unwrap(), 8.0);
127        assert_eq!(format_timestamp(3661.25).unwrap(), "01:01:01.250");
128    }
129
130    #[test]
131    fn rejects_invalid_timestamp_inputs() {
132        assert_eq!(
133            MediaTimestamp::new(-1.0),
134            Err(MediaTimestampError::InvalidSeconds)
135        );
136        assert_eq!(
137            timestamp_from_frame(60, 0.0),
138            Err(MediaTimestampError::InvalidFps)
139        );
140        assert_eq!(
141            frame_index_at_time(f64::NAN, 30.0),
142            Err(MediaTimestampError::InvalidSeconds)
143        );
144        assert_eq!(
145            clamp_timestamp(1.0, -1.0),
146            Err(MediaTimestampError::InvalidDuration)
147        );
148    }
149}