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