diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 402cc2c5..31e786c4 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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, diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index e5472b1a..173389de 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -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 { 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 { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + 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"); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 2bb5e914..28db9c15 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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, /// 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", diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index ed3678e8..89b496bb 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -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> { + 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::>(); + *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 { 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 { ), ] } + +#[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:?}"); + } +} diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 56bbedac..06c09d2e 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -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 { 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)); + } } diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index e14ff230..d0a60dce 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -173,6 +173,10 @@ fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // 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 { } fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // 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"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1f736668..d84315e2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 diff --git a/docs/effects.md b/docs/effects.md index 9b791cbb..f54f62f6 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -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..`) 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..`) 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..`) 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 |