talw-timecode 0.1.0

SMPTE timecode arithmetic — parse, format, convert, drop-frame
Documentation
use crate::framerate::FrameRate;

pub fn frames_to_components(total_frames: i64, rate: FrameRate) -> (u8, u8, u8, u8) {
    let nom = rate.nominal();

    if !rate.is_drop_frame() {
        return non_drop_decompose(total_frames, nom);
    }

    let drop = rate.drop_count();
    let frames_per_min = nom * 60 - drop;
    let frames_per_10min = frames_per_min * 10 + drop;

    let abs_frames = if total_frames < 0 {
        let day_frames = frames_per_10min * 6 * 24;
        ((total_frames % day_frames as i64) + day_frames as i64) as u64
    } else {
        total_frames as u64
    };

    let first_minute_frames = (nom * 60) as u64;

    let ten_min_blocks = abs_frames / frames_per_10min as u64;
    let remainder = abs_frames % frames_per_10min as u64;

    let (minutes_in_block, frames_in_block) = if remainder < first_minute_frames {
        (0u64, remainder)
    } else {
        let adjusted = remainder - first_minute_frames;
        let mins = 1 + adjusted / frames_per_min as u64;
        let fr = adjusted % frames_per_min as u64 + drop as u64;
        (mins, fr)
    };

    let total_minutes = ten_min_blocks * 10 + minutes_in_block;
    let hours = (total_minutes / 60) % 24;
    let minutes = total_minutes % 60;
    let seconds = frames_in_block / nom as u64;
    let frames = frames_in_block % nom as u64;

    (hours as u8, minutes as u8, seconds as u8, frames as u8)
}

pub fn components_to_frames(h: u8, m: u8, s: u8, f: u8, rate: FrameRate) -> i64 {
    let nom = rate.nominal();

    let nominal_frames =
        h as i64 * 3600 * nom as i64
        + m as i64 * 60 * nom as i64
        + s as i64 * nom as i64
        + f as i64;

    if !rate.is_drop_frame() {
        return nominal_frames;
    }

    let drop = rate.drop_count() as i64;
    let total_minutes = h as i64 * 60 + m as i64;
    let drop_adjustment = drop * (total_minutes - total_minutes / 10);

    nominal_frames - drop_adjustment
}

fn non_drop_decompose(total_frames: i64, nom: u32) -> (u8, u8, u8, u8) {
    let fps = nom as u64;
    let day_frames = fps * 86400;

    let abs_frames = if total_frames < 0 {
        ((total_frames % day_frames as i64) + day_frames as i64) as u64
    } else {
        total_frames as u64
    };

    let frames = abs_frames % fps;
    let total_secs = abs_frames / fps;
    let seconds = total_secs % 60;
    let total_mins = total_secs / 60;
    let minutes = total_mins % 60;
    let hours = (total_mins / 60) % 24;

    (hours as u8, minutes as u8, seconds as u8, frames as u8)
}

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

    #[test]
    fn non_drop_zero() {
        assert_eq!(frames_to_components(0, FrameRate::Fps24), (0, 0, 0, 0));
    }

    #[test]
    fn non_drop_one_second() {
        assert_eq!(frames_to_components(24, FrameRate::Fps24), (0, 0, 1, 0));
    }

    #[test]
    fn non_drop_one_hour() {
        assert_eq!(
            frames_to_components(86400, FrameRate::Fps24),
            (1, 0, 0, 0)
        );
    }

    #[test]
    fn non_drop_roundtrip() {
        for frames in [0, 1, 12, 24, 1439, 86400, 172800, 2073599] {
            let (h, m, s, f) = frames_to_components(frames, FrameRate::Fps24);
            assert_eq!(
                components_to_frames(h, m, s, f, FrameRate::Fps24),
                frames
            );
        }
    }

    #[test]
    fn drop_frame_minute_boundary_29_97() {
        // Frame 1799 = 00:00:59:29 (last frame before minute 1)
        assert_eq!(
            frames_to_components(1799, FrameRate::Fps29_97Df),
            (0, 0, 59, 29)
        );
        // Frame 1800 = 00:01:00;02 (frames 0,1 are dropped)
        assert_eq!(
            frames_to_components(1800, FrameRate::Fps29_97Df),
            (0, 1, 0, 2)
        );
    }

    #[test]
    fn drop_frame_10th_minute_29_97() {
        // At 10-minute boundary, no frames are dropped
        // 10 minutes = 10*60*30 - 9*2 = 17982 frames
        let ten_min_frames = 17982i64;
        assert_eq!(
            frames_to_components(ten_min_frames, FrameRate::Fps29_97Df),
            (0, 10, 0, 0)
        );
    }

    #[test]
    fn drop_frame_roundtrip_29_97() {
        for frames in [0, 1, 2, 1798, 1799, 1800, 1801, 1802, 17982, 17983, 107892] {
            let (h, m, s, f) = frames_to_components(frames, FrameRate::Fps29_97Df);
            let back = components_to_frames(h, m, s, f, FrameRate::Fps29_97Df);
            assert_eq!(back, frames, "roundtrip failed at frame {frames}: ({h}:{m}:{s};{f})");
        }
    }

    #[test]
    fn drop_frame_59_94_minute_boundary() {
        // 59.94 DF drops 4 frames per minute (except every 10th)
        // Frame 3599 = 00:00:59:59
        assert_eq!(
            frames_to_components(3599, FrameRate::Fps59_94Df),
            (0, 0, 59, 59)
        );
        // Frame 3600 = 00:01:00;04
        assert_eq!(
            frames_to_components(3600, FrameRate::Fps59_94Df),
            (0, 1, 0, 4)
        );
    }

    #[test]
    fn drop_frame_59_94_roundtrip() {
        for frames in [0, 4, 3599, 3600, 3604, 35964, 35968] {
            let (h, m, s, f) = frames_to_components(frames, FrameRate::Fps59_94Df);
            let back = components_to_frames(h, m, s, f, FrameRate::Fps59_94Df);
            assert_eq!(back, frames, "59.94DF roundtrip failed at frame {frames}");
        }
    }

    #[test]
    fn negative_wraps_to_24h() {
        let (h, _, _, _) = frames_to_components(-24, FrameRate::Fps24);
        assert_eq!(h, 23);
    }
}