Skip to main content

alpha2_render/
catalog.rs

1use serde_json::{Value, json};
2
3use crate::{
4    EffectDef, EffectKind, EffectResourceKind, ParamSpec, ParamType, ParamUnit, ParamValue,
5    builtin_effect_definitions, builtin_transition_definitions,
6};
7
8/// Returns the complete engine-owned effect catalog as a host-friendly JSON value.
9///
10/// Shader source is deliberately excluded: consumers receive the authoring
11/// contract while registration and ownership remain inside the engine.
12#[must_use]
13pub fn builtin_catalog() -> Value {
14    let effects = builtin_effect_definitions()
15        .iter()
16        .map(catalog_entry)
17        .collect::<Vec<_>>();
18    let transitions = builtin_transition_definitions()
19        .iter()
20        .map(catalog_entry)
21        .collect::<Vec<_>>();
22    json!({ "effects": effects, "transitions": transitions })
23}
24
25/// Serializes [`builtin_catalog`] for process and WebAssembly boundaries.
26#[must_use]
27pub fn builtin_catalog_json() -> String {
28    builtin_catalog().to_string()
29}
30
31fn catalog_entry(definition: &EffectDef) -> Value {
32    let (label, group) = presentation(&definition.id, definition.kind);
33    json!({
34        "id": definition.id,
35        "version": definition.version,
36        "kind": match definition.kind {
37            EffectKind::Effect => "effect",
38            EffectKind::Transition => "transition",
39            EffectKind::Generator => "generator",
40        },
41        "inputs": definition
42            .inputs
43            .iter()
44            .map(|input| json!({ "name": input.name }))
45            .collect::<Vec<_>>(),
46        "label": label,
47        "group": group,
48        "params": definition.params.iter().map(param).collect::<Vec<_>>(),
49        "resources": definition.resources.iter().map(|resource| json!({
50            "name": resource.name,
51            "kind": match resource.kind { EffectResourceKind::Lut3d => "lut3d" },
52        })).collect::<Vec<_>>(),
53    })
54}
55
56fn param(spec: &ParamSpec) -> Value {
57    let value = match &spec.default {
58        ParamValue::Float(value) => json!(value),
59        ParamValue::Vec2(value) => json!(value),
60        ParamValue::Vec4(value) | ParamValue::Color(value) => json!(value),
61        ParamValue::Enum(value) => json!(value),
62        ParamValue::Bool(value) => json!(value),
63    };
64    json!({
65        "name": spec.name,
66        "label": humanize(&spec.name),
67        "type": match spec.param_type {
68            ParamType::Float => "float",
69            ParamType::Vec2 => "vec2",
70            ParamType::Vec4 => "vec4",
71            ParamType::Color => "color",
72            ParamType::Enum => "enum",
73            ParamType::Bool => "bool",
74        },
75        "default": value,
76        "min": spec.min,
77        "max": spec.max,
78        "step": spec.step,
79        "unit": match spec.unit {
80            ParamUnit::Normalized => "normalized",
81            ParamUnit::Degrees => "degrees",
82            ParamUnit::Color => "color",
83            ParamUnit::Enum => "enum",
84            ParamUnit::Bool => "bool",
85        },
86        "enumValues": spec.enum_values,
87        // Whether a project may keyframe it with a curve. Enum and bool values have nothing
88        // between them, so their keyframes only hold.
89        "animatable": matches!(
90            spec.param_type,
91            ParamType::Float | ParamType::Vec2 | ParamType::Vec4 | ParamType::Color
92        ),
93    })
94}
95
96fn presentation(id: &str, kind: EffectKind) -> (String, &'static str) {
97    let short = id.rsplit('.').next().unwrap_or(id);
98    let label = if short == "lut-3d" {
99        "3D LUT".into()
100    } else {
101        humanize(short)
102    };
103    let group = match kind {
104        EffectKind::Effect if matches!(short, "gaussian-blur" | "sharpen") => "Blur & Sharpen",
105        EffectKind::Effect if short == "chroma-key" => "Keying",
106        EffectKind::Effect if matches!(short, "color-controls" | "lut-3d") => "Color",
107        EffectKind::Effect if matches!(short, "crop" | "mirror" | "pixelate") => "Distort",
108        EffectKind::Effect => "Stylize",
109        EffectKind::Transition
110            if matches!(
111                short,
112                "wipe-up"
113                    | "wipe-down"
114                    | "wipe-left"
115                    | "wipe-right"
116                    | "window-blinds"
117                    | "window-slice"
118                    | "wind"
119                    | "pinwheel"
120                    | "heart"
121            ) =>
122        {
123            "Wipes"
124        }
125        EffectKind::Transition
126            if matches!(
127                short,
128                "fade"
129                    | "directional"
130                    | "circle-open"
131                    | "angular"
132                    | "pixelize"
133                    | "directional-wipe"
134                    | "circle"
135                    | "cross-warp"
136                    | "dreamy"
137                    | "radial"
138            ) =>
139        {
140            "Classic"
141        }
142        EffectKind::Transition => "Stylized",
143        EffectKind::Generator => "Generate",
144    };
145    (label, group)
146}
147
148fn humanize(value: &str) -> String {
149    value
150        .split(['-', '_'])
151        .filter(|part| !part.is_empty())
152        .map(|part| {
153            let mut chars = part.chars();
154            chars.next().map_or_else(String::new, |first| {
155                first.to_uppercase().collect::<String>() + chars.as_str()
156            })
157        })
158        .collect::<Vec<_>>()
159        .join(" ")
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn catalog_exposes_lut_resource_without_shader_source() {
168        let catalog = builtin_catalog();
169        let lut = catalog["effects"]
170            .as_array()
171            .unwrap()
172            .iter()
173            .find(|entry| entry["id"] == "alpha2.effect.lut-3d")
174            .unwrap();
175        assert_eq!(lut["resources"][0]["kind"], "lut3d");
176        assert_eq!(lut["label"], "3D LUT");
177        assert!(lut.get("wgslBody").is_none());
178
179        let crop = catalog["effects"]
180            .as_array()
181            .unwrap()
182            .iter()
183            .find(|entry| entry["id"] == "alpha2.effect.crop")
184            .unwrap();
185        assert_eq!(crop["params"][0]["min"], 0.0);
186        assert_eq!(crop["params"][0]["max"], 1.0);
187        assert!((crop["params"][0]["step"].as_f64().unwrap() - 0.005).abs() < 1e-8);
188    }
189
190    #[test]
191    fn every_built_in_lists_its_named_inputs_and_none_has_any() {
192        let catalog = builtin_catalog();
193        for entry in catalog["effects"]
194            .as_array()
195            .unwrap()
196            .iter()
197            .chain(catalog["transitions"].as_array().unwrap())
198        {
199            assert_eq!(entry["inputs"], json!([]), "{}", entry["id"]);
200        }
201    }
202
203    #[test]
204    fn continuous_parameters_are_animatable_and_discrete_ones_hold() {
205        let catalog = builtin_catalog();
206        let mut seen = std::collections::BTreeSet::new();
207        for entry in catalog["effects"]
208            .as_array()
209            .unwrap()
210            .iter()
211            .chain(catalog["transitions"].as_array().unwrap())
212        {
213            for param in entry["params"].as_array().unwrap() {
214                let kind = param["type"].as_str().unwrap();
215                let animatable = param["animatable"].as_bool().unwrap();
216                assert_eq!(
217                    animatable,
218                    matches!(kind, "float" | "vec2" | "vec4" | "color"),
219                    "{}.{}",
220                    entry["id"],
221                    param["name"]
222                );
223                seen.insert(kind.to_owned());
224            }
225        }
226        assert!(seen.contains("float") && seen.contains("enum"), "{seen:?}");
227    }
228
229    #[test]
230    fn a_color_parameter_is_a_color_and_thresholds_are_a_vec4() {
231        let catalog = builtin_catalog();
232        for entry in catalog["effects"]
233            .as_array()
234            .unwrap()
235            .iter()
236            .chain(catalog["transitions"].as_array().unwrap())
237        {
238            for param in entry["params"].as_array().unwrap() {
239                if param["type"] == "color" {
240                    assert_eq!(param["unit"], "color", "{}.{}", entry["id"], param["name"]);
241                }
242            }
243        }
244        // color-phase's steps are per-channel progress thresholds: keyframed, they move
245        // component by component, not as a premultiplied color.
246        let phase = catalog["transitions"]
247            .as_array()
248            .unwrap()
249            .iter()
250            .find(|entry| entry["id"] == "alpha2.transition.color-phase")
251            .unwrap();
252        for param in phase["params"].as_array().unwrap() {
253            assert_eq!(param["type"], "vec4", "{}", param["name"]);
254            assert_eq!(
255                (param["min"].as_f64(), param["max"].as_f64()),
256                (Some(0.0), Some(1.0))
257            );
258        }
259    }
260}