diff options
Diffstat (limited to 'rust/crates')
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), + ); + } +} |
