M9.11: Blur & Sharpen and Distort effects (CC Radial Blur, Camera-Shake Deblur, CC Flo Motion, Liquify, Rolling Shutter Repair)

Liquify stores its strokes as effect data and replays them into a cached
displacement mesh; liquify.stroke / liquify.clear commands for agents.
Rolling Shutter Repair measures motion with effectcraft-track KLT (new
effects -> track same-layer edge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 21:08:04 -04:00
parent d9fe03a965
commit 451f63cc0d
10 changed files with 1248 additions and 1 deletions

1
Cargo.lock generated
View File

@ -1182,6 +1182,7 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-track",
"rayon",
"serde_json",
]

View File

@ -15,6 +15,7 @@ effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-track = { workspace = true }
rayon = { workspace = true }
[dev-dependencies]

338
crates/effects/src/blur3.rs Normal file
View File

@ -0,0 +1,338 @@
//! 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;
let search = (ctx.params.f("searchRange").max(0.0) * b.scale).round() as i64;
let patch = (ctx.params.f("patchSize").max(8.0) * b.scale).round().max(4.0) as usize;
let thresh = 1.0 + ctx.params.f("threshold").max(0.0) / 100.0;
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 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("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);
}
}

View File

@ -0,0 +1,719 @@
//! Distort: CC Flo Motion, Liquify and Rolling Shutter Repair.
//!
//! * **CC Flo Motion** — two knots that pull the image toward themselves (positive amount) or
//! push it away (negative), with a falloff exponent, optional tiling and supersampled
//! antialiasing.
//! * **Liquify** — brush strokes build a displacement mesh (a grid of layer-space offsets). The
//! strokes are the effect's data (the hidden `distortionMesh` text, one stroke per line, so it
//! animates with hold keyframes and is written by the `liquify.stroke` command); replaying
//! them is deterministic and cached. Tools: warp, turbulence, twirl clockwise / counter-
//! clockwise, pucker, bloat, shift pixels, reflection, clone, reconstruction and freeze / thaw.
//! Distortion Percentage scales the mesh and Distortion Mesh Offset moves it.
//! * **Rolling Shutter Repair** — every scanline of a CMOS frame is exposed a little later than
//! the previous one, so moving content shears. Motion is measured between the neighbouring
//! frames with pyramidal Lucas–Kanade (`effectcraft-track`) on a grid of points; each pixel is
//! then resampled from where it was at the frame's mid-exposure time:
//! `out(p) = in(p + v(p)·rate·(s(p) − ½))`, with `s` the scan position (0…1 along the scan
//! direction) and `v` the per-frame velocity (Warp: one smooth velocity per scanline band;
//! Pixel Motion: a dense interpolated field).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::util::{hash1, point_in_poly};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true }
}
// ---------------------------------------------------------------- CC Flo Motion
fn flo_motion(ctx: &EffectCtx, mut b: Buf) -> Buf {
let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)];
if knots.iter().all(|k| k.1 == 0.0) {
return b;
}
let tile = ctx.params.b("tileEdges");
let ss = match ctx.params.e("antialiasing") {
0 => 1,
1 => 2,
_ => 3,
};
let falloff = ctx.params.f("falloff").max(0.01);
let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
let radius = (lw * lw + lh * lh).sqrt() * 0.5;
let src = b.img.clone();
let (scale, off) = (b.scale, b.offset);
let map = |lx: f64, ly: f64| -> (f64, f64) {
let (mut x, mut y) = (lx, ly);
for (k, a) in knots {
if a == 0.0 {
continue;
}
let (dx, dy) = (x - k[0], y - k[1]);
let r = (dx * dx + dy * dy).sqrt() / radius;
// Weight 1 at the knot, falling off with distance.
let w = (1.0 - r.min(1.0)).powf(falloff);
// Pull: sample closer to the knot (content flows out of it); push: farther.
let s = (1.0 - a.clamp(-4.0, 0.95) * w).max(0.02);
x = k[0] + dx * s;
y = k[1] + dy * s;
}
(x, y)
};
b.img.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let mut acc = [0.0f32; 4];
for sy in 0..ss {
for sx in 0..ss {
let px = x as f64 + (sx as f64 + 0.5) / ss as f64;
let py = y as f64 + (sy as f64 + 0.5) / ss as f64;
let (lx, ly) = map((px - off[0]) / scale, (py - off[1]) / scale);
let (lx, ly) = if tile { (lx.rem_euclid(lw), ly.rem_euclid(lh)) } else { (lx, ly) };
let q = src.sample_bilinear(lx * scale + off[0], ly * scale + off[1]);
for c in 0..4 {
acc[c] += q[c];
}
}
}
let n = (ss * ss) as f32;
*o = acc.map(|v| v / n);
}
});
b
}
// ---------------------------------------------------------------- Liquify
/// Liquify tools (the stroke text uses these names).
pub const LIQUIFY_TOOLS: [&str; 12] = [
"warp",
"turbulence",
"twirlClockwise",
"twirlCounterclockwise",
"pucker",
"bloat",
"shiftPixels",
"reflection",
"clone",
"reconstruction",
"freeze",
"thaw",
];
/// One recorded Liquify stroke.
#[derive(Clone, Debug, PartialEq)]
pub struct LiquifyStroke {
pub tool: usize,
/// Brush size (diameter) and pressure (0–100), turbulent jitter (0–100), layer pixels.
pub size: f64,
pub pressure: f64,
pub jitter: f64,
/// Clone offset (source − destination).
pub clone_offset: [f64; 2],
pub points: Vec<[f64; 2]>,
}
impl LiquifyStroke {
/// Text form: `tool size pressure jitter cloneX cloneY x,y x,y …`.
pub fn to_line(&self) -> String {
let mut s = format!("{} {} {} {} {} {}", LIQUIFY_TOOLS[self.tool], self.size, self.pressure, self.jitter, self.clone_offset[0], self.clone_offset[1]);
for p in &self.points {
s.push_str(&format!(" {},{}", p[0], p[1]));
}
s
}
pub fn parse(line: &str) -> Option<LiquifyStroke> {
let mut it = line.split_whitespace();
let name = it.next()?;
let tool = LIQUIFY_TOOLS.iter().position(|t| t.eq_ignore_ascii_case(name))?;
let mut f = || -> Option<f64> { it.next()?.parse().ok() };
let (size, pressure, jitter, cx, cy) = (f()?, f()?, f()?, f()?, f()?);
let points: Vec<[f64; 2]> = it
.filter_map(|t| {
let (a, b) = t.split_once(',')?;
Some([a.parse().ok()?, b.parse().ok()?])
})
.collect();
(!points.is_empty()).then_some(LiquifyStroke { tool, size, pressure, jitter, clone_offset: [cx, cy], points })
}
}
/// Parse the `distortionMesh` text into strokes (bad lines are skipped).
pub fn parse_strokes(s: &str) -> Vec<LiquifyStroke> {
s.lines().filter_map(LiquifyStroke::parse).collect()
}
/// Displacement mesh: offsets (layer pixels) at grid nodes spaced `cell` apart.
#[derive(Clone, Debug, PartialEq)]
pub struct Mesh {
pub nx: usize,
pub ny: usize,
pub cell: f64,
pub d: Vec<[f64; 2]>,
pub freeze: Vec<f64>,
}
impl Mesh {
fn new(w: f64, h: f64, cell: f64) -> Mesh {
let nx = (w / cell).ceil() as usize + 1;
let ny = (h / cell).ceil() as usize + 1;
Mesh { nx, ny, cell, d: vec![[0.0; 2]; nx * ny], freeze: vec![0.0; nx * ny] }
}
/// Bilinear displacement at a layer point (zero outside the mesh).
pub fn at(&self, x: f64, y: f64) -> [f64; 2] {
let fx = x / self.cell;
let fy = y / self.cell;
if fx < 0.0 || fy < 0.0 || fx > (self.nx - 1) as f64 || fy > (self.ny - 1) as f64 {
return [0.0; 2];
}
let (x0, y0) = ((fx.floor() as usize).min(self.nx - 2), (fy.floor() as usize).min(self.ny - 2));
let (x1, y1) = ((x0 + 1).min(self.nx - 1), (y0 + 1).min(self.ny - 1));
let (tx, ty) = (fx - x0 as f64, fy - y0 as f64);
let g = |i: usize, j: usize| self.d[j * self.nx + i];
let l = |a: [f64; 2], b: [f64; 2], t: f64| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
l(l(g(x0, y0), g(x1, y0), tx), l(g(x0, y1), g(x1, y1), tx), ty)
}
}
/// Replay strokes into a displacement mesh over a `w`×`h` layer. `frozen(x, y)` reports
/// mask-frozen points (Freeze Area Mask).
pub fn build_mesh(strokes: &[LiquifyStroke], w: f64, h: f64, frozen: &(dyn Fn(f64, f64) -> bool + Sync)) -> Mesh {
let cell = (w.max(h) / 128.0).clamp(2.0, 16.0);
let mut m = Mesh::new(w, h, cell);
for (i, f) in m.freeze.iter_mut().enumerate() {
let (x, y) = ((i % m.nx) as f64 * cell, (i / m.nx) as f64 * cell);
if frozen(x, y) {
*f = 1.0;
}
}
for (si, s) in strokes.iter().enumerate() {
let r = (s.size * 0.5).max(1.0);
let pr = (s.pressure / 100.0).clamp(0.0, 1.0);
// Dabs every r/4 along the stroke; each dab knows the local motion of the brush.
let mut dabs: Vec<([f64; 2], [f64; 2])> = vec![];
if s.points.len() == 1 {
dabs.push((s.points[0], [0.0; 2]));
}
for seg in s.points.windows(2) {
let (a, b) = (seg[0], seg[1]);
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
let n = ((len / (r * 0.25)).ceil() as usize).max(1);
let step = [(b[0] - a[0]) / n as f64, (b[1] - a[1]) / n as f64];
for k in 1..=n {
dabs.push(([a[0] + step[0] * k as f64, a[1] + step[1] * k as f64], step));
}
}
for (di, (c, mv)) in dabs.iter().enumerate() {
let (gx0, gx1) = (((c[0] - r) / cell).floor().max(0.0) as usize, (((c[0] + r) / cell).ceil() as usize).min(m.nx - 1));
let (gy0, gy1) = (((c[1] - r) / cell).floor().max(0.0) as usize, (((c[1] + r) / cell).ceil() as usize).min(m.ny - 1));
let prev = m.d.clone();
for gy in gy0..=gy1 {
for gx in gx0..=gx1 {
let (x, y) = (gx as f64 * cell, gy as f64 * cell);
let (dx, dy) = (x - c[0], y - c[1]);
let dist = (dx * dx + dy * dy).sqrt();
if dist >= r {
continue;
}
let i = gy * m.nx + gx;
// Smooth brush falloff.
let fall = {
let t = 1.0 - dist / r;
t * t * (3.0 - 2.0 * t)
};
let tool = LIQUIFY_TOOLS[s.tool];
if tool == "freeze" || tool == "thaw" {
let v = pr * fall;
m.freeze[i] = if tool == "freeze" { m.freeze[i].max(v) } else { (m.freeze[i] - v).max(0.0) };
continue;
}
let w = pr * fall * (1.0 - m.freeze[i]) * 0.5;
if w <= 0.0 {
continue;
}
let d = &mut m.d[i];
match tool {
"warp" => {
d[0] -= mv[0] * w * 2.0;
d[1] -= mv[1] * w * 2.0;
}
"turbulence" => {
let j = s.jitter / 100.0 * r * 0.1;
let seed = (si as u32).wrapping_mul(7919) ^ di as u32;
d[0] += (hash1(gx as u32, gy as u32, seed) as f64 * 2.0 - 1.0) * j * w;
d[1] += (hash1(gx as u32, gy as u32, seed ^ 0x55) as f64 * 2.0 - 1.0) * j * w;
d[0] -= mv[0] * w;
d[1] -= mv[1] * w;
}
"twirlClockwise" | "twirlCounterclockwise" => {
let sgn = if tool == "twirlClockwise" { -1.0 } else { 1.0 };
let a = sgn * 0.08 * w;
let (sn, cs) = a.sin_cos();
d[0] += dx * cs - dy * sn - dx;
d[1] += dx * sn + dy * cs - dy;
}
"pucker" => {
d[0] += dx * 0.05 * w;
d[1] += dy * 0.05 * w;
}
"bloat" => {
d[0] -= dx * 0.05 * w;
d[1] -= dy * 0.05 * w;
}
"shiftPixels" => {
// Perpendicular to the stroke (to its left).
d[0] -= mv[1] * w * 2.0;
d[1] += mv[0] * w * 2.0;
}
"reflection" => {
// Mirror across the stroke line through the dab.
let l = (mv[0] * mv[0] + mv[1] * mv[1]).sqrt();
if l > 1e-9 {
let n = [-mv[1] / l, mv[0] / l];
let k = dx * n[0] + dy * n[1];
let tgt = [-2.0 * k * n[0], -2.0 * k * n[1]];
d[0] += (tgt[0] - d[0]) * w;
d[1] += (tgt[1] - d[1]) * w;
}
}
"clone" => {
// Copy the distortion found at the clone source.
let sx = x + s.clone_offset[0];
let sy = y + s.clone_offset[1];
let fx = (sx / cell).round().clamp(0.0, (m.nx - 1) as f64) as usize;
let fy = (sy / cell).round().clamp(0.0, (m.ny - 1) as f64) as usize;
let src = prev[fy * m.nx + fx];
let tgt = [src[0] + s.clone_offset[0], src[1] + s.clone_offset[1]];
d[0] += (tgt[0] - d[0]) * w;
d[1] += (tgt[1] - d[1]) * w;
}
// Reconstruction: relax toward no distortion.
_ => {
d[0] *= 1.0 - w;
d[1] *= 1.0 - w;
}
}
}
}
}
}
m
}
type MeshCache = Mutex<HashMap<u64, Arc<Mesh>>>;
fn cached_mesh(ctx: &EffectCtx) -> Arc<Mesh> {
use std::hash::{Hash, Hasher};
let text = ctx.params.s("distortionMesh");
let mask = ctx.params.f("freezeAreaMask").round() as usize;
let poly = mask.checked_sub(1).and_then(|i| ctx.env.masks.get(i)).cloned();
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
ctx.layer_size[0].to_bits().hash(&mut h);
ctx.layer_size[1].to_bits().hash(&mut h);
if let Some(p) = &poly {
for q in &p.points {
q[0].to_bits().hash(&mut h);
q[1].to_bits().hash(&mut h);
}
p.inverted.hash(&mut h);
}
let key = h.finish();
static CACHE: OnceLock<MeshCache> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(m) = cache.lock().ok().and_then(|c| c.get(&key).cloned()) {
return m;
}
let strokes = parse_strokes(text);
let frozen = |x: f64, y: f64| match &poly {
Some(p) => point_in_poly(&p.points, x, y) != p.inverted,
None => false,
};
let m = Arc::new(build_mesh(&strokes, ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0), &frozen));
if let Ok(mut c) = cache.lock() {
if c.len() > 32 {
c.clear();
}
c.insert(key, m.clone());
}
m
}
fn liquify(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pct = ctx.params.f("distortionPercentage") / 100.0;
let show_freeze = ctx.params.b("viewFreezeAreaMask");
let show_mesh = ctx.params.b("viewMesh");
if ctx.params.s("distortionMesh").trim().is_empty() && !show_freeze && !show_mesh {
return b;
}
let mesh = cached_mesh(ctx);
let off = ctx.params.v2("distortionMeshOffset");
let mesh_color = ctx.params.color("meshColor");
let src = b.img.clone();
let (scale, o) = (b.scale, b.offset);
let line_w = 0.6 / scale.max(1e-6);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let lx = (x as f64 + 0.5 - o[0]) / scale;
let ly = (y as f64 + 0.5 - o[1]) / scale;
let d = mesh.at(lx - off[0], ly - off[1]);
let (sx, sy) = (lx + d[0] * pct, ly + d[1] * pct);
let mut c = if d == [0.0; 2] { *px } else { src.sample_bilinear(sx * scale + o[0], sy * scale + o[1]) };
if show_freeze {
let fx = ((lx - off[0]) / mesh.cell).round().clamp(0.0, (mesh.nx - 1) as f64) as usize;
let fy = ((ly - off[1]) / mesh.cell).round().clamp(0.0, (mesh.ny - 1) as f64) as usize;
let f = mesh.freeze[fy * mesh.nx + fx] as f32;
if f > 0.0 {
c[0] = c[0] * (1.0 - 0.5 * f) + 0.5 * f;
c[3] = c[3].max(0.5 * f);
}
}
if show_mesh {
// Grid lines every 4 mesh cells, displaced with the mesh.
let gs = mesh.cell * 4.0;
let (gx, gy) = ((lx + d[0] * pct - off[0]).rem_euclid(gs), (ly + d[1] * pct - off[1]).rem_euclid(gs));
if gx < line_w || gy < line_w {
c = [mesh_color[0], mesh_color[1], mesh_color[2], 1.0];
}
}
*px = c;
}
});
b
}
// ---------------------------------------------------------------- Rolling Shutter Repair
/// Per-pixel velocity field (layer pixels per frame) on a coarse grid, from tracked points.
struct Flow {
nx: usize,
ny: usize,
cell: f64,
v: Vec<[f64; 2]>,
}
impl Flow {
fn at(&self, x: f64, y: f64) -> [f64; 2] {
let fx = (x / self.cell - 0.5).clamp(0.0, (self.nx - 1) as f64);
let fy = (y / self.cell - 0.5).clamp(0.0, (self.ny - 1) as f64);
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(self.nx - 1), (y0 + 1).min(self.ny - 1));
let (tx, ty) = (fx - x0 as f64, fy - y0 as f64);
let g = |i: usize, j: usize| self.v[j * self.nx + i];
let l = |a: [f64; 2], b: [f64; 2], t: f64| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
l(l(g(x0, y0), g(x1, y0), tx), l(g(x0, y1), g(x1, y1), tx), ty)
}
}
fn median(v: &mut [f64]) -> f64 {
v.sort_by(f64::total_cmp);
v[v.len() / 2]
}
/// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same
/// buffer grid). `per_row`: Warp method (one velocity per scanline band).
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option<Flow> {
use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track};
let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 });
let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4);
let (w, h) = (pc.width(), pc.height());
let n = if detailed { 24 } else { 12 };
let cell_w = w as f64 / n as f64;
let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize;
let cell_h = h as f64 / rows as f64;
let pts: Vec<[f64; 2]> = (0..rows).flat_map(|j| (0..n).map(move |i| [(i as f64 + 0.5) * cell_w, (j as f64 + 0.5) * cell_h])).collect();
let o = LkOpts { radius: 4, fb_max: 1.0, ..Default::default() };
let go = |other: Option<&Image>| other.map(|img| track(&pc, &GrayPyramid::from_image(img, [0.0; 2], factor, 4), &pts, None, &o));
let (tn, tp) = (go(next), go(prev));
// Velocity in plane pixels per frame.
let vel: Vec<Option<[f64; 2]>> = (0..pts.len())
.map(|i| {
let p = pts[i];
let fw = tn.as_ref().and_then(|t| t[i]).map(|q| [q[0] - p[0], q[1] - p[1]]);
let bw = tp.as_ref().and_then(|t| t[i]).map(|q| [p[0] - q[0], p[1] - q[1]]);
match (fw, bw) {
(Some(a), Some(b)) => Some([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]),
(a, b) => a.or(b),
}
})
.collect();
let valid: Vec<[f64; 2]> = vel.iter().flatten().copied().collect();
if valid.is_empty() {
return None;
}
let mut vx: Vec<f64> = valid.iter().map(|v| v[0]).collect();
let mut vy: Vec<f64> = valid.iter().map(|v| v[1]).collect();
let global = [median(&mut vx), median(&mut vy)];
let k = factor as f64 / scale.max(1e-9);
let mut v = vec![[0.0; 2]; n * rows];
for j in 0..rows {
// Row (band) median for Warp; per point (outliers replaced) for Pixel Motion.
let band: Vec<[f64; 2]> = (0..n).filter_map(|i| vel[j * n + i]).collect();
let row_med = if band.is_empty() {
global
} else {
let mut a: Vec<f64> = band.iter().map(|v| v[0]).collect();
let mut b: Vec<f64> = band.iter().map(|v| v[1]).collect();
[median(&mut a), median(&mut b)]
};
for i in 0..n {
let s = if per_row {
row_med
} else {
match vel[j * n + i] {
Some(q) if (q[0] - row_med[0]).abs() + (q[1] - row_med[1]).abs() < 3.0 + 0.5 * (row_med[0].abs() + row_med[1].abs()) => q,
_ => row_med,
}
};
v[j * n + i] = [s[0] * k, s[1] * k];
}
}
// Cell size in layer pixels (grid is uniform in plane pixels; assume square plane pixels).
Some(Flow { nx: n, ny: rows, cell: cell_w * k, v })
}
fn same_grid(nb: Buf, b: &Buf) -> Image {
if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale {
return nb.img;
}
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)
})
}
fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rate = ctx.params.f("rollingShutterRate") / 100.0;
if rate == 0.0 {
return b;
}
let Some(host) = ctx.env.host else { return b };
let fps = ctx.fps();
let prev = host.self_at(ctx.time - 1.0 / fps, ctx.env.effect_index).map(|nb| same_grid(nb, &b));
let next = host.self_at(ctx.time + 1.0 / fps, ctx.env.effect_index).map(|nb| same_grid(nb, &b));
if prev.is_none() && next.is_none() {
return b;
}
let per_row = ctx.params.e("method") == 0;
let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("detailedAnalysis"), per_row) else { return b };
let dir = ctx.params.e("scanDirection");
let src = b.img.clone();
let (w, h) = (b.img.width as f64, b.img.height as f64);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let s = match dir {
1 => 1.0 - fy / h,
2 => fx / w,
3 => 1.0 - fx / w,
_ => fy / h,
};
let tau = rate * (s - 0.5);
let v = flow.at(fx, fy);
*px = src.sample_bilinear_clamped(fx + v[0] * tau, fy + v[1] * tau);
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec(
"ec.distort.ccflomotion",
"CC Flo Motion",
vec![
p("knot1", "Knot 1", Value::Vec2([0.25, 0.5]), ParamUi::Point),
p("amount1", "Amount 1", num(50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)),
p("knot2", "Knot 2", Value::Vec2([0.75, 0.5]), ParamUi::Point),
p("amount2", "Amount 2", num(-50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)),
p("tileEdges", "Tile Edges", Value::Bool(false), ParamUi::Checkbox),
p("antialiasing", "Antialiasing", Value::Enum(1), popup(&["Low", "Medium", "High"])),
p("falloff", "Falloff", num(2.0), slider(0.01, 10.0, 0.1, 5.0, 2)),
],
flo_motion,
),
spec(
"ec.distort.liquify",
"Liquify",
vec![
p("tool", "Tool", Value::Enum(0), popup(&LIQUIFY_TOOLS)),
p("brushSize", "Brush Size", num(64.0), slider(1.0, 600.0, 1.0, 600.0, 0)),
p("brushPressure", "Brush Pressure", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)),
p("freezeAreaMask", "Freeze Area Mask", num(0.0), ParamUi::Mask),
p("turbulentJitter", "Turbulent Jitter", num(70.0), slider(1.0, 100.0, 1.0, 100.0, 0)),
p("cloneOffset", "Clone Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
p("reconstructionMode", "Reconstruction Mode", Value::Enum(0), popup(&["Revert", "Displace", "Amplitwist", "Affine"])),
p("viewFreezeAreaMask", "View Freeze Area Mask", Value::Bool(false), ParamUi::Checkbox),
p("viewMesh", "View Mesh", Value::Bool(false), ParamUi::Checkbox),
p("meshSize", "Mesh Size", Value::Enum(1), popup(&["Small", "Medium", "Large"])),
p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color),
// The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command.
p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden),
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])),
],
liquify,
),
spec(
"ec.distort.rollingshutterrepair",
"Rolling Shutter Repair",
vec![
p("rollingShutterRate", "Rolling Shutter Rate", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("scanDirection", "Scan Direction", Value::Enum(0), popup(&["Top → Bottom", "Bottom → Top", "Left → Right", "Right → Left"])),
p("method", "Method", Value::Enum(0), popup(&["Warp", "Pixel Motion"])),
p("detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
p("pixelMotionDetail", "Pixel Motion Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
rolling_shutter,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
fn grid_img(w: u32, h: u32) -> Image {
crate::util::gen_image(w, h, |x, y| {
let v = if x % 8 == 0 || y % 8 == 0 { 1.0 } else { 0.2 };
[v, v * 0.5, x as f32 / w as f32, 1.0]
})
}
#[test]
fn flo_motion_pulls_and_pushes() {
let img = grid_img(64, 64);
let out = run_fx(
"ec.distort.ccflomotion",
&[("knot1", Value::Vec2([32.0, 32.0])), ("amount1", num(80.0)), ("amount2", num(0.0))],
img.clone(),
0.0,
EffectEnv::default(),
);
// The knot itself is fixed; pixels near it are magnified (sampled closer to the knot).
assert!((out.img.get(32, 32)[2] - img.get(32, 32)[2]).abs() < 0.01);
assert!((out.img.get(40, 32)[2] - img.get(40, 32)[2]).abs() > 0.01);
assert!(out.img.get(40, 32)[2] < img.get(40, 32)[2]);
let none = run_fx("ec.distort.ccflomotion", &[("amount1", num(0.0)), ("amount2", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(none.img.data, img.data);
let again = run_fx(
"ec.distort.ccflomotion",
&[("knot1", Value::Vec2([32.0, 32.0])), ("amount1", num(80.0)), ("amount2", num(0.0))],
img,
0.0,
EffectEnv::default(),
);
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn liquify_strokes_round_trip_and_warp() {
let s = LiquifyStroke { tool: 0, size: 30.0, pressure: 80.0, jitter: 70.0, clone_offset: [0.0, 0.0], points: vec![[20.0, 32.0], [40.0, 32.0]] };
assert_eq!(LiquifyStroke::parse(&s.to_line()), Some(s.clone()));
assert!(LiquifyStroke::parse("nope 1 2 3 4 5 1,2").is_none());
let img = grid_img(64, 64);
let out = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
assert_ne!(out.img.data, img.data);
// Warping to the right: a pixel right of the start now shows content from its left.
let at = |i: &Image| i.get(36, 32)[2];
assert!(at(&out.img) < at(&img), "{} vs {}", at(&out.img), at(&img));
// Far away, untouched.
assert_eq!(out.img.get(5, 5), img.get(5, 5));
// Distortion Percentage 0 = identity; deterministic.
let zero = run_fx(
"ec.distort.liquify",
&[("distortionMesh", Value::Str(s.to_line())), ("distortionPercentage", num(0.0))],
img.clone(),
0.0,
EffectEnv::default(),
);
assert_eq!(zero.img.data, img.data);
let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(out.img.data, again.img.data);
// Reconstruction undoes the warp; freeze protects.
let mut rec = s.clone();
rec.tool = 9;
rec.pressure = 100.0;
let lines = format!("{}\n{}\n{}\n{}", s.to_line(), rec.to_line(), rec.to_line(), rec.to_line());
let m1 = build_mesh(&parse_strokes(&s.to_line()), 64.0, 64.0, &|_, _| false);
let m2 = build_mesh(&parse_strokes(&lines), 64.0, 64.0, &|_, _| false);
let mag = |m: &Mesh| m.d.iter().map(|d| d[0].abs() + d[1].abs()).sum::<f64>();
assert!(mag(&m2) < mag(&m1) * 0.5);
let frozen = build_mesh(&parse_strokes(&s.to_line()), 64.0, 64.0, &|_, _| true);
assert_eq!(mag(&frozen), 0.0);
// Every tool changes (or deliberately keeps) the mesh without NaNs.
for t in 0..LIQUIFY_TOOLS.len() {
let st = LiquifyStroke { tool: t, clone_offset: [10.0, 0.0], ..s.clone() };
let m = build_mesh(&[s.clone(), st], 64.0, 64.0, &|_, _| false);
assert!(m.d.iter().all(|d| d[0].is_finite() && d[1].is_finite()), "{}", LIQUIFY_TOOLS[t]);
}
}
/// A textured frame panning right at 3 px per frame, captured with a rolling shutter: row
/// `y` is exposed `(y / h − ½)` frames late (rate 100 %).
fn texture(x: f64, y: f64) -> f32 {
let a = ((x * 0.31).sin() * (y * 0.23).cos() + (x * 0.07 + y * 0.11).sin()) as f32;
0.5 + 0.25 * a
}
fn rs_frame(f: f64, shutter: bool) -> Image {
let (w, h) = (96u32, 64u32);
crate::util::gen_image(w, h, |x, y| {
let tau = if shutter { (y as f64 + 0.5) / h as f64 - 0.5 } else { 0.0 };
let shift = 3.0 * (f + tau);
let v = texture(x as f64 + 0.5 - shift, y as f64 + 0.5);
[v, v, v, 1.0]
})
}
struct Pan;
impl EffectHost for Pan {
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> {
Some(Buf { img: rs_frame(t * 24.0, true), offset: [0.0; 2], scale: 1.0 })
}
}
#[test]
fn rolling_shutter_repair_unskews_a_pan() {
let host = Pan;
let env = EffectEnv { host: Some(&host), frame_rate: 24.0, ..Default::default() };
let t = 5.0 / 24.0;
let skewed = rs_frame(5.0, true);
let truth = rs_frame(5.0, false);
let err = |a: &Image| {
let mut s = 0.0;
let mut n = 0.0;
for y in 4..60 {
for x in 12..84 {
s += (a.get(x, y)[0] - truth.get(x, y)[0]).abs();
n += 1.0;
}
}
s / n
};
for method in [0u32, 1] {
let out = run_fx("ec.distort.rollingshutterrepair", &[("rollingShutterRate", num(100.0)), ("method", Value::Enum(method))], skewed.clone(), t, env);
assert!(err(&out.img) < err(&skewed) * 0.4, "method {method}: {} vs {}", err(&out.img), err(&skewed));
let again =
run_fx("ec.distort.rollingshutterrepair", &[("rollingShutterRate", num(100.0)), ("method", Value::Enum(method))], skewed.clone(), t, env);
assert_eq!(out.img.data, again.img.data);
}
let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default());
assert_eq!(none.img.data, skewed.data);
}
}

View File

@ -8,6 +8,7 @@
pub mod audio_fx;
mod blur2;
mod blur3;
mod channel;
mod channel2;
mod color2;
@ -17,6 +18,7 @@ mod controls;
mod distort;
mod distort2;
mod distort3;
pub mod distort4;
mod generate;
mod generate2;
mod generate3;
@ -356,6 +358,8 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(obsolete::specs());
v.extend(ocio::specs());
v.extend(vr::specs());
v.extend(blur3::specs());
v.extend(distort4::specs());
v.extend(time_fx::specs());
v.extend(audio_fx::specs());
v.extend(paint::specs());
@ -472,6 +476,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.time.ccwidetime",
"ec.time.pixelmotionblur",
"ec.vr.digitalglitch",
"ec.blur.camerashakedeblur",
"ec.distort.rollingshutterrepair",
];
/// See [`TIME_DEPENDENT`].

View File

@ -0,0 +1,156 @@
//! Liquify brush strokes (`liquify.stroke`, `liquify.clear`).
//!
//! A stroke is appended to the layer's Liquify effect (added when the layer has none) as one
//! line of its hidden Distortion Mesh data (see `effectcraft_effects::distort4`). Brush size,
//! pressure, turbulent jitter and clone offset default to the effect's own tool options. When
//! the Distortion Mesh is animated, the stroke goes into a keyframe at the current time (copying
//! the mesh in effect there), as After Effects records mesh keyframes.
use effectcraft_effects::distort4::{LIQUIFY_TOOLS, LiquifyStroke, parse_strokes};
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, str_p};
use crate::{EngineError, Result, Session, cmd};
const ID: &str = "ec.distort.liquify";
fn is_liquify(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == ID)
}
fn liquify_effect(fx: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2], want: Option<Uid>) -> Result<Uid> {
if let Some(u) = want {
return fx.groups().find(|g| g.uid == u && is_liquify(g)).map(|g| g.uid).ok_or_else(|| bad("liquify.stroke", "no such Liquify effect"));
}
if let Some(g) = fx.groups().filter(|g| is_liquify(g)).last() {
return Ok(g.uid);
}
let spec = effectcraft_effects::find(ID).ok_or_else(|| EngineError::Other("Liquify effect missing".into()))?;
let g = effectcraft_effects::instantiate(spec, ids, "Liquify", layer_size);
let uid = g.uid;
fx.children.push(g.into());
Ok(uid)
}
fn num_of(g: &PropGroup, id: &str, default: f64) -> f64 {
g.get(id).map(|p| p.value.as_f64()).unwrap_or(default)
}
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "liquify.stroke";
let (cid, lid) = layer_p(s, p, cmd)?;
let tool_name = str_p(p, "tool").unwrap_or("warp");
let tool = LIQUIFY_TOOLS
.iter()
.position(|t| t.eq_ignore_ascii_case(tool_name))
.ok_or_else(|| bad(cmd, format!("unknown tool `{tool_name}` ({})", LIQUIFY_TOOLS.join(" | "))))?;
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer space)"))?;
let mut points = vec![];
for q in raw {
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?;
let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else {
return Err(bad(cmd, "each point is [x, y]"));
};
points.push([x, y]);
}
if points.is_empty() {
return Err(bad(cmd, "`points` is empty"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = comp.frame_rate.snap_nearest(super::time_p(s, p, Some(comp)));
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
if layer.effects().is_none() || !layer.source.is_av() {
return Err(bad(cmd, "Liquify works on footage, solid, text, shape and precomp layers"));
}
let lt = layer.layer_time(t);
let (w, h) = effectcraft_render::source_size(&s.project, layer);
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
let want = p.get("effect").and_then(Value::as_u64);
let (fx_uid, count) = s.edit("Liquify Stroke", None, |proj, _st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?;
let mut ids = Ids(&mut next);
let fx_uid = liquify_effect(fx, &mut ids, size, want)?;
proj.next_id = next;
let l = layer_mut(proj, cid, lid)?;
let g = l.props.sub_mut("effects").and_then(|fx| fx.find_group_mut(fx_uid)).ok_or_else(|| bad(cmd, "Liquify effect gone"))?;
let co = g.get("cloneOffset").map(|p| p.value.as_vec2()).unwrap_or([0.0; 2]);
let st = LiquifyStroke {
tool,
size: f_p(p, "size").unwrap_or_else(|| num_of(g, "brushSize", 64.0)).max(1.0),
pressure: f_p(p, "pressure").unwrap_or_else(|| num_of(g, "brushPressure", 50.0)).clamp(1.0, 100.0),
jitter: f_p(p, "jitter").unwrap_or_else(|| num_of(g, "turbulentJitter", 70.0)).clamp(1.0, 100.0),
clone_offset: match p.get("cloneOffset").and_then(Value::as_array) {
Some(a) => [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)],
None => co,
},
points: points.clone(),
};
let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "Liquify effect has no Distortion Mesh"))?;
let append = |cur: &str| if cur.trim().is_empty() { st.to_line() } else { format!("{}\n{}", cur.trim_end(), st.to_line()) };
let text = if prop.keys.is_empty() {
let cur = match &prop.value {
KV::Str(x) => x.clone(),
_ => String::new(),
};
let v = append(&cur);
prop.value = KV::Str(v.clone());
v
} else {
// Hold keyframes: the mesh in effect at `lt` is the last key at or before it.
let cur = prop.keys.iter().rfind(|k| k.time <= lt).or(prop.keys.first()).map(|k| k.value.clone());
let cur = match cur {
Some(KV::Str(x)) => x,
_ => String::new(),
};
let v = append(&cur);
match prop.keys.iter_mut().find(|k| k.time == lt) {
Some(k) => k.value = KV::Str(v.clone()),
None => {
prop.keys.push(Keyframe::new(lt, KV::Str(v.clone())));
prop.keys.sort_by_key(|k| k.time);
}
}
v
};
Ok((fx_uid, parse_strokes(&text).len()))
})?;
Ok(json!({"effect": fx_uid, "strokes": count}))
}
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "liquify.clear";
let (cid, lid) = layer_p(s, p, cmd)?;
let want = p.get("effect").and_then(Value::as_u64);
s.edit("Clear Liquify Mesh", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?;
let uid =
fx.groups().filter(|g| is_liquify(g) && want.is_none_or(|u| u == g.uid)).last().map(|g| g.uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?;
let g = fx.find_group_mut(uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?;
let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "no Distortion Mesh"))?;
prop.keys.clear();
prop.value = KV::Str(String::new());
Ok(())
})?;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"liquify.stroke",
"Liquify Stroke",
[],
None,
"{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (layer space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}",
has_comp,
stroke
),
cmd!("liquify.clear", "Clear Liquify Mesh", [], None, "{layer?, effect?: uid}", has_comp, clear),
]
}

