effectcraft/crates/effects/src/lib.rs

319 lines
10 KiB
Rust

//! 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.
mod blur2;
mod channel;
mod color2;
mod color_fx;
mod controls;
mod distort;
mod distort2;
mod generate;
mod generate2;
mod keying;
mod matte;
mod misc;
mod noise;
mod perspective;
mod stylize2;
mod transition;
pub mod util;
mod utility;
use std::collections::HashMap;
use std::sync::OnceLock;
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",
"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<String, Value>,
}
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,
_ => "",
}
}
}
/// 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;
}
}
/// 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,
}
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<ParamSpec>,
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<Vec<EffectSpec>> = 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.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
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)))
}
/// 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 = 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(),
};
let mut pr = Property::new(ids.alloc(), ps.id, 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;
}
g.children.push(pr.into());
}
g
}
/// 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",
];
/// See [`TIME_DEPENDENT`].
pub fn is_time_dependent(id: &str) -> bool {
TIME_DEPENDENT.contains(&id)
}
/// Run an effect on a buffer.
pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
(spec.render)(ctx, buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[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: &params, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false };
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]);
}
}
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: &params, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false };
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 {
assert!(is_time_dependent(s.id), "{} depends on time but is not in TIME_DEPENDENT", s.id);
}
}
}
}