summaryrefslogtreecommitdiff
path: root/rust/crates/effect-defs
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/effect-defs
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-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.rs147
-rw-r--r--rust/crates/effect-defs/src/definitions/mod.rs12
-rw-r--r--rust/crates/effect-defs/src/types.rs3
-rw-r--r--rust/crates/effect-defs/tests/effect_defs.rs270
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(&params_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, &params_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, &params_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, &params_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, &params_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, &params_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, &params_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, &params_with_intensity(10.0), 960, 540);
+ assert_eq!(passes.len(), 1);
+ assert_close(number_uniform(&passes[0], "u_sigma"), 1.0);
+}