summaryrefslogtreecommitdiff
path: root/rust/crates/time/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
commite4b67094e757917588fb624a30aa046667403add (patch)
tree55452692eac69a5a63b58f2a5cdb701dd7023d68 /rust/crates/time/src
parent79df73643185c02493364a077402726d1fa7b9ec (diff)
downloadopencut-e4b67094e757917588fb624a30aa046667403add.tar.gz
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'rust/crates/time/src')
-rw-r--r--rust/crates/time/src/frame_rate.rs121
-rw-r--r--rust/crates/time/src/media_time.rs428
-rw-r--r--rust/crates/time/src/time.rs439
-rw-r--r--rust/crates/time/src/timecode.rs287
4 files changed, 854 insertions, 421 deletions
diff --git a/rust/crates/time/src/frame_rate.rs b/rust/crates/time/src/frame_rate.rs
new file mode 100644
index 00000000..7af10649
--- /dev/null
+++ b/rust/crates/time/src/frame_rate.rs
@@ -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);
+ }
+}
diff --git a/rust/crates/time/src/media_time.rs b/rust/crates/time/src/media_time.rs
new file mode 100644
index 00000000..11d268e5
--- /dev/null
+++ b/rust/crates/time/src/media_time.rs
@@ -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()),
+ );
+ }
+}
diff --git a/rust/crates/time/src/time.rs b/rust/crates/time/src/time.rs
index 84b69327..3d45c47f 100644
--- a/rust/crates/time/src/time.rs
+++ b/rust/crates/time/src/time.rs
@@ -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,
+};
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),
+ );
+ }
+}