diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
| commit | 32438ce25f69de99d2523b98237f62cfa90890d7 (patch) | |
| tree | 3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/effect-defs | |
| parent | f5a82152394fde4d8f740f664ab7737b82861618 (diff) | |
| download | opencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz | |
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures).
- timeline (45): placement, resize/ripple, split, snap, retime,
audio separation, scene helpers
- animation (73): bezier solver, channel sampling, keyframe ops,
split/clamp/clone, param definitions
- commands (9): Command trait, BatchCommand, history, 19 concrete
commands, freeze-frame port 1:1
- audio (22): audible collection, range mixing, mastering limiter,
rms/peak buckets
- text (19): cosmic-text layout/measure/raster, backgrounds,
decorations
- graphics (52): full CSS gradient parser, tiny-skia raster,
4 shape definitions
- effect-defs (16): blur definition, registry, pass resolution
- mask-defs (19): all 9 mask renderers via tiny-skia
- renderer (6): node model + scene builder port
- desktop: GPUI projects screen wired to ProjectStore
- scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/effect-defs')
| -rw-r--r-- | rust/crates/effect-defs/src/definitions/blur.rs | 147 | ||||
| -rw-r--r-- | rust/crates/effect-defs/src/definitions/mod.rs | 12 | ||||
| -rw-r--r-- | rust/crates/effect-defs/src/types.rs | 3 | ||||
| -rw-r--r-- | rust/crates/effect-defs/tests/effect_defs.rs | 270 |
4 files changed, 432 insertions, 0 deletions
diff --git a/rust/crates/effect-defs/src/definitions/blur.rs b/rust/crates/effect-defs/src/definitions/blur.rs new file mode 100644 index 00000000..8ad0dd55 --- /dev/null +++ b/rust/crates/effect-defs/src/definitions/blur.rs @@ -0,0 +1,147 @@ +use std::collections::HashMap; + +use effects::{EffectPass, UniformValue}; +use scene::params::{ParamValue, ParamValues}; + +use crate::math::{js_max, js_min, js_parse_float}; +use crate::params::{NumberParamDefinition, ParamDefinition}; +use crate::types::{EffectDefinition, EffectPassTemplate, EffectRendererConfig, PassContext}; + +pub const GAUSSIAN_BLUR_SHADER: &str = "gaussian-blur"; + +const MAX_SINGLE_PASS_SIGMA: f64 = 10.0; +const MAX_STEP: f64 = 4.0; +const MAX_EFFECTIVE_SIGMA: f64 = MAX_SINGLE_PASS_SIGMA * MAX_STEP; +const MAX_ITERATIONS: f64 = 8.0; + +pub const INTENSITY_TO_SIGMA_DIVISOR: f64 = 5.0; + +pub fn build_gaussian_blur_passes(sigma_x: f64, sigma_y: f64) -> Vec<EffectPass> { + let max_sigma = js_max(sigma_x, sigma_y); + if max_sigma < 0.001 { + return Vec::new(); + } + + let iterations = js_min( + MAX_ITERATIONS, + js_max( + 1.0, + ((max_sigma * max_sigma) / (MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA)).ceil(), + ), + ); + let iteration_count = iterations.max(0.0) as usize; + let per_pass_sigma_x = sigma_x / iterations.sqrt(); + let per_pass_sigma_y = sigma_y / iterations.sqrt(); + let step_x = js_max(1.0, per_pass_sigma_x / MAX_SINGLE_PASS_SIGMA); + let step_y = js_max(1.0, per_pass_sigma_y / MAX_SINGLE_PASS_SIGMA); + + let mut passes = Vec::with_capacity(iteration_count * 2); + for _ in 0..iteration_count { + passes.push(blur_pass(per_pass_sigma_x, step_x, [1.0, 0.0])); + passes.push(blur_pass(per_pass_sigma_y, step_y, [0.0, 1.0])); + } + passes +} + +pub fn intensity_to_sigma(intensity: f64, resolution: f64, reference: f64) -> f64 { + (intensity / INTENSITY_TO_SIGMA_DIVISOR) * (resolution / reference) +} + +pub fn parse_intensity(effect_params: &ParamValues) -> f64 { + match effect_params.get("intensity") { + Some(ParamValue::Number(value)) => *value, + Some(ParamValue::String(value)) => js_parse_float(value), + Some(ParamValue::Bool(true)) => js_parse_float("true"), + Some(ParamValue::Bool(false)) => js_parse_float("false"), + None => js_parse_float("undefined"), + } +} + +pub fn blur_effect_definition() -> EffectDefinition { + EffectDefinition { + effect_type: "blur", + name: "Blur", + keywords: &["blur", "soft", "defocus"], + params: vec![ParamDefinition::Number(NumberParamDefinition::new( + "intensity", + "Intensity", + 15.0, + 0.0, + Some(100.0), + 1.0, + ))], + renderer: EffectRendererConfig { + passes: vec![ + EffectPassTemplate { + shader: GAUSSIAN_BLUR_SHADER, + uniforms: horizontal_template_uniforms, + }, + EffectPassTemplate { + shader: GAUSSIAN_BLUR_SHADER, + uniforms: vertical_template_uniforms, + }, + ], + build_passes: Some(build_passes_from_params), + }, + } +} + +fn blur_pass(sigma: f64, step: f64, direction: [f32; 2]) -> EffectPass { + EffectPass { + shader: GAUSSIAN_BLUR_SHADER.to_string(), + uniforms: HashMap::from([ + ("u_sigma".to_string(), UniformValue::Number(sigma as f32)), + ("u_step".to_string(), UniformValue::Number(step as f32)), + ( + "u_direction".to_string(), + UniformValue::Vector(direction.to_vec()), + ), + ]), + } +} + +fn horizontal_template_uniforms(context: &PassContext) -> HashMap<String, UniformValue> { + let sigma = js_max( + intensity_to_sigma( + parse_intensity(context.effect_params), + context.width as f64, + 1920.0, + ), + 0.001, + ); + HashMap::from([ + ("u_sigma".to_string(), UniformValue::Number(sigma as f32)), + ("u_step".to_string(), UniformValue::Number(1.0)), + ( + "u_direction".to_string(), + UniformValue::Vector(vec![1.0, 0.0]), + ), + ]) +} + +fn vertical_template_uniforms(context: &PassContext) -> HashMap<String, UniformValue> { + let sigma = js_max( + intensity_to_sigma( + parse_intensity(context.effect_params), + context.height as f64, + 1080.0, + ), + 0.001, + ); + HashMap::from([ + ("u_sigma".to_string(), UniformValue::Number(sigma as f32)), + ("u_step".to_string(), UniformValue::Number(1.0)), + ( + "u_direction".to_string(), + UniformValue::Vector(vec![0.0, 1.0]), + ), + ]) +} + +fn build_passes_from_params(context: &PassContext) -> Vec<EffectPass> { + let intensity = parse_intensity(context.effect_params); + build_gaussian_blur_passes( + intensity_to_sigma(intensity, context.width as f64, 1920.0), + intensity_to_sigma(intensity, context.height as f64, 1080.0), + ) +} diff --git a/rust/crates/effect-defs/src/definitions/mod.rs b/rust/crates/effect-defs/src/definitions/mod.rs new file mode 100644 index 00000000..59f2575c --- /dev/null +++ b/rust/crates/effect-defs/src/definitions/mod.rs @@ -0,0 +1,12 @@ +pub mod blur; + +use crate::registry::EffectsRegistry; + +pub fn register_default_effects(registry: &mut EffectsRegistry) { + for definition in [blur::blur_effect_definition()] { + if registry.has(definition.effect_type) { + continue; + } + registry.register(definition); + } +} diff --git a/rust/crates/effect-defs/src/types.rs b/rust/crates/effect-defs/src/types.rs index c9815fd7..2b1e79d2 100644 --- a/rust/crates/effect-defs/src/types.rs +++ b/rust/crates/effect-defs/src/types.rs @@ -11,16 +11,19 @@ pub struct PassContext<'a> { pub height: u32, } +#[derive(Debug)] pub struct EffectPassTemplate { pub shader: &'static str, pub uniforms: fn(&PassContext) -> HashMap<String, UniformValue>, } +#[derive(Debug)] pub struct EffectRendererConfig { pub passes: Vec<EffectPassTemplate>, pub build_passes: Option<fn(&PassContext) -> Vec<EffectPass>>, } +#[derive(Debug)] pub struct EffectDefinition { pub effect_type: &'static str, pub name: &'static str, diff --git a/rust/crates/effect-defs/tests/effect_defs.rs b/rust/crates/effect-defs/tests/effect_defs.rs new file mode 100644 index 00000000..048ceeb2 --- /dev/null +++ b/rust/crates/effect-defs/tests/effect_defs.rs @@ -0,0 +1,270 @@ +use effect_defs::{ + EffectPassTemplate, EffectRendererConfig, EffectsRegistry, GAUSSIAN_BLUR_SHADER, + ParamDefinition, blur_effect_definition, build_default_param_values, + build_gaussian_blur_passes, intensity_to_sigma, params_with_defaults, parse_intensity, + register_default_effects, resolve_effect_passes, +}; +use effects::{EffectPass, UniformValue}; +use scene::params::{ParamValue, ParamValues}; + +fn number_uniform(pass: &EffectPass, key: &str) -> f32 { + match pass.uniforms.get(key) { + Some(UniformValue::Number(value)) => *value, + other => panic!("expected number uniform {key}, got {other:?}"), + } +} + +fn vector_uniform(pass: &EffectPass, key: &str) -> Vec<f32> { + match pass.uniforms.get(key) { + Some(UniformValue::Vector(value)) => value.clone(), + other => panic!("expected vector uniform {key}, got {other:?}"), + } +} + +fn params_with_intensity(value: f64) -> ParamValues { + ParamValues::from([("intensity".to_string(), ParamValue::Number(value))]) +} + +fn assert_close(actual: f32, expected: f64) { + assert!( + (actual as f64 - expected).abs() < 1e-5, + "expected {expected}, got {actual}" + ); +} + +#[test] +fn intensity_to_sigma_scales_by_resolution_ratio() { + assert_eq!(intensity_to_sigma(15.0, 1920.0, 1920.0), 3.0); + assert_eq!(intensity_to_sigma(10.0, 960.0, 1920.0), 1.0); + assert_eq!(intensity_to_sigma(0.0, 1920.0, 1920.0), 0.0); + assert_eq!(intensity_to_sigma(100.0, 1080.0, 1080.0), 20.0); +} + +#[test] +fn blur_passes_below_threshold_sigma_are_empty() { + assert!(build_gaussian_blur_passes(0.0, 0.0).is_empty()); + assert!(build_gaussian_blur_passes(0.0009, 0.0009).is_empty()); + assert!(build_gaussian_blur_passes(f64::NAN, 3.0).is_empty()); +} + +#[test] +fn blur_passes_single_iteration_at_default_intensity() { + let passes = build_gaussian_blur_passes(3.0, 3.0); + assert_eq!(passes.len(), 2); + + assert_eq!(passes[0].shader, GAUSSIAN_BLUR_SHADER); + assert_close(number_uniform(&passes[0], "u_sigma"), 3.0); + assert_close(number_uniform(&passes[0], "u_step"), 1.0); + assert_eq!(vector_uniform(&passes[0], "u_direction"), vec![1.0, 0.0]); + + assert_eq!(passes[1].shader, GAUSSIAN_BLUR_SHADER); + assert_close(number_uniform(&passes[1], "u_sigma"), 3.0); + assert_close(number_uniform(&passes[1], "u_step"), 1.0); + assert_eq!(vector_uniform(&passes[1], "u_direction"), vec![0.0, 1.0]); +} + +#[test] +fn blur_passes_step_up_when_sigma_exceeds_single_pass_limit() { + let passes = build_gaussian_blur_passes(20.0, 20.0); + assert_eq!(passes.len(), 2); + assert_close(number_uniform(&passes[0], "u_sigma"), 20.0); + assert_close(number_uniform(&passes[0], "u_step"), 2.0); + assert_close(number_uniform(&passes[1], "u_step"), 2.0); +} + +#[test] +fn blur_passes_iterate_for_large_sigma() { + let passes = build_gaussian_blur_passes(50.0, 50.0); + assert_eq!(passes.len(), 4); + assert_close(number_uniform(&passes[0], "u_sigma"), 35.35533905932738); + assert_close(number_uniform(&passes[0], "u_step"), 3.5355339059327378); + assert_close(number_uniform(&passes[2], "u_sigma"), 35.35533905932738); +} + +#[test] +fn blur_passes_cap_at_max_iterations() { + let passes = build_gaussian_blur_passes(400.0, 400.0); + assert_eq!(passes.len(), 16); + assert_close(number_uniform(&passes[0], "u_sigma"), 141.4213562373095); + assert_close(number_uniform(&passes[0], "u_step"), 14.14213562373095); +} + +#[test] +fn blur_passes_support_anisotropic_sigma() { + let passes = build_gaussian_blur_passes(3.0, 0.0); + assert_eq!(passes.len(), 2); + assert_close(number_uniform(&passes[0], "u_sigma"), 3.0); + assert_close(number_uniform(&passes[1], "u_sigma"), 0.0); + assert_close(number_uniform(&passes[1], "u_step"), 1.0); +} + +#[test] +fn parse_intensity_matches_js_coercion() { + assert_eq!(parse_intensity(¶ms_with_intensity(25.0)), 25.0); + assert_eq!( + parse_intensity(&ParamValues::from([( + "intensity".to_string(), + ParamValue::String("25".to_string()) + )])), + 25.0 + ); + assert_eq!( + parse_intensity(&ParamValues::from([( + "intensity".to_string(), + ParamValue::String("25abc".to_string()) + )])), + 25.0 + ); + assert!( + parse_intensity(&ParamValues::from([( + "intensity".to_string(), + ParamValue::Bool(true) + )])) + .is_nan() + ); + assert!(parse_intensity(&ParamValues::new()).is_nan()); + assert!( + parse_intensity(&ParamValues::from([( + "intensity".to_string(), + ParamValue::String("abc".to_string()) + )])) + .is_nan() + ); +} + +#[test] +fn blur_definition_matches_web_schema() { + let definition = blur_effect_definition(); + assert_eq!(definition.effect_type, "blur"); + assert_eq!(definition.name, "Blur"); + assert_eq!(definition.keywords, ["blur", "soft", "defocus"]); + assert_eq!(definition.params.len(), 1); + + let ParamDefinition::Number(intensity) = &definition.params[0] else { + panic!("expected number param"); + }; + assert_eq!(intensity.key, "intensity"); + assert_eq!(intensity.label, "Intensity"); + assert_eq!(intensity.default_value, 15.0); + assert_eq!(intensity.min, 0.0); + assert_eq!(intensity.max, Some(100.0)); + assert_eq!(intensity.step, 1.0); + + assert_eq!(definition.renderer.passes.len(), 2); + assert!(definition.renderer.build_passes.is_some()); + for template in &definition.renderer.passes { + assert_eq!(template.shader, GAUSSIAN_BLUR_SHADER); + } +} + +#[test] +fn registry_resolves_definitions_by_type() { + let mut registry = EffectsRegistry::new(); + register_default_effects(&mut registry); + register_default_effects(&mut registry); + + assert!(registry.has("blur")); + assert!(!registry.has("unknown")); + assert_eq!(registry.get_all().len(), 1); + assert_eq!(registry.get("blur").unwrap().name, "Blur"); + + let error = registry.get("unknown").unwrap_err(); + assert_eq!(error.to_string(), "Unknown effect: unknown"); +} + +#[test] +fn default_param_values_come_from_definition() { + let definition = blur_effect_definition(); + let defaults = build_default_param_values(&definition.params); + assert_eq!( + defaults.get("intensity"), + Some(&ParamValue::Number(15.0)) + ); + + let merged = params_with_defaults(&definition, &ParamValues::new()); + assert_eq!(merged.get("intensity"), Some(&ParamValue::Number(15.0))); + + let overridden = params_with_defaults(&definition, ¶ms_with_intensity(42.0)); + assert_eq!( + overridden.get("intensity"), + Some(&ParamValue::Number(42.0)) + ); +} + +#[test] +fn resolve_effect_passes_prefers_build_passes() { + let definition = blur_effect_definition(); + let passes = resolve_effect_passes(&definition, ¶ms_with_intensity(15.0), 1920, 1080); + assert_eq!(passes.len(), 2); + assert_close(number_uniform(&passes[0], "u_sigma"), 3.0); + assert_close(number_uniform(&passes[1], "u_sigma"), 3.0); + assert_eq!(vector_uniform(&passes[0], "u_direction"), vec![1.0, 0.0]); + assert_eq!(vector_uniform(&passes[1], "u_direction"), vec![0.0, 1.0]); + + let stepped = resolve_effect_passes(&definition, ¶ms_with_intensity(100.0), 1920, 1080); + assert_eq!(stepped.len(), 2); + assert_close(number_uniform(&stepped[0], "u_sigma"), 20.0); + assert_close(number_uniform(&stepped[0], "u_step"), 2.0); +} + +#[test] +fn resolve_effect_passes_with_defaults_applied_matches_explicit_params() { + let definition = blur_effect_definition(); + let merged = params_with_defaults(&definition, &ParamValues::new()); + let from_defaults = resolve_effect_passes(&definition, &merged, 1920, 1080); + let explicit = resolve_effect_passes(&definition, ¶ms_with_intensity(15.0), 1920, 1080); + assert_eq!(from_defaults.len(), explicit.len()); + assert_close( + number_uniform(&from_defaults[0], "u_sigma"), + number_uniform(&explicit[0], "u_sigma") as f64, + ); +} + +#[test] +fn resolve_effect_passes_without_intensity_produces_no_passes() { + let definition = blur_effect_definition(); + let passes = resolve_effect_passes(&definition, &ParamValues::new(), 1920, 1080); + assert!(passes.is_empty()); +} + +#[test] +fn resolve_effect_passes_falls_back_to_pass_templates() { + let mut definition = blur_effect_definition(); + definition.renderer.build_passes = None; + + let passes = resolve_effect_passes(&definition, ¶ms_with_intensity(15.0), 1920, 1080); + assert_eq!(passes.len(), 2); + assert_close(number_uniform(&passes[0], "u_sigma"), 3.0); + assert_close(number_uniform(&passes[0], "u_step"), 1.0); + assert_eq!(vector_uniform(&passes[0], "u_direction"), vec![1.0, 0.0]); + assert_close(number_uniform(&passes[1], "u_sigma"), 3.0); + assert_eq!(vector_uniform(&passes[1], "u_direction"), vec![0.0, 1.0]); + + let zero = resolve_effect_passes(&definition, ¶ms_with_intensity(0.0), 1920, 1080); + assert_eq!(zero.len(), 2); + assert_close(number_uniform(&zero[0], "u_sigma"), 0.001); + assert_close(number_uniform(&zero[1], "u_sigma"), 0.001); +} + +#[test] +fn template_path_scales_sigma_with_resolution() { + let definition = blur_effect_definition(); + let renderer = EffectRendererConfig { + passes: vec![EffectPassTemplate { + shader: GAUSSIAN_BLUR_SHADER, + uniforms: definition.renderer.passes[0].uniforms, + }], + build_passes: None, + }; + let template_only = effect_defs::EffectDefinition { + effect_type: definition.effect_type, + name: definition.name, + keywords: definition.keywords, + params: definition.params.clone(), + renderer, + }; + + let passes = resolve_effect_passes(&template_only, ¶ms_with_intensity(10.0), 960, 540); + assert_eq!(passes.len(), 1); + assert_close(number_uniform(&passes[0], "u_sigma"), 1.0); +} |
