refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate

This commit is contained in:
Maze Winther
2026-04-07 01:09:13 +02:00
parent 79df736431
commit e4b67094e7
102 changed files with 4977 additions and 3707 deletions
+3 -2
View File
@@ -5,13 +5,14 @@ edition = "2024"
[lib]
path = "src/time.rs"
crate-type = ["rlib", "cdylib"]
crate-type = ["rlib"]
[dependencies]
bridge = { version = "0.1.0", path = "../bridge" }
num-traits = "0.2.19"
serde = { version = "1", features = ["derive"] }
tsify-next = { version = "0.5", optional = true }
wasm-bindgen = { version = "0.2.115", optional = true }
[features]
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
+121
View File
@@ -0,0 +1,121 @@
use serde::{Deserialize, Serialize};
use crate::media_time::TICKS_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 struct FrameRate {
pub numerator: u32,
pub denominator: u32,
}
impl FrameRate {
pub const FPS_23_976: Self = Self {
numerator: 24_000,
denominator: 1_001,
};
pub const FPS_24: Self = Self {
numerator: 24,
denominator: 1,
};
pub const FPS_25: Self = Self {
numerator: 25,
denominator: 1,
};
pub const FPS_29_97: Self = Self {
numerator: 30_000,
denominator: 1_001,
};
pub const FPS_30: Self = Self {
numerator: 30,
denominator: 1,
};
pub const FPS_48: Self = Self {
numerator: 48,
denominator: 1,
};
pub const FPS_50: Self = Self {
numerator: 50,
denominator: 1,
};
pub const FPS_59_94: Self = Self {
numerator: 60_000,
denominator: 1_001,
};
pub const FPS_60: Self = Self {
numerator: 60,
denominator: 1,
};
pub const FPS_120: Self = Self {
numerator: 120,
denominator: 1,
};
pub const fn new(numerator: u32, denominator: u32) -> Self {
Self {
numerator,
denominator,
}
}
pub const fn is_valid(self) -> bool {
self.numerator > 0 && self.denominator > 0
}
pub fn as_f64(self) -> Option<f64> {
if !self.is_valid() {
return None;
}
Some(f64::from(self.numerator) / f64::from(self.denominator))
}
pub fn frame_number_upper_bound(self) -> Option<u32> {
if !self.is_valid() {
return None;
}
Some(self.numerator.div_ceil(self.denominator))
}
pub fn ticks_per_frame(self) -> Option<i64> {
if !self.is_valid() {
return None;
}
let tick_numerator = TICKS_PER_SECOND.checked_mul(i64::from(self.denominator))?;
let tick_denominator = i64::from(self.numerator);
if tick_numerator % tick_denominator != 0 {
return None;
}
Some(tick_numerator / tick_denominator)
}
}
#[cfg(test)]
mod tests {
use super::FrameRate;
#[test]
fn resolves_ticks_per_standard_frame_rate() {
assert_eq!(FrameRate::FPS_23_976.ticks_per_frame(), Some(5_005));
assert_eq!(FrameRate::FPS_24.ticks_per_frame(), Some(5_000));
assert_eq!(FrameRate::FPS_25.ticks_per_frame(), Some(4_800));
assert_eq!(FrameRate::FPS_29_97.ticks_per_frame(), Some(4_004));
assert_eq!(FrameRate::FPS_30.ticks_per_frame(), Some(4_000));
assert_eq!(FrameRate::FPS_48.ticks_per_frame(), Some(2_500));
assert_eq!(FrameRate::FPS_50.ticks_per_frame(), Some(2_400));
assert_eq!(FrameRate::FPS_59_94.ticks_per_frame(), Some(2_002));
assert_eq!(FrameRate::FPS_60.ticks_per_frame(), Some(2_000));
assert_eq!(FrameRate::FPS_120.ticks_per_frame(), Some(1_000));
}
#[test]
fn rejects_invalid_or_unsupported_rates() {
assert_eq!(FrameRate::new(0, 1).ticks_per_frame(), None);
assert_eq!(FrameRate::new(1, 0).ticks_per_frame(), None);
assert_eq!(FrameRate::new(7, 3).ticks_per_frame(), None);
}
}
+428
View File
@@ -0,0 +1,428 @@
use std::ops::{Add, Div, Mul, Neg, Sub};
use bridge::export;
use num_traits::ToPrimitive;
use serde::{Deserialize, Serialize};
use crate::frame_rate::FrameRate;
#[export]
pub const TICKS_PER_SECOND: i64 = 120_000;
const TICKS_PER_SECOND_F64: f64 = TICKS_PER_SECOND as f64;
#[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, Ord, PartialOrd, Hash)]
pub struct MediaTime(i64);
impl MediaTime {
pub const ZERO: Self = Self(0);
pub const ONE_TICK: Self = Self(1);
pub const fn from_ticks(ticks: i64) -> Self {
Self(ticks)
}
pub const fn as_ticks(self) -> i64 {
self.0
}
pub fn from_seconds_f64(seconds: f64) -> Option<Self> {
if !seconds.is_finite() {
return None;
}
let ticks = (seconds * TICKS_PER_SECOND_F64).round().to_i64()?;
Some(Self(ticks))
}
pub fn to_seconds_f64(self) -> f64 {
self.0.to_f64().unwrap_or(0.0) / TICKS_PER_SECOND_F64
}
pub fn from_frame(frame: i64, rate: FrameRate) -> Option<Self> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(Self(frame.checked_mul(ticks_per_frame)?))
}
pub fn to_frame_round(self, rate: FrameRate) -> Option<i64> {
let ticks_per_frame = rate.ticks_per_frame()?;
let remainder = self.0.rem_euclid(ticks_per_frame);
let floor = self.0.div_euclid(ticks_per_frame);
if remainder * 2 >= ticks_per_frame {
Some(floor + 1)
} else {
Some(floor)
}
}
pub fn to_frame_floor(self, rate: FrameRate) -> Option<i64> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(self.0.div_euclid(ticks_per_frame))
}
pub fn round_to_frame(self, rate: FrameRate) -> Option<Self> {
Self::from_frame(self.to_frame_round(rate)?, rate)
}
pub fn floor_to_frame(self, rate: FrameRate) -> Option<Self> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(Self(self.0.div_euclid(ticks_per_frame) * ticks_per_frame))
}
pub fn is_frame_aligned(self, rate: FrameRate) -> Option<bool> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(self.0.rem_euclid(ticks_per_frame) == 0)
}
pub fn last_frame_time(self, rate: FrameRate) -> Option<Self> {
if self <= Self::ZERO {
return Some(Self::ZERO);
}
let last_inclusive_tick = self.0.checked_sub(1).unwrap_or(0);
Self::from_ticks(last_inclusive_tick).floor_to_frame(rate)
}
pub fn snapped_seek_time(self, duration: Self, rate: FrameRate) -> Option<Self> {
let snapped = self.round_to_frame(rate)?;
Some(snapped.clamp(Self::ZERO, duration))
}
pub fn clamp(self, min: Self, max: Self) -> Self {
Self(self.0.clamp(min.0, max.0))
}
pub fn min(self, other: Self) -> Self {
Self(self.0.min(other.0))
}
pub fn max(self, other: Self) -> Self {
Self(self.0.max(other.0))
}
}
impl Add for MediaTime {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl Sub for MediaTime {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
impl Neg for MediaTime {
type Output = Self;
fn neg(self) -> Self::Output {
Self(-self.0)
}
}
impl Mul<i64> for MediaTime {
type Output = Self;
fn mul(self, rhs: i64) -> Self::Output {
Self(self.0 * rhs)
}
}
impl Div<i64> for MediaTime {
type Output = Self;
fn div(self, rhs: i64) -> Self::Output {
Self(self.0 / rhs)
}
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeFromSecondsOptions {
pub seconds: f64,
}
#[export]
pub fn media_time_from_seconds(
MediaTimeFromSecondsOptions { seconds }: MediaTimeFromSecondsOptions,
) -> Option<MediaTime> {
MediaTime::from_seconds_f64(seconds)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeToSecondsOptions {
pub time: MediaTime,
}
#[export]
pub fn media_time_to_seconds(MediaTimeToSecondsOptions { time }: MediaTimeToSecondsOptions) -> f64 {
time.to_seconds_f64()
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeFromFrameOptions {
pub frame: i64,
pub rate: FrameRate,
}
#[export]
pub fn media_time_from_frame(
MediaTimeFromFrameOptions { frame, rate }: MediaTimeFromFrameOptions,
) -> Option<MediaTime> {
MediaTime::from_frame(frame, rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeToFrameOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn media_time_to_frame(
MediaTimeToFrameOptions { time, rate }: MediaTimeToFrameOptions,
) -> Option<i64> {
time.to_frame_round(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoundToFrameOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn round_to_frame(
RoundToFrameOptions { time, rate }: RoundToFrameOptions,
) -> Option<MediaTime> {
time.round_to_frame(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FloorToFrameOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn floor_to_frame(
FloorToFrameOptions { time, rate }: FloorToFrameOptions,
) -> Option<MediaTime> {
time.floor_to_frame(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IsFrameAlignedOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn is_frame_aligned(
IsFrameAlignedOptions { time, rate }: IsFrameAlignedOptions,
) -> Option<bool> {
time.is_frame_aligned(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastFrameTimeOptions {
pub duration: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn last_frame_time(
LastFrameTimeOptions { duration, rate }: LastFrameTimeOptions,
) -> Option<MediaTime> {
duration.last_frame_time(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnappedSeekTimeOptions {
pub time: MediaTime,
pub duration: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn snapped_seek_time(
SnappedSeekTimeOptions {
time,
duration,
rate,
}: SnappedSeekTimeOptions,
) -> Option<MediaTime> {
time.snapped_seek_time(duration, rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeAddOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_add(MediaTimeAddOptions { lhs, rhs }: MediaTimeAddOptions) -> MediaTime {
lhs + rhs
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeSubOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_sub(MediaTimeSubOptions { lhs, rhs }: MediaTimeSubOptions) -> MediaTime {
lhs - rhs
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeMinOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_min(MediaTimeMinOptions { lhs, rhs }: MediaTimeMinOptions) -> MediaTime {
lhs.min(rhs)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeMaxOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_max(MediaTimeMaxOptions { lhs, rhs }: MediaTimeMaxOptions) -> MediaTime {
lhs.max(rhs)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeClampOptions {
pub time: MediaTime,
pub min: MediaTime,
pub max: MediaTime,
}
#[export]
pub fn media_time_clamp(
MediaTimeClampOptions { time, min, max }: MediaTimeClampOptions,
) -> MediaTime {
time.clamp(min, max)
}
#[cfg(test)]
mod tests {
use crate::frame_rate::FrameRate;
use super::{MediaTime, TICKS_PER_SECOND};
#[test]
fn converts_between_seconds_and_ticks() {
assert_eq!(
MediaTime::from_seconds_f64(1.5),
Some(MediaTime::from_ticks(180_000))
);
assert_eq!(MediaTime::from_ticks(180_000).to_seconds_f64(), 1.5);
assert_eq!(TICKS_PER_SECOND, 120_000);
}
#[test]
fn rejects_non_finite_seconds() {
assert_eq!(MediaTime::from_seconds_f64(f64::NAN), None);
assert_eq!(MediaTime::from_seconds_f64(f64::INFINITY), None);
assert_eq!(MediaTime::from_seconds_f64(f64::NEG_INFINITY), None);
}
#[test]
fn snaps_to_the_nearest_frame() {
let rate = FrameRate::FPS_30;
let time = MediaTime::from_seconds_f64(1.26).unwrap();
assert_eq!(time.to_frame_round(rate), Some(38));
assert_eq!(
time.round_to_frame(rate),
Some(MediaTime::from_ticks(152_000))
);
}
#[test]
fn floors_to_frame() {
let rate = FrameRate::FPS_30;
let ticks_per_frame = 4_000;
let time = MediaTime::from_ticks(ticks_per_frame * 5 + 1);
assert_eq!(time.to_frame_floor(rate), Some(5));
assert_eq!(time.to_frame_round(rate), Some(5));
let almost_next = MediaTime::from_ticks(ticks_per_frame * 5 + ticks_per_frame / 2);
assert_eq!(almost_next.to_frame_floor(rate), Some(5));
assert_eq!(almost_next.to_frame_round(rate), Some(6));
}
#[test]
fn computes_last_frame_time_and_snapped_seek_time() {
let rate = FrameRate::new(5, 1);
let duration = MediaTime::from_seconds_f64(10.0).unwrap();
assert_eq!(
duration.last_frame_time(rate),
Some(MediaTime::from_seconds_f64(9.8).unwrap()),
);
assert_eq!(
MediaTime::from_seconds_f64(10.0)
.unwrap()
.snapped_seek_time(duration, rate),
Some(MediaTime::from_seconds_f64(10.0).unwrap()),
);
}
}
+18 -421
View File
@@ -1,422 +1,19 @@
use bridge::export;
use serde::{Deserialize, Serialize};
mod frame_rate;
mod media_time;
mod timecode;
const SECONDS_PER_HOUR: f64 = 3600.0;
const SECONDS_PER_MINUTE: f64 = 60.0;
const CENTISECONDS_PER_SECOND: f64 = 100.0;
#[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 RoundToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[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_in_seconds: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
}
#[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 fps: Option<f64>,
}
#[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,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameToTimeOptions {
pub frame: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapTimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSnappedSeekTimeOptions {
pub raw_time: f64,
pub duration: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetLastFrameTimeOptions {
pub duration: f64,
pub fps: f64,
}
#[export]
pub fn round_to_frame(RoundToFrameOptions { time, fps }: RoundToFrameOptions) -> f64 {
(time * fps).round() / fps
}
#[export]
pub fn format_time_code(
FormatTimeCodeOptions {
time_in_seconds,
format,
fps,
}: FormatTimeCodeOptions,
) -> Option<String> {
let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let hours = (time_in_seconds / SECONDS_PER_HOUR).floor() as u64;
let minutes = ((time_in_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).floor() as u64;
let seconds = (time_in_seconds % SECONDS_PER_MINUTE).floor() as u64;
let centiseconds = ((time_in_seconds % 1.0) * CENTISECONDS_PER_SECOND).floor() as u64;
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 fps = fps?;
if fps <= 0.0 {
return None;
}
let frames = ((time_in_seconds % 1.0) * fps).floor() as u64;
Some(format!(
"{hours:02}:{minutes:02}:{seconds:02}:{frames:02}",
))
}
}
}
#[export]
pub fn parse_time_code(
ParseTimeCodeOptions {
time_code,
format,
fps,
}: ParseTimeCodeOptions,
) -> Option<f64> {
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 *seconds >= SECONDS_PER_MINUTE as u32 {
return None;
}
Some((*minutes as f64 * SECONDS_PER_MINUTE) + *seconds as f64)
}
TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else {
return None;
};
if *minutes >= SECONDS_PER_MINUTE as u32 || *seconds >= SECONDS_PER_MINUTE as u32 {
return None;
}
Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64,
)
}
TimeCodeFormat::HhMmSsCs => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
return None;
};
if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *centiseconds >= CENTISECONDS_PER_SECOND as u32
{
return None;
}
Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
+ (*centiseconds as f64 / CENTISECONDS_PER_SECOND),
)
}
TimeCodeFormat::HhMmSsFf => {
let fps = fps?;
if fps <= 0.0 {
return None;
}
let [hours, minutes, seconds, frames] = parts.as_slice() else {
return None;
};
if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *frames as f64 >= fps
{
return None;
}
Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
+ (*frames as f64 / fps),
)
}
}
}
#[export]
pub fn guess_time_code_format(
GuessTimeCodeFormatOptions { time_code }: GuessTimeCodeFormatOptions,
) -> Option<TimeCodeFormat> {
if time_code.trim().is_empty() {
return None;
}
let part_count = time_code
.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 time_to_frame(TimeToFrameOptions { time, fps }: TimeToFrameOptions) -> f64 {
(time * fps).round()
}
#[export]
pub fn frame_to_time(FrameToTimeOptions { frame, fps }: FrameToTimeOptions) -> f64 {
frame / fps
}
#[export]
pub fn snap_time_to_frame(SnapTimeToFrameOptions { time, fps }: SnapTimeToFrameOptions) -> f64 {
if fps <= 0.0 {
return time;
}
frame_to_time(FrameToTimeOptions {
frame: time_to_frame(TimeToFrameOptions { time, fps }),
fps,
})
}
#[export]
pub fn get_snapped_seek_time(
GetSnappedSeekTimeOptions {
raw_time,
duration,
fps,
}: GetSnappedSeekTimeOptions,
) -> f64 {
let snapped_time = snap_time_to_frame(SnapTimeToFrameOptions { time: raw_time, fps });
let last_frame = get_last_frame_time(GetLastFrameTimeOptions { duration, fps });
snapped_time.clamp(0.0, last_frame)
}
#[export]
pub fn get_last_frame_time(
GetLastFrameTimeOptions { duration, fps }: GetLastFrameTimeOptions,
) -> f64 {
if duration <= 0.0 {
return 0.0;
}
if fps <= 0.0 {
return duration;
}
let frame_offset = 1.0 / fps;
(duration - frame_offset).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_to_the_nearest_frame() {
assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.24, fps: 10.0 }), 1.2);
assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
}
#[test]
fn formats_default_time_codes() {
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 3723.45,
format: None,
fps: None,
}),
Some("01:02:03:44".to_string()),
);
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 65.0,
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some("01:05".to_string()),
);
}
#[test]
fn formats_frame_based_time_codes() {
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some("00:00:01:15".to_string()),
);
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: None,
}),
None,
);
}
#[test]
fn parses_time_codes() {
assert_eq!(
parse_time_code(ParseTimeCodeOptions {
time_code: "01:05".to_string(),
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some(65.0),
);
assert_eq!(
parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:15".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some(1.5),
);
assert_eq!(
parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:30".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
None,
);
}
#[test]
fn guesses_time_code_formats() {
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions { time_code: "01:05".to_string() }),
Some(TimeCodeFormat::MmSs),
);
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions {
time_code: "00:00:01".to_string(),
}),
Some(TimeCodeFormat::HhMmSs),
);
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions {
time_code: "00:00:01:15".to_string(),
}),
Some(TimeCodeFormat::HhMmSsFf),
);
}
#[test]
fn snaps_and_clamps_seek_time() {
assert_eq!(time_to_frame(TimeToFrameOptions { time: 1.26, fps: 10.0 }), 13.0);
assert_eq!(frame_to_time(FrameToTimeOptions { frame: 13.0, fps: 10.0 }), 1.3);
assert_eq!(snap_time_to_frame(SnapTimeToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
assert_eq!(get_last_frame_time(GetLastFrameTimeOptions { duration: 10.0, fps: 5.0 }), 9.8);
assert_eq!(
get_snapped_seek_time(GetSnappedSeekTimeOptions {
raw_time: 10.0,
duration: 10.0,
fps: 5.0,
}),
9.8,
);
}
}
pub use frame_rate::FrameRate;
pub use media_time::{
FloorToFrameOptions, IsFrameAlignedOptions, LastFrameTimeOptions, MediaTime,
MediaTimeAddOptions, MediaTimeClampOptions, MediaTimeFromFrameOptions,
MediaTimeFromSecondsOptions, MediaTimeMaxOptions, MediaTimeMinOptions, MediaTimeSubOptions,
MediaTimeToFrameOptions, MediaTimeToSecondsOptions, RoundToFrameOptions,
SnappedSeekTimeOptions, TICKS_PER_SECOND, floor_to_frame, is_frame_aligned, last_frame_time,
media_time_add, media_time_clamp, media_time_from_frame, media_time_from_seconds,
media_time_max, media_time_min, media_time_sub, media_time_to_frame, media_time_to_seconds,
round_to_frame, snapped_seek_time,
};
pub use timecode::{
FormatTimecodeOptions, GuessTimecodeFormatOptions, ParseTimecodeOptions, TimeCodeFormat,
format_timecode, guess_timecode_format, parse_timecode,
};
+287
View File
@@ -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),
);
}
}