M9.13: Transform shutter-angle motion blur, Glow Operation / Arbitrary Map, Cartoon Edge Enhancement

- Transform: Use Composition's Shutter Angle / Shutter Angle, sampling the transform across
  the shutter via EffectHost::params_at (EffectEnv::shutter carries the comp shutter when
  motion blur is on); the GPU path defers to the CPU when blurring.
- Glow: Glow Operation (blend modes) and Glow Colors > Arbitrary Map.
- Cartoon: Advanced > Edge Enhancement (shock-filter sharpening / softening).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 04:23:15 -04:00
parent 69202c7b3e
commit 3a7c9c5f61
8 changed files with 281 additions and 11 deletions

View File

@ -11,7 +11,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"),
("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"),
("ec.color.selectivecolor", "simplified Colors / Details layout"),
("ec.distort.transform", "no shutter-angle motion blur"),
("ec.distort.reshape", "no correspondence points"),
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
@ -28,8 +27,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
("ec.vr.converter", "common layouts only"),
("ec.sim.caustics", "no Sky group or light type"),
("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"),
("ec.stylize.glow", "no Glow Operation or arbitrary colour map"),
("ec.stylize.cartoon", "no Edge Enhancement"),
];
/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path,

View File

@ -257,6 +257,32 @@ fn polar(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Transform's shutter: (angle, phase) in degrees and the number of samples, or None when it
/// renders without motion blur. Use Composition's Shutter Angle takes the comp's shutter
/// (only when the comp and layer motion-blur switches are on); otherwise Shutter Angle, centred
/// on the frame.
pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
let (angle, phase, n) = if ctx.params.b("useCompositionShutterAngle") {
ctx.env.shutter?
} else {
let a = ctx.params.f("shutterAngle").clamp(0.0, 720.0);
(a, -a * 0.5, 16)
};
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
}
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
let anchor = b.to_px(pr.v2("anchor"));
let pos = b.to_px(pr.v2("position"));
let sh = pr.f("scaleHeight");
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(pr.f("rotation"))
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1))
}
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
@ -268,6 +294,34 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rot = ctx.params.f("rotation");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
// Motion blur: the transform at Samples instants across the shutter, averaged (the
// transform at other times comes from the host; without one the frame renders sharp).
if let Some((angle, phase, n)) = transform_shutter(ctx)
&& let Some(host) = ctx.env.host
{
let fd = 1.0 / ctx.fps();
let mut acc = Image::new(b.img.width, b.img.height);
let mut count = 0.0f32;
for k in 0..n {
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
let Some(pr) = host.params_at(t) else { continue };
let mut one = Image::new(b.img.width, b.img.height);
let op = pr.f("opacity") as f32 / 100.0;
effectcraft_raster::composite_warp(
&mut one,
&b.img,
&transform_matrix(&pr, &b),
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
);
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
count += 1.0;
}
if count > 0.0 {
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
b.img = acc;
return b;
}
}
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(skew, skew_axis)
@ -397,6 +451,8 @@ pub fn specs() -> Vec<EffectSpec> {
p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("useCompositionShutterAngle", "Use Composition's Shutter Angle", Value::Bool(true), ParamUi::Checkbox),
p("shutterAngle", "Shutter Angle", num(0.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
p("sampling", "Sampling", Value::Enum(0), popup(&["Bilinear", "Bicubic"])),
],
transform,
@ -510,4 +566,44 @@ mod tests {
let b = run("ec.distort.transform", &[("rotation", num(17.0)), ("sampling", Value::Enum(1))], im);
assert!(a.img.data.iter().zip(&b.img.data).any(|(p, q)| differs(*p, *q)));
}
#[test]
fn transform_shutter_angle_motion_blur() {
/// Position moves right 200 px per second.
struct Moving;
impl crate::EffectHost for Moving {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let s = crate::find("ec.distort.transform").unwrap();
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [40.0, 20.0]))).collect() };
p.values.insert("position".into(), Value::Vec2([20.0 + 200.0 * t, 10.0]));
Some(p)
}
}
let mut sq = Image::new(40, 20);
for y in 5..15 {
for x in 15..25 {
sq.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
let render = |vals: &[(&str, Value)], shutter: Option<(f64, f64, u32)>| {
let env = crate::EffectEnv { host: Some(&Moving), frame_rate: 10.0, shutter, ..Default::default() };
crate::run_fx("ec.distort.transform", vals, sq.clone(), 0.0, env).img
};
let partial = |i: &Image| i.data.iter().filter(|p| p[3] > 0.02 && p[3] < 0.98).count();
// Use Composition Shutter Angle with the comp / layer motion blur off: sharp.
assert_eq!(partial(&render(&[], None)), 0);
// With the comp shutter (180°): smeared along x over 10 px.
let comp = render(&[], Some((180.0, -90.0, 16)));
assert!(partial(&comp) > 50, "{}", partial(&comp));
// Own Shutter Angle.
let own = render(&[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", num(360.0))], None);
assert!(partial(&own) > partial(&comp));
assert_eq!(partial(&render(&[("useCompositionShutterAngle", Value::Bool(false))], None)), 0, "0 deg: sharp");
}
}

View File

@ -66,11 +66,12 @@ pub use color_fx::{
HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip,
};
pub use color2::Curve;
pub use distort::transform_shutter;
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};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation};
/// Effect categories in Effects & Presets order.
pub const CATEGORIES: &[&str] = &[
@ -292,6 +293,10 @@ pub struct EffectEnv<'a> {
pub working_space: Option<effectcraft_color::ColorSpace>,
/// Working-space pixels are linear light (Linearize Working Space).
pub working_linear: bool,
/// The composition's shutter (angle and phase in degrees, samples per frame) when both the
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
/// Shutter Angle).
pub shutter: Option<(f64, f64, u32)>,
}
/// What an effect gets to render with.
@ -708,6 +713,9 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.matte.refinehard",
// Card Wipe's position / rotation jitter moves with time.
"ec.transition.cardwipe",
// Transform's motion blur and Radio Waves' birth parameters read the parameters at
// other times.
"ec.distort.transform",
// Temporal Smoothing reads neighbouring frames.
"ec.color.autolevels",
"ec.color.autocontrast",

