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/animation/src/params.rs | |
| 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/animation/src/params.rs')
| -rw-r--r-- | rust/crates/animation/src/params.rs | 445 |
1 files changed, 445 insertions, 0 deletions
diff --git a/rust/crates/animation/src/params.rs b/rust/crates/animation/src/params.rs new file mode 100644 index 00000000..c677e392 --- /dev/null +++ b/rust/crates/animation/src/params.rs @@ -0,0 +1,445 @@ +use scene::ParamValue; + +use crate::channel_layout::{ + ChannelLayout, boolean_channel_layout, color_channel_layout, number_channel_layout, + string_channel_layout, +}; +use crate::types::{AnimationInterpolation, NumericSpec}; + +#[derive(Clone, Debug, PartialEq)] +pub struct NumberParamDefinition { + pub key: String, + pub label: String, + pub default: f64, + pub min: f64, + pub max: Option<f64>, + pub step: f64, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BooleanParamDefinition { + pub key: String, + pub label: String, + pub default: bool, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ColorParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectOption { + pub value: String, + pub label: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub options: Vec<SelectOption>, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FontParamDefinition { + pub key: String, + pub label: String, + pub default: String, + pub channels: Option<ChannelLayout>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ParamDefinition { + Number(NumberParamDefinition), + Boolean(BooleanParamDefinition), + Color(ColorParamDefinition), + Select(SelectParamDefinition), + Text(TextParamDefinition), + Font(FontParamDefinition), +} + +impl ParamDefinition { + pub fn number( + key: &str, + label: &str, + default: f64, + min: f64, + max: Option<f64>, + step: f64, + ) -> Self { + ParamDefinition::Number(NumberParamDefinition { + key: key.to_string(), + label: label.to_string(), + default, + min, + max, + step, + channels: None, + }) + } + + pub fn boolean(key: &str, label: &str, default: bool) -> Self { + ParamDefinition::Boolean(BooleanParamDefinition { + key: key.to_string(), + label: label.to_string(), + default, + channels: None, + }) + } + + pub fn color(key: &str, label: &str, default: &str) -> Self { + ParamDefinition::Color(ColorParamDefinition { + key: key.to_string(), + label: label.to_string(), + default: default.to_string(), + channels: None, + }) + } + + pub fn select(key: &str, label: &str, default: &str, options: Vec<SelectOption>) -> Self { + ParamDefinition::Select(SelectParamDefinition { + key: key.to_string(), + label: label.to_string(), + default: default.to_string(), + options, + channels: None, + }) + } + + pub fn key(&self) -> &str { + match self { + ParamDefinition::Number(param) => ¶m.key, + ParamDefinition::Boolean(param) => ¶m.key, + ParamDefinition::Color(param) => ¶m.key, + ParamDefinition::Select(param) => ¶m.key, + ParamDefinition::Text(param) => ¶m.key, + ParamDefinition::Font(param) => ¶m.key, + } + } + + pub fn default_value(&self) -> ParamValue { + match self { + ParamDefinition::Number(param) => ParamValue::Number(param.default), + ParamDefinition::Boolean(param) => ParamValue::Bool(param.default), + ParamDefinition::Color(param) => ParamValue::String(param.default.clone()), + ParamDefinition::Select(param) => ParamValue::String(param.default.clone()), + ParamDefinition::Text(param) => ParamValue::String(param.default.clone()), + ParamDefinition::Font(param) => ParamValue::String(param.default.clone()), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ParamValueKind { + Number, + Color, + Discrete, +} + +pub fn get_param_channel_layout(param: &ParamDefinition) -> ChannelLayout { + match param { + ParamDefinition::Number(param) => param + .channels + .clone() + .unwrap_or_else(number_channel_layout), + ParamDefinition::Boolean(param) => param + .channels + .clone() + .unwrap_or_else(boolean_channel_layout), + ParamDefinition::Color(param) => param.channels.clone().unwrap_or_else(color_channel_layout), + ParamDefinition::Select(param) => param + .channels + .clone() + .unwrap_or_else(string_channel_layout), + ParamDefinition::Text(param) => param + .channels + .clone() + .unwrap_or_else(string_channel_layout), + ParamDefinition::Font(param) => param + .channels + .clone() + .unwrap_or_else(string_channel_layout), + } +} + +pub fn get_param_value_kind(param: &ParamDefinition) -> ParamValueKind { + let layout = get_param_channel_layout(param); + match &layout { + ChannelLayout::Composite(_) => ParamValueKind::Color, + ChannelLayout::Leaf(component) => match component.value_kind { + crate::channel_layout::ChannelValueKind::Scalar => ParamValueKind::Number, + crate::channel_layout::ChannelValueKind::Discrete => ParamValueKind::Discrete, + }, + } +} + +pub fn get_param_default_interpolation(param: &ParamDefinition) -> AnimationInterpolation { + let layout = get_param_channel_layout(param); + layout + .components() + .first() + .map(|component| component.default_interpolation) + .unwrap_or(AnimationInterpolation::Linear) +} + +pub fn get_param_numeric_range(param: &ParamDefinition) -> Option<NumericSpec> { + let ParamDefinition::Number(param) = param else { + return None; + }; + Some(NumericSpec { + min: Some(param.min), + max: param.max, + step: Some(param.step), + }) +} + +fn fraction_digits_for_step(step: f64) -> usize { + if step != 0.0 && step.abs() < 1e-6 { + let exponential = format!("{step:e}"); + if let Some((_, exponent)) = exponential.split_once('e') { + if let Ok(value) = exponent.parse::<i32>() { + return value.unsigned_abs() as usize; + } + } + return 0; + } + let normalized = format!("{step}"); + normalized + .split_once('.') + .map(|(_, fraction)| fraction.len()) + .unwrap_or(0) +} + +pub fn snap_to_step(value: f64, step: f64) -> f64 { + if step <= 0.0 { + return value; + } + let snapped = (value / step).round() * step; + let digits = fraction_digits_for_step(step); + format!("{snapped:.digits$}").parse().unwrap_or(snapped) +} + +pub fn coerce_param_value(param: &ParamDefinition, value: &ParamValue) -> Option<ParamValue> { + match param { + ParamDefinition::Number(param) => { + let ParamValue::Number(number) = value else { + return None; + }; + if number.is_nan() { + return None; + } + let stepped = snap_to_step(*number, param.step); + let max = param.max.unwrap_or(f64::INFINITY); + Some(ParamValue::Number(stepped.min(max).max(param.min))) + } + ParamDefinition::Boolean(_) => match value { + ParamValue::Bool(value) => Some(ParamValue::Bool(*value)), + _ => None, + }, + ParamDefinition::Color(_) | ParamDefinition::Text(_) | ParamDefinition::Font(_) => { + match value { + ParamValue::String(value) => Some(ParamValue::String(value.clone())), + _ => None, + } + } + ParamDefinition::Select(param) => match value { + ParamValue::String(value) + if param.options.iter().any(|option| &option.value == value) => + { + Some(ParamValue::String(value.clone())) + } + _ => None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn intensity_param() -> ParamDefinition { + ParamDefinition::number("intensity", "Intensity", 0.0, 0.0, Some(1.0), 0.25) + } + + fn blend_param() -> ParamDefinition { + ParamDefinition::select( + "blend", + "Blend", + "normal", + vec![ + SelectOption { + value: "normal".to_string(), + label: "Normal".to_string(), + }, + SelectOption { + value: "multiply".to_string(), + label: "Multiply".to_string(), + }, + ], + ) + } + + #[test] + fn snaps_and_clamps_number_params() { + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Number(0.62)), + Some(ParamValue::Number(0.5)) + ); + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Number(1.2)), + Some(ParamValue::Number(1.0)) + ); + } + + #[test] + fn rejects_nan_and_non_number_values() { + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Number(f64::NAN)), + None + ); + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::String("0.5".to_string())), + None + ); + assert_eq!( + coerce_param_value(&intensity_param(), &ParamValue::Bool(true)), + None + ); + } + + #[test] + fn passthrough_with_step_zero_guard() { + let param = ParamDefinition::number("x", "X", 0.0, 0.0, None, 0.0); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Number(0.123)), + Some(ParamValue::Number(0.123)) + ); + } + + #[test] + fn accepts_valid_select_values() { + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::String("normal".to_string())), + Some(ParamValue::String("normal".to_string())) + ); + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::String("multiply".to_string())), + Some(ParamValue::String("multiply".to_string())) + ); + } + + #[test] + fn rejects_select_values_outside_options() { + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::String("screen".to_string())), + None + ); + } + + #[test] + fn rejects_non_string_select_values() { + assert_eq!( + coerce_param_value(&blend_param(), &ParamValue::Number(42.0)), + None + ); + assert_eq!(coerce_param_value(&blend_param(), &ParamValue::Bool(true)), None); + } + + #[test] + fn boolean_params_accept_booleans_and_reject_other_types() { + let param = ParamDefinition::boolean("visible", "Visible", true); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Bool(true)), + Some(ParamValue::Bool(true)) + ); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Bool(false)), + Some(ParamValue::Bool(false)) + ); + assert_eq!(coerce_param_value(¶m, &ParamValue::Number(1.0)), None); + assert_eq!( + coerce_param_value(¶m, &ParamValue::String("true".to_string())), + None + ); + } + + #[test] + fn color_params_accept_strings_and_reject_other_types() { + let param = ParamDefinition::color("fill", "Fill", "#ffffff"); + assert_eq!( + coerce_param_value(¶m, &ParamValue::String("#ff0000".to_string())), + Some(ParamValue::String("#ff0000".to_string())) + ); + assert_eq!( + coerce_param_value(¶m, &ParamValue::Number(0xff0000 as f64)), + None + ); + assert_eq!(coerce_param_value(¶m, &ParamValue::Bool(true)), None); + } + + #[test] + fn value_kind_maps_param_type_to_binding_kind() { + let number = ParamDefinition::number("n", "N", 0.0, 0.0, None, 1.0); + assert_eq!(get_param_value_kind(&number), ParamValueKind::Number); + let color = ParamDefinition::color("c", "C", "#fff"); + assert_eq!(get_param_value_kind(&color), ParamValueKind::Color); + let boolean = ParamDefinition::boolean("b", "B", false); + assert_eq!(get_param_value_kind(&boolean), ParamValueKind::Discrete); + assert_eq!(get_param_value_kind(&blend_param()), ParamValueKind::Discrete); + } + + #[test] + fn default_interpolation_is_linear_for_continuous_hold_for_discrete() { + let number = ParamDefinition::number("n", "N", 0.0, 0.0, None, 1.0); + assert_eq!( + get_param_default_interpolation(&number), + AnimationInterpolation::Linear + ); + let color = ParamDefinition::color("c", "C", "#fff"); + assert_eq!( + get_param_default_interpolation(&color), + AnimationInterpolation::Linear + ); + let boolean = ParamDefinition::boolean("b", "B", false); + assert_eq!( + get_param_default_interpolation(&boolean), + AnimationInterpolation::Hold + ); + assert_eq!( + get_param_default_interpolation(&blend_param()), + AnimationInterpolation::Hold + ); + } + + #[test] + fn numeric_range_returns_spec_for_number_params_only() { + assert_eq!( + get_param_numeric_range(&intensity_param()), + Some(NumericSpec { + min: Some(0.0), + max: Some(1.0), + step: Some(0.25), + }) + ); + let color = ParamDefinition::color("c", "C", "#fff"); + assert_eq!(get_param_numeric_range(&color), None); + let boolean = ParamDefinition::boolean("b", "B", false); + assert_eq!(get_param_numeric_range(&boolean), None); + } +} |
