//! Video timecode arithmetic. //! //! Timecodes represent positions in video as `HH:MM:SS:FF` where FF is a //! frame count within the current second. The number of frames per second //! (fps) determines the range of FF (0..fps-1). //! //! ## Functions //! //! - `tc_to_frames(hours, minutes, seconds, frames, fps)` — convert a //! timecode to a total frame count. //! - `frames_to_tc(total_frames, fps)` — convert total frames back to a //! packed timecode value `HH * 1_000_000 + MM * 10_000 + SS * 100 + FF` //! for easy extraction of components. //! - `tc_add_frames(hours, minutes, seconds, frames, fps, add_frames)` — //! add (or subtract) a number of frames to a timecode and return the new //! total frame count. //! //! Common fps values: 24, 25, 29.97 (NTSC drop-frame), 30, 48, 60. //! //! For now we work in non-drop-frame (NDF) mode. Drop-frame support can be //! added later. use super::{FunctionError, FunctionRegistry}; /// Convert a timecode (H, M, S, F, fps) to total frame count. fn tc_to_frames_fn(args: &[f64]) -> Result { if args.len() != 5 { return Err(FunctionError::new( "tc_to_frames expects 5 arguments: hours, minutes, seconds, frames, fps", )); } let hours = args[0]; let minutes = args[1]; let seconds = args[2]; let frames = args[3]; let fps = args[4]; validate_timecode_components(hours, minutes, seconds, frames, fps)?; let fps_i = fps as u64; let total = (hours as u64) * 3600 * fps_i + (minutes as u64) * 60 * fps_i + (seconds as u64) * fps_i + (frames as u64); Ok(total as f64) } /// Convert total frames to a packed timecode: HH*1_000_000 + MM*10_000 + SS*100 + FF. /// Returns the packed value. Also returns components via the packed encoding. fn frames_to_tc_fn(args: &[f64]) -> Result { if args.len() != 2 { return Err(FunctionError::new( "frames_to_tc expects 2 arguments: total_frames, fps", )); } let total = args[0]; let fps = args[1]; if total < 0.0 || total.fract() != 0.0 { return Err(FunctionError::new( "total_frames must be a non-negative integer", )); } if fps <= 0.0 || fps.fract() != 0.0 { return Err(FunctionError::new( "fps must be a positive integer", )); } let total = total as u64; let fps_i = fps as u64; let ff = total % fps_i; let rem = total / fps_i; let ss = rem % 60; let rem = rem / 60; let mm = rem % 60; let hh = rem / 60; // Pack into a single number: HH_MM_SS_FF let packed = hh * 1_000_000 + mm * 10_000 + ss * 100 + ff; Ok(packed as f64) } /// Add frames to a timecode and return new total frame count. fn tc_add_frames_fn(args: &[f64]) -> Result { if args.len() != 6 { return Err(FunctionError::new( "tc_add_frames expects 6 arguments: hours, minutes, seconds, frames, fps, add_frames", )); } let hours = args[0]; let minutes = args[1]; let seconds = args[2]; let frames = args[3]; let fps = args[4]; let add_frames = args[5]; validate_timecode_components(hours, minutes, seconds, frames, fps)?; let fps_i = fps as u64; let total = (hours as u64) * 3600 * fps_i + (minutes as u64) * 60 * fps_i + (seconds as u64) * fps_i + (frames as u64); let new_total = total as i64 + add_frames as i64; if new_total < 0 { return Err(FunctionError::new( "Resulting timecode would be negative", )); } Ok(new_total as f64) } fn validate_timecode_components( hours: f64, minutes: f64, seconds: f64, frames: f64, fps: f64, ) -> Result<(), FunctionError> { if fps <= 0.0 || fps.fract() != 0.0 { return Err(FunctionError::new("fps must be a positive integer")); } if hours < 0.0 || hours.fract() != 0.0 { return Err(FunctionError::new( "hours must be a non-negative integer", )); } if minutes < 0.0 || minutes >= 60.0 || minutes.fract() != 0.0 { return Err(FunctionError::new( "minutes must be an integer in 0..59", )); } if seconds < 0.0 || seconds >= 60.0 || seconds.fract() != 0.0 { return Err(FunctionError::new( "seconds must be an integer in 0..59", )); } if frames < 0.0 || frames >= fps || frames.fract() != 0.0 { return Err(FunctionError::new(format!( "frames must be an integer in 0..{} (fps={})", fps as u64 - 1, fps as u64, ))); } Ok(()) } /// Register timecode functions. pub fn register(reg: &mut FunctionRegistry) { reg.register_fixed("tc_to_frames", 5, tc_to_frames_fn); reg.register_fixed("frames_to_tc", 2, frames_to_tc_fn); reg.register_fixed("tc_add_frames", 6, tc_add_frames_fn); } #[cfg(test)] mod tests { use super::*; fn reg() -> FunctionRegistry { FunctionRegistry::new() } // --- tc_to_frames --- #[test] fn tc_to_frames_zero() { // 00:00:00:00 at 24fps => 0 frames let v = reg() .call("tc_to_frames", &[0.0, 0.0, 0.0, 0.0, 24.0]) .unwrap(); assert!((v - 0.0).abs() < 1e-10); } #[test] fn tc_to_frames_one_second_24fps() { // 00:00:01:00 at 24fps => 24 frames let v = reg() .call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 24.0]) .unwrap(); assert!((v - 24.0).abs() < 1e-10); } #[test] fn tc_to_frames_one_minute_24fps() { // 00:01:00:00 at 24fps => 1440 frames let v = reg() .call("tc_to_frames", &[0.0, 1.0, 0.0, 0.0, 24.0]) .unwrap(); assert!((v - 1440.0).abs() < 1e-10); } #[test] fn tc_to_frames_one_hour_24fps() { // 01:00:00:00 at 24fps => 86400 frames let v = reg() .call("tc_to_frames", &[1.0, 0.0, 0.0, 0.0, 24.0]) .unwrap(); assert!((v - 86400.0).abs() < 1e-10); } #[test] fn tc_to_frames_mixed() { // 01:02:03:04 at 24fps => 1*3600*24 + 2*60*24 + 3*24 + 4 = 86400 + 2880 + 72 + 4 = 89356 let v = reg() .call("tc_to_frames", &[1.0, 2.0, 3.0, 4.0, 24.0]) .unwrap(); assert!((v - 89356.0).abs() < 1e-10); } #[test] fn tc_to_frames_25fps() { // 00:00:01:00 at 25fps => 25 frames let v = reg() .call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 25.0]) .unwrap(); assert!((v - 25.0).abs() < 1e-10); } #[test] fn tc_to_frames_30fps() { // 00:00:01:00 at 30fps => 30 frames let v = reg() .call("tc_to_frames", &[0.0, 0.0, 1.0, 0.0, 30.0]) .unwrap(); assert!((v - 30.0).abs() < 1e-10); } #[test] fn tc_to_frames_60fps() { // 00:01:00:00 at 60fps => 3600 frames let v = reg() .call("tc_to_frames", &[0.0, 1.0, 0.0, 0.0, 60.0]) .unwrap(); assert!((v - 3600.0).abs() < 1e-10); } #[test] fn tc_to_frames_invalid_minutes() { let err = reg() .call("tc_to_frames", &[0.0, 60.0, 0.0, 0.0, 24.0]) .unwrap_err(); assert!(err.message.contains("minutes")); } #[test] fn tc_to_frames_invalid_frames() { // Frames >= fps is invalid let err = reg() .call("tc_to_frames", &[0.0, 0.0, 0.0, 24.0, 24.0]) .unwrap_err(); assert!(err.message.contains("frames")); } #[test] fn tc_to_frames_invalid_fps() { let err = reg() .call("tc_to_frames", &[0.0, 0.0, 0.0, 0.0, 0.0]) .unwrap_err(); assert!(err.message.contains("fps")); } // --- frames_to_tc --- #[test] fn frames_to_tc_zero() { let v = reg().call("frames_to_tc", &[0.0, 24.0]).unwrap(); assert!((v - 0.0).abs() < 1e-10); } #[test] fn frames_to_tc_one_second() { // 24 frames at 24fps => 00:00:01:00 => packed 100 let v = reg().call("frames_to_tc", &[24.0, 24.0]).unwrap(); assert!((v - 100.0).abs() < 1e-10); } #[test] fn frames_to_tc_one_minute() { // 1440 frames at 24fps => 00:01:00:00 => packed 10000 let v = reg().call("frames_to_tc", &[1440.0, 24.0]).unwrap(); assert!((v - 10000.0).abs() < 1e-10); } #[test] fn frames_to_tc_one_hour() { // 86400 frames at 24fps => 01:00:00:00 => packed 1000000 let v = reg().call("frames_to_tc", &[86400.0, 24.0]).unwrap(); assert!((v - 1_000_000.0).abs() < 1e-10); } #[test] fn frames_to_tc_mixed() { // 89356 frames at 24fps => 01:02:03:04 => packed 1020304 let v = reg().call("frames_to_tc", &[89356.0, 24.0]).unwrap(); assert!((v - 1_020_304.0).abs() < 1e-10); } #[test] fn frames_to_tc_roundtrip() { let r = reg(); // Convert to frames then back let frames = r .call("tc_to_frames", &[2.0, 30.0, 15.0, 12.0, 30.0]) .unwrap(); let packed = r.call("frames_to_tc", &[frames, 30.0]).unwrap(); // 02:30:15:12 => packed 2301512 assert!((packed - 2_301_512.0).abs() < 1e-10); } #[test] fn frames_to_tc_negative_error() { let err = reg().call("frames_to_tc", &[-1.0, 24.0]).unwrap_err(); assert!(err.message.contains("non-negative")); } // --- tc_add_frames --- #[test] fn tc_add_frames_simple() { let r = reg(); // 00:00:00:00 at 24fps + 48 frames => 48 let v = r .call("tc_add_frames", &[0.0, 0.0, 0.0, 0.0, 24.0, 48.0]) .unwrap(); assert!((v - 48.0).abs() < 1e-10); } #[test] fn tc_add_frames_subtract() { let r = reg(); // 00:00:02:00 at 24fps = 48 frames, subtract 24 => 24 let v = r .call("tc_add_frames", &[0.0, 0.0, 2.0, 0.0, 24.0, -24.0]) .unwrap(); assert!((v - 24.0).abs() < 1e-10); } #[test] fn tc_add_frames_negative_result_error() { let r = reg(); let err = r .call("tc_add_frames", &[0.0, 0.0, 0.0, 0.0, 24.0, -1.0]) .unwrap_err(); assert!(err.message.contains("negative")); } #[test] fn tc_add_frames_cross_minute_boundary() { let r = reg(); // 00:00:59:23 at 24fps + 1 frame let base = r .call("tc_to_frames", &[0.0, 0.0, 59.0, 23.0, 24.0]) .unwrap(); let v = r .call("tc_add_frames", &[0.0, 0.0, 59.0, 23.0, 24.0, 1.0]) .unwrap(); assert!((v - (base + 1.0)).abs() < 1e-10); // Verify the result converts to 00:01:00:00 let packed = r.call("frames_to_tc", &[v, 24.0]).unwrap(); assert!((packed - 10000.0).abs() < 1e-10); } }