View File

@ -17,6 +17,7 @@ mod layer;
mod layer_menu;
mod layer_time;
mod link;
mod liquify;
mod lottie;
mod markers;
mod mask;
@ -127,6 +128,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(track::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(liquify::specs());
v.extend(settings::specs());
v.extend(stubs::specs());
v

View File

@ -227,3 +227,26 @@ fn project_with_paint_and_puppet_round_trips() {
let v: Value = serde_json::from_str(&j).unwrap();
assert!(v.to_string().contains("ec.paint.paint") && v.to_string().contains("ec.distort.puppet"));
}
#[test]
fn liquify_stroke_adds_the_effect_and_warps() {
let (mut s, id) = setup();
// Layer (5, 30) is comp (55, 50). Before: opaque blue.
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
let r = s.execute("liquify.stroke", json!({"layer": id, "tool": "warp", "points": [[-20, 30], [10, 30]], "size": 40, "pressure": 100})).unwrap();
assert_eq!(r["strokes"], 1);
let l = layer(&s, id);
let fx = l.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&fx.kind, effectcraft_project::GroupKind::Effect { effect } if effect == "ec.distort.liquify"));
// The warp pulls transparent pixels from outside the layer over its left edge.
assert!(px(&s, 0.0, 55, 50)[3] < 0.9, "{:?}", px(&s, 0.0, 55, 50));
// A second stroke goes into the same effect.
let r2 = s.execute("liquify.stroke", json!({"layer": id, "tool": "reconstruction", "points": [[0, 30]], "size": 60, "pressure": 100})).unwrap();
assert_eq!(r2["strokes"], 2);
assert_eq!(r2["effect"], r["effect"]);
assert!(s.execute("liquify.stroke", json!({"layer": id, "tool": "smudge", "points": [[0, 0]]})).is_err());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, id).effects().unwrap().groups().next().is_none());
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
}

View File

@ -35,7 +35,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L5 | `automation` | The MCP server, headless or bridged to the running app |
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`,
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`,
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`.
## 2. Time

View File

@ -55,6 +55,7 @@ const SAME_LAYER: &[(&str, &str)] = &[
("effects", "project"),
("effects", "text"),
("effects", "path"),
("effects", "track"),
("media", "render"),
("expr", "render"),
("export", "render"),