summaryrefslogtreecommitdiff
path: root/rust/crates
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
parent79df73643185c02493364a077402726d1fa7b9ec (diff)
downloadopencut-e4b67094e757917588fb624a30aa046667403add.tar.gz
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'rust/crates')
-rw-r--r--rust/crates/bridge/src/bridge.rs50
-rw-r--r--rust/crates/effects/Cargo.toml14
-rw-r--r--rust/crates/effects/src/effects.rs5
-rw-r--r--rust/crates/effects/src/pipeline.rs306
-rw-r--r--rust/crates/effects/src/shaders/gaussian_blur.wgsl (renamed from rust/crates/gpu/src/shaders/gaussian_blur.wgsl)0
-rw-r--r--rust/crates/effects/src/types.rs13
-rw-r--r--rust/crates/gpu/Cargo.toml4
-rw-r--r--rust/crates/gpu/src/context.rs163
-rw-r--r--rust/crates/gpu/src/effect_pipeline.rs194
-rw-r--r--rust/crates/gpu/src/lib.rs23
-rw-r--r--rust/crates/gpu/src/shader_registry.rs183
-rw-r--r--rust/crates/masks/Cargo.toml13
-rw-r--r--rust/crates/masks/src/feather.rs (renamed from rust/crates/gpu/src/mask_feather.rs)50
-rw-r--r--rust/crates/masks/src/masks.rs5
-rw-r--r--rust/crates/masks/src/sdf.rs (renamed from rust/crates/gpu/src/sdf_pipeline.rs)33
-rw-r--r--rust/crates/masks/src/shaders/jfa_distance.wgsl (renamed from rust/crates/gpu/src/shaders/jfa_distance.wgsl)0
-rw-r--r--rust/crates/masks/src/shaders/jfa_init.wgsl (renamed from rust/crates/gpu/src/shaders/jfa_init.wgsl)0
-rw-r--r--rust/crates/masks/src/shaders/jfa_step.wgsl (renamed from rust/crates/gpu/src/shaders/jfa_step.wgsl)0
-rw-r--r--rust/crates/time/Cargo.toml5
-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
23 files changed, 1436 insertions, 900 deletions
diff --git a/rust/crates/bridge/src/bridge.rs b/rust/crates/bridge/src/bridge.rs
index 52dee731..99b00615 100644
--- a/rust/crates/bridge/src/bridge.rs
+++ b/rust/crates/bridge/src/bridge.rs
@@ -1,10 +1,36 @@
use proc_macro::TokenStream;
use quote::quote;
-use syn::{ItemFn, parse_macro_input};
+use syn::{FnArg, Item, ItemConst, ItemFn, parse_macro_input};
#[proc_macro_attribute]
pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
- let function = parse_macro_input!(item as ItemFn);
+ match parse_macro_input!(item as Item) {
+ Item::Fn(function) => export_fn(function),
+ Item::Const(constant) => export_const(constant),
+ other => syn::Error::new_spanned(other, "#[export] only supports fn and const items")
+ .to_compile_error()
+ .into(),
+ }
+}
+
+fn export_fn(function: ItemFn) -> TokenStream {
+ let param_count = function
+ .sig
+ .inputs
+ .iter()
+ .filter(|arg| matches!(arg, FnArg::Typed(_)))
+ .count();
+
+ if param_count > 1 {
+ return syn::Error::new_spanned(
+ &function.sig.inputs,
+ "#[export] functions must accept a single options struct, not positional arguments. \
+ Wrap parameters in a struct: `fn foo(FooOptions { a, b }: FooOptions)`",
+ )
+ .to_compile_error()
+ .into();
+ }
+
let js_name = snake_to_camel(&function.sig.ident.to_string());
quote! {
@@ -14,6 +40,26 @@ pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
.into()
}
+fn export_const(constant: ItemConst) -> TokenStream {
+ let js_name = constant.ident.to_string();
+ let const_ident = &constant.ident;
+ let getter_ident = syn::Ident::new(
+ &format!("__const_{}", constant.ident.to_string().to_lowercase()),
+ constant.ident.span(),
+ );
+
+ quote! {
+ #constant
+
+ #[cfg(feature = "wasm")]
+ #[::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name)]
+ pub fn #getter_ident() -> f64 {
+ #const_ident as f64
+ }
+ }
+ .into()
+}
+
fn snake_to_camel(name: &str) -> String {
let mut camel = String::with_capacity(name.len());
let mut should_uppercase_next = false;
diff --git a/rust/crates/effects/Cargo.toml b/rust/crates/effects/Cargo.toml
new file mode 100644
index 00000000..5b6e2a1e
--- /dev/null
+++ b/rust/crates/effects/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "effects"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+path = "src/effects.rs"
+crate-type = ["rlib"]
+
+[dependencies]
+bytemuck = { version = "1.25.0", features = ["derive"] }
+gpu = { version = "0.1.0", path = "../gpu" }
+thiserror = "2.0.18"
+wgpu = "29.0.1"
diff --git a/rust/crates/effects/src/effects.rs b/rust/crates/effects/src/effects.rs
new file mode 100644
index 00000000..c6d750bb
--- /dev/null
+++ b/rust/crates/effects/src/effects.rs
@@ -0,0 +1,5 @@
+mod pipeline;
+mod types;
+
+pub use pipeline::{ApplyEffectsOptions, EffectPipeline, EffectsError};
+pub use types::{EffectPass, UniformValue};
diff --git a/rust/crates/effects/src/pipeline.rs b/rust/crates/effects/src/pipeline.rs
new file mode 100644
index 00000000..58692bde
--- /dev/null
+++ b/rust/crates/effects/src/pipeline.rs
@@ -0,0 +1,306 @@
+use std::collections::HashMap;
+
+use bytemuck::{Pod, Zeroable};
+use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
+use thiserror::Error;
+use wgpu::util::DeviceExt;
+
+use crate::{EffectPass, UniformValue};
+
+const GAUSSIAN_BLUR_SHADER_ID: &str = "gaussian-blur";
+const GAUSSIAN_BLUR_SHADER_SOURCE: &str = include_str!("shaders/gaussian_blur.wgsl");
+
+pub struct ApplyEffectsOptions<'a> {
+ pub source: &'a wgpu::Texture,
+ pub width: u32,
+ pub height: u32,
+ pub passes: &'a [EffectPass],
+}
+
+pub struct EffectPipeline {
+ uniform_bind_group_layout: wgpu::BindGroupLayout,
+ pipelines: HashMap<String, wgpu::RenderPipeline>,
+}
+
+#[derive(Debug, Error)]
+pub enum EffectsError {
+ #[error("At least one effect pass is required")]
+ MissingEffectPasses,
+ #[error("Unknown effect shader '{shader}'")]
+ UnknownEffectShader { shader: String },
+ #[error("Missing uniform '{uniform}' for shader '{shader}'")]
+ MissingUniform { shader: String, uniform: String },
+ #[error("Uniform '{uniform}' for shader '{shader}' must be a number")]
+ InvalidNumberUniform { shader: String, uniform: String },
+ #[error(
+ "Uniform '{uniform}' for shader '{shader}' must be a vector of length {expected_length}"
+ )]
+ InvalidVectorUniform {
+ shader: String,
+ uniform: String,
+ expected_length: usize,
+ },
+ #[error("Shader '{shader}' does not support uniform '{uniform}'")]
+ UnsupportedUniform { shader: String, uniform: String },
+}
+
+#[repr(C)]
+#[derive(Clone, Copy, Pod, Zeroable)]
+struct EffectUniformBuffer {
+ resolution: [f32; 2],
+ direction: [f32; 2],
+ scalars: [f32; 4],
+}
+
+impl EffectPipeline {
+ pub fn new(context: &GpuContext) -> Self {
+ let uniform_bind_group_layout =
+ context
+ .device()
+ .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("effects-uniform-bind-group-layout"),
+ entries: &[wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
+ count: None,
+ }],
+ });
+ let vertex_shader_module =
+ context
+ .device()
+ .create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("effects-fullscreen-shader"),
+ source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()),
+ });
+ let gaussian_blur_shader_module =
+ context
+ .device()
+ .create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("effects-gaussian-blur-shader"),
+ source: wgpu::ShaderSource::Wgsl(GAUSSIAN_BLUR_SHADER_SOURCE.into()),
+ });
+ let pipeline_layout =
+ context
+ .device()
+ .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("effects-pipeline-layout"),
+ bind_group_layouts: &[
+ Some(context.texture_sampler_bind_group_layout()),
+ Some(&uniform_bind_group_layout),
+ ],
+ immediate_size: 0,
+ });
+ let gaussian_blur_pipeline =
+ context
+ .device()
+ .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("effects-gaussian-blur-pipeline"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &vertex_shader_module,
+ entry_point: Some("vertex_main"),
+ buffers: &[wgpu::VertexBufferLayout {
+ array_stride: std::mem::size_of::<[f32; 2]>() as u64,
+ step_mode: wgpu::VertexStepMode::Vertex,
+ attributes: &[wgpu::VertexAttribute {
+ format: wgpu::VertexFormat::Float32x2,
+ offset: 0,
+ shader_location: 0,
+ }],
+ }],
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &gaussian_blur_shader_module,
+ entry_point: Some("fragment_main"),
+ targets: &[Some(wgpu::ColorTargetState {
+ format: GPU_TEXTURE_FORMAT,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ }),
+ primitive: wgpu::PrimitiveState::default(),
+ depth_stencil: None,
+ multisample: wgpu::MultisampleState::default(),
+ multiview_mask: None,
+ cache: None,
+ });
+ let pipelines =
+ HashMap::from([(GAUSSIAN_BLUR_SHADER_ID.to_string(), gaussian_blur_pipeline)]);
+
+ Self {
+ uniform_bind_group_layout,
+ pipelines,
+ }
+ }
+
+ pub fn apply(
+ &self,
+ context: &GpuContext,
+ ApplyEffectsOptions {
+ source,
+ width,
+ height,
+ passes,
+ }: ApplyEffectsOptions<'_>,
+ ) -> Result<wgpu::Texture, EffectsError> {
+ let mut current_texture: Option<wgpu::Texture> = None;
+
+ for pass in passes {
+ let input_texture = current_texture.as_ref().unwrap_or(source);
+ let output_texture =
+ context.create_render_texture(width, height, "effects-pass-output");
+ let input_view = input_texture.create_view(&wgpu::TextureViewDescriptor::default());
+ let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
+ let texture_bind_group =
+ context
+ .device()
+ .create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("effects-texture-bind-group"),
+ layout: context.texture_sampler_bind_group_layout(),
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(&input_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(context.linear_sampler()),
+ },
+ ],
+ });
+ let uniform_buffer =
+ context
+ .device()
+ .create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: Some("effects-uniform-buffer"),
+ contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ });
+ let uniform_bind_group =
+ context
+ .device()
+ .create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("effects-uniform-bind-group"),
+ layout: &self.uniform_bind_group_layout,
+ entries: &[wgpu::BindGroupEntry {
+ binding: 0,
+ resource: uniform_buffer.as_entire_binding(),
+ }],
+ });
+ let pipeline = self.pipelines.get(&pass.shader).ok_or_else(|| {
+ EffectsError::UnknownEffectShader {
+ shader: pass.shader.clone(),
+ }
+ })?;
+ let mut encoder =
+ context
+ .device()
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("effects-command-encoder"),
+ });
+
+ {
+ let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("effects-render-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &output_view,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ occlusion_query_set: None,
+ timestamp_writes: None,
+ multiview_mask: None,
+ });
+ render_pass.set_pipeline(pipeline);
+ render_pass.set_vertex_buffer(0, context.fullscreen_quad().slice(..));
+ render_pass.set_bind_group(0, &texture_bind_group, &[]);
+ render_pass.set_bind_group(1, &uniform_bind_group, &[]);
+ render_pass.draw(0..6, 0..1);
+ }
+
+ context.queue().submit([encoder.finish()]);
+ current_texture = Some(output_texture);
+ }
+
+ current_texture.ok_or(EffectsError::MissingEffectPasses)
+ }
+}
+
+fn pack_effect_uniforms(
+ pass: &EffectPass,
+ width: u32,
+ height: u32,
+) -> Result<EffectUniformBuffer, EffectsError> {
+ let shader = pass.shader.as_str();
+ let sigma = read_number_uniform(pass, "u_sigma")?;
+ let step = read_number_uniform(pass, "u_step")?;
+ let direction = read_vec2_uniform(pass, "u_direction")?;
+
+ for uniform in pass.uniforms.keys() {
+ if uniform == "u_sigma" || uniform == "u_step" || uniform == "u_direction" {
+ continue;
+ }
+ return Err(EffectsError::UnsupportedUniform {
+ shader: shader.to_string(),
+ uniform: uniform.clone(),
+ });
+ }
+
+ Ok(EffectUniformBuffer {
+ resolution: [width as f32, height as f32],
+ direction,
+ scalars: [sigma, step, 0.0, 0.0],
+ })
+}
+
+fn read_number_uniform(pass: &EffectPass, uniform: &str) -> Result<f32, EffectsError> {
+ let Some(value) = pass.uniforms.get(uniform) else {
+ return Err(EffectsError::MissingUniform {
+ shader: pass.shader.clone(),
+ uniform: uniform.to_string(),
+ });
+ };
+ match value {
+ UniformValue::Number(value) => Ok(*value),
+ UniformValue::Vector(_) => Err(EffectsError::InvalidNumberUniform {
+ shader: pass.shader.clone(),
+ uniform: uniform.to_string(),
+ }),
+ }
+}
+
+fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], EffectsError> {
+ let Some(value) = pass.uniforms.get(uniform) else {
+ return Err(EffectsError::MissingUniform {
+ shader: pass.shader.clone(),
+ uniform: uniform.to_string(),
+ });
+ };
+ let UniformValue::Vector(values) = value else {
+ return Err(EffectsError::InvalidVectorUniform {
+ shader: pass.shader.clone(),
+ uniform: uniform.to_string(),
+ expected_length: 2,
+ });
+ };
+ if values.len() != 2 {
+ return Err(EffectsError::InvalidVectorUniform {
+ shader: pass.shader.clone(),
+ uniform: uniform.to_string(),
+ expected_length: 2,
+ });
+ }
+ Ok([values[0], values[1]])
+}
diff --git a/rust/crates/gpu/src/shaders/gaussian_blur.wgsl b/rust/crates/effects/src/shaders/gaussian_blur.wgsl
index 6c8d311d..6c8d311d 100644
--- a/rust/crates/gpu/src/shaders/gaussian_blur.wgsl
+++ b/rust/crates/effects/src/shaders/gaussian_blur.wgsl
diff --git a/rust/crates/effects/src/types.rs b/rust/crates/effects/src/types.rs
new file mode 100644
index 00000000..bd013195
--- /dev/null
+++ b/rust/crates/effects/src/types.rs
@@ -0,0 +1,13 @@
+use std::collections::HashMap;
+
+#[derive(Clone, Debug)]
+pub struct EffectPass {
+ pub shader: String,
+ pub uniforms: HashMap<String, UniformValue>,
+}
+
+#[derive(Clone, Debug)]
+pub enum UniformValue {
+ Number(f32),
+ Vector(Vec<f32>),
+}
diff --git a/rust/crates/gpu/Cargo.toml b/rust/crates/gpu/Cargo.toml
index d68e8a65..4be1e8d6 100644
--- a/rust/crates/gpu/Cargo.toml
+++ b/rust/crates/gpu/Cargo.toml
@@ -7,3 +7,7 @@ edition = "2024"
bytemuck = { version = "1.25.0", features = ["derive"] }
thiserror = "2.0.18"
wgpu = "29.0.1"
+
+[features]
+default = []
+wasm = []
diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs
index 702f65ba..1526a67d 100644
--- a/rust/crates/gpu/src/context.rs
+++ b/rust/crates/gpu/src/context.rs
@@ -1,12 +1,8 @@
use wgpu::util::DeviceExt;
-use crate::{
- GPU_TEXTURE_FORMAT, GpuError,
- effect_pipeline::{ApplyEffectsOptions, apply_effects},
- mask_feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline},
- sdf_pipeline::SdfPipeline,
- shader_registry::ShaderRegistry,
-};
+use crate::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuError};
+
+const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
[-1.0, -1.0],
@@ -25,9 +21,8 @@ pub struct GpuContext {
fullscreen_quad: wgpu::Buffer,
linear_sampler: wgpu::Sampler,
nearest_sampler: wgpu::Sampler,
- shader_registry: ShaderRegistry,
- sdf_pipeline: SdfPipeline,
- mask_feather_pipeline: MaskFeatherPipeline,
+ texture_sampler_bind_group_layout: wgpu::BindGroupLayout,
+ blit_pipeline: wgpu::RenderPipeline,
}
impl GpuContext {
@@ -77,9 +72,74 @@ impl GpuContext {
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
- let shader_registry = ShaderRegistry::new(&device);
- let sdf_pipeline = SdfPipeline::new(&device);
- let mask_feather_pipeline = MaskFeatherPipeline::new(&device);
+ let texture_sampler_bind_group_layout =
+ device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("gpu-texture-sampler-bind-group-layout"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ multisampled: false,
+ view_dimension: wgpu::TextureViewDimension::D2,
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ ],
+ });
+ let vertex_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("gpu-fullscreen-shader"),
+ source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()),
+ });
+ let blit_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("gpu-blit-shader"),
+ source: wgpu::ShaderSource::Wgsl(BLIT_SHADER_SOURCE.into()),
+ });
+ let blit_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("gpu-blit-pipeline-layout"),
+ bind_group_layouts: &[Some(&texture_sampler_bind_group_layout)],
+ immediate_size: 0,
+ });
+ let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("gpu-blit-pipeline"),
+ layout: Some(&blit_pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &vertex_shader_module,
+ entry_point: Some("vertex_main"),
+ buffers: &[wgpu::VertexBufferLayout {
+ array_stride: std::mem::size_of::<[f32; 2]>() as u64,
+ step_mode: wgpu::VertexStepMode::Vertex,
+ attributes: &[wgpu::VertexAttribute {
+ format: wgpu::VertexFormat::Float32x2,
+ offset: 0,
+ shader_location: 0,
+ }],
+ }],
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &blit_shader_module,
+ entry_point: Some("fragment_main"),
+ targets: &[Some(wgpu::ColorTargetState {
+ format: GPU_TEXTURE_FORMAT,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ }),
+ primitive: wgpu::PrimitiveState::default(),
+ depth_stencil: None,
+ multisample: wgpu::MultisampleState::default(),
+ multiview_mask: None,
+ cache: None,
+ });
Ok(Self {
instance,
@@ -89,26 +149,11 @@ impl GpuContext {
fullscreen_quad,
linear_sampler,
nearest_sampler,
- shader_registry,
- sdf_pipeline,
- mask_feather_pipeline,
+ texture_sampler_bind_group_layout,
+ blit_pipeline,
})
}
- pub fn apply_effects(
- &self,
- options: ApplyEffectsOptions<'_>,
- ) -> Result<wgpu::Texture, GpuError> {
- apply_effects(self, options)
- }
-
- pub fn apply_mask_feather(
- &self,
- options: ApplyMaskFeatherOptions<'_>,
- ) -> wgpu::Texture {
- self.mask_feather_pipeline.apply_mask_feather(self, options)
- }
-
pub fn create_render_texture(
&self,
width: u32,
@@ -162,12 +207,8 @@ impl GpuContext {
&self.nearest_sampler
}
- pub fn shader_registry(&self) -> &ShaderRegistry {
- &self.shader_registry
- }
-
- pub fn sdf_pipeline(&self) -> &SdfPipeline {
- &self.sdf_pipeline
+ pub fn texture_sampler_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
+ &self.texture_sampler_bind_group_layout
}
pub fn render_texture_to_surface(
@@ -202,7 +243,7 @@ impl GpuContext {
.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu-blit-bind-group"),
- layout: self.shader_registry.effect_texture_bind_group_layout(),
+ layout: &self.texture_sampler_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
@@ -237,7 +278,7 @@ impl GpuContext {
timestamp_writes: None,
multiview_mask: None,
});
- render_pass.set_pipeline(self.shader_registry.blit_pipeline());
+ render_pass.set_pipeline(&self.blit_pipeline);
render_pass.set_vertex_buffer(0, self.fullscreen_quad.slice(..));
render_pass.set_bind_group(0, &bind_group, &[]);
render_pass.draw(0..6, 0..1);
@@ -247,4 +288,50 @@ impl GpuContext {
surface_texture.present();
Ok(())
}
+
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ pub fn import_offscreen_canvas_texture(
+ &self,
+ canvas: &wgpu::web_sys::OffscreenCanvas,
+ width: u32,
+ height: u32,
+ label: &'static str,
+ ) -> wgpu::Texture {
+ let texture = self.create_render_texture(width, height, label);
+ self.queue.copy_external_image_to_texture(
+ &wgpu::CopyExternalImageSourceInfo {
+ source: wgpu::ExternalImageSource::OffscreenCanvas(canvas.clone()),
+ origin: wgpu::Origin2d::ZERO,
+ flip_y: true,
+ },
+ wgpu::CopyExternalImageDestInfo {
+ texture: &texture,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ color_space: wgpu::PredefinedColorSpace::Srgb,
+ premultiplied_alpha: false,
+ },
+ wgpu::Extent3d {
+ width,
+ height,
+ depth_or_array_layers: 1,
+ },
+ );
+ texture
+ }
+
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ pub fn render_texture_to_offscreen_canvas(
+ &self,
+ texture: &wgpu::Texture,
+ canvas: &wgpu::web_sys::OffscreenCanvas,
+ width: u32,
+ height: u32,
+ ) -> Result<(), GpuError> {
+ let surface = self
+ .instance
+ .create_surface(wgpu::SurfaceTarget::OffscreenCanvas(canvas.clone()))?;
+ self.render_texture_to_surface(texture, &surface, width, height)
+ }
}
diff --git a/rust/crates/gpu/src/effect_pipeline.rs b/rust/crates/gpu/src/effect_pipeline.rs
deleted file mode 100644
index c600551a..00000000
--- a/rust/crates/gpu/src/effect_pipeline.rs
+++ /dev/null
@@ -1,194 +0,0 @@
-use std::collections::HashMap;
-
-use bytemuck::{Pod, Zeroable};
-use wgpu::util::DeviceExt;
-
-use crate::{GpuError, context::GpuContext};
-
-pub struct ApplyEffectsOptions<'a> {
- pub source: &'a wgpu::Texture,
- pub width: u32,
- pub height: u32,
- pub passes: &'a [EffectPass],
-}
-
-#[derive(Clone, Debug)]
-pub struct EffectPass {
- pub shader: String,
- pub uniforms: HashMap<String, UniformValue>,
-}
-
-#[derive(Clone, Debug)]
-pub enum UniformValue {
- Number(f32),
- Vector(Vec<f32>),
-}
-
-#[repr(C)]
-#[derive(Clone, Copy, Pod, Zeroable)]
-struct EffectUniformBuffer {
- resolution: [f32; 2],
- direction: [f32; 2],
- scalars: [f32; 4],
-}
-
-pub fn apply_effects(
- context: &GpuContext,
- ApplyEffectsOptions {
- source,
- width,
- height,
- passes,
- }: ApplyEffectsOptions<'_>,
-) -> Result<wgpu::Texture, GpuError> {
- let mut current_texture: Option<wgpu::Texture> = None;
-
- for pass in passes {
- let input_texture = current_texture.as_ref().unwrap_or(source);
- let output_texture =
- context.create_render_texture(width, height, "gpu-effect-pass-output");
- let input_view = input_texture.create_view(&wgpu::TextureViewDescriptor::default());
- let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
- let texture_bind_group = context
- .device()
- .create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("gpu-effect-texture-bind-group"),
- layout: context
- .shader_registry()
- .effect_texture_bind_group_layout(),
- entries: &[
- wgpu::BindGroupEntry {
- binding: 0,
- resource: wgpu::BindingResource::TextureView(&input_view),
- },
- wgpu::BindGroupEntry {
- binding: 1,
- resource: wgpu::BindingResource::Sampler(context.linear_sampler()),
- },
- ],
- });
- let uniform_buffer = context
- .device()
- .create_buffer_init(&wgpu::util::BufferInitDescriptor {
- label: Some("gpu-effect-uniform-buffer"),
- contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
- usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
- });
- let uniform_bind_group = context
- .device()
- .create_bind_group(&wgpu::BindGroupDescriptor {
- label: Some("gpu-effect-uniform-bind-group"),
- layout: context
- .shader_registry()
- .effect_uniform_bind_group_layout(),
- entries: &[wgpu::BindGroupEntry {
- binding: 0,
- resource: uniform_buffer.as_entire_binding(),
- }],
- });
- let pipeline = context.shader_registry().get_effect_pipeline(&pass.shader)?;
- let mut encoder = context
- .device()
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("gpu-effect-command-encoder"),
- });
-
- {
- let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
- label: Some("gpu-effect-render-pass"),
- color_attachments: &[Some(wgpu::RenderPassColorAttachment {
- view: &output_view,
- resolve_target: None,
- depth_slice: None,
- ops: wgpu::Operations {
- load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
- store: wgpu::StoreOp::Store,
- },
- })],
- depth_stencil_attachment: None,
- occlusion_query_set: None,
- timestamp_writes: None,
- multiview_mask: None,
- });
- render_pass.set_pipeline(pipeline);
- render_pass.set_vertex_buffer(0, context.fullscreen_quad().slice(..));
- render_pass.set_bind_group(0, &texture_bind_group, &[]);
- render_pass.set_bind_group(1, &uniform_bind_group, &[]);
- render_pass.draw(0..6, 0..1);
- }
-
- context.queue().submit([encoder.finish()]);
- current_texture = Some(output_texture);
- }
-
- current_texture.ok_or_else(|| GpuError::UnknownEffectShader {
- shader: "missing-effect-pass".to_string(),
- })
-}
-
-fn pack_effect_uniforms(
- pass: &EffectPass,
- width: u32,
- height: u32,
-) -> Result<EffectUniformBuffer, GpuError> {
- let shader = pass.shader.as_str();
- let sigma = read_number_uniform(pass, "u_sigma")?;
- let step = read_number_uniform(pass, "u_step")?;
- let direction = read_vec2_uniform(pass, "u_direction")?;
-
- for uniform in pass.uniforms.keys() {
- if uniform == "u_sigma" || uniform == "u_step" || uniform == "u_direction" {
- continue;
- }
- return Err(GpuError::UnsupportedUniform {
- shader: shader.to_string(),
- uniform: uniform.clone(),
- });
- }
-
- Ok(EffectUniformBuffer {
- resolution: [width as f32, height as f32],
- direction,
- scalars: [sigma, step, 0.0, 0.0],
- })
-}
-
-fn read_number_uniform(pass: &EffectPass, uniform: &str) -> Result<f32, GpuError> {
- let Some(value) = pass.uniforms.get(uniform) else {
- return Err(GpuError::MissingUniform {
- shader: pass.shader.clone(),
- uniform: uniform.to_string(),
- });
- };
- match value {
- UniformValue::Number(value) => Ok(*value),
- UniformValue::Vector(_) => Err(GpuError::InvalidNumberUniform {
- shader: pass.shader.clone(),
- uniform: uniform.to_string(),
- }),
- }
-}
-
-fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], GpuError> {
- let Some(value) = pass.uniforms.get(uniform) else {
- return Err(GpuError::MissingUniform {
- shader: pass.shader.clone(),
- uniform: uniform.to_string(),
- });
- };
- let UniformValue::Vector(values) = value else {
- return Err(GpuError::InvalidVectorUniform {
- shader: pass.shader.clone(),
- uniform: uniform.to_string(),
- expected_length: 2,
- });
- };
- if values.len() != 2 {
- return Err(GpuError::InvalidVectorUniform {
- shader: pass.shader.clone(),
- uniform: uniform.to_string(),
- expected_length: 2,
- });
- }
- Ok([values[0], values[1]])
-}
diff --git a/rust/crates/gpu/src/lib.rs b/rust/crates/gpu/src/lib.rs
index c53e63ab..b6ca6c1c 100644
--- a/rust/crates/gpu/src/lib.rs
+++ b/rust/crates/gpu/src/lib.rs
@@ -1,17 +1,12 @@
mod context;
-mod effect_pipeline;
-mod mask_feather;
-mod sdf_pipeline;
-mod shader_registry;
use thiserror::Error;
-pub use wgpu;
pub use context::GpuContext;
-pub use effect_pipeline::{ApplyEffectsOptions, EffectPass, UniformValue};
-pub use mask_feather::ApplyMaskFeatherOptions;
+pub use wgpu;
pub const GPU_TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
+pub const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
#[derive(Debug, Error)]
pub enum GpuError {
@@ -23,18 +18,4 @@ pub enum GpuError {
CreateSurface(#[from] wgpu::CreateSurfaceError),
#[error("The output surface does not support the required texture format")]
UnsupportedSurfaceFormat,
- #[error("Unknown effect shader '{shader}'")]
- UnknownEffectShader { shader: String },
- #[error("Missing uniform '{uniform}' for shader '{shader}'")]
- MissingUniform { shader: String, uniform: String },
- #[error("Uniform '{uniform}' for shader '{shader}' must be a number")]
- InvalidNumberUniform { shader: String, uniform: String },
- #[error("Uniform '{uniform}' for shader '{shader}' must be a vector of length {expected_length}")]
- InvalidVectorUniform {
- shader: String,
- uniform: String,
- expected_length: usize,
- },
- #[error("Shader '{shader}' does not support uniform '{uniform}'")]
- UnsupportedUniform { shader: String, uniform: String },
}
diff --git a/rust/crates/gpu/src/shader_registry.rs b/rust/crates/gpu/src/shader_registry.rs
deleted file mode 100644
index b6af173e..00000000
--- a/rust/crates/gpu/src/shader_registry.rs
+++ /dev/null
@@ -1,183 +0,0 @@
-use std::collections::HashMap;
-
-use crate::{GPU_TEXTURE_FORMAT, GpuError};
-
-const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
-const GAUSSIAN_BLUR_SHADER_SOURCE: &str = include_str!("shaders/gaussian_blur.wgsl");
-const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
-pub const GAUSSIAN_BLUR_SHADER_ID: &str = "gaussian-blur";
-
-pub struct ShaderRegistry {
- effect_texture_bind_group_layout: wgpu::BindGroupLayout,
- effect_uniform_bind_group_layout: wgpu::BindGroupLayout,
- effect_pipelines: HashMap<String, wgpu::RenderPipeline>,
- blit_pipeline: wgpu::RenderPipeline,
-}
-
-impl ShaderRegistry {
- pub fn new(device: &wgpu::Device) -> Self {
- let effect_texture_bind_group_layout =
- device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
- label: Some("gpu-effect-texture-bind-group-layout"),
- entries: &[
- wgpu::BindGroupLayoutEntry {
- binding: 0,
- visibility: wgpu::ShaderStages::FRAGMENT,
- ty: wgpu::BindingType::Texture {
- multisampled: false,
- view_dimension: wgpu::TextureViewDimension::D2,
- sample_type: wgpu::TextureSampleType::Float { filterable: true },
- },
- count: None,
- },
- wgpu::BindGroupLayoutEntry {
- binding: 1,
- visibility: wgpu::ShaderStages::FRAGMENT,
- ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
- count: None,
- },
- ],
- });
- let effect_uniform_bind_group_layout =
- device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
- label: Some("gpu-effect-uniform-bind-group-layout"),
- entries: &[wgpu::BindGroupLayoutEntry {
- binding: 0,
- visibility: wgpu::ShaderStages::FRAGMENT,
- ty: wgpu::BindingType::Buffer {
- ty: wgpu::BufferBindingType::Uniform,
- has_dynamic_offset: false,
- min_binding_size: None,
- },
- count: None,
- }],
- });
-
- let vertex_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
- label: Some("gpu-fullscreen-shader"),
- source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()),
- });
- let gaussian_blur_shader_module =
- device.create_shader_module(wgpu::ShaderModuleDescriptor {
- label: Some("gpu-gaussian-blur-shader"),
- source: wgpu::ShaderSource::Wgsl(GAUSSIAN_BLUR_SHADER_SOURCE.into()),
- });
- let blit_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
- label: Some("gpu-blit-shader"),
- source: wgpu::ShaderSource::Wgsl(BLIT_SHADER_SOURCE.into()),
- });
- let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
- label: Some("gpu-effect-pipeline-layout"),
- bind_group_layouts: &[
- Some(&effect_texture_bind_group_layout),
- Some(&effect_uniform_bind_group_layout),
- ],
- immediate_size: 0,
- });
- let blit_pipeline_layout =
- device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
- label: Some("gpu-blit-pipeline-layout"),
- bind_group_layouts: &[Some(&effect_texture_bind_group_layout)],
- immediate_size: 0,
- });
- let gaussian_blur_pipeline =
- device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
- label: Some("gpu-gaussian-blur-pipeline"),
- layout: Some(&pipeline_layout),
- vertex: wgpu::VertexState {
- module: &vertex_shader_module,
- entry_point: Some("vertex_main"),
- buffers: &[wgpu::VertexBufferLayout {
- array_stride: std::mem::size_of::<[f32; 2]>() as u64,
- step_mode: wgpu::VertexStepMode::Vertex,
- attributes: &[wgpu::VertexAttribute {
- format: wgpu::VertexFormat::Float32x2,
- offset: 0,
- shader_location: 0,
- }],
- }],
- compilation_options: wgpu::PipelineCompilationOptions::default(),
- },
- fragment: Some(wgpu::FragmentState {
- module: &gaussian_blur_shader_module,
- entry_point: Some("fragment_main"),
- targets: &[Some(wgpu::ColorTargetState {
- format: GPU_TEXTURE_FORMAT,
- blend: None,
- write_mask: wgpu::ColorWrites::ALL,
- })],
- compilation_options: wgpu::PipelineCompilationOptions::default(),
- }),
- primitive: wgpu::PrimitiveState::default(),
- depth_stencil: None,
- multisample: wgpu::MultisampleState::default(),
- multiview_mask: None,
- cache: None,
- });
- let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
- label: Some("gpu-blit-pipeline"),
- layout: Some(&blit_pipeline_layout),
- vertex: wgpu::VertexState {
- module: &vertex_shader_module,
- entry_point: Some("vertex_main"),
- buffers: &[wgpu::VertexBufferLayout {
- array_stride: std::mem::size_of::<[f32; 2]>() as u64,
- step_mode: wgpu::VertexStepMode::Vertex,
- attributes: &[wgpu::VertexAttribute {
- format: wgpu::VertexFormat::Float32x2,
- offset: 0,
- shader_location: 0,
- }],
- }],
- compilation_options: wgpu::PipelineCompilationOptions::default(),
- },
- fragment: Some(wgpu::FragmentState {
- module: &blit_shader_module,
- entry_point: Some("fragment_main"),
- targets: &[Some(wgpu::ColorTargetState {
- format: GPU_TEXTURE_FORMAT,
- blend: None,
- write_mask: wgpu::ColorWrites::ALL,
- })],
- compilation_options: wgpu::PipelineCompilationOptions::default(),
- }),
- primitive: wgpu::PrimitiveState::default(),
- depth_stencil: None,
- multisample: wgpu::MultisampleState::default(),
- multiview_mask: None,
- cache: None,
- });
-
- let effect_pipelines = HashMap::from([(
- GAUSSIAN_BLUR_SHADER_ID.to_string(),
- gaussian_blur_pipeline,
- )]);
-
- Self {
- effect_texture_bind_group_layout,
- effect_uniform_bind_group_layout,
- effect_pipelines,
- blit_pipeline,
- }
- }
-
- pub fn get_effect_pipeline(&self, shader: &str) -> Result<&wgpu::RenderPipeline, GpuError> {
- self.effect_pipelines
- .get(shader)
- .ok_or_else(|| GpuError::UnknownEffectShader {
- shader: shader.to_string(),
- })
- }
-
- pub fn effect_texture_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
- &self.effect_texture_bind_group_layout
- }
-
- pub fn effect_uniform_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
- &self.effect_uniform_bind_group_layout
- }
-
- pub fn blit_pipeline(&self) -> &wgpu::RenderPipeline {
- &self.blit_pipeline
- }
-}
diff --git a/rust/crates/masks/Cargo.toml b/rust/crates/masks/Cargo.toml
new file mode 100644
index 00000000..58e10712
--- /dev/null
+++ b/rust/crates/masks/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "masks"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+path = "src/masks.rs"
+crate-type = ["rlib"]
+
+[dependencies]
+bytemuck = { version = "1.25.0", features = ["derive"] }
+gpu = { version = "0.1.0", path = "../gpu" }
+wgpu = "29.0.1"
diff --git a/rust/crates/gpu/src/mask_feather.rs b/rust/crates/masks/src/feather.rs
index 029b59ed..e58572f4 100644
--- a/rust/crates/gpu/src/mask_feather.rs
+++ b/rust/crates/masks/src/feather.rs
@@ -1,9 +1,9 @@
use bytemuck::{Pod, Zeroable};
+use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
use wgpu::util::DeviceExt;
-use crate::{GPU_TEXTURE_FORMAT, context::GpuContext};
+use crate::SdfPipeline;
-const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
const JFA_DISTANCE_SHADER_SOURCE: &str = include_str!("shaders/jfa_distance.wgsl");
pub struct ApplyMaskFeatherOptions<'a> {
@@ -14,6 +14,7 @@ pub struct ApplyMaskFeatherOptions<'a> {
}
pub struct MaskFeatherPipeline {
+ sdf_pipeline: SdfPipeline,
inside_texture_bind_group_layout: wgpu::BindGroupLayout,
outside_texture_bind_group_layout: wgpu::BindGroupLayout,
uniform_bind_group_layout: wgpu::BindGroupLayout,
@@ -29,7 +30,8 @@ struct DistanceUniformBuffer {
}
impl MaskFeatherPipeline {
- pub fn new(device: &wgpu::Device) -> Self {
+ pub fn new(context: &GpuContext) -> Self {
+ let device = context.device();
let inside_texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu-mask-distance-inside-layout"),
@@ -140,6 +142,7 @@ impl MaskFeatherPipeline {
});
Self {
+ sdf_pipeline: SdfPipeline::new(context),
inside_texture_bind_group_layout,
outside_texture_bind_group_layout,
uniform_bind_group_layout,
@@ -157,11 +160,10 @@ impl MaskFeatherPipeline {
feather,
}: ApplyMaskFeatherOptions<'_>,
) -> wgpu::Texture {
- let sdf = context
- .sdf_pipeline()
+ let sdf = self
+ .sdf_pipeline
.compute_signed_distance_field(context, mask, width, height);
- let output_texture =
- context.create_render_texture(width, height, "gpu-mask-feather-output");
+ let output_texture = context.create_render_texture(width, height, "masks-feather-output");
let inside_view = sdf
.inside_texture
.create_view(&wgpu::TextureViewDescriptor::default());
@@ -201,17 +203,18 @@ impl MaskFeatherPipeline {
},
],
});
- let uniform_buffer = context
- .device()
- .create_buffer_init(&wgpu::util::BufferInitDescriptor {
- label: Some("gpu-mask-distance-uniform-buffer"),
- contents: bytemuck::bytes_of(&DistanceUniformBuffer {
- resolution: [width as f32, height as f32],
- feather_half: feather / 2.0,
- _padding: 0.0,
- }),
- usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
- });
+ let uniform_buffer =
+ context
+ .device()
+ .create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: Some("gpu-mask-distance-uniform-buffer"),
+ contents: bytemuck::bytes_of(&DistanceUniformBuffer {
+ resolution: [width as f32, height as f32],
+ feather_half: feather / 2.0,
+ _padding: 0.0,
+ }),
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ });
let uniform_bind_group = context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -222,11 +225,12 @@ impl MaskFeatherPipeline {
resource: uniform_buffer.as_entire_binding(),
}],
});
- let mut encoder = context
- .device()
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("gpu-mask-distance-command-encoder"),
- });
+ let mut encoder =
+ context
+ .device()
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("gpu-mask-distance-command-encoder"),
+ });
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
diff --git a/rust/crates/masks/src/masks.rs b/rust/crates/masks/src/masks.rs
new file mode 100644
index 00000000..2bf33342
--- /dev/null
+++ b/rust/crates/masks/src/masks.rs
@@ -0,0 +1,5 @@
+mod feather;
+mod sdf;
+
+pub use feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline};
+pub use sdf::{SdfPipeline, SignedDistanceFieldTextures};
diff --git a/rust/crates/gpu/src/sdf_pipeline.rs b/rust/crates/masks/src/sdf.rs
index d2612fe1..0e7ec01a 100644
--- a/rust/crates/gpu/src/sdf_pipeline.rs
+++ b/rust/crates/masks/src/sdf.rs
@@ -1,9 +1,7 @@
use bytemuck::{Pod, Zeroable};
+use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
use wgpu::util::DeviceExt;
-use crate::{GPU_TEXTURE_FORMAT, context::GpuContext};
-
-const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
const JFA_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl");
const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl");
@@ -36,7 +34,8 @@ struct JfaStepUniformBuffer {
}
impl SdfPipeline {
- pub fn new(device: &wgpu::Device) -> Self {
+ pub fn new(context: &GpuContext) -> Self {
+ let device = context.device();
let texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu-sdf-texture-bind-group-layout"),
@@ -213,13 +212,14 @@ impl SdfPipeline {
},
],
});
- let uniform_buffer = context
- .device()
- .create_buffer_init(&wgpu::util::BufferInitDescriptor {
- label: Some("gpu-sdf-uniform-buffer"),
- contents: uniform_buffer_bytes,
- usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
- });
+ let uniform_buffer =
+ context
+ .device()
+ .create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: Some("gpu-sdf-uniform-buffer"),
+ contents: uniform_buffer_bytes,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ });
let uniform_bind_group = context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -230,11 +230,12 @@ impl SdfPipeline {
resource: uniform_buffer.as_entire_binding(),
}],
});
- let mut encoder = context
- .device()
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("gpu-sdf-command-encoder"),
- });
+ let mut encoder =
+ context
+ .device()
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("gpu-sdf-command-encoder"),
+ });
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
diff --git a/rust/crates/gpu/src/shaders/jfa_distance.wgsl b/rust/crates/masks/src/shaders/jfa_distance.wgsl
index e65a7023..e65a7023 100644
--- a/rust/crates/gpu/src/shaders/jfa_distance.wgsl
+++ b/rust/crates/masks/src/shaders/jfa_distance.wgsl
diff --git a/rust/crates/gpu/src/shaders/jfa_init.wgsl b/rust/crates/masks/src/shaders/jfa_init.wgsl
index 29aa38c7..29aa38c7 100644
--- a/rust/crates/gpu/src/shaders/jfa_init.wgsl
+++ b/rust/crates/masks/src/shaders/jfa_init.wgsl
diff --git a/rust/crates/gpu/src/shaders/jfa_step.wgsl b/rust/crates/masks/src/shaders/jfa_step.wgsl
index e20d7fb0..e20d7fb0 100644
--- a/rust/crates/gpu/src/shaders/jfa_step.wgsl
+++ b/rust/crates/masks/src/shaders/jfa_step.wgsl
diff --git a/rust/crates/time/Cargo.toml b/rust/crates/time/Cargo.toml
index bf66e4e2..070695dc 100644
--- a/rust/crates/time/Cargo.toml
+++ b/rust/crates/time/Cargo.toml
@@ -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"] \ No newline at end of file
+wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
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),
+ );
+ }
+}