906 lines
35 KiB
Rust
906 lines
35 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 blur3;
|
|
pub mod catalog;
|
|
mod channel;
|
|
mod channel2;
|
|
mod channel3d;
|
|
mod color2;
|
|
mod color3;
|
|
mod color_fx;
|
|
mod controls;
|
|
mod distort;
|
|
mod distort2;
|
|
mod distort3;
|
|
pub mod distort4;
|
|
mod generate;
|
|
mod generate2;
|
|
mod generate3;
|
|
mod keying;
|
|
mod keying2;
|
|
pub mod keylight;
|
|
mod matte;
|
|
pub mod migrate;
|
|
mod misc;
|
|
pub mod mocha_shape;
|
|
mod noise;
|
|
mod noise2;
|
|
mod noise3;
|
|
mod obsolete;
|
|
mod ocio;
|
|
pub mod paint;
|
|
mod perspective;
|
|
mod perspective2;
|
|
pub mod puppet;
|
|
pub mod roto;
|
|
mod sim;
|
|
mod sim2;
|
|
mod sim3;
|
|
mod stylize2;
|
|
mod stylize3;
|
|
mod textfx;
|
|
mod time_fx;
|
|
mod transition;
|
|
mod transition2;
|
|
pub mod util;
|
|
mod utility;
|
|
mod utility2;
|
|
mod vr;
|
|
pub mod warp_stab;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::OnceLock;
|
|
|
|
pub use color_fx::{exposure_settings, levels_clip};
|
|
pub use color2::Curve;
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::build::Ids;
|
|
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
|
|
pub use effectcraft_raster::{AuxChannels, Image};
|
|
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS};
|
|
|
|
/// 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`). Nested properties
|
|
/// are also keyed by their match path (`group/sub/param`, the first group with each match id),
|
|
/// which is how effects with twirl-down groups declare and read them.
|
|
pub fn flatten_params(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -> Params {
|
|
fn walk(g: &PropGroup, pre: &str, mpre: &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) => {
|
|
let v = eval(p);
|
|
if !mpre.is_empty() {
|
|
out.entry(format!("{mpre}{}", p.match_id)).or_insert_with(|| v.clone());
|
|
}
|
|
out.insert(format!("{pre}{}", p.match_id), v);
|
|
}
|
|
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, &format!("{mpre}{}/", sg.match_id), 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>>;
|
|
/// 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
|
|
}
|
|
/// Auxiliary 3D channels (depth, object / material IDs, normals, Cryptomatte, any named
|
|
/// EXR channel) of the effect's own layer source at the current time, in layer space (see
|
|
/// [`AuxChannels`]). `None` when the source has none (the 3D Channel effects then pass the
|
|
/// layer through).
|
|
fn aux(&self) -> Option<std::sync::Arc<AuxChannels>> {
|
|
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,
|
|
/// The project's working colour space (`None` = unmanaged, treated as sRGB).
|
|
pub working_space: Option<effectcraft_color::ColorSpace>,
|
|
/// Working-space pixels are linear light (Linearize Working Space).
|
|
pub working_linear: bool,
|
|
}
|
|
|
|
/// 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(noise3::specs());
|
|
v.extend(color3::specs());
|
|
v.extend(obsolete::specs());
|
|
v.extend(ocio::specs());
|
|
v.extend(vr::specs());
|
|
v.extend(blur3::specs());
|
|
v.extend(distort4::specs());
|
|
v.extend(keylight::specs());
|
|
v.extend(mocha_shape::specs());
|
|
v.extend(sim3::specs());
|
|
v.extend(channel3d::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.extend(roto::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)))
|
|
.or_else(|| {
|
|
// After Effects' third-party display names we register under a generic name.
|
|
keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten()
|
|
})
|
|
.or_else(|| {
|
|
// Display names of earlier versions.
|
|
migrate::EFFECT_NAME_ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name_or_id)).and_then(|(_, id)| find(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).
|
|
// A grouped layer parameter's companion sits next to it in its group, so it flattens
|
|
// to `group/paramSource` = `layer_source_id("group/param")`.
|
|
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(), &layer_source_id(leaf), &format!("{} Source", ps.name), Value::Enum(2)).with_ui(ParamUi::Hidden);
|
|
src.hold_only = true;
|
|
match path {
|
|
Some(path) => nested_group(&mut g, ids, path).children.push(src.into()),
|
|
None => g.children.push(src.into()),
|
|
}
|
|
}
|
|
}
|
|
g
|
|
}
|
|
|
|
/// Display names of twirl-down parameter groups (`group/param` spec ids) by match id, for
|
|
/// effects without their own table.
|
|
pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|
// Color Correction: Exposure, Levels (Individual Controls), Change to Color, Colorama,
|
|
// Shadow/Highlight, Lumetri Color.
|
|
("master", "Master"),
|
|
("rgb", "RGB"),
|
|
("red", "Red"),
|
|
("green", "Green"),
|
|
("blue", "Blue"),
|
|
("alpha", "Alpha"),
|
|
("toleranceGroup", "Tolerance"),
|
|
("inputPhase", "Input Phase"),
|
|
("outputCycle", "Output Cycle"),
|
|
("moreOptions", "More Options"),
|
|
("basicCorrection", "Basic Correction"),
|
|
("whiteBalance", "White Balance"),
|
|
("tone", "Tone"),
|
|
("creative", "Creative"),
|
|
("adjustments", "Adjustments"),
|
|
("curves", "Curves"),
|
|
("colorWheels", "Color Wheels"),
|
|
("vignette", "Vignette"),
|
|
// Camera Lens Blur.
|
|
("irisProperties", "Iris Properties"),
|
|
("highlight", "Highlight"),
|
|
// Numbers.
|
|
("format", "Format"),
|
|
("fillAndStroke", "Fill and Stroke"),
|
|
// Stylize: Cartoon, Roughen Edges.
|
|
("fill", "Fill"),
|
|
("edge", "Edge"),
|
|
("evolutionOptions", "Evolution Options"),
|
|
// Noise & Grain: Fractal / Turbulent Noise, Add / Match Grain, Remove Grain, Noise Alpha.
|
|
("transform", "Transform"),
|
|
("subSettings", "Sub Settings"),
|
|
("tweaking", "Tweaking"),
|
|
("channelIntensities", "Channel Intensities"),
|
|
("channelSize", "Channel Size"),
|
|
("color", "Color"),
|
|
("application", "Application"),
|
|
("animation", "Animation"),
|
|
("noiseReductionSettings", "Noise Reduction Settings"),
|
|
("fineTuning", "Fine Tuning"),
|
|
("unsharpMask", "Unsharp Mask"),
|
|
("noiseOptions", "Noise Options (Animation)"),
|
|
// Time: Timewarp.
|
|
("tuning", "Tuning"),
|
|
("motionBlur", "Motion Blur"),
|
|
// Simulation.
|
|
("extras", "Extras"),
|
|
("wiggle", "Wiggle"),
|
|
("light", "Light"),
|
|
("shading", "Shading"),
|
|
("producer", "Producer"),
|
|
("physics", "Physics"),
|
|
("directionAxis", "Direction Axis"),
|
|
("gravityVector", "Gravity Vector"),
|
|
("particle", "Particle"),
|
|
("hairfallMap", "Hairfall Map"),
|
|
("hairColor", "Hair Color"),
|
|
("bottom", "Bottom"),
|
|
("water", "Water"),
|
|
("lighting", "Lighting"),
|
|
("material", "Material"),
|
|
("shape", "Shape"),
|
|
("force1", "Force 1"),
|
|
("force2", "Force 2"),
|
|
("bubbles", "Bubbles"),
|
|
("rendering", "Rendering"),
|
|
("heightMapControls", "Height Map Controls"),
|
|
("simulation", "Simulation"),
|
|
("producer1", "Producer 1"),
|
|
("producer2", "Producer 2"),
|
|
("xPosition", "X Position"),
|
|
("yPosition", "Y Position"),
|
|
("zPosition", "Z Position"),
|
|
("xRotation", "X Rotation"),
|
|
("yRotation", "Y Rotation"),
|
|
("zRotation", "Z Rotation"),
|
|
("xScale", "X Scale"),
|
|
("yScale", "Y Scale"),
|
|
("cameraPosition", "Camera Position"),
|
|
("cannon", "Cannon"),
|
|
("grid", "Grid"),
|
|
("layerExploder", "Layer Exploder"),
|
|
("layerMap", "Layer Map"),
|
|
("gravity", "Gravity"),
|
|
("repel", "Repel"),
|
|
("wall", "Wall"),
|
|
("persistentPropertyMapper", "Persistent Property Mapper"),
|
|
// Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`).
|
|
("ultraSettings", "Ultra Settings"),
|
|
("fillAndStroke", "Fill and Stroke"),
|
|
("pathOptions", "Path Options"),
|
|
("controlPoints", "Control Points"),
|
|
("character", "Character"),
|
|
("orientation", "Orientation"),
|
|
("paragraph", "Paragraph"),
|
|
("preview", "Preview"),
|
|
// Generate.
|
|
("positionsColors", "Positions & Colors"),
|
|
("feather", "Feather"),
|
|
("tilingOptions", "Tiling Options"),
|
|
("polygon", "Polygon"),
|
|
("waveMotion", "Wave Motion"),
|
|
("waveStroke", "Stroke"),
|
|
("coreSettings", "Core Settings"),
|
|
("glowSettings", "Glow Settings"),
|
|
("expertSettings", "Expert Settings"),
|
|
("mandelbrot", "Mandelbrot"),
|
|
("julia", "Julia"),
|
|
("postInversionOffset", "Post-Inversion Offset"),
|
|
("fractalColor", "Color"),
|
|
("highQualitySettings", "High Quality Settings"),
|
|
("edgeOptions", "Edge Options"),
|
|
("strokeOptions", "Stroke Options"),
|
|
];
|
|
|
|
/// Display names of nested parameter groups by match id.
|
|
fn group_name(m: &str) -> &str {
|
|
warp_stab::GROUPS.iter().chain(roto::GROUPS).chain(keylight::GROUPS).chain(PARAM_GROUPS).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",
|
|
// Temporal Smoothing reads neighbouring frames.
|
|
"ec.color.autolevels",
|
|
"ec.color.autocontrast",
|
|
"ec.color.autocolor",
|
|
"ec.color.shadowhighlight",
|
|
"ec.vr.digitalglitch",
|
|
"ec.blur.camerashakedeblur",
|
|
"ec.distort.rollingshutterrepair",
|
|
"ec.obsolete.mochashape",
|
|
// Each frame gets its own stabilizing warp.
|
|
warp_stab::ID,
|
|
// Each frame has its own segmentation.
|
|
roto::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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Registry-wide parameter sanity: unique ids and names, non-empty display names, defaults
|
|
/// of the right kind and inside their ranges, popup defaults inside their options.
|
|
#[test]
|
|
fn every_param_is_well_formed() {
|
|
let mut problems = vec![];
|
|
let mut names = std::collections::HashSet::new();
|
|
for s in registry() {
|
|
if s.name.trim().is_empty() {
|
|
problems.push(format!("{}: empty display name", s.id));
|
|
}
|
|
if !names.insert(s.name) {
|
|
problems.push(format!("{}: duplicate display name {}", s.id, s.name));
|
|
}
|
|
let mut ids = std::collections::HashSet::new();
|
|
for p in &s.params {
|
|
let at = format!("{} / {}", s.id, p.id);
|
|
if !ids.insert(p.id) {
|
|
problems.push(format!("{at}: duplicate id"));
|
|
}
|
|
if p.id.is_empty() || p.id.contains(char::is_whitespace) {
|
|
problems.push(format!("{at}: bad id"));
|
|
}
|
|
if p.name.trim().is_empty() {
|
|
problems.push(format!("{at}: empty display name"));
|
|
}
|
|
match (&p.ui, &p.default) {
|
|
(ParamUi::Slider { min, max, slider_min, slider_max, .. }, d) => {
|
|
if !(min <= max && slider_min <= slider_max) {
|
|
problems.push(format!("{at}: inverted range"));
|
|
}
|
|
if !(slider_min >= min && slider_max <= max) {
|
|
problems.push(format!("{at}: slider range {slider_min}..{slider_max} outside valid range {min}..{max}"));
|
|
}
|
|
match d {
|
|
Value::Scalar(v) if v < min || v > max => problems.push(format!("{at}: default {v} outside {min}..{max}")),
|
|
Value::Scalar(_) => {}
|
|
d => problems.push(format!("{at}: slider default {d:?} is not a number")),
|
|
}
|
|
}
|
|
(ParamUi::Popup { options }, d) => {
|
|
if options.is_empty() || options.iter().any(|o| o.trim().is_empty()) {
|
|
problems.push(format!("{at}: empty popup option"));
|
|
}
|
|
let uniq: std::collections::HashSet<_> = options.iter().collect();
|
|
if uniq.len() != options.len() {
|
|
problems.push(format!("{at}: duplicate popup option"));
|
|
}
|
|
match d {
|
|
Value::Enum(i) if (*i as usize) < options.len() => {}
|
|
d => problems.push(format!("{at}: popup default {d:?} not among {} options", options.len())),
|
|
}
|
|
}
|
|
(ParamUi::Checkbox, d) if !matches!(d, Value::Bool(_)) => problems.push(format!("{at}: checkbox default {d:?}")),
|
|
(ParamUi::Color, d) if !matches!(d, Value::Color(_)) => problems.push(format!("{at}: color default {d:?}")),
|
|
(ParamUi::Point, d) if !matches!(d, Value::Vec2(_)) => problems.push(format!("{at}: point default {d:?}")),
|
|
(ParamUi::Point3, d) if !matches!(d, Value::Vec3(_)) => problems.push(format!("{at}: 3D point default {d:?}")),
|
|
(ParamUi::Layer, d) if !matches!(d, Value::Layer(_)) => problems.push(format!("{at}: layer default {d:?}")),
|
|
(ParamUi::Percent | ParamUi::Angle | ParamUi::Pixels | ParamUi::Number, d) if !matches!(d, Value::Scalar(_) | Value::Vec2(_)) => {
|
|
problems.push(format!("{at}: numeric default {d:?}"))
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
assert!(problems.is_empty(), "{} problems:\n{}", problems.len(), problems.join("\n"));
|
|
}
|
|
|
|
/// 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<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: ¶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);
|
|
}
|
|
}
|