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]
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#[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 "animatable": matches!(
95 spec.param_type,
96 ParamType::Float | ParamType::Vec2 | ParamType::Vec4 | ParamType::Color
97 ),
98 })
99}
100
101const 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 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 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}