Files
effectcraft/crates/effects/src/blur3.rs
T
Brandon ThomasandClaude Opus 5.5 355a6255a2 M9.12: effect parameter fidelity – all categories
Align every effect's display name, category, parameter names, order,
twirl-down groups, popup options, units, defaults and ranges with the
public After Effects effect reference, and implement controls that were
ignored or missing where cheap. One commit because the categories share
the registry, migration tables and GPU kernels (only green states).

Infrastructure
- effects::migrate: saved instances are upgraded on project open
  (Session::replace_project → upgrade_effects): renamed ids
  (PARAM_ID_ALIASES), params moved into twirl-down groups, popup values
  remapped by label (POPUP_LABEL_ALIASES), refreshed names/UI hints,
  added params created with legacy_default values that keep old renders.
- flatten_params also keys nested props by match path (group/param), so
  effects declare grouped params with `group/param` ids; layer-source
  companions sit next to grouped layer params.
- lookup() resolves old effect names (EFFECT_NAME_ALIASES).
- Registry-wide test: unique ids/names, non-empty names, defaults in
  range, popup defaults within options, value kinds match UI.
- docs/effects.md generated from the registry (catalog.rs) with GPU /
  32-bpc badges and per-effect status; a test keeps it current.

Batches
- Blur & Sharpen, Channel, 3D Channel, Perspective, Text, Utility:
  Camera Lens Blur iris/highlight groups, Compound Blur layer, Camera-
  Shake Deblur method/strength, Invert full channel list (CPU HLS/YIQ),
  Set Channels/Set Matte layers, Channel Combiner targets, 3D Channel
  Extract clamp/invert.
- Color Correction: temporal smoothing + scene detect for Auto Color/
  Contrast/Levels and Shadow/Highlight, Brightness & Contrast legacy
  switch, Change to Color tolerance group, Leave Color, Photo Filter
  presets, Exposure channels, Levels clip popups, Colorama phase source,
  Color Link sample list, Lumetri groups (CPU + GPU kernels).
- Distort: Bulge, Displacement Map layer/behaviour, Magnify, Optics
  Compensation resize, Ripple conversion, Transform sampling, Turbulent
  Displace types/pinning/evolution, Wave Warp types/pinning, Twirl and
  Ripple radius fixes.
- Generate: 4-Color Gradient, Checkerboard, Grid, Circle size/feather/
  blending, Advanced Lightning types, Cell Pattern, Radio Waves, Fractal
  palettes, Scribble/Vegas names.
- Keying, Matte, Obsolete: Color Difference Key partials/views, Unmult
  controls, Key Light groups, Refine Soft/Hard Matte, Roto Brush groups,
  Mocha shape moved to Matte, Spill Suppressor accuracy, Basic/Path Text
  groups.
- Noise & Grain, Immersive Video, Audio, Time: Fractal/Turbulent Noise
  full control set (noise3.rs, GPU), grain look groups, Remove Grain,
  VR effects, Compressor/Gate/Distortion controls, Timewarp groups,
  VR Sphere to Plane name.
- Simulation: AE twirl-down groups for all simulations, Foam strength,
  Particle Playground barrel radius and mapper targets.
- Stylize, Transition: Glow looping/dimensions/alpha (CPU + GPU),
  Mosaic averaging, Cartoon groups, Roughen Edges evolution, Texturize
  layer, Gradient Wipe layer, Radial Wipe both, Card Wipe options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:15:06 -04:00

391 lines
17 KiB
Rust

//! Blur & Sharpen: CC Radial Blur and Camera-Shake Deblur.
//!
//! * **CC Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
//! fading) blurs around a centre point, sampled along the ray / arc through each pixel with a
//! sample count set by Quality.
//! * **Camera-Shake Deblur** — frame-based restoration in the spirit of published video deblurring
//! by "lucky frames": the frame is split into patches; for each patch the sharpness (mean
//! squared gradient) is compared with the same patch in the neighbouring frames within Blur
//! Duration; when a neighbour is clearly sharper, its patch is aligned to the current frame by
//! a small translation search (sum of squared differences on luma) and substituted with
//! feathered patch blending. Deterministic; neighbours come from [`crate::EffectHost::self_at`].
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::util::Plane;
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true }
}
// ---------------------------------------------------------------- CC Radial Blur
pub const RADIAL_TYPES: [&str; 5] = ["Straight Zoom", "Fading Zoom", "Centered", "Rotate", "Rotate Fading"];
fn cc_radial_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amount = ctx.params.f("amount");
if amount == 0.0 {
return b;
}
let kind = ctx.params.e("type");
let quality = ctx.params.f("quality").clamp(1.0, 100.0);
let c = b.to_px(ctx.params.v2("center"));
let src = b.img.clone();
// Zoom: fraction of the distance to the centre; rotate: degrees of arc.
let zoom = (amount / 100.0).clamp(-1.0, 1.0) * 0.5;
let arc = amount.to_radians() * 0.25;
b.img.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
let r = (dx * dx + dy * dy).sqrt();
let span = if kind >= 3 { r * arc.abs() } else { r * zoom.abs() };
let n = ((span * quality / 50.0).ceil() as usize).clamp(1, 256);
if n <= 1 {
*o = src.get(x as i64, y as i64);
continue;
}
let mut acc = [0.0f32; 4];
let mut wsum = 0.0f32;
for i in 0..n {
let t = i as f64 / (n - 1) as f64;
let (sx, sy, w) = match kind {
// Toward the centre, equal weights.
0 => {
let k = 1.0 - zoom * t;
(c.0 + dx * k, c.1 + dy * k, 1.0)
}
// Toward the centre, weights fading with distance along the streak.
1 => {
let k = 1.0 - zoom * t;
(c.0 + dx * k, c.1 + dy * k, (1.0 - t) as f32 + 0.05)
}
// Both ways around the pixel.
2 => {
let k = 1.0 + zoom * (t - 0.5);
(c.0 + dx * k, c.1 + dy * k, 1.0)
}
_ => {
let a = arc * (t - 0.5);
let (s, co) = a.sin_cos();
let w = if kind == 4 { (1.0 - (2.0 * t - 1.0).abs()) as f32 + 0.05 } else { 1.0 };
(c.0 + dx * co - dy * s, c.1 + dx * s + dy * co, w)
}
};
let q = src.sample_bilinear(sx, sy);
for k in 0..4 {
acc[k] += q[k] * w;
}
wsum += w;
}
*o = acc.map(|v| v / wsum);
}
});
b
}
// ---------------------------------------------------------------- Camera-Shake Deblur
/// Mean squared luma gradient over a rectangle (sharpness).
fn sharpness(l: &Plane, x0: usize, y0: usize, x1: usize, y1: usize) -> f64 {
let mut s = 0.0f64;
let mut n = 0usize;
for y in y0..y1.saturating_sub(1) {
for x in x0..x1.saturating_sub(1) {
let v = l.get(x, y);
let gx = l.get(x + 1, y) - v;
let gy = l.get(x, y + 1) - v;
s += (gx * gx + gy * gy) as f64;
n += 1;
}
}
if n == 0 { 0.0 } else { s / n as f64 }
}
/// Best integer translation (dx, dy) within ±`r` aligning `b` to `a` on a patch (SSD).
fn align(a: &Plane, bp: &Plane, x0: usize, y0: usize, x1: usize, y1: usize, r: i64) -> (i64, i64) {
let mut best = (f64::INFINITY, 0i64, 0i64);
let step = ((x1 - x0).max(y1 - y0) / 24).max(1);
for dy in -r..=r {
for dx in -r..=r {
let mut s = 0.0f64;
for y in (y0..y1).step_by(step) {
for x in (x0..x1).step_by(step) {
let d = a.get(x, y) - bp.get_clamped(x as i64 + dx, y as i64 + dy);
s += (d * d) as f64;
}
}
// Prefer the smallest shift on ties.
let s = s * (1.0 + 1e-9 * (dx * dx + dy * dy) as f64);
if s < best.0 {
best = (s, dx, dy);
}
}
}
(best.1, best.2)
}
fn camera_shake_deblur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(host) = ctx.env.host else { return b };
let dur = ctx.params.f("blurDuration").clamp(1.0, 10.0).round() as i64;
// Standard (index 1) aligns substitutes over a smaller search window than High Quality.
let mut search = (ctx.params.f("searchRange").max(0.0) * b.scale).round() as i64;
if ctx.params.e("deblurMethod") == 1 {
search /= 2;
}
let patch = (ctx.params.f("patchSize").max(8.0) * b.scale).round().max(4.0) as usize;
// Shake Sensitivity scales how much sharper a neighbour must be (50 = the Threshold as set,
// 100 = any sharper neighbour counts, 0 = twice the Threshold).
let sens = ctx.params.f("shakeSensitivity").clamp(0.0, 100.0);
let thresh = 1.0 + ctx.params.f("threshold").max(0.0) / 100.0 * (100.0 - sens) / 50.0;
let strength = (ctx.params.f("strength") / 100.0).clamp(1.0, 2.0) as f32;
let show = ctx.params.b("showBlurryFrames");
let fps = ctx.fps();
let (w, h) = (b.img.width as usize, b.img.height as usize);
if w == 0 || h == 0 {
return b;
}
// Neighbouring frames in the effect's own input, resampled into this buffer's grid.
let mut neigh: Vec<(Image, Plane)> = vec![];
for k in (-dur..=dur).filter(|k| *k != 0) {
let Some(nb) = host.self_at(ctx.time + k as f64 / fps, ctx.env.effect_index) else { continue };
let img = if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale {
nb.img
} else {
let k = nb.scale / b.scale.max(1e-9);
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0];
let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1];
nb.img.sample_bilinear(lx, ly)
})
};
let l = Plane::luma(&img);
neigh.push((img, l));
}
if neigh.is_empty() {
return b;
}
let cur_l = Plane::luma(&b.img);
let (nx, ny) = (w.div_ceil(patch), h.div_ceil(patch));
// Per patch: (neighbour index, shift) of the substitute, or None to keep.
let choice: Vec<Option<(usize, i64, i64)>> = (0..nx * ny)
.into_par_iter()
.map(|i| {
let (px, py) = (i % nx, i / nx);
let (x0, y0) = (px * patch, py * patch);
let (x1, y1) = ((x0 + patch).min(w), (y0 + patch).min(h));
let s0 = sharpness(&cur_l, x0, y0, x1, y1);
let mut best: Option<(f64, usize)> = None;
for (k, (_, l)) in neigh.iter().enumerate() {
let s = sharpness(l, x0, y0, x1, y1);
if s > s0 * thresh && best.is_none_or(|(bs, _)| s > bs) {
best = Some((s, k));
}
}
let (_, k) = best?;
let (dx, dy) = align(&cur_l, &neigh[k].1, x0, y0, x1, y1, search);
Some((k, dx, dy))
})
.collect();
if choice.iter().all(Option::is_none) {
return b;
}
let src = b.img.clone();
// Feathered blending between patch decisions: each pixel mixes the four nearest patch
// centres bilinearly.
let pick = |i: usize, x: usize, y: usize| -> Px {
match choice[i] {
None => src.data[y * w + x],
Some((k, dx, dy)) => neigh[k].0.get_clamped(x as i64 + dx, y as i64 + dy),
}
};
b.img.rows_mut().for_each(|(y, row)| {
let fy = ((y as f64 + 0.5) / patch as f64 - 0.5).clamp(0.0, (ny - 1) as f64);
let (y0, ty) = (fy.floor() as usize, (fy - fy.floor()) as f32);
let y1 = (y0 + 1).min(ny - 1);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f64 + 0.5) / patch as f64 - 0.5).clamp(0.0, (nx - 1) as f64);
let (x0, tx) = (fx.floor() as usize, (fx - fx.floor()) as f32);
let x1 = (x0 + 1).min(nx - 1);
let ids = [y0 * nx + x0, y0 * nx + x1, y1 * nx + x0, y1 * nx + x1];
if ids.iter().all(|&i| choice[i].is_none()) {
continue;
}
let ws = [(1.0 - tx) * (1.0 - ty), tx * (1.0 - ty), (1.0 - tx) * ty, tx * ty];
let mut acc = [0.0f32; 4];
for (i, wgt) in ids.iter().zip(ws) {
let q = pick(*i, x, y);
for c in 0..4 {
acc[c] += q[c] * wgt;
}
}
if strength != 1.0 {
// Strength above 100 % pushes past the substitute (away from the blurry pixel).
let s0 = src.data[y * w + x];
for c in 0..4 {
acc[c] = s0[c] + (acc[c] - s0[c]) * strength;
}
acc[3] = acc[3].clamp(0.0, 1.0);
for c in 0..3 {
acc[c] = acc[c].max(0.0);
}
}
if show {
acc[0] = (acc[0] + 0.3 * acc[3]).min(acc[3].max(acc[0]));
}
*o = acc;
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec(
"ec.blur.ccradial",
"CC Radial Blur",
vec![
p("type", "Type", Value::Enum(1), popup(&RADIAL_TYPES)),
p("amount", "Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("quality", "Quality", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 1)),
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
],
cc_radial_blur,
),
spec(
"ec.blur.camerashakedeblur",
"Camera-Shake Deblur",
vec![
p("blurDuration", "Blur Duration", num(2.0), slider(1.0, 10.0, 1.0, 10.0, 0)),
p("deblurMethod", "Deblur Method", Value::Enum(0), popup(&["High Quality (slower)", "Standard"])),
p("strength", "Strength", num(100.0), slider(100.0, 200.0, 100.0, 200.0, 0)),
p("shakeSensitivity", "Shake Sensitivity", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("searchRange", "Search Range", num(8.0), slider(0.0, 64.0, 0.0, 32.0, 0)),
p("patchSize", "Patch Size", num(32.0), slider(8.0, 256.0, 8.0, 128.0, 0)),
p("threshold", "Threshold", num(20.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p("showBlurryFrames", "Show Blurry Frames", Value::Bool(false), ParamUi::Checkbox),
],
camera_shake_deblur,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
fn checker(w: u32, h: u32, cell: u32) -> Image {
crate::util::gen_image(w, h, |x, y| {
let v = if ((x as u32 / cell) + (y as u32 / cell)).is_multiple_of(2) { 1.0 } else { 0.0 };
[v, v, v, 1.0]
})
}
#[test]
fn cc_radial_blur_keeps_centre_and_smears_edges() {
let img = checker(64, 64, 4);
for kind in 0..5u32 {
let out = run_fx(
"ec.blur.ccradial",
&[("type", Value::Enum(kind)), ("amount", num(40.0)), ("center", Value::Vec2([32.0, 32.0]))],
img.clone(),
0.0,
EffectEnv::default(),
);
// Far from the centre the checker is smeared toward grey.
let edge = out.img.get(2, 2)[0];
assert!(edge > 0.05 && edge < 0.95, "type {kind}: {edge}");
let again = run_fx(
"ec.blur.ccradial",
&[("type", Value::Enum(kind)), ("amount", num(40.0)), ("center", Value::Vec2([32.0, 32.0]))],
img.clone(),
0.0,
EffectEnv::default(),
);
assert_eq!(out.img.data, again.img.data);
}
// Zero amount is identity; a constant image stays constant.
let flat = Image::filled(16, 16, [0.4, 0.4, 0.4, 1.0]);
let o = run_fx("ec.blur.ccradial", &[("amount", num(80.0))], flat.clone(), 0.0, EffectEnv::default());
assert!(o.img.data.iter().all(|p| (p[0] - 0.4).abs() < 1e-5));
let o = run_fx("ec.blur.ccradial", &[("amount", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(o.img.data, img.data);
}
/// Frames: sharp checker at even frames, a blurred one at odd frames (shifted by 2 px).
struct Shaky;
impl EffectHost for Shaky {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
let f = (t * 30.0).round() as i64;
Some(Buf { img: frame(f), offset: [0.0; 2], scale: 1.0 })
}
}
fn frame(f: i64) -> Image {
let sharp = crate::util::gen_image(48, 48, |x, y| {
let v = if (((x + 2) / 6) + (y / 6)) % 2 == 0 { 1.0 } else { 0.0 };
[v, v, v, 1.0]
});
if f % 2 == 0 { sharp } else { effectcraft_raster::gaussian_blur(&sharp, 2.5, 2.5, true) }
}
#[test]
fn camera_shake_deblur_substitutes_sharp_neighbours() {
let host = Shaky;
let env = EffectEnv { host: Some(&host), frame_rate: 30.0, ..Default::default() };
let blurry = frame(1);
let out = run_fx("ec.blur.camerashakedeblur", &[("patchSize", num(16.0)), ("searchRange", num(3.0))], blurry.clone(), 1.0 / 30.0, env);
let sharp = frame(0);
let err = |a: &Image| a.data.iter().zip(&sharp.data).map(|(p, q)| (p[0] - q[0]).abs()).sum::<f32>() / a.data.len() as f32;
assert!(err(&out.img) < err(&blurry) * 0.5, "{} vs {}", err(&out.img), err(&blurry));
// A sharp frame is left alone (its neighbours are blurrier).
let keep = run_fx("ec.blur.camerashakedeblur", &[], sharp.clone(), 0.0, env);
assert_eq!(keep.img.data, sharp.data);
// Deterministic; no host = pass-through.
let again = run_fx("ec.blur.camerashakedeblur", &[("patchSize", num(16.0)), ("searchRange", num(3.0))], blurry.clone(), 1.0 / 30.0, env);
assert_eq!(out.img.data, again.img.data);
let none = run_fx("ec.blur.camerashakedeblur", &[], blurry.clone(), 0.0, EffectEnv::default());
assert_eq!(none.img.data, blurry.data);
}
#[test]
fn camera_shake_deblur_sensitivity_and_strength() {
let host = Shaky;
let env = EffectEnv { host: Some(&host), frame_rate: 30.0, ..Default::default() };
let blurry = frame(1);
let base = [("patchSize", num(16.0)), ("searchRange", num(3.0)), ("threshold", num(500.0))];
let dist = |a: &Image| a.data.iter().zip(&blurry.data).map(|(p, q)| (p[0] - q[0]).abs()).sum::<f32>();
// Full sensitivity deblurs whatever the threshold; lower sensitivity never deblurs more.
let low = run_fx(
"ec.blur.camerashakedeblur",
&[base[0].clone(), base[1].clone(), base[2].clone(), ("shakeSensitivity", num(0.0))],
blurry.clone(),
1.0 / 30.0,
env,
);
let high = run_fx(
"ec.blur.camerashakedeblur",
&[base[0].clone(), base[1].clone(), base[2].clone(), ("shakeSensitivity", num(100.0))],
blurry.clone(),
1.0 / 30.0,
env,
);
assert_ne!(high.img.data, blurry.data);
assert!(dist(&high.img) >= dist(&low.img));
// Strength 200 % moves further from the blurry frame than 100 %.
let s1 = run_fx("ec.blur.camerashakedeblur", &[base[0].clone(), base[1].clone()], blurry.clone(), 1.0 / 30.0, env);
let s2 = run_fx("ec.blur.camerashakedeblur", &[base[0].clone(), base[1].clone(), ("strength", num(200.0))], blurry.clone(), 1.0 / 30.0, env);
assert!(dist(&s2.img) > dist(&s1.img) * 1.05, "{} vs {}", dist(&s2.img), dist(&s1.img));
}
}