//! The effects library. //! //! Every effect is an [`EffectSpec`]: a stable id (`ec.blur.gaussian`), its display name and //! category (the Effects & Presets tree), typed parameters with defaults and UI hints, and a CPU //! render function. Effect instances on layers are ordinary property groups (see //! [`instantiate`]), so parameters animate, take expressions and are addressable like any other //! property. pub mod audio_fx; mod blur2; mod channel; mod channel2; mod color2; mod color3; mod color_fx; mod controls; mod distort; mod distort2; mod distort3; mod generate; mod generate2; mod generate3; mod keying; mod keying2; mod matte; mod misc; mod noise; mod noise2; mod obsolete; pub mod paint; mod perspective; mod perspective2; pub mod puppet; mod sim; mod sim2; mod stylize2; mod stylize3; mod textfx; mod time_fx; mod transition; mod transition2; pub mod util; mod utility; mod utility2; pub mod warp_stab; use std::collections::HashMap; use std::sync::OnceLock; pub use color2::Curve; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::Image; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ "3D Channel", "Audio", "Blur & Sharpen", "Channel", "Color Correction", "Distort", "Expression Controls", "Generate", "Immersive Video", "Keying", "Matte", "Noise & Grain", "Obsolete", "Paint", "Perspective", "Simulation", "Stylize", "Text", "Time", "Transition", "Utility", ]; pub struct ParamSpec { /// Point defaults are fractions of the layer size (0.5, 0.5 = layer centre). pub id: &'static str, pub name: &'static str, pub default: Value, pub ui: ParamUi, } /// Parameter values evaluated at the current time. #[derive(Clone, Debug, Default)] pub struct Params { pub values: HashMap, } impl Params { pub fn get(&self, id: &str) -> Option<&Value> { self.values.get(id) } pub fn f(&self, id: &str) -> f64 { self.values.get(id).map(Value::as_f64).unwrap_or(0.0) } pub fn v2(&self, id: &str) -> [f64; 2] { self.values.get(id).map(Value::as_vec2).unwrap_or([0.0; 2]) } pub fn color(&self, id: &str) -> [f32; 4] { self.values.get(id).map(Value::as_color).unwrap_or([1.0; 4]) } pub fn b(&self, id: &str) -> bool { self.values.get(id).map(Value::as_bool).unwrap_or(false) } pub fn e(&self, id: &str) -> u32 { self.values.get(id).map(Value::as_enum).unwrap_or(0) } /// String parameter (`Value::Str`), empty when missing or of another kind. pub fn s(&self, id: &str) -> &str { match self.values.get(id) { Some(Value::Str(s)) => s, _ => "", } } /// Key prefixes (`{prefix}#1/`, `{prefix}#2/`, …) of the nested groups flattened under /// `prefix` by [`flatten_params`], in order. pub fn groups(&self, prefix: &str) -> Vec { let mut out = vec![]; for i in 1.. { let pre = format!("{prefix}#{i}/"); if !self.values.contains_key(&format!("{pre}@match")) { break; } out.push(pre); } out } /// The first nested group under `prefix` with match id `m`. pub fn group(&self, prefix: &str, m: &str) -> Option { self.groups(prefix).into_iter().find(|g| self.s(&format!("{g}@match")) == m) } } /// Evaluate an effect instance's parameters, including nested groups (Paint strokes, Puppet /// meshes and pins). Direct properties keep their match id as the key. The `i`-th child group /// (1-based, counting groups only) of a group with key prefix `pre` gets the prefix /// `{pre}#{i}/`, its properties `{pre}#{i}/{match}`, and metadata keys `@match`, `@name` /// (`Value::Str`), `@enabled` (`Value::Bool`) and `@uid` (`Value::Scalar`). pub fn flatten_params(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -> Params { fn walk(g: &PropGroup, pre: &str, eval: &mut dyn FnMut(&Property) -> Value, out: &mut HashMap) { let mut gi = 0; for c in &g.children { match c { effectcraft_project::Node::Prop(p) => { out.insert(format!("{pre}{}", p.match_id), eval(p)); } effectcraft_project::Node::Group(sg) => { gi += 1; let sp = format!("{pre}#{gi}/"); out.insert(format!("{sp}@match"), Value::Str(sg.match_id.clone())); out.insert(format!("{sp}@name"), Value::Str(sg.name.clone())); out.insert(format!("{sp}@enabled"), Value::Bool(sg.enabled)); out.insert(format!("{sp}@uid"), Value::Scalar(sg.uid as f64)); walk(sg, &sp, eval, out); } } } } let mut values = HashMap::new(); walk(g, "", eval, &mut values); Params { values } } /// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`. #[derive(Clone, Debug, Default)] pub struct Buf { pub img: Image, pub offset: [f64; 2], pub scale: f64, } impl Buf { pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { return; } self.img = self.img.padded(pad); self.offset[0] += pad as f64; self.offset[1] += pad as f64; } } /// A layer mask at the current time, flattened to a polyline in layer coordinates. #[derive(Clone, Debug, Default, PartialEq)] pub struct MaskShape { pub name: String, pub points: Vec<[f64; 2]>, pub closed: bool, pub inverted: bool, } /// Pixels of another layer referenced by a layer parameter: its buffer (layer space, see /// [`Buf`]) and its source size in layer pixels. #[derive(Clone, Debug, Default)] pub struct LayerPixels { pub buf: Buf, pub size: [f64; 2], } /// Services the renderer offers effects that look beyond their own pixels. pub trait EffectHost: Sync { /// Layer `id` of the same composition at the current comp time. `masks_and_effects` /// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references. fn layer(&self, id: u64, masks_and_effects: bool) -> Option; /// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp /// time `start` seconds, at `rate` Hz. `None` when the layer has no audio. fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option>; /// Layer `id` with its masks but none of its effects (a layer parameter's "Masks" choice). /// Defaults to the plain source. fn layer_masks(&self, id: u64) -> Option { self.layer(id, false) } /// The effect's own layer at another **layer time** `layer_time` (seconds): its source with /// masks applied, followed by the first `effects` effects of its stack (0 = none, which is /// what After Effects' Time effects see). `None` when unavailable (no host, recursion /// limit, adjustment layers). Time effects use this to read neighbouring frames. fn self_at(&self, _layer_time: f64, _effects: usize) -> Option { None } /// Layer `id` of the same composition at comp time `comp_time` (seconds), like /// [`EffectHost::layer`] but at another time. fn layer_at(&self, _id: u64, _comp_time: f64, _masks_and_effects: bool) -> Option { None } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). #[derive(Clone, Copy, Default)] pub struct EffectEnv<'a> { /// The layer's masks (enabled ones, top to bottom). pub masks: &'a [MaskShape], pub host: Option<&'a dyn EffectHost>, /// Composition time in seconds. pub comp_time: f64, /// Composition frame rate (0 when unknown). pub frame_rate: f64, /// Index of the running effect in its layer's stack (for [`EffectHost::self_at`]). pub effect_index: usize, } /// What an effect gets to render with. pub struct EffectCtx<'a> { pub params: &'a Params, /// Layer time in seconds. pub time: f64, /// Layer source size in layer pixels (the effect's "layer bounds"). pub layer_size: [f64; 2], /// Instance seed (stable per effect instance). pub seed: u32, /// Rendering for an adjustment layer (operating on the comp below). pub adjustment: bool, /// Masks, other layers, audio, comp timing. pub env: EffectEnv<'a>, } impl EffectCtx<'_> { /// The layer chosen in layer parameter `id`, via the host. /// `masks_and_effects` is the effect's built-in choice, used when the instance has no /// source companion parameter (see [`layer_source_id`]). pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option { let lid = self.params.get(id)?.as_layer()?; let host = self.env.host?; match self.params.get(&layer_source_id(id)).map(Value::as_enum) { Some(0) => host.layer(lid, false), Some(1) => host.layer_masks(lid), Some(_) => host.layer(lid, true), None => host.layer(lid, masks_and_effects), } } /// Frame rate for frame-based maths (comp rate, else 30). pub fn fps(&self) -> f64 { if self.env.frame_rate > 0.0 { self.env.frame_rate } else { 30.0 } } } pub type RenderFn = fn(&EffectCtx, Buf) -> Buf; pub struct EffectSpec { pub id: &'static str, pub name: &'static str, pub category: &'static str, pub params: Vec, pub render: RenderFn, /// Implemented on the GPU path too (shown with a badge). pub gpu: bool, /// Supports 32 bpc float (badge). pub float: bool, } pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi { ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals } } pub(crate) fn popup(opts: &[&str]) -> ParamUi { ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() } } pub(crate) fn num(v: f64) -> Value { Value::Scalar(v) } pub(crate) fn col(r: f64, g: f64, b: f64) -> Value { Value::Color([r, g, b, 1.0]) } /// All effects, sorted by category then name. pub fn registry() -> &'static [EffectSpec] { static R: OnceLock> = OnceLock::new(); R.get_or_init(|| { let mut v = Vec::new(); v.extend(misc::specs()); v.extend(color_fx::specs()); v.extend(generate::specs()); v.extend(distort::specs()); v.extend(controls::specs()); v.extend(keying::specs()); v.extend(matte::specs()); v.extend(channel::specs()); v.extend(blur2::specs()); v.extend(color2::specs()); v.extend(stylize2::specs()); v.extend(noise::specs()); v.extend(generate2::specs()); v.extend(distort2::specs()); v.extend(perspective::specs()); v.extend(transition::specs()); v.extend(utility::specs()); v.extend(sim::specs()); v.extend(sim2::specs()); v.extend(distort3::specs()); v.extend(perspective2::specs()); v.extend(utility2::specs()); v.extend(generate3::specs()); v.extend(textfx::specs()); v.extend(stylize3::specs()); v.extend(transition2::specs()); v.extend(channel2::specs()); v.extend(keying2::specs()); v.extend(noise2::specs()); v.extend(color3::specs()); v.extend(obsolete::specs()); v.extend(time_fx::specs()); v.extend(audio_fx::specs()); v.extend(paint::specs()); v.extend(puppet::specs()); v.extend(warp_stab::specs()); v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name))); for s in v.iter_mut() { if GPU_EFFECTS.contains(&s.id) { s.gpu = true; } } v }) } pub fn find(id: &str) -> Option<&'static EffectSpec> { registry().iter().find(|s| s.id == id) } /// Find by id or (case-insensitive) display name. pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> { find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))) } /// A parameter's default value on a layer of `layer_size` (point defaults are fractions of the /// layer size). pub fn default_value(ps: &ParamSpec, layer_size: [f64; 2]) -> Value { match (&ps.ui, &ps.default) { (ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]), _ => ps.default.clone(), } } /// Options of a layer parameter's source popup (After Effects shows it next to the layer /// popup): what of the chosen layer the effect sees. pub const LAYER_SOURCE_OPTIONS: [&str; 3] = ["Source", "Masks", "Effects & Masks"]; /// Id of the hidden companion parameter holding layer parameter `id`'s source choice (an index /// into [`LAYER_SOURCE_OPTIONS`]). pub fn layer_source_id(id: &str) -> String { format!("{id}Source") } /// Build the property group for a new instance of an effect. pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_size: [f64; 2]) -> PropGroup { let mut g = ids.group(spec.id, instance_name); g.kind = GroupKind::Effect { effect: spec.id.to_string() }; for ps in &spec.params { let default = default_value(ps, layer_size); // `group/sub/param` ids nest the parameter in twirl-down groups (Warp Stabilizer's // Stabilization / Borders / Advanced). let (path, leaf) = ps.id.rsplit_once('/').map(|(g, l)| (Some(g), l)).unwrap_or((None, ps.id)); let mut pr = Property::new(ids.alloc(), leaf, ps.name, default).with_ui(ps.ui.clone()); if matches!(ps.ui, ParamUi::Point) { pr.spatial = true; } if matches!(ps.ui, ParamUi::Checkbox | ParamUi::Popup { .. } | ParamUi::Layer) { pr.hold_only = true; } match path { Some(path) => nested_group(&mut g, ids, path).children.push(pr.into()), None => g.children.push(pr.into()), } // Layer parameters carry a source choice (Source / Masks / Effects & Masks) unless the // effect declares its own (with its own default). let sid = layer_source_id(ps.id); if matches!(ps.ui, ParamUi::Layer) && !spec.params.iter().any(|q| q.id == sid) { let mut src = Property::new(ids.alloc(), &sid, &format!("{} Source", ps.name), Value::Enum(2)).with_ui(ParamUi::Hidden); src.hold_only = true; g.children.push(src.into()); } } g } /// Display names of nested parameter groups by match id. fn group_name(m: &str) -> &str { warp_stab::GROUPS.iter().find(|(id, _)| *id == m).map(|(_, n)| *n).unwrap_or(m) } /// The nested group at `path` (`borders/autoScale`) under `g`, created on first use. fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut PropGroup { let (first, rest) = match path.split_once('/') { Some((a, b)) => (a, Some(b)), None => (path, None), }; if g.sub(first).is_none() { g.children.push(ids.group(first, group_name(first)).into()); } let sub = g.sub_mut(first).expect("just added"); match rest { Some(r) => nested_group(sub, ids, r), None => sub, } } /// Effects the GPU compositor (`effectcraft-gpu`) implements with the CPU effect's semantics /// (Mercury GPU Acceleration; the Effects & Presets GPU badge). Expression controls are /// pass-throughs and count as GPU effects too. pub const GPU_EFFECTS: &[&str] = &[ "ec.blur.gaussian", "ec.blur.fastbox", "ec.blur.directional", "ec.stylize.glow", "ec.color.levels", "ec.color.curves", "ec.color.huesaturation", "ec.color.tint", "ec.generate.fill", "ec.generate.gradientramp", "ec.noise.fractal", "ec.perspective.dropshadow", "ec.color.brightnesscontrast", "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated /// parameters). The renderer's layer cache folds the layer time into the key for these. /// The `time_dependence_is_declared` test checks the list against every registered effect. pub const TIME_DEPENDENT: &[&str] = &[ "ec.distort.wavewarp", "ec.distort.ripple", "ec.generate.radiowaves", "ec.stylize.scatter", "ec.stylize.strobe", "ec.noise.noise", "ec.noise.addgrain", "ec.noise.noisealpha", "ec.noise.noisehlsauto", "ec.obsolete.lightning", "ec.text.timecode", "ec.text.numbers", // Time effects read neighbouring frames of the layer. "ec.time.echo", "ec.time.posterizetime", "ec.time.timedifference", "ec.time.timedisplacement", "ec.time.timewarp", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", // Each frame gets its own stabilizing warp. warp_stab::ID, ]; /// See [`TIME_DEPENDENT`]. pub fn is_time_dependent(id: &str) -> bool { // Simulations replay from layer time 0, so every frame differs even when the defaults // happen to look static at the test's two sample times. id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) } /// Run an effect on a buffer. pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) } /// Test helper: run effect `id` on `img` (scale 1, layer size = image size) with its defaults /// overridden by `vals`, at layer time `time`, with an optional environment. #[cfg(test)] pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { let s = find(id).unwrap_or_else(|| panic!("no effect {id}")); let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; for (k, v) in vals { assert!(params.values.contains_key(*k), "{id}: unknown param {k}"); params.values.insert(k.to_string(), v.clone()); } let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env }; apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } #[cfg(test)] mod tests { use super::*; #[test] fn run_fx_passthrough() { let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]); let out = run_fx("ec.control.slider", &[("slider", num(3.0))], img.clone(), 0.0, EffectEnv::default()); assert_eq!(out.img.data, img.data); } #[test] #[ignore] fn dump_registry() { for s in registry() { println!("{} | {} | {}", s.category, s.name, s.id); } } #[test] fn registry_is_consistent() { let r = registry(); assert!(r.len() >= 40, "{}", r.len()); let mut ids = std::collections::HashSet::new(); for s in r { assert!(ids.insert(s.id), "duplicate {}", s.id); assert!(CATEGORIES.contains(&s.category), "{} in unknown category {}", s.id, s.category); let mut pids = std::collections::HashSet::new(); for p in &s.params { assert!(pids.insert(p.id), "{}: duplicate param {}", s.id, p.id); } } } /// Every effect runs on a small image with its default parameters without panicking and /// keeps alpha in range. #[test] fn all_effects_run_with_defaults() { let mut img = Image::new(48, 32); for y in 0..32 { for x in 0..48 { let a = if (8..40).contains(&x) && (6..26).contains(&y) { 1.0 } else { 0.0 }; img.set(x, y, [x as f32 / 48.0 * a, y as f32 / 32.0 * a, 0.5 * a, a]); } } for s in registry() { let params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false, env: Default::default() }; let out = apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }); assert!(!out.img.is_empty(), "{}", s.id); assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id); } } /// Effects that read the clock must be listed in `TIME_DEPENDENT`, or the layer cache would /// serve stale pixels. Render every effect at two times with default parameters (plus /// "random…" switches turned on) and require identical output from unlisted ones. #[test] fn time_dependence_is_declared() { let mut img = Image::new(40, 30); for y in 0..30 { for x in 0..40 { let a = if (6..34).contains(&x) && (5..25).contains(&y) { 1.0 } else { 0.3 }; img.set(x, y, [((x * 7 + y * 3) % 11) as f32 / 11.0 * a, y as f32 / 30.0 * a, 0.4 * a, a]); } } let mut missing = vec![]; for s in registry() { let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; for (k, v) in params.values.iter_mut() { if k.to_lowercase().contains("random") && matches!(v, Value::Bool(_)) { *v = Value::Bool(true); } } let run = |t: f64| { let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false, env: Default::default() }; apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img }; let (a, b) = (run(0.0), run(1.37)); if a != b && !is_time_dependent(s.id) { missing.push(s.id); } } assert!(missing.is_empty(), "these depend on time but are not in TIME_DEPENDENT: {missing:?}"); } /// A host that tags the returned pixels with the requested mode (R = 0 source, 0.5 masks, /// 1 effects & masks). struct ModeHost; impl EffectHost for ModeHost { fn layer(&self, _: u64, me: bool) -> Option { let v = if me { 1.0 } else { 0.0 }; Some(LayerPixels { buf: Buf { img: Image::filled(1, 1, [v, 0.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [1.0, 1.0] }) } fn layer_masks(&self, _: u64) -> Option { Some(LayerPixels { buf: Buf { img: Image::filled(1, 1, [0.5, 0.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [1.0, 1.0] }) } fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { None } } #[test] fn layer_param_source_companion_selects_the_mode() { let host = ModeHost; let mode = |companion: Option, builtin: bool| { let mut params = Params::default(); params.values.insert("l".into(), Value::Layer(Some(3))); if let Some(c) = companion { params.values.insert(layer_source_id("l"), Value::Enum(c)); } let env = EffectEnv { host: Some(&host), ..Default::default() }; let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env }; ctx.layer_param("l", builtin).unwrap().buf.img.get(0, 0)[0] }; assert_eq!(mode(None, true), 1.0); assert_eq!(mode(None, false), 0.0); assert_eq!(mode(Some(0), true), 0.0); assert_eq!(mode(Some(1), true), 0.5); assert_eq!(mode(Some(2), false), 1.0); // Instances get a companion (default Effects & Masks) unless the effect declares one. let mut next = 0; let g = instantiate(find("ec.channel.blend").unwrap(), &mut Ids(&mut next), "Blend", [10.0, 10.0]); assert_eq!(g.get("blendWithLayerSource").map(|p| p.value.clone()), Some(Value::Enum(2))); let g = instantiate(find("ec.noise.matchgrain").unwrap(), &mut Ids(&mut next), "Match Grain", [10.0, 10.0]); assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } }