mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
mod pipeline;
|
||||
mod types;
|
||||
|
||||
pub use pipeline::{ApplyEffectsOptions, EffectPipeline, EffectsError};
|
||||
pub use types::{EffectPass, UniformValue};
|
||||
@@ -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]])
|
||||
}
|
||||
@@ -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>),
|
||||
}
|
||||
@@ -7,3 +7,7 @@ edition = "2024"
|
||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||
thiserror = "2.0.18"
|
||||
wgpu = "29.0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
wasm = []
|
||||
|
||||
+125
-38
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]])
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 {
|
||||
@@ -0,0 +1,5 @@
|
||||
mod feather;
|
||||
mod sdf;
|
||||
|
||||
pub use feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline};
|
||||
pub use sdf::{SdfPipeline, SignedDistanceFieldTextures};
|
||||
@@ -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 {
|
||||
@@ -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"]
|
||||
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
-421
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "opencut-wasm"
|
||||
version = "0.1.3"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
description = "Shared video editor logic compiled to WebAssembly"
|
||||
repository = "https://github.com/opencut/opencut"
|
||||
@@ -11,11 +11,19 @@ path = "src/wasm.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
gpu = { version = "0.1.0", path = "../crates/gpu" }
|
||||
bridge = { version = "0.1.0", path = "../crates/bridge" }
|
||||
effects = { version = "0.1.0", path = "../crates/effects" }
|
||||
gpu = { version = "0.1.0", path = "../crates/gpu", features = ["wasm"] }
|
||||
js-sys = "0.3.93"
|
||||
masks = { version = "0.1.0", path = "../crates/masks" }
|
||||
num-traits = "0.2.19"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
|
||||
wasm-bindgen = "0.2.116"
|
||||
wasm-bindgen-futures = "0.4.66"
|
||||
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] }
|
||||
|
||||
[features]
|
||||
default = ["wasm"]
|
||||
wasm = []
|
||||
|
||||
+4
-1
@@ -11,7 +11,10 @@ npm install opencut-wasm
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { formatTimeCode } from "opencut-wasm";
|
||||
import { formatTimecode, mediaTimeFromSeconds } from "opencut-wasm";
|
||||
|
||||
const ticks = mediaTimeFromSeconds(1.5);
|
||||
const label = formatTimecode({ ticks });
|
||||
```
|
||||
|
||||
All exports are documented in the [TypeScript definitions](./opencut_wasm.d.ts).
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use effects::{ApplyEffectsOptions, EffectPass, UniformValue};
|
||||
use gpu::wgpu;
|
||||
use js_sys::Object;
|
||||
use serde::Deserialize;
|
||||
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
|
||||
|
||||
use crate::gpu::{
|
||||
import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property,
|
||||
render_texture_to_canvas, with_gpu_runtime,
|
||||
};
|
||||
|
||||
struct ApplyEffectPassesOptions {
|
||||
source: wgpu::web_sys::OffscreenCanvas,
|
||||
width: u32,
|
||||
height: u32,
|
||||
passes: Vec<EffectPassInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EffectPassInput {
|
||||
shader: String,
|
||||
uniforms: Vec<EffectUniformInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EffectUniformInput {
|
||||
name: String,
|
||||
value: Vec<f32>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = applyEffectPasses)]
|
||||
pub fn apply_effect_passes(options: JsValue) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
|
||||
let ApplyEffectPassesOptions {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
} = parse_apply_effect_passes_options(options)?;
|
||||
|
||||
with_gpu_runtime(|runtime| {
|
||||
let source_texture = import_canvas_texture(
|
||||
&runtime.context,
|
||||
&source,
|
||||
width,
|
||||
height,
|
||||
"effects-input-texture",
|
||||
);
|
||||
let effect_passes = map_effect_passes(passes);
|
||||
let result_texture = runtime
|
||||
.effects
|
||||
.apply(
|
||||
&runtime.context,
|
||||
ApplyEffectsOptions {
|
||||
source: &source_texture,
|
||||
width,
|
||||
height,
|
||||
passes: &effect_passes,
|
||||
},
|
||||
)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
render_texture_to_canvas(&runtime.context, &result_texture, width, height)
|
||||
})
|
||||
}
|
||||
|
||||
fn map_effect_passes(effect_passes: Vec<EffectPassInput>) -> Vec<EffectPass> {
|
||||
effect_passes
|
||||
.into_iter()
|
||||
.map(|pass| EffectPass {
|
||||
shader: pass.shader,
|
||||
uniforms: pass
|
||||
.uniforms
|
||||
.into_iter()
|
||||
.map(|uniform| {
|
||||
let value = if uniform.value.len() == 1 {
|
||||
UniformValue::Number(uniform.value[0])
|
||||
} else {
|
||||
UniformValue::Vector(uniform.value)
|
||||
};
|
||||
(uniform.name, value)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_apply_effect_passes_options(value: JsValue) -> Result<ApplyEffectPassesOptions, JsValue> {
|
||||
let object: Object = value
|
||||
.dyn_into()
|
||||
.map_err(|_| JsValue::from_str("applyEffectPasses expects an options object"))?;
|
||||
|
||||
Ok(ApplyEffectPassesOptions {
|
||||
source: read_offscreen_canvas_property(&object, "source")?,
|
||||
width: read_u32_property(&object, "width")?,
|
||||
height: read_u32_property(&object, "height")?,
|
||||
passes: read_serde_property(&object, "passes")?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use effects::EffectPipeline;
|
||||
use gpu::{GpuContext, wgpu};
|
||||
use js_sys::{Object, Reflect};
|
||||
use masks::MaskFeatherPipeline;
|
||||
use serde::Deserialize;
|
||||
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
|
||||
|
||||
pub(crate) struct GpuRuntime {
|
||||
pub(crate) context: GpuContext,
|
||||
pub(crate) effects: EffectPipeline,
|
||||
pub(crate) masks: MaskFeatherPipeline,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static GPU_RUNTIME: RefCell<Option<GpuRuntime>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = initializeGpu)]
|
||||
pub async fn initialize_gpu() -> Result<(), JsValue> {
|
||||
if GPU_RUNTIME.with(|runtime| runtime.borrow().is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let context = GpuContext::new()
|
||||
.await
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
let effects = EffectPipeline::new(&context);
|
||||
let masks = MaskFeatherPipeline::new(&context);
|
||||
|
||||
GPU_RUNTIME.with(|runtime| {
|
||||
runtime.replace(Some(GpuRuntime {
|
||||
context,
|
||||
effects,
|
||||
masks,
|
||||
}));
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn with_gpu_runtime<T>(
|
||||
action: impl FnOnce(&GpuRuntime) -> Result<T, JsValue>,
|
||||
) -> Result<T, JsValue> {
|
||||
GPU_RUNTIME.with(|runtime| {
|
||||
let borrow = runtime.borrow();
|
||||
let Some(gpu_runtime) = borrow.as_ref() else {
|
||||
return Err(JsValue::from_str(
|
||||
"GPU context not initialized. Call initializeGpu() first.",
|
||||
));
|
||||
};
|
||||
action(gpu_runtime)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn import_canvas_texture(
|
||||
context: &GpuContext,
|
||||
canvas: &wgpu::web_sys::OffscreenCanvas,
|
||||
width: u32,
|
||||
height: u32,
|
||||
label: &'static str,
|
||||
) -> wgpu::Texture {
|
||||
context.import_offscreen_canvas_texture(canvas, width, height, label)
|
||||
}
|
||||
|
||||
pub(crate) fn render_texture_to_canvas(
|
||||
context: &GpuContext,
|
||||
texture: &wgpu::Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
|
||||
let canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?;
|
||||
context
|
||||
.render_texture_to_offscreen_canvas(texture, &canvas, width, height)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
Ok(canvas)
|
||||
}
|
||||
|
||||
pub(crate) fn read_property(object: &Object, name: &str) -> Result<JsValue, JsValue> {
|
||||
Reflect::get(object, &JsValue::from_str(name))
|
||||
.map_err(|_| JsValue::from_str(&format!("Missing property '{name}'")))
|
||||
}
|
||||
|
||||
pub(crate) fn read_offscreen_canvas_property(
|
||||
object: &Object,
|
||||
name: &str,
|
||||
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
|
||||
read_property(object, name)?
|
||||
.dyn_into::<wgpu::web_sys::OffscreenCanvas>()
|
||||
.map_err(|_| JsValue::from_str(&format!("Property '{name}' must be an OffscreenCanvas")))
|
||||
}
|
||||
|
||||
pub(crate) fn read_u32_property(object: &Object, name: &str) -> Result<u32, JsValue> {
|
||||
let value = read_property(object, name)?;
|
||||
let Some(number) = value.as_f64() else {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Property '{name}' must be a number"
|
||||
)));
|
||||
};
|
||||
Ok(number as u32)
|
||||
}
|
||||
|
||||
pub(crate) fn read_f32_property(object: &Object, name: &str) -> Result<f32, JsValue> {
|
||||
let value = read_property(object, name)?;
|
||||
let Some(number) = value.as_f64() else {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Property '{name}' must be a number"
|
||||
)));
|
||||
};
|
||||
Ok(number as f32)
|
||||
}
|
||||
|
||||
pub(crate) fn read_serde_property<T>(object: &Object, name: &str) -> Result<T, JsValue>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let value = read_property(object, name)?;
|
||||
serde_wasm_bindgen::from_value(value)
|
||||
.map_err(|error| JsValue::from_str(&format!("Invalid property '{name}': {error}")))
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use gpu::{EffectPass, GpuContext, UniformValue, wgpu};
|
||||
use js_sys::{Object, Reflect};
|
||||
use serde::Deserialize;
|
||||
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
|
||||
|
||||
thread_local! {
|
||||
static GPU_CONTEXT: RefCell<Option<GpuContext>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct ApplyEffectPassesOptions {
|
||||
source: wgpu::web_sys::OffscreenCanvas,
|
||||
width: u32,
|
||||
height: u32,
|
||||
passes: Vec<EffectPassInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EffectPassInput {
|
||||
shader: String,
|
||||
uniforms: Vec<EffectUniformInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EffectUniformInput {
|
||||
name: String,
|
||||
value: Vec<f32>,
|
||||
}
|
||||
|
||||
struct ApplyMaskFeatherOptions {
|
||||
mask: wgpu::web_sys::OffscreenCanvas,
|
||||
width: u32,
|
||||
height: u32,
|
||||
feather: f32,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = initializeGpu)]
|
||||
pub async fn initialize_gpu() -> Result<(), JsValue> {
|
||||
if GPU_CONTEXT.with(|context| context.borrow().is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let context = GpuContext::new()
|
||||
.await
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
GPU_CONTEXT.with(|gpu_context| {
|
||||
gpu_context.replace(Some(context));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = applyEffectPasses)]
|
||||
pub fn apply_effect_passes(
|
||||
options: JsValue,
|
||||
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
|
||||
let ApplyEffectPassesOptions {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
} = parse_apply_effect_passes_options(options)?;
|
||||
|
||||
with_gpu_context(|context| {
|
||||
let source_texture = import_canvas_texture(context, &source, width, height)?;
|
||||
let effect_passes = map_effect_passes(passes);
|
||||
let result_texture = context
|
||||
.apply_effects(gpu::ApplyEffectsOptions {
|
||||
source: &source_texture,
|
||||
width,
|
||||
height,
|
||||
passes: &effect_passes,
|
||||
})
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
let output_canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?;
|
||||
let surface = context
|
||||
.instance()
|
||||
.create_surface(wgpu::SurfaceTarget::OffscreenCanvas(output_canvas.clone()))
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
|
||||
context
|
||||
.render_texture_to_surface(&result_texture, &surface, width, height)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
Ok(output_canvas)
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = applyMaskFeather)]
|
||||
pub fn apply_mask_feather(
|
||||
options: JsValue,
|
||||
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
|
||||
let ApplyMaskFeatherOptions {
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
} = parse_apply_mask_feather_options(options)?;
|
||||
|
||||
with_gpu_context(|context| {
|
||||
let mask_texture = import_canvas_texture(context, &mask, width, height)?;
|
||||
let result_texture = context.apply_mask_feather(gpu::ApplyMaskFeatherOptions {
|
||||
mask: &mask_texture,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
});
|
||||
let output_canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?;
|
||||
let surface = context
|
||||
.instance()
|
||||
.create_surface(wgpu::SurfaceTarget::OffscreenCanvas(output_canvas.clone()))
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
|
||||
context
|
||||
.render_texture_to_surface(&result_texture, &surface, width, height)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
Ok(output_canvas)
|
||||
})
|
||||
}
|
||||
|
||||
fn with_gpu_context<T>(
|
||||
action: impl FnOnce(&GpuContext) -> Result<T, JsValue>,
|
||||
) -> Result<T, JsValue> {
|
||||
GPU_CONTEXT.with(|context| {
|
||||
let borrow = context.borrow();
|
||||
let Some(gpu_context) = borrow.as_ref() else {
|
||||
return Err(JsValue::from_str(
|
||||
"GPU context not initialized. Call initializeGpu() first.",
|
||||
));
|
||||
};
|
||||
action(gpu_context)
|
||||
})
|
||||
}
|
||||
|
||||
fn import_canvas_texture(
|
||||
context: &GpuContext,
|
||||
canvas: &wgpu::web_sys::OffscreenCanvas,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<wgpu::Texture, JsValue> {
|
||||
let texture = context.create_render_texture(width, height, "gpu-bridge-input-texture");
|
||||
context.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,
|
||||
},
|
||||
);
|
||||
Ok(texture)
|
||||
}
|
||||
|
||||
fn map_effect_passes(effect_passes: Vec<EffectPassInput>) -> Vec<EffectPass> {
|
||||
effect_passes
|
||||
.into_iter()
|
||||
.map(|pass| EffectPass {
|
||||
shader: pass.shader,
|
||||
uniforms: pass
|
||||
.uniforms
|
||||
.into_iter()
|
||||
.map(|uniform| {
|
||||
let value = if uniform.value.len() == 1 {
|
||||
UniformValue::Number(uniform.value[0])
|
||||
} else {
|
||||
UniformValue::Vector(uniform.value)
|
||||
};
|
||||
(uniform.name, value)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_apply_effect_passes_options(value: JsValue) -> Result<ApplyEffectPassesOptions, JsValue> {
|
||||
let object: Object = value
|
||||
.dyn_into()
|
||||
.map_err(|_| JsValue::from_str("applyEffectPasses expects an options object"))?;
|
||||
|
||||
Ok(ApplyEffectPassesOptions {
|
||||
source: read_offscreen_canvas_property(&object, "source")?,
|
||||
width: read_u32_property(&object, "width")?,
|
||||
height: read_u32_property(&object, "height")?,
|
||||
passes: read_serde_property(&object, "passes")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_apply_mask_feather_options(value: JsValue) -> Result<ApplyMaskFeatherOptions, JsValue> {
|
||||
let object: Object = value
|
||||
.dyn_into()
|
||||
.map_err(|_| JsValue::from_str("applyMaskFeather expects an options object"))?;
|
||||
|
||||
Ok(ApplyMaskFeatherOptions {
|
||||
mask: read_offscreen_canvas_property(&object, "mask")?,
|
||||
width: read_u32_property(&object, "width")?,
|
||||
height: read_u32_property(&object, "height")?,
|
||||
feather: read_f32_property(&object, "feather")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_property(object: &Object, name: &str) -> Result<JsValue, JsValue> {
|
||||
Reflect::get(object, &JsValue::from_str(name))
|
||||
.map_err(|_| JsValue::from_str(&format!("Missing property '{name}'")))
|
||||
}
|
||||
|
||||
fn read_offscreen_canvas_property(
|
||||
object: &Object,
|
||||
name: &str,
|
||||
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
|
||||
read_property(object, name)?
|
||||
.dyn_into::<wgpu::web_sys::OffscreenCanvas>()
|
||||
.map_err(|_| JsValue::from_str(&format!("Property '{name}' must be an OffscreenCanvas")))
|
||||
}
|
||||
|
||||
fn read_u32_property(object: &Object, name: &str) -> Result<u32, JsValue> {
|
||||
let value = read_property(object, name)?;
|
||||
let Some(number) = value.as_f64() else {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Property '{name}' must be a number"
|
||||
)));
|
||||
};
|
||||
Ok(number as u32)
|
||||
}
|
||||
|
||||
fn read_f32_property(object: &Object, name: &str) -> Result<f32, JsValue> {
|
||||
let value = read_property(object, name)?;
|
||||
let Some(number) = value.as_f64() else {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Property '{name}' must be a number"
|
||||
)));
|
||||
};
|
||||
Ok(number as f32)
|
||||
}
|
||||
|
||||
fn read_serde_property<T>(object: &Object, name: &str) -> Result<T, JsValue>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let value = read_property(object, name)?;
|
||||
serde_wasm_bindgen::from_value(value)
|
||||
.map_err(|error| JsValue::from_str(&format!("Invalid property '{name}': {error}")))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use gpu::wgpu;
|
||||
use js_sys::Object;
|
||||
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
|
||||
|
||||
use crate::gpu::{
|
||||
import_canvas_texture, read_f32_property, read_offscreen_canvas_property, read_u32_property,
|
||||
render_texture_to_canvas, with_gpu_runtime,
|
||||
};
|
||||
|
||||
struct ApplyMaskFeatherOptions {
|
||||
mask: wgpu::web_sys::OffscreenCanvas,
|
||||
width: u32,
|
||||
height: u32,
|
||||
feather: f32,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = applyMaskFeather)]
|
||||
pub fn apply_mask_feather(options: JsValue) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
|
||||
let ApplyMaskFeatherOptions {
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
} = parse_apply_mask_feather_options(options)?;
|
||||
|
||||
with_gpu_runtime(|runtime| {
|
||||
let mask_texture = import_canvas_texture(
|
||||
&runtime.context,
|
||||
&mask,
|
||||
width,
|
||||
height,
|
||||
"masks-input-texture",
|
||||
);
|
||||
let result_texture = runtime.masks.apply_mask_feather(
|
||||
&runtime.context,
|
||||
masks::ApplyMaskFeatherOptions {
|
||||
mask: &mask_texture,
|
||||
width,
|
||||
height,
|
||||
feather,
|
||||
},
|
||||
);
|
||||
render_texture_to_canvas(&runtime.context, &result_texture, width, height)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_apply_mask_feather_options(value: JsValue) -> Result<ApplyMaskFeatherOptions, JsValue> {
|
||||
let object: Object = value
|
||||
.dyn_into()
|
||||
.map_err(|_| JsValue::from_str("applyMaskFeather expects an options object"))?;
|
||||
|
||||
Ok(ApplyMaskFeatherOptions {
|
||||
mask: read_offscreen_canvas_property(&object, "mask")?,
|
||||
width: read_u32_property(&object, "width")?,
|
||||
height: read_u32_property(&object, "height")?,
|
||||
feather: read_f32_property(&object, "feather")?,
|
||||
})
|
||||
}
|
||||
+10
-2
@@ -1,6 +1,14 @@
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod gpu_bridge;
|
||||
mod effects;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod gpu;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod masks;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use gpu_bridge::*;
|
||||
pub use effects::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use gpu::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use masks::*;
|
||||
pub use time::*;
|
||||
|
||||
Reference in New Issue
Block a user