diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-07 01:09:13 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-07 01:09:13 +0200 |
| commit | e4b67094e757917588fb624a30aa046667403add (patch) | |
| tree | 55452692eac69a5a63b58f2a5cdb701dd7023d68 /rust/crates/time/src/timecode.rs | |
| parent | 79df73643185c02493364a077402726d1fa7b9ec (diff) | |
| download | opencut-e4b67094e757917588fb624a30aa046667403add.tar.gz | |
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'rust/crates/time/src/timecode.rs')
| -rw-r--r-- | rust/crates/time/src/timecode.rs | 287 |
1 files changed, 287 insertions, 0 deletions
diff --git a/rust/crates/time/src/timecode.rs b/rust/crates/time/src/timecode.rs new file mode 100644 index 00000000..0eb484d3 --- /dev/null +++ b/rust/crates/time/src/timecode.rs @@ -0,0 +1,287 @@ +use bridge::export; +use serde::{Deserialize, Serialize}; + +use crate::{ + frame_rate::FrameRate, + media_time::{MediaTime, TICKS_PER_SECOND}, +}; + +const SECONDS_PER_HOUR: i64 = 3_600; +const SECONDS_PER_MINUTE: i64 = 60; +const CENTISECONDS_PER_SECOND: i64 = 100; +const TICKS_PER_CENTISECOND: i64 = TICKS_PER_SECOND / CENTISECONDS_PER_SECOND; + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum TimeCodeFormat { + #[serde(rename = "MM:SS")] + MmSs, + #[serde(rename = "HH:MM:SS")] + HhMmSs, + #[serde(rename = "HH:MM:SS:CS")] + HhMmSsCs, + #[serde(rename = "HH:MM:SS:FF")] + HhMmSsFf, +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FormatTimecodeOptions { + pub time: MediaTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option<TimeCodeFormat>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate: Option<FrameRate>, +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseTimecodeOptions { + pub time_code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option<TimeCodeFormat>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate: Option<FrameRate>, +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GuessTimecodeFormatOptions { + pub time_code: String, +} + +#[export] +pub fn guess_timecode_format( + GuessTimecodeFormatOptions { time_code }: GuessTimecodeFormatOptions, +) -> Option<TimeCodeFormat> { + if time_code.trim().is_empty() { + return None; + } + + let part_count = time_code + .trim() + .split(':') + .try_fold(0usize, |count, part| { + part.parse::<u32>().ok().map(|_| count + 1) + })?; + + match part_count { + 2 => Some(TimeCodeFormat::MmSs), + 3 => Some(TimeCodeFormat::HhMmSs), + 4 => Some(TimeCodeFormat::HhMmSsFf), + _ => None, + } +} + +#[export] +pub fn format_timecode( + FormatTimecodeOptions { time, format, rate }: FormatTimecodeOptions, +) -> Option<String> { + let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs); + let total_ticks = u64::try_from(time.as_ticks().max(0)).ok()?; + let ticks_per_second = u64::try_from(TICKS_PER_SECOND).ok()?; + let total_seconds = total_ticks / ticks_per_second; + let hour_ticks = u64::try_from(SECONDS_PER_HOUR).ok()? * ticks_per_second; + let minute_ticks = u64::try_from(SECONDS_PER_MINUTE).ok()? * ticks_per_second; + let seconds_per_minute = u64::try_from(SECONDS_PER_MINUTE).ok()?; + let ticks_per_centisecond = u64::try_from(TICKS_PER_CENTISECOND).ok()?; + + let hours = total_ticks / hour_ticks; + let minutes = (total_ticks % hour_ticks) / minute_ticks; + let seconds = total_seconds % seconds_per_minute; + let second_ticks = total_ticks % ticks_per_second; + let centiseconds = second_ticks / ticks_per_centisecond; + + match format { + TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")), + TimeCodeFormat::HhMmSs => Some(format!("{hours:02}:{minutes:02}:{seconds:02}")), + TimeCodeFormat::HhMmSsCs => Some(format!( + "{hours:02}:{minutes:02}:{seconds:02}:{centiseconds:02}" + )), + TimeCodeFormat::HhMmSsFf => { + let rate = rate?; + let ticks_per_frame = rate.ticks_per_frame()?; + let frames = second_ticks / u64::try_from(ticks_per_frame).ok()?; + Some(format!("{hours:02}:{minutes:02}:{seconds:02}:{frames:02}")) + } + } +} + +#[export] +pub fn parse_timecode( + ParseTimecodeOptions { + time_code, + format, + rate, + }: ParseTimecodeOptions, +) -> Option<MediaTime> { + if time_code.trim().is_empty() { + return None; + } + + let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs); + let parts = time_code + .trim() + .split(':') + .map(|part| part.parse::<u32>().ok()) + .collect::<Option<Vec<_>>>()?; + + match format { + TimeCodeFormat::MmSs => { + let [minutes, seconds] = parts.as_slice() else { + return None; + }; + if i64::from(*seconds) >= SECONDS_PER_MINUTE { + return None; + } + + Some(MediaTime::from_ticks( + (i64::from(*minutes) * SECONDS_PER_MINUTE + i64::from(*seconds)) * TICKS_PER_SECOND, + )) + } + TimeCodeFormat::HhMmSs => { + let [hours, minutes, seconds] = parts.as_slice() else { + return None; + }; + if i64::from(*minutes) >= SECONDS_PER_MINUTE + || i64::from(*seconds) >= SECONDS_PER_MINUTE + { + return None; + } + + Some(MediaTime::from_ticks( + (i64::from(*hours) * SECONDS_PER_HOUR + + i64::from(*minutes) * SECONDS_PER_MINUTE + + i64::from(*seconds)) + * TICKS_PER_SECOND, + )) + } + TimeCodeFormat::HhMmSsCs => { + let [hours, minutes, seconds, centiseconds] = parts.as_slice() else { + return None; + }; + if i64::from(*minutes) >= SECONDS_PER_MINUTE + || i64::from(*seconds) >= SECONDS_PER_MINUTE + || i64::from(*centiseconds) >= CENTISECONDS_PER_SECOND + { + return None; + } + + Some(MediaTime::from_ticks( + (i64::from(*hours) * SECONDS_PER_HOUR + + i64::from(*minutes) * SECONDS_PER_MINUTE + + i64::from(*seconds)) + * TICKS_PER_SECOND + + i64::from(*centiseconds) * TICKS_PER_CENTISECOND, + )) + } + TimeCodeFormat::HhMmSsFf => { + let rate = rate?; + let frame_upper_bound = rate.frame_number_upper_bound()?; + let [hours, minutes, seconds, frames] = parts.as_slice() else { + return None; + }; + if i64::from(*minutes) >= SECONDS_PER_MINUTE + || i64::from(*seconds) >= SECONDS_PER_MINUTE + || *frames >= frame_upper_bound + { + return None; + } + + Some( + MediaTime::from_ticks( + (i64::from(*hours) * SECONDS_PER_HOUR + + i64::from(*minutes) * SECONDS_PER_MINUTE + + i64::from(*seconds)) + * TICKS_PER_SECOND, + ) + MediaTime::from_frame(i64::from(*frames), rate)?, + ) + } + } +} + +#[cfg(test)] +mod tests { + use crate::frame_rate::FrameRate; + use crate::media_time::MediaTime; + + use super::{FormatTimecodeOptions, GuessTimecodeFormatOptions, ParseTimecodeOptions}; + use super::{TimeCodeFormat, format_timecode, guess_timecode_format, parse_timecode}; + + #[test] + fn formats_default_and_frame_timecodes() { + assert_eq!( + format_timecode(FormatTimecodeOptions { + time: MediaTime::from_seconds_f64(3723.45).unwrap(), + format: None, + rate: None, + }), + Some("01:02:03:45".to_string()), + ); + assert_eq!( + format_timecode(FormatTimecodeOptions { + time: MediaTime::from_seconds_f64(1.5).unwrap(), + format: Some(TimeCodeFormat::HhMmSsFf), + rate: Some(FrameRate::FPS_30), + }), + Some("00:00:01:15".to_string()), + ); + } + + #[test] + fn parses_timecodes() { + assert_eq!( + parse_timecode(ParseTimecodeOptions { + time_code: "01:05".to_string(), + format: Some(TimeCodeFormat::MmSs), + rate: None, + }), + Some(MediaTime::from_seconds_f64(65.0).unwrap()), + ); + assert_eq!( + parse_timecode(ParseTimecodeOptions { + time_code: "00:00:01:15".to_string(), + format: Some(TimeCodeFormat::HhMmSsFf), + rate: Some(FrameRate::FPS_30), + }), + Some(MediaTime::from_seconds_f64(1.5).unwrap()), + ); + assert_eq!( + parse_timecode(ParseTimecodeOptions { + time_code: "00:00:01:30".to_string(), + format: Some(TimeCodeFormat::HhMmSsFf), + rate: Some(FrameRate::FPS_30), + }), + None, + ); + } + + #[test] + fn guesses_timecode_formats() { + assert_eq!( + guess_timecode_format(GuessTimecodeFormatOptions { + time_code: "01:05".to_string(), + }), + Some(TimeCodeFormat::MmSs), + ); + assert_eq!( + guess_timecode_format(GuessTimecodeFormatOptions { + time_code: "00:00:01".to_string(), + }), + Some(TimeCodeFormat::HhMmSs), + ); + assert_eq!( + guess_timecode_format(GuessTimecodeFormatOptions { + time_code: "00:00:01:15".to_string(), + }), + Some(TimeCodeFormat::HhMmSsFf), + ); + } +} |
