summaryrefslogtreecommitdiff
path: root/rust/wasm/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
commite4b67094e757917588fb624a30aa046667403add (patch)
tree55452692eac69a5a63b58f2a5cdb701dd7023d68 /rust/wasm/src
parent79df73643185c02493364a077402726d1fa7b9ec (diff)
downloadopencut-e4b67094e757917588fb624a30aa046667403add.tar.gz
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'rust/wasm/src')
-rw-r--r--rust/wasm/src/effects.rs101
-rw-r--r--rust/wasm/src/gpu.rs123
-rw-r--r--rust/wasm/src/gpu_bridge.rs256
-rw-r--r--rust/wasm/src/masks.rs60
-rw-r--r--rust/wasm/src/wasm.rs12
5 files changed, 294 insertions, 258 deletions
diff --git a/rust/wasm/src/effects.rs b/rust/wasm/src/effects.rs
new file mode 100644
index 00000000..1f5c2947
--- /dev/null
+++ b/rust/wasm/src/effects.rs
@@ -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")?,
+ })
+}
diff --git a/rust/wasm/src/gpu.rs b/rust/wasm/src/gpu.rs
new file mode 100644
index 00000000..70f3a241
--- /dev/null
+++ b/rust/wasm/src/gpu.rs
@@ -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}")))
+}
diff --git a/rust/wasm/src/gpu_bridge.rs b/rust/wasm/src/gpu_bridge.rs
deleted file mode 100644
index 85db0c8b..00000000
--- a/rust/wasm/src/gpu_bridge.rs
+++ /dev/null
@@ -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}")))
-}
diff --git a/rust/wasm/src/masks.rs b/rust/wasm/src/masks.rs
new file mode 100644
index 00000000..e4f30e21
--- /dev/null
+++ b/rust/wasm/src/masks.rs
@@ -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")?,
+ })
+}
diff --git a/rust/wasm/src/wasm.rs b/rust/wasm/src/wasm.rs
index 0a78a314..27ece766 100644
--- a/rust/wasm/src/wasm.rs
+++ b/rust/wasm/src/wasm.rs
@@ -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::*;