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#[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#[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 "animatable": matches!(
91 spec.param_type,
92 ParamType::Float | ParamType::Vec2 | ParamType::Vec4 | ParamType::Color
93 ),
94 })
95}
96
97const 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 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 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}