View File

@ -120,12 +120,48 @@ pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32)
t.max(0.0).powf(0.5f32.ln() / m.ln())
}
/// Glow Operation options (blend modes the glow is applied with).
pub const GLOW_OPERATIONS: [&str; 17] = [
"Normal",
"Add",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Color Dodge",
"Color Burn",
"Darken",
"Lighten",
"Difference",
"Exclusion",
"Hue",
"Saturation",
"Color",
"Luminosity",
];
/// Glow's Glow Operation as a blend mode (Add for projects saved before it existed).
pub fn glow_operation(ctx: &EffectCtx) -> effectcraft_color::BlendMode {
let name = GLOW_OPERATIONS.get(ctx.params.get("glowOperation").map(|v| v.as_enum()).unwrap_or(1) as usize).copied().unwrap_or("Add");
effectcraft_color::BlendMode::ALL.iter().copied().find(|m| m.label() == name).unwrap_or(effectcraft_color::BlendMode::Add)
}
/// Glow's Arbitrary Map: red, green and blue curves of the glow brightness, as Curves point
/// lists `x,y …` separated by `|` (an empty or missing curve is the identity).
fn glow_map(s: &str) -> Vec<Option<crate::Curve>> {
let mut parts = s.split('|');
(0..3).map(|_| parts.next().and_then(crate::Curve::parse)).collect()
}
fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
let thr = ctx.params.f("threshold") as f32 / 100.0;
let radius = ctx.params.f("radius") * b.scale;
let intensity = ctx.params.f("intensity") as f32;
let alpha_based = ctx.params.e("based") == 0;
let use_colors = ctx.params.e("colors") == 1;
let arbitrary = (ctx.params.e("colors") == 2).then(|| glow_map(ctx.params.s("arbitraryMap")));
let glow_op = glow_operation(ctx);
let looping = ctx.params.e("colorLooping");
let loops = ctx.params.f("colorLoops") as f32;
let phase = (ctx.params.f("colorPhase") / 360.0) as f32;
@ -147,7 +183,11 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Glow Based On: brightness from the colours, or from the alpha channel.
let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) };
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
if use_colors {
if let Some(map) = &arbitrary {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::<Vec<f32>>();
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
} else if use_colors {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
@ -170,6 +210,8 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
// None: the glow alone.
2 => *o = [g[0], g[1], g[2], g[3]],
// On Top with another Glow Operation: that blend mode.
_ if glow_op != effectcraft_color::BlendMode::Add => *o = effectcraft_color::blend_pixel(glow_op, *o, g, 0.0),
// On Top: add (screen-like add on premultiplied colour; alpha grows by the glow's alpha).
_ => {
for c in 0..3 {
@ -571,7 +613,10 @@ pub fn specs() -> Vec<EffectSpec> {
p("radius", "Glow Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("intensity", "Glow Intensity", num(1.0), slider(0.0, 255.0, 0.0, 4.0, 1)),
p("operation", "Composite Original", Value::Enum(0), popup(&["On Top", "Behind", "None"])),
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors"])),
p("glowOperation", "Glow Operation", Value::Enum(1), popup(&GLOW_OPERATIONS)),
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors", "Arbitrary Map"])),
// Arbitrary Map: "red points | green points | blue points" (Curves format).
p("arbitraryMap", "Arbitrary Map", Value::Str(String::new()), ParamUi::Hidden),
p("colorLooping", "Color Looping", Value::Enum(2), popup(&["Sawtooth A>B", "Sawtooth B>A", "Triangle A>B>A", "Triangle B>A>B"])),
p("colorLoops", "Color Loops", num(1.0), slider(1.0, 127.0, 1.0, 10.0, 1)),
p("colorPhase", "Color Phase", num(0.0), ParamUi::Angle),
@ -687,3 +732,52 @@ pub fn specs() -> Vec<EffectSpec> {
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
/// A bright square on mid grey.
fn scene() -> effectcraft_raster::Image {
let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]);
for y in 8..16 {
for x in 8..16 {
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
img
}
#[test]
fn glow_operation_and_arbitrary_map() {
// Pixels addressed in layer space (Glow pads its buffer).
struct Out(Buf);
impl Out {
fn get(&self, x: i64, y: i64) -> [f32; 4] {
self.0.img.get(x + self.0.offset[0] as i64, y + self.0.offset[1] as i64)
}
}
let run = |vals: &[(&str, Value)]| Out(run_fx("ec.stylize.glow", vals, scene(), 0.0, EffectEnv::default()));
let add = run(&[]);
assert_eq!(GLOW_OPERATIONS[1], "Add");
// Multiply darkens around the square where Add brightens.
let mul = run(&[("glowOperation", Value::Enum(2))]);
assert!(add.get(6, 12)[0] > 0.31 && mul.get(6, 12)[0] <= 0.3 + 1e-4, "{:?} {:?}", add.get(6, 12), mul.get(6, 12));
assert_eq!(
glow_operation(&EffectCtx {
params: &crate::Params::default(),
time: 0.0,
layer_size: [1.0; 2],
seed: 0,
adjustment: false,
env: Default::default()
}),
effectcraft_color::BlendMode::Add
);
// Arbitrary Map: the glow takes the mapped colour (here pure red for every brightness).
let red = run(&[("colors", Value::Enum(2)), ("arbitraryMap", Value::Str("0,1 1,1|0,0 1,0|0,0 1,0".into())), ("operation", Value::Enum(2))]);
let p = red.get(6, 12);
assert!(p[0] > 0.05 && p[1] < 1e-4 && p[2] < 1e-4, "{p:?}");
}
}

View File

@ -61,6 +61,41 @@ fn cartoon(ctx: &EffectCtx, mut b: Buf) -> Buf {
} else {
b.img.clone()
};
// Advanced ▸ Edge Enhancement: one shock-filter step. Each pixel samples from the side of
// the nearest edge it belongs to (along the luma gradient, by the sign of the Laplacian):
// positive values sharpen the transitions, negative ones soften them.
let enh = ctx.params.f("advanced/edgeEnhancement") / 100.0;
let smoothed = if enh < 0.0 {
// Softening: blend towards a blur of the picture.
let s = 1.5 * b.scale;
let blur = effectcraft_raster::gaussian_blur(&smoothed, s, s, true);
let k = (-enh) as f32;
let mut out = smoothed;
out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| (0..4).for_each(|c| p[c] += (q[c] - p[c]) * k));
out
} else if enh > 0.0 {
let l = Plane::luma(&smoothed);
let (w, h) = (l.w as i64, l.h as i64);
let s = enh * 2.0 * b.scale;
let src = smoothed.clone();
let mut out = smoothed;
out.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (x, y) = ((i as i64) % w, (i as i64) / w);
let g = |dx: i64, dy: i64| l.get_clamped(x + dx, y + dy);
let (gx, gy) = ((g(1, 0) - g(-1, 0)) * 0.5, (g(0, 1) - g(0, -1)) * 0.5);
let lap = g(1, 0) + g(-1, 0) + g(0, 1) + g(0, -1) - 4.0 * g(0, 0);
let m = (gx * gx + gy * gy).sqrt();
if m < 1e-4 || lap.abs() < 1e-5 || y >= h {
return;
}
let k = -lap.signum() as f64 * s / m as f64;
let (sx, sy) = (x as f64 + 0.5 + gx as f64 * k, y as f64 + 0.5 + gy as f64 * k);
*px = src.sample_bilinear_clamped(sx, sy);
});
out
} else {
smoothed
};
let luma = Plane::luma(&smoothed);
let edges = if render >= 1 {
let mag = sobel(&luma);
@ -543,6 +578,7 @@ pub fn specs() -> Vec<EffectSpec> {
p("edge/edgeWidth", "Width", num(1.5), slider(0.0, 10.0, 0.0, 5.0, 2)),
p("edge/edgeSoftness", "Softness", num(60.0), pct()),
p("edge/edgeOpacity", "Opacity", num(100.0), pct()),
p("advanced/edgeEnhancement", "Edge Enhancement", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("advanced/edgeBlackLevel", "Edge Black Level", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("advanced/edgeContrast", "Edge Contrast", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
],
@ -993,4 +1029,30 @@ mod tests {
assert_eq!(cyc(0.0), cyc(360.0));
assert_ne!(cyc(0.0), cyc(180.0));
}
#[test]
fn cartoon_edge_enhancement_sharpens_or_softens() {
// A soft 8-pixel ramp from black to white.
let mut img = Image::new(32, 4);
for y in 0..4 {
for x in 0..32 {
let v = 0.5 + 0.5 * ((x as f32 - 15.5) / 4.0).tanh();
img.set(x, y, [v, v, v, 1.0]);
}
}
let fill = |e: f64| {
let v = [
("render", Value::Enum(0)),
("detailRadius", num(0.0)),
("fill/shadingSteps", num(64.0)),
("fill/shadingSmoothness", num(100.0)),
("advanced/edgeEnhancement", num(e)),
];
run_at("ec.stylize.cartoon", &v, img.clone(), 0.0).img
};
let steep = |o: &Image| (0..31).map(|x| (o.get(x + 1, 2)[0] - o.get(x, 2)[0]).abs()).fold(0.0f32, f32::max);
let (base, sharp, soft) = (fill(0.0), fill(100.0), fill(-100.0));
assert!(steep(&sharp) > steep(&base) + 0.02, "{} vs {}", steep(&sharp), steep(&base));
assert!(steep(&soft) < steep(&base) + 1e-4, "{} vs {}", steep(&soft), steep(&base));
}
}

View File

@ -173,6 +173,10 @@ fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
}
fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// Other Glow Operations and the Arbitrary Map render on the CPU.
if ctx.params.e("colors") == 2 || effectcraft_effects::glow_operation(ctx) != effectcraft_color::BlendMode::Add {
return None;
}
let thr = ctx.params.f("threshold") as f32 / 100.0;
let radius = ctx.params.f("radius") * b.scale;
let intensity = ctx.params.f("intensity") as f32;
@ -231,6 +235,10 @@ fn drop_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
}
fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// Motion blur (shutter angle) renders on the CPU.
if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() {
return None;
}
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let sh = ctx.params.f("scaleHeight");

View File

@ -653,6 +653,11 @@ impl<'a> Renderer<'a> {
effect_index: 0,
working_space: self.pipe.space,
working_linear: self.pipe.linear,
shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some((
ctx.comp.shutter_angle,
ctx.comp.shutter_phase,
ctx.comp.motion_blur_samples,
)),
};
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx

View File

@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 303 effects with 2746 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
EffectCraft ships 303 effects with 2751 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
- **GPU**: 24 effects also run on the GPU compositor with identical results.
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 281 implemented in full, 22 partial (what is missing is listed).
- **Status**: 284 implemented in full, 19 partial (what is missing is listed).
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
@ -162,7 +162,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect |
| Smear | `ec.distort.smear` | 8 | | 32 | Implemented |
| Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented |
| Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur |
| Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented |
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | | 32 | Implemented |
| Twirl | `ec.distort.twirl` | 3 | | 32 | Implemented |
| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented |
@ -351,11 +351,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Threshold | `ec.stylize.ccthreshold` | 4 | | 32 | Implemented |
| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | | 32 | Implemented |
| CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented |
| Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement |
| Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented |
| Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented |
| Emboss | `ec.stylize.emboss` | 4 | | 32 | Implemented |
| Find Edges | `ec.stylize.findedges` | 2 | | 32 | Implemented |
| Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map |
| Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented |
| Mosaic | `ec.stylize.mosaic` | 3 | | 32 | Implemented |
| Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented |
| Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented |