Skip to main content

alpha2_render/
catalog.rs

1use serde_json::{Value, json};
2
3use crate::builtins::{builtin_effect_groups, builtin_transition_groups};
4use crate::{
5    EffectDef, EffectKind, EffectResourceKind, ParamSpec, ParamType, ParamUnit, ParamValue,
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 entries = |groups: Vec<(&'static str, Vec<EffectDef>)>| {
15        groups
16            .into_iter()
17            .flat_map(|(group, definitions)| {
18                definitions
19                    .into_iter()
20                    .map(move |definition| catalog_entry(&definition, group))
21            })
22            .collect::<Vec<_>>()
23    };
24    json!({
25        "effects": entries(builtin_effect_groups()),
26        "transitions": entries(builtin_transition_groups()),
27    })
28}
29
30/// Serializes [`builtin_catalog`] for process and WebAssembly boundaries.
31#[must_use]
32pub fn builtin_catalog_json() -> String {
33    builtin_catalog().to_string()
34}
35
36fn catalog_entry(definition: &EffectDef, group: &str) -> Value {
37    let label = label(&definition.id);
38    json!({
39        "id": definition.id,
40        "version": definition.version,
41        "kind": match definition.kind {
42            EffectKind::Effect => "effect",
43            EffectKind::Transition => "transition",
44            EffectKind::Generator => "generator",
45        },
46        "inputs": definition
47            .inputs
48            .iter()
49            .map(|input| json!({ "name": input.name }))
50            .collect::<Vec<_>>(),
51        "label": label,
52        "group": group,
53        "params": definition.params.iter().map(param).collect::<Vec<_>>(),
54        "resources": definition.resources.iter().map(|resource| json!({
55            "name": resource.name,
56            "kind": match resource.kind { EffectResourceKind::Lut3d => "lut3d" },
57        })).collect::<Vec<_>>(),
58    })
59}
60
61fn param(spec: &ParamSpec) -> Value {
62    let value = match &spec.default {
63        ParamValue::Float(value) => json!(value),
64        ParamValue::Vec2(value) => json!(value),
65        ParamValue::Vec4(value) | ParamValue::Color(value) => json!(value),
66        ParamValue::Enum(value) => json!(value),
67        ParamValue::Bool(value) => json!(value),
68    };
69    json!({
70        "name": spec.name,
71        "label": humanize(&spec.name),
72        "type": match spec.param_type {
73            ParamType::Float => "float",
74            ParamType::Vec2 => "vec2",
75            ParamType::Vec4 => "vec4",
76            ParamType::Color => "color",
77            ParamType::Enum => "enum",
78            ParamType::Bool => "bool",
79        },
80        "default": value,
81        "min": spec.min,
82        "max": spec.max,
83        "step": spec.step,
84        "unit": match spec.unit {
85            ParamUnit::Normalized => "normalized",
86            ParamUnit::Degrees => "degrees",
87            ParamUnit::Color => "color",
88            ParamUnit::Enum => "enum",
89            ParamUnit::Bool => "bool",
90        },
91        "enumValues": spec.enum_values,
92        // Whether a project may keyframe it with a curve. Enum and bool values have nothing
93        // between them, so their keyframes only hold.
94        "animatable": matches!(
95            spec.param_type,
96            ParamType::Float | ParamType::Vec2 | ParamType::Vec4 | ParamType::Color
97        ),
98    })
99}
100
101/// The labels `humanize` would get wrong: initialisms and the like, by the
102/// short id of the built-in.
103const LABELS: &[(&str, &str)] = &[
104    ("lut-3d", "3D LUT"),
105    ("black-and-white", "Black & White"),
106    ("hue-saturation", "Hue/Saturation"),
107    ("rgb-split", "RGB Split"),
108    ("crt", "CRT"),
109    ("vhs", "VHS"),
110    ("hsv-fade", "HSV Fade"),
111    ("tv-static", "TV Static"),
112    ("old-tv-lost-signal", "Old TV Lost Signal"),
113];
114
115fn label(id: &str) -> String {
116    let short = id.rsplit('.').next().unwrap_or(id);
117    LABELS
118        .iter()
119        .find(|(name, _)| *name == short)
120        .map_or_else(|| humanize(short), |(_, label)| (*label).to_owned())
121}
122
123fn humanize(value: &str) -> String {
124    value
125        .split(['-', '_'])
126        .filter(|part| !part.is_empty())
127        .map(|part| {
128            let mut chars = part.chars();
129            chars.next().map_or_else(String::new, |first| {
130                first.to_uppercase().collect::<String>() + chars.as_str()
131            })
132        })
133        .collect::<Vec<_>>()
134        .join(" ")
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn catalog_exposes_lut_resource_without_shader_source() {
143        let catalog = builtin_catalog();
144        let lut = catalog["effects"]
145            .as_array()
146            .unwrap()
147            .iter()
148            .find(|entry| entry["id"] == "alpha2.effect.lut-3d")
149            .unwrap();
150        assert_eq!(lut["resources"][0]["kind"], "lut3d");
151        assert_eq!(lut["label"], "3D LUT");
152        assert!(lut.get("wgslBody").is_none());
153
154        let crop = catalog["effects"]
155            .as_array()
156            .unwrap()
157            .iter()
158            .find(|entry| entry["id"] == "alpha2.effect.crop")
159            .unwrap();
160        assert_eq!(crop["params"][0]["min"], 0.0);
161        assert_eq!(crop["params"][0]["max"], 1.0);
162        assert!((crop["params"][0]["step"].as_f64().unwrap() - 0.005).abs() < 1e-8);
163    }
164
165    #[test]
166    fn every_built_in_lists_the_named_inputs_it_declares() {
167        let catalog = builtin_catalog();
168        let definitions = crate::builtin_effect_definitions()
169            .into_iter()
170            .chain(crate::builtin_transition_definitions())
171            .collect::<Vec<_>>();
172        let entries = catalog["effects"]
173            .as_array()
174            .unwrap()
175            .iter()
176            .chain(catalog["transitions"].as_array().unwrap())
177            .collect::<Vec<_>>();
178        assert_eq!(entries.len(), definitions.len());
179        for (entry, definition) in entries.into_iter().zip(&definitions) {
180            assert_eq!(entry["id"], definition.id);
181            let names = definition
182                .inputs
183                .iter()
184                .map(|input| json!({ "name": input.name }))
185                .collect::<Vec<_>>();
186            assert_eq!(entry["inputs"], json!(names), "{}", entry["id"]);
187        }
188    }
189
190    #[test]
191    fn every_built_in_is_alpha2_s_own_or_a_gl_transitions_port() {
192        // A source with other terms needs a notice and an exception here.
193        let own = crate::builtins::ALPHA2_EFFECT_LICENSE;
194        for definition in crate::builtin_effect_definitions()
195            .into_iter()
196            .chain(crate::builtins::internal_effect_definitions())
197        {
198            assert_eq!(
199                definition.license.as_deref(),
200                Some(own),
201                "{}",
202                definition.id
203            );
204        }
205        for definition in crate::builtin_transition_definitions() {
206            let license = definition.license.as_deref().unwrap_or_default();
207            assert!(
208                license == own
209                    || (license.starts_with("MIT; Author: ")
210                        && license.contains(crate::builtins::GL_TRANSITIONS_BASE)),
211                "{}: {license}",
212                definition.id
213            );
214        }
215    }
216
217    #[test]
218    fn every_label_exception_names_a_built_in_and_changes_its_label() {
219        let shorts = crate::builtin_effect_definitions()
220            .into_iter()
221            .chain(crate::builtin_transition_definitions())
222            .map(|definition| definition.id.rsplit('.').next().unwrap().to_owned())
223            .collect::<Vec<_>>();
224        for (short, label) in LABELS {
225            assert!(shorts.iter().any(|name| name == short), "{short}");
226            assert_ne!(humanize(short), *label, "{short} needs no exception");
227        }
228    }
229
230    #[test]
231    fn continuous_parameters_are_animatable_and_discrete_ones_hold() {
232        let catalog = builtin_catalog();
233        let mut seen = std::collections::BTreeSet::new();
234        for entry in catalog["effects"]
235            .as_array()
236            .unwrap()
237            .iter()
238            .chain(catalog["transitions"].as_array().unwrap())
239        {
240            for param in entry["params"].as_array().unwrap() {
241                let kind = param["type"].as_str().unwrap();
242                let animatable = param["animatable"].as_bool().unwrap();
243                assert_eq!(
244                    animatable,
245                    matches!(kind, "float" | "vec2" | "vec4" | "color"),
246                    "{}.{}",
247                    entry["id"],
248                    param["name"]
249                );
250                seen.insert(kind.to_owned());
251            }
252        }
253        assert!(seen.contains("float") && seen.contains("enum"), "{seen:?}");
254    }
255
256    #[test]
257    fn a_color_parameter_is_a_color_and_thresholds_are_a_vec4() {
258        let catalog = builtin_catalog();
259        for entry in catalog["effects"]
260            .as_array()
261            .unwrap()
262            .iter()
263            .chain(catalog["transitions"].as_array().unwrap())
264        {
265            for param in entry["params"].as_array().unwrap() {
266                if param["type"] == "color" {
267                    assert_eq!(param["unit"], "color", "{}.{}", entry["id"], param["name"]);
268                }
269            }
270        }
271        // color-phase's steps are per-channel progress thresholds: keyframed, they move
272        // component by component, not as a premultiplied color.
273        let phase = catalog["transitions"]
274            .as_array()
275            .unwrap()
276            .iter()
277            .find(|entry| entry["id"] == "alpha2.transition.color-phase")
278            .unwrap();
279        for param in phase["params"].as_array().unwrap() {
280            assert_eq!(param["type"], "vec4", "{}", param["name"]);
281            assert_eq!(
282                (param["min"].as_f64(), param["max"].as_f64()),
283                (Some(0.0), Some(1.0))
284            );
285        }
286    }
287}