effectcraft/crates/effects/src/lib.rs

515 lines
18 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.
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;
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",
"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<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,
_ => "",
}
}
/// 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<String> {
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<String> {
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<String, Value>) {
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<LayerPixels>;
/// `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<Vec<f32>>;
/// 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<Buf> {
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<LayerPixels> {
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.
pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option<LayerPixels> {
let lid = self.params.get(id)?.as_layer()?;
self.env.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<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.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.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",
"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",
];
/// 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: &params, 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: &params, 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: &params, 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:?}");
}
}