OpenCut/rust/crates/time/src/timecode.rs

288 lines
9.3 KiB
Rust

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),
);
}
}