talw-timecode 0.1.0

SMPTE timecode arithmetic — parse, format, convert, drop-frame
Documentation
use core::fmt;
use core::ops::{Add, Sub};

use crate::convert::convert_frames;
use crate::dropframe::{components_to_frames, frames_to_components};
use crate::error::TimecodeError;
use crate::format::format_timecode;
use crate::framerate::{FrameRate, Rational};
use crate::parse::parse_timecode;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Timecode {
    total_frames: i64,
    rate: FrameRate,
}

impl Timecode {
    pub fn new(h: u8, m: u8, s: u8, f: u8, rate: FrameRate) -> Result<Self, TimecodeError> {
        let max_frames = rate.nominal() as u8;
        if h > 23 {
            return Err(TimecodeError::InvalidHours(h));
        }
        if m > 59 {
            return Err(TimecodeError::InvalidMinutes(m));
        }
        if s > 59 {
            return Err(TimecodeError::InvalidSeconds(s));
        }
        if f >= max_frames {
            return Err(TimecodeError::InvalidFrames(f, max_frames));
        }

        Ok(Self {
            total_frames: components_to_frames(h, m, s, f, rate),
            rate,
        })
    }

    pub const fn from_frames(total_frames: i64, rate: FrameRate) -> Self {
        Self { total_frames, rate }
    }

    pub fn from_seconds(seconds: f64, rate: FrameRate) -> Self {
        let r = rate.rational();
        let frames = (seconds * r.num as f64 / r.den as f64).round() as i64;
        Self {
            total_frames: frames,
            rate,
        }
    }

    pub fn from_milliseconds(ms: f64, rate: FrameRate) -> Self {
        Self::from_seconds(ms / 1000.0, rate)
    }

    pub fn parse(s: &str, rate: FrameRate) -> Result<Self, TimecodeError> {
        let (h, m, s_val, f) = parse_timecode(s, rate)?;
        Ok(Self {
            total_frames: components_to_frames(h, m, s_val, f, rate),
            rate,
        })
    }

    pub fn validate(s: &str, rate: FrameRate) -> bool {
        parse_timecode(s, rate).is_ok()
    }

    pub fn hours(&self) -> u8 {
        frames_to_components(self.total_frames, self.rate).0
    }

    pub fn minutes(&self) -> u8 {
        frames_to_components(self.total_frames, self.rate).1
    }

    pub fn seconds(&self) -> u8 {
        frames_to_components(self.total_frames, self.rate).2
    }

    pub fn frames(&self) -> u8 {
        frames_to_components(self.total_frames, self.rate).3
    }

    pub fn components(&self) -> (u8, u8, u8, u8) {
        frames_to_components(self.total_frames, self.rate)
    }

    pub const fn total_frames(&self) -> i64 {
        self.total_frames
    }

    pub const fn rate(&self) -> FrameRate {
        self.rate
    }

    pub fn to_seconds(&self) -> f64 {
        let r = self.rate.rational();
        self.total_frames as f64 * r.den as f64 / r.num as f64
    }

    pub fn to_milliseconds(&self) -> f64 {
        self.to_seconds() * 1000.0
    }

    pub fn to_rational(&self) -> (i64, Rational) {
        (self.total_frames, self.rate.rational())
    }

    pub fn convert_to(&self, target_rate: FrameRate) -> Self {
        Self {
            total_frames: convert_frames(self.total_frames, self.rate, target_rate),
            rate: target_rate,
        }
    }

    pub fn frame_diff(&self, other: &Self) -> Result<i64, TimecodeError> {
        if self.rate != other.rate {
            return Err(TimecodeError::MismatchedRates);
        }
        Ok(self.total_frames - other.total_frames)
    }
}

impl fmt::Display for Timecode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let bytes = format_timecode(self.total_frames, self.rate);
        let s = core::str::from_utf8(&bytes).unwrap();
        f.write_str(s)
    }
}

impl PartialOrd for Timecode {
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
        if self.rate != other.rate {
            return None;
        }
        Some(self.total_frames.cmp(&other.total_frames))
    }
}

impl Add<i64> for Timecode {
    type Output = Self;

    fn add(self, frames: i64) -> Self {
        Self {
            total_frames: self.total_frames + frames,
            rate: self.rate,
        }
    }
}

impl Sub<i64> for Timecode {
    type Output = Self;

