summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-29 14:25:25 +0200
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:58:46 +0200
commit5777a555f60b780b7db430fa7bfa66a3bcb2623f (patch)
treeef8878dc0cbacc4b164efed8a8bf685561302a46
parent220ec757d42b54f498d031436e5b4b03931b165c (diff)
downloadopencut-5777a555f60b780b7db430fa7bfa66a3bcb2623f.tar.gz
feat: add shared Rust crates for cross-platform time utilities
Introduce `bridge` proc-macro crate with `#[export]` attribute that conditionally applies `wasm_bindgen` for WASM builds, and `time` crate porting all timecode/frame utilities from TypeScript to Rust.
-rw-r--r--Cargo.toml2
-rw-r--r--apps/web/next-env.d.ts2
-rw-r--r--rust/README.md35
-rw-r--r--rust/crates/bridge/Cargo.toml12
-rw-r--r--rust/crates/bridge/src/bridge.rs49
-rw-r--r--rust/crates/time/Cargo.toml15
-rw-r--r--rust/crates/time/src/time.rs274
7 files changed, 388 insertions, 1 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 60b15458..012a9f27 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,4 +2,6 @@
resolver = "2"
members = [
"apps/desktop",
+ "rust/crates/time"
+ "rust/crates/bridge"
]
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index cb4c1e39..3a636e00 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/rust/README.md b/rust/README.md
new file mode 100644
index 00000000..1048f66d
--- /dev/null
+++ b/rust/README.md
@@ -0,0 +1,35 @@
+# rust/
+
+Shared Rust crates that power OpenCut across platforms (web via WASM, desktop natively).
+
+## Adding a new crate
+
+1. Create it under `rust/crates/`
+2. Add `bridge` as a dependency
+3. Annotate public functions with `#[export]`
+
+## How `#[export]` works
+
+```rust
+use bridge::export;
+
+#[export]
+pub fn round_to_frame(time: f64, fps: f64) -> f64 {
+ (time * fps).round() / fps
+}
+```
+
+Without the `wasm` feature, the macro is a no-op. With `--features wasm`, it expands to:
+
+```rust
+#[wasm_bindgen(js_name = "roundToFrame")]
+pub fn round_to_frame(time: f64, fps: f64) -> f64 { ... }
+```
+
+Desktop uses the crates directly as Cargo dependencies.
+
+## Testing
+
+```bash
+cargo test -p <crate>
+```
diff --git a/rust/crates/bridge/Cargo.toml b/rust/crates/bridge/Cargo.toml
new file mode 100644
index 00000000..3b1f0254
--- /dev/null
+++ b/rust/crates/bridge/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "bridge"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+path = "src/bridge.rs"
+proc-macro = true
+
+[dependencies]
+quote = "1.0.45"
+syn = { version = "2.0.117", features = ["full"] }
diff --git a/rust/crates/bridge/src/bridge.rs b/rust/crates/bridge/src/bridge.rs
new file mode 100644
index 00000000..52dee731
--- /dev/null
+++ b/rust/crates/bridge/src/bridge.rs
@@ -0,0 +1,49 @@
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::{ItemFn, parse_macro_input};
+
+#[proc_macro_attribute]
+pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
+ let function = parse_macro_input!(item as ItemFn);
+ let js_name = snake_to_camel(&function.sig.ident.to_string());
+
+ quote! {
+ #[cfg_attr(feature = "wasm", ::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name))]
+ #function
+ }
+ .into()
+}
+
+fn snake_to_camel(name: &str) -> String {
+ let mut camel = String::with_capacity(name.len());
+ let mut should_uppercase_next = false;
+
+ for character in name.chars() {
+ if character == '_' {
+ should_uppercase_next = true;
+ continue;
+ }
+
+ if should_uppercase_next {
+ camel.push(character.to_ascii_uppercase());
+ should_uppercase_next = false;
+ continue;
+ }
+
+ camel.push(character);
+ }
+
+ camel
+}
+
+#[cfg(test)]
+mod tests {
+ use super::snake_to_camel;
+
+ #[test]
+ fn converts_snake_case_to_camel_case() {
+ assert_eq!(snake_to_camel("do_something"), "doSomething");
+ assert_eq!(snake_to_camel("a_b_c"), "aBC");
+ assert_eq!(snake_to_camel("already"), "already");
+ }
+}
diff --git a/rust/crates/time/Cargo.toml b/rust/crates/time/Cargo.toml
new file mode 100644
index 00000000..08a93224
--- /dev/null
+++ b/rust/crates/time/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "time"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+path = "src/time.rs"
+crate-type = ["rlib", "cdylib"]
+
+[dependencies]
+bridge = { version = "0.1.0", path = "../bridge" }
+wasm-bindgen = { version = "0.2.115", optional = true }
+
+[features]
+wasm = ["dep:wasm-bindgen"] \ No newline at end of file
diff --git a/rust/crates/time/src/time.rs b/rust/crates/time/src/time.rs
new file mode 100644
index 00000000..222bb712
--- /dev/null
+++ b/rust/crates/time/src/time.rs
@@ -0,0 +1,274 @@
+use bridge::export;
+
+const DEFAULT_TIME_CODE_FORMAT: &str = "HH:MM:SS:CS";
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum TimeCodeFormat {
+ MmSs,
+ HhMmSs,
+ HhMmSsCs,
+ HhMmSsFf,
+}
+
+impl TimeCodeFormat {
+ fn parse(format: Option<&str>) -> Option<Self> {
+ match format.unwrap_or(DEFAULT_TIME_CODE_FORMAT) {
+ "MM:SS" => Some(Self::MmSs),
+ "HH:MM:SS" => Some(Self::HhMmSs),
+ "HH:MM:SS:CS" => Some(Self::HhMmSsCs),
+ "HH:MM:SS:FF" => Some(Self::HhMmSsFf),
+ _ => None,
+ }
+ }
+
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::MmSs => "MM:SS",
+ Self::HhMmSs => "HH:MM:SS",
+ Self::HhMmSsCs => "HH:MM:SS:CS",
+ Self::HhMmSsFf => "HH:MM:SS:FF",
+ }
+ }
+}
+
+#[export]
+pub fn round_to_frame(time: f64, fps: f64) -> f64 {
+ (time * fps).round() / fps
+}
+
+#[export]
+pub fn format_time_code(
+ time_in_seconds: f64,
+ format: Option<String>,
+ fps: Option<f64>,
+) -> Option<String> {
+ let format = TimeCodeFormat::parse(format.as_deref())?;
+ let hours = (time_in_seconds / 3600.0).floor() as u64;
+ let minutes = ((time_in_seconds % 3600.0) / 60.0).floor() as u64;
+ let seconds = (time_in_seconds % 60.0).floor() as u64;
+ let centiseconds = ((time_in_seconds % 1.0) * 100.0).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(time_code: &str, format: Option<String>, fps: Option<f64>) -> Option<f64> {
+ if time_code.trim().is_empty() {
+ return None;
+ }
+
+ let format = TimeCodeFormat::parse(format.as_deref())?;
+ 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 >= 60 {
+ return None;
+ }
+
+ Some((*minutes as f64 * 60.0) + *seconds as f64)
+ }
+ TimeCodeFormat::HhMmSs => {
+ let [hours, minutes, seconds] = parts.as_slice() else {
+ return None;
+ };
+ if *minutes >= 60 || *seconds >= 60 {
+ return None;
+ }
+
+ Some((*hours as f64 * 3600.0) + (*minutes as f64 * 60.0) + *seconds as f64)
+ }
+ TimeCodeFormat::HhMmSsCs => {
+ let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
+ return None;
+ };
+ if *minutes >= 60 || *seconds >= 60 || *centiseconds >= 100 {
+ return None;
+ }
+
+ Some(
+ (*hours as f64 * 3600.0)
+ + (*minutes as f64 * 60.0)
+ + *seconds as f64
+ + (*centiseconds as f64 / 100.0),
+ )
+ }
+ TimeCodeFormat::HhMmSsFf => {
+ let fps = fps?;
+ if fps <= 0.0 {
+ return None;
+ }
+
+ let [hours, minutes, seconds, frames] = parts.as_slice() else {
+ return None;
+ };
+ if *minutes >= 60 || *seconds >= 60 || *frames as f64 >= fps {
+ return None;
+ }
+
+ Some(
+ (*hours as f64 * 3600.0)
+ + (*minutes as f64 * 60.0)
+ + *seconds as f64
+ + (*frames as f64 / fps),
+ )
+ }
+ }
+}
+
+#[export]
+pub fn guess_time_code_format(time_code: &str) -> Option<String> {
+ 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.as_str().to_string()),
+ 3 => Some(TimeCodeFormat::HhMmSs.as_str().to_string()),
+ 4 => Some(TimeCodeFormat::HhMmSsFf.as_str().to_string()),
+ _ => None,
+ }
+}
+
+#[export]
+pub fn time_to_frame(time: f64, fps: f64) -> f64 {
+ (time * fps).round()
+}
+
+#[export]
+pub fn frame_to_time(frame: f64, fps: f64) -> f64 {
+ frame / fps
+}
+
+#[export]
+pub fn snap_time_to_frame(time: f64, fps: f64) -> f64 {
+ if fps <= 0.0 {
+ return time;
+ }
+
+ frame_to_time(time_to_frame(time, fps), fps)
+}
+
+#[export]
+pub fn get_snapped_seek_time(raw_time: f64, duration: f64, fps: f64) -> f64 {
+ let snapped_time = snap_time_to_frame(raw_time, fps);
+ let last_frame = get_last_frame_time(duration, fps);
+ snapped_time.clamp(0.0, last_frame)
+}
+
+#[export]
+pub fn get_last_frame_time(duration: f64, fps: f64) -> 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(1.24, 10.0), 1.2);
+ assert_eq!(round_to_frame(1.26, 10.0), 1.3);
+ }
+
+ #[test]
+ fn formats_default_time_codes() {
+ assert_eq!(
+ format_time_code(3723.45, None, None),
+ Some("01:02:03:44".to_string()),
+ );
+ assert_eq!(
+ format_time_code(65.0, Some("MM:SS".to_string()), None),
+ Some("01:05".to_string()),
+ );
+ }
+
+ #[test]
+ fn formats_frame_based_time_codes() {
+ assert_eq!(
+ format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), Some(30.0)),
+ Some("00:00:01:15".to_string()),
+ );
+ assert_eq!(
+ format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), None),
+ None,
+ );
+ }
+
+ #[test]
+ fn parses_time_codes() {
+ assert_eq!(
+ parse_time_code("01:05", Some("MM:SS".to_string()), None),
+ Some(65.0),
+ );
+ assert_eq!(
+ parse_time_code("00:00:01:15", Some("HH:MM:SS:FF".to_string()), Some(30.0)),
+ Some(1.5),
+ );
+ assert_eq!(
+ parse_time_code("00:00:01:30", Some("HH:MM:SS:FF".to_string()), Some(30.0)),
+ None,
+ );
+ }
+
+ #[test]
+ fn guesses_time_code_formats() {
+ assert_eq!(guess_time_code_format("01:05"), Some("MM:SS".to_string()));
+ assert_eq!(
+ guess_time_code_format("00:00:01"),
+ Some("HH:MM:SS".to_string()),
+ );
+ assert_eq!(
+ guess_time_code_format("00:00:01:15"),
+ Some("HH:MM:SS:FF".to_string()),
+ );
+ }
+
+ #[test]
+ fn snaps_and_clamps_seek_time() {
+ assert_eq!(time_to_frame(1.26, 10.0), 13.0);
+ assert_eq!(frame_to_time(13.0, 10.0), 1.3);
+ assert_eq!(snap_time_to_frame(1.26, 10.0), 1.3);
+ assert_eq!(get_last_frame_time(10.0, 5.0), 9.8);
+ assert_eq!(get_snapped_seek_time(10.0, 10.0, 5.0), 9.8);
+ }
+}