Skip to main content

wavyte/effects/
fx.rs

1use crate::{
2    composition::model::EffectInstance,
3    foundation::core::Affine,
4    foundation::error::{WavyteError, WavyteResult},
5};
6
7#[derive(Clone, Debug, PartialEq)]
8/// Parsed effect representation used by compiler normalization.
9pub enum Effect {
10    /// Multiply clip opacity by `value`.
11    OpacityMul {
12        /// Multiplicative opacity factor.
13        value: f32,
14    },
15    /// Post-multiply clip transform with an affine matrix.
16    TransformPost {
17        /// Affine matrix post-multiplied onto clip transform.
18        value: Affine,
19    },
20    /// Gaussian blur pass parameters.
21    Blur {
22        /// Blur radius in pixels.
23        radius_px: u32,
24        /// Standard deviation in pixels.
25        sigma: f32,
26    },
27}
28
29#[derive(Clone, Copy, Debug, PartialEq)]
30/// Inline effects folded directly into draw operation state.
31pub struct InlineFx {
32    /// Multiplicative opacity factor accumulated from effects.
33    pub opacity_mul: f32,
34    /// Post-transform matrix accumulated from effects.
35    pub transform_post: Affine,
36}
37
38impl Default for InlineFx {
39    fn default() -> Self {
40        Self {
41            opacity_mul: 1.0,
42            transform_post: Affine::IDENTITY,
43        }
44    }
45}
46
47#[derive(Clone, Debug, PartialEq)]
48/// Effects that require explicit offscreen render passes.
49pub enum PassFx {
50    /// Gaussian blur applied to a surface.
51    Blur {
52        /// Blur radius in pixels.
53        radius_px: u32,
54        /// Standard deviation in pixels.
55        sigma: f32,
56    },
57}
58
59#[derive(Clone, Debug, Default, PartialEq)]
60/// Normalized effect pipeline for one clip.
61pub struct FxPipeline {
62    /// Inline adjustments applied at compile time.
63    pub inline: InlineFx,
64    /// Ordered offscreen passes to execute after scene draw.
65    pub passes: Vec<PassFx>,
66}
67
68/// Parse a user-provided effect instance into typed effect data.
69pub fn parse_effect(inst: &EffectInstance) -> WavyteResult<Effect> {
70    let kind = inst.kind.trim().to_ascii_lowercase();
71    if kind.is_empty() {
72        return Err(WavyteError::validation("effect kind must be non-empty"));
73    }
74
75    match kind.as_str() {
76        "opacitymul" | "opacity_mul" | "opacity-mul" => {
77            let value = get_f32(&inst.params, "value")?;
78            if !value.is_finite() || value < 0.0 {
79                return Err(WavyteError::validation(
80                    "OpacityMul.value must be finite and >= 0",
81                ));
82            }
83            Ok(Effect::OpacityMul { value })
84        }
85        "transformpost" | "transform_post" | "transform-post" => {
86            let value = parse_affine(&inst.params)?;
87            Ok(Effect::TransformPost { value })
88        }
89        "blur" => {
90            let radius_px = get_u32(&inst.params, "radius_px")?;
91            if radius_px > 256 {
92                return Err(WavyteError::validation(
93                    "Blur.radius_px must be <= 256 in v0.2.1",
94                ));
95            }
96            let sigma = match inst.params.get("sigma") {
97                Some(v) => {
98                    let s = v
99                        .as_f64()
100                        .ok_or_else(|| WavyteError::validation("Blur.sigma must be a number"))?
101                        as f32;
102                    if !s.is_finite() || s <= 0.0 {
103                        return Err(WavyteError::validation("Blur.sigma must be finite and > 0"));
104                    }
105                    s
106                }
107                None => (radius_px as f32) / 2.0,
108            };
109            Ok(Effect::Blur { radius_px, sigma })
110        }
111        _ => Err(WavyteError::validation(format!(
112            "unknown effect kind '{kind}'"
113        ))),
114    }
115}
116
117/// Fold parsed effects into inline and pass-level representations.
118pub fn normalize_effects(effects: &[Effect]) -> FxPipeline {
119    let mut inline = InlineFx::default();
120    let mut passes = Vec::<PassFx>::new();
121
122    for e in effects {
123        match *e {
124            Effect::OpacityMul { value } => inline.opacity_mul *= value,
125            Effect::TransformPost { value } => inline.transform_post *= value,
126            Effect::Blur { radius_px, sigma } => {
127                if radius_px == 0 {
128                    continue;
129                }
130                passes.push(PassFx::Blur { radius_px, sigma });
131            }
132        }
133    }
134
135    if !inline.opacity_mul.is_finite() || inline.opacity_mul < 0.0 {
136        inline.opacity_mul = 0.0;
137    }
138
139    if inline.opacity_mul == 1.0 && inline.transform_post == Affine::IDENTITY && passes.is_empty() {
140        FxPipeline::default()
141    } else {
142        FxPipeline { inline, passes }
143    }
144}
145
146fn get_u32(obj: &serde_json::Value, key: &str) -> WavyteResult<u32> {
147    let Some(v) = obj.get(key) else {
148        return Err(WavyteError::validation(format!(
149            "missing effect param '{key}'"
150        )));
151    };
152    let Some(n) = v.as_u64() else {
153        return Err(WavyteError::validation(format!(
154            "effect param '{key}' must be an integer"
155        )));
156    };
157    u32::try_from(n)
158        .map_err(|_| WavyteError::validation(format!("effect param '{key}' is out of range")))
159}
160
161fn get_f32(obj: &serde_json::Value, key: &str) -> WavyteResult<f32> {
162    let Some(v) = obj.get(key) else {
163        return Err(WavyteError::validation(format!(
164            "missing effect param '{key}'"
165        )));
166    };
167    let Some(n) = v.as_f64() else {
168        return Err(WavyteError::validation(format!(
169            "effect param '{key}' must be a number"
170        )));
171    };
172    let n = n as f32;
173    if !n.is_finite() {
174        return Err(WavyteError::validation(format!(
175            "effect param '{key}' must be finite"
176        )));
177    }
178    Ok(n)
179}
180
181fn parse_affine(params: &serde_json::Value) -> WavyteResult<Affine> {
182    if let Some(a) = params.get("affine") {
183        let Some(arr) = a.as_array() else {
184            return Err(WavyteError::validation(
185                "transform_post.affine must be an array",
186            ));
187        };
188        if arr.len() != 6 {
189            return Err(WavyteError::validation(
190                "transform_post.affine must have length 6",
191            ));
192        }
193        let mut coeffs = [0.0f64; 6];
194        for (i, v) in arr.iter().enumerate() {
195            coeffs[i] = v.as_f64().ok_or_else(|| {
196                WavyteError::validation("transform_post.affine entries must be numbers")
197            })?;
198        }
199        return Ok(Affine::new(coeffs));
200    }
201
202    // Structured fallback.
203    let t = match params.get("translate") {
204        Some(v) => {
205            let a = v
206                .as_array()
207                .ok_or_else(|| WavyteError::validation("transform_post.translate must be [x,y]"))?;
208            if a.len() != 2 {
209                return Err(WavyteError::validation(
210                    "transform_post.translate must be [x,y]",
211                ));
212            }
213            let x = a[0].as_f64().ok_or_else(|| {
214                WavyteError::validation("transform_post.translate x must be number")
215            })?;
216            let y = a[1].as_f64().ok_or_else(|| {
217                WavyteError::validation("transform_post.translate y must be number")
218            })?;
219            Affine::translate((x, y))
220        }
221        None => Affine::IDENTITY,
222    };
223
224    let rot = match (params.get("rotation_rad"), params.get("rotate_deg")) {
225        (Some(v), _) => {
226            let r = v.as_f64().ok_or_else(|| {
227                WavyteError::validation("transform_post.rotation_rad must be number")
228            })?;
229            Affine::rotate(r)
230        }
231        (None, Some(v)) => {
232            let deg = v.as_f64().ok_or_else(|| {
233                WavyteError::validation("transform_post.rotate_deg must be number")
234            })?;
235            Affine::rotate(deg.to_radians())
236        }
237        (None, None) => Affine::IDENTITY,
238    };
239
240    let scale = match params.get("scale") {
241        Some(v) => {
242            let a = v
243                .as_array()
244                .ok_or_else(|| WavyteError::validation("transform_post.scale must be [sx,sy]"))?;
245            if a.len() != 2 {
246                return Err(WavyteError::validation(
247                    "transform_post.scale must be [sx,sy]",
248                ));
249            }
250            let sx = a[0]
251                .as_f64()
252                .ok_or_else(|| WavyteError::validation("transform_post.scale sx must be number"))?;
253            let sy = a[1]
254                .as_f64()
255                .ok_or_else(|| WavyteError::validation("transform_post.scale sy must be number"))?;
256            Affine::scale_non_uniform(sx, sy)
257        }
258        None => Affine::IDENTITY,
259    };
260
261    Ok(t * rot * scale)
262}
263
264#[cfg(test)]
265#[path = "../../tests/unit/effects/fx.rs"]
266mod tests;