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 @@ /// /// -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 +``` 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 { + 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, + fps: Option, +) -> Option { + 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, fps: Option) -> Option { + 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::().ok()) + .collect::>>()?; + + 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 { + if time_code.trim().is_empty() { + return None; + } + + let part_count = time_code + .split(':') + .try_fold(0usize, |count, part| { + part.parse::().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); + } +}