Files
effectcraft/crates/effects/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 08a48cc1e3 M9.7: effect controls widgets (EFF-1/EFF-2)
Angle dial with revolutions (0x+45.0°), point crosshair + on-viewer effect
point controls via a small viewer hook, colour eyedropper sampling the
composited frame, Curves graph editor and Levels histogram/triangles bound
to their params, layer params with a Source/Masks/Effects & Masks companion,
mask (Path) popups, keyframe navigator, drag-to-reorder effect headers.

Engine: effect.copy / effect.paste / effect.reset; effect.remove takes the
selection; Edit > Copy/Paste/Duplicate/Clear act on selected effects; all
undoable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:05:17 -04:00

544 lines
20 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;
mod perspective;
mod perspective2;
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;
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",
"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;
}
}
/// 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>>;
/// 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<LayerPixels> {
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<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.
/// `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<LayerPixels> {
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<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.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)))
}
/// 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);
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());
// 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
}
/// 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:?}");
}
/// 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<LayerPixels> {
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<LayerPixels> {
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<Vec<f32>> {
None
}
}
#[test]
fn layer_param_source_companion_selects_the_mode() {
let host = ModeHost;
let mode = |companion: Option<u32>, 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: &params, 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);
}
}