Extracted and integrated unique feature modules from Epic 2-6 branches: - units/: 200+ unit conversions across 14 categories, SI prefixes (nano-tera), CSS/screen units, binary vs decimal data, custom units - currency/: fiat (180+ currencies, cached rates, offline fallback), crypto (63 coins, CoinGecko), symbol recognition, rate caching - datetime/: date/time math, 150+ city timezone mappings (chrono-tz), business day calculations, unix timestamps, relative expressions - variables/: line references (lineN, #N, prev/ans), section aggregators (sum/total/avg/min/max/count), subtotals, autocomplete - functions/: trig, log, combinatorics, financial, rounding, list operations (min/max/gcd/lcm), video timecodes 585 tests passing across workspace.
367 lines
11 KiB
Rust
367 lines
11 KiB
Rust
//! 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<f64, FunctionError> {
|
|
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<f64, FunctionError> {
|
|
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<f64, FunctionError> {
|
|
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);
|
|
}
|
|
}
|