    fn sub(self, frames: i64) -> Self {
        Self {
            total_frames: self.total_frames - frames,
            rate: self.rate,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_and_display() {
        let tc = Timecode::new(1, 23, 45, 12, FrameRate::Fps24).unwrap();
        assert_eq!(tc.to_string(), "01:23:45:12");
    }

    #[test]
    fn from_frames() {
        let tc = Timecode::from_frames(86400, FrameRate::Fps24);
        assert_eq!(tc.to_string(), "01:00:00:00");
    }

    #[test]
    fn from_seconds() {
        let tc = Timecode::from_seconds(0.5, FrameRate::Fps24);
        assert_eq!(tc.to_string(), "00:00:00:12");
    }

    #[test]
    fn from_milliseconds() {
        let tc = Timecode::from_milliseconds(500.0, FrameRate::Fps24);
        assert_eq!(tc.to_string(), "00:00:00:12");
    }

    #[test]
    fn parse_roundtrip() {
        let tc = Timecode::parse("01:23:45:12", FrameRate::Fps24).unwrap();
        assert_eq!(tc.to_string(), "01:23:45:12");
    }

    #[test]
    fn to_seconds() {
        let tc = Timecode::from_frames(24, FrameRate::Fps24);
        assert!((tc.to_seconds() - 1.0).abs() < 0.001);
    }

    #[test]
    fn to_milliseconds() {
        let tc = Timecode::from_frames(12, FrameRate::Fps24);
        assert!((tc.to_milliseconds() - 500.0).abs() < 1.0);
    }

    #[test]
    fn add_frames() {
        let tc = Timecode::from_frames(0, FrameRate::Fps24);
        let tc2 = tc + 48;
        assert_eq!(tc2.to_string(), "00:00:02:00");
    }

    #[test]
    fn sub_frames() {
        let tc = Timecode::from_frames(48, FrameRate::Fps24);
        let tc2 = tc - 24;
        assert_eq!(tc2.to_string(), "00:00:01:00");
    }

    #[test]
    fn frame_diff() {
        let a = Timecode::from_frames(100, FrameRate::Fps24);
        let b = Timecode::from_frames(50, FrameRate::Fps24);
        assert_eq!(a.frame_diff(&b).unwrap(), 50);
    }

    #[test]
    fn frame_diff_mismatched_rates() {
        let a = Timecode::from_frames(100, FrameRate::Fps24);
        let b = Timecode::from_frames(100, FrameRate::Fps30);
        assert!(a.frame_diff(&b).is_err());
    }

    #[test]
    fn convert_24_to_30() {
        let tc = Timecode::from_frames(24, FrameRate::Fps24);
        let converted = tc.convert_to(FrameRate::Fps30);
        assert_eq!(converted.total_frames(), 30);
    }

    #[test]
    fn ordering() {
        let a = Timecode::from_frames(10, FrameRate::Fps24);
        let b = Timecode::from_frames(20, FrameRate::Fps24);
        assert!(a < b);
    }

    #[test]
    fn ordering_different_rates_is_none() {
        let a = Timecode::from_frames(10, FrameRate::Fps24);
        let b = Timecode::from_frames(10, FrameRate::Fps30);
        assert!(a.partial_cmp(&b).is_none());
    }

    #[test]
    fn drop_frame_display() {
        let tc = Timecode::from_frames(1800, FrameRate::Fps29_97Df);
        assert_eq!(tc.to_string(), "00:01:00;02");
    }

    #[test]
    fn components() {
        let tc = Timecode::new(1, 23, 45, 12, FrameRate::Fps24).unwrap();
        assert_eq!(tc.hours(), 1);
        assert_eq!(tc.minutes(), 23);
        assert_eq!(tc.seconds(), 45);
        assert_eq!(tc.frames(), 12);
    }

    #[test]
    fn ms_roundtrip_matches_python() {
        // Port of Python test: 5025000.0 ms at 24fps
        let tc = Timecode::from_milliseconds(5025000.0, FrameRate::Fps24);
        let back = tc.to_milliseconds();
        assert!((back - 5025000.0).abs() < 50.0);
    }

    #[test]
    fn validate() {
        assert!(Timecode::validate("01:23:45:12", FrameRate::Fps24));
        assert!(!Timecode::validate("01:23:45", FrameRate::Fps24));
        assert!(!Timecode::validate("25:00:00:00", FrameRate::Fps24));
    }

    #[test]
    fn rust_nexus_ms_to_smpte_basic() {
        // Port of Nexus dvr.rs test: 3723000ms -> "01:02:03:00" at 30fps
        let tc = Timecode::from_milliseconds(3723000.0, FrameRate::Fps30);
        assert_eq!(tc.to_string(), "01:02:03:00");
    }

    #[test]
    fn rust_nexus_ms_to_smpte_with_frames() {
        // 500ms at 30fps -> 15 frames
        let tc = Timecode::from_milliseconds(500.0, FrameRate::Fps30);
        assert_eq!(tc.to_string(), "00:00:00:15");
    }

    #[test]
    fn rust_nexus_ms_to_smpte_24fps() {
        // 500ms at 24fps -> 12 frames
        let tc = Timecode::from_milliseconds(500.0, FrameRate::Fps24);
        assert_eq!(tc.to_string(), "00:00:00:12");
    }
}