mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
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>
287 lines
12 KiB
Rust
287 lines
12 KiB
Rust
//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer
|
||
//! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a
|
||
//! left and right view layer).
|
||
|
||
use std::f64::consts::PI;
|
||
|
||
use effectcraft_color::luminance;
|
||
use effectcraft_keyframe::Value;
|
||
use effectcraft_project::ParamUi;
|
||
use effectcraft_raster::{Image, Px};
|
||
use rayon::prelude::*;
|
||
|
||
use crate::util::{Plane, fit_layer, gauss_plane, premul, unpremul};
|
||
use crate::{Buf, EffectCtx, EffectSpec, LayerPixels, num, p, popup, slider};
|
||
|
||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||
EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- CC Environment
|
||
|
||
/// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates.
|
||
fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) {
|
||
match mapping {
|
||
1 => {
|
||
// Probe (mirror ball).
|
||
let m = 2.0 * (r[0] * r[0] + r[1] * r[1] + (r[2] + 1.0).powi(2)).sqrt().max(1e-9);
|
||
(0.5 + r[0] / m, 0.5 + r[1] / m)
|
||
}
|
||
2 => {
|
||
// Vertical cross cube map: 3 faces wide, 4 tall.
|
||
let (ax, ay, az) = (r[0].abs(), r[1].abs(), r[2].abs());
|
||
let (col, row, u, v) = if ax >= ay && ax >= az {
|
||
if r[0] > 0.0 { (2.0, 1.0, -r[2] / ax, r[1] / ax) } else { (0.0, 1.0, r[2] / ax, r[1] / ax) }
|
||
} else if ay >= az {
|
||
if r[1] < 0.0 { (1.0, 0.0, r[0] / ay, -r[2] / ay) } else { (1.0, 2.0, r[0] / ay, r[2] / ay) }
|
||
} else if r[2] > 0.0 {
|
||
(1.0, 1.0, r[0] / az, r[1] / az)
|
||
} else {
|
||
(1.0, 3.0, r[0] / az, -r[1] / az)
|
||
};
|
||
((col + (u + 1.0) * 0.5) / 3.0, (row + (v + 1.0) * 0.5) / 4.0)
|
||
}
|
||
_ => {
|
||
// Spherical (equirectangular).
|
||
let lon = r[0].atan2(r[2]);
|
||
let lat = r[1].clamp(-1.0, 1.0).asin();
|
||
(0.5 + lon / (2.0 * PI), 0.5 + lat / PI)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn cc_environment(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let env = ctx.layer_param("environment", true).unwrap_or_else(|| LayerPixels { buf: b.clone(), size: ctx.layer_size });
|
||
let env_img = if ctx.params.b("filterEnvironment") {
|
||
let s = 2.0 * env.buf.scale;
|
||
effectcraft_raster::gaussian_blur(&env.buf.img, s, s, true)
|
||
} else {
|
||
env.buf.img.clone()
|
||
};
|
||
let mapping = ctx.params.e("mapping");
|
||
let height = ctx.params.f("height") / 100.0 * 20.0;
|
||
// Relief: luminance inside, rounded off by the alpha edge.
|
||
let relief = Plane::from_image(&b.img, |px| {
|
||
let (c, a) = unpremul(px);
|
||
(luminance(c[0], c[1], c[2]) * 0.5 + 0.5) * a
|
||
});
|
||
let soft = 1.5 * b.scale.max(0.25);
|
||
let hmap = gauss_plane(&relief, soft, soft);
|
||
let (es, eo, esz) = (env.buf.scale, env.buf.offset, env.size);
|
||
let src = b.img.clone();
|
||
b.img.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
let a = src.get(x as i64, y as i64)[3];
|
||
if a <= 0.0 {
|
||
*px = [0.0; 4];
|
||
continue;
|
||
}
|
||
let (xi, yi) = (x as i64, y as i64);
|
||
let gx = (hmap.get_clamped(xi + 1, yi) - hmap.get_clamped(xi - 1, yi)) as f64 * 0.5 / b.scale.max(1e-6);
|
||
let gy = (hmap.get_clamped(xi, yi + 1) - hmap.get_clamped(xi, yi - 1)) as f64 * 0.5 / b.scale.max(1e-6);
|
||
let n = [-gx * height, -gy * height, 1.0];
|
||
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||
let n = [n[0] / l, n[1] / l, n[2] / l];
|
||
// Reflect the view ray (towards −z) about the normal.
|
||
let d = -n[2];
|
||
let r = [-2.0 * d * n[0], -2.0 * d * n[1], -1.0 - 2.0 * d * n[2]];
|
||
let r = [r[0], r[1], -r[2]];
|
||
let (u, v) = env_uv(mapping, r);
|
||
let ex = u.rem_euclid(1.0) * esz[0] * es + eo[0];
|
||
let ey = v.clamp(0.0, 1.0) * esz[1] * es + eo[1];
|
||
let e = env_img.sample_bilinear_clamped(ex, ey);
|
||
let (ec, _) = unpremul(e);
|
||
*px = premul(ec, a);
|
||
}
|
||
});
|
||
b
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 3D Glasses
|
||
|
||
const VIEWS: [&str; 9] = [
|
||
"Stereo Pair (Side by Side)",
|
||
"Over Under",
|
||
"Interlace Upper L Lower R",
|
||
"Red Green LR",
|
||
"Red Blue LR",
|
||
"Balanced Colored Red Blue",
|
||
"Balanced Red Green LR",
|
||
"Balanced Red Blue LR",
|
||
"Difference",
|
||
];
|
||
|
||
fn shift(img: &Image, dx: f64, dy: f64) -> Image {
|
||
if dx == 0.0 && dy == 0.0 {
|
||
return img.clone();
|
||
}
|
||
crate::util::gen_image(img.width, img.height, |x, y| img.sample_bilinear(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy))
|
||
}
|
||
|
||
fn lum(p: Px) -> f32 {
|
||
luminance(p[0], p[1], p[2])
|
||
}
|
||
|
||
fn glasses_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let get = |id: &str| match ctx.layer_param(id, true) {
|
||
Some(o) => fit_layer(ctx, &b, &o, false),
|
||
None => b.img.clone(),
|
||
};
|
||
let (mut l, mut r) = (get("leftView"), get("rightView"));
|
||
if ctx.params.b("leftRightSwap") {
|
||
std::mem::swap(&mut l, &mut r);
|
||
}
|
||
let pct = ctx.params.e("units") == 1;
|
||
let unit = if pct { ctx.layer_size[0] / 100.0 } else { 1.0 } * b.scale;
|
||
let conv = ctx.params.f("sceneConvergence") * unit;
|
||
let va = ctx.params.f("verticalAlignment") * unit;
|
||
let l = shift(&l, conv * 0.5, 0.0);
|
||
let r = shift(&r, -conv * 0.5, va);
|
||
let view = ctx.params.e("view3d");
|
||
let k = (ctx.params.f("balance") / 20.0).clamp(0.0, 1.0) as f32;
|
||
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);
|
||
*px = match view {
|
||
0 => {
|
||
if fx < w * 0.5 {
|
||
l.sample_bilinear(fx * 2.0, fy)
|
||
} else {
|
||
r.sample_bilinear((fx - w * 0.5) * 2.0, fy)
|
||
}
|
||
}
|
||
1 => {
|
||
if fy < h * 0.5 {
|
||
l.sample_bilinear(fx, fy * 2.0)
|
||
} else {
|
||
r.sample_bilinear(fx, (fy - h * 0.5) * 2.0)
|
||
}
|
||
}
|
||
2 => {
|
||
if y % 2 == 0 {
|
||
l.get(x as i64, y as i64)
|
||
} else {
|
||
r.get(x as i64, y as i64)
|
||
}
|
||
}
|
||
_ => {
|
||
let lp = l.get(x as i64, y as i64);
|
||
let rp = r.get(x as i64, y as i64);
|
||
let (lc, la) = unpremul(lp);
|
||
let (rc, ra) = unpremul(rp);
|
||
let a = la.max(ra);
|
||
let (ll, rl) = (lum(premul(lc, 1.0)), lum(premul(rc, 1.0)));
|
||
let c = match view {
|
||
3 => [ll, rl, 0.0],
|
||
4 => [ll, 0.0, rl],
|
||
5 => [lc[0] + (ll - lc[0]) * k, rc[1], rc[2]],
|
||
6 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, rl, 0.0],
|
||
7 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, 0.0, rl],
|
||
_ => [(lc[0] - rc[0]).abs(), (lc[1] - rc[1]).abs(), (lc[2] - rc[2]).abs()],
|
||
};
|
||
premul(c, a)
|
||
}
|
||
};
|
||
}
|
||
});
|
||
b
|
||
}
|
||
|
||
pub fn specs() -> Vec<EffectSpec> {
|
||
vec![
|
||
spec(
|
||
"ec.perspective.ccenvironment",
|
||
"CC Environment",
|
||
vec![
|
||
p("environment", "Environment", Value::Layer(None), ParamUi::Layer),
|
||
p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])),
|
||
p("filterEnvironment", "Filter Environment", Value::Bool(true), ParamUi::Checkbox),
|
||
p("height", "Height", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||
],
|
||
cc_environment,
|
||
),
|
||
spec(
|
||
"ec.perspective.3dglasses",
|
||
"3D Glasses",
|
||
vec![
|
||
p("leftView", "Left View", Value::Layer(None), ParamUi::Layer),
|
||
p("rightView", "Right View", Value::Layer(None), ParamUi::Layer),
|
||
p("sceneConvergence", "Scene Convergence", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||
p("verticalAlignment", "Vertical Alignment", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||
p("units", "Units", Value::Enum(0), popup(&["Pixels", "% of Source"])),
|
||
p("leftRightSwap", "Swap Left-Right", Value::Bool(false), ParamUi::Checkbox),
|
||
p("view3d", "3D View", Value::Enum(5), popup(&VIEWS)),
|
||
p("balance", "Balance", num(7.0), slider(0.0, 20.0, 0.0, 20.0, 1)),
|
||
],
|
||
glasses_3d,
|
||
),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{EffectEnv, EffectHost, run_fx};
|
||
|
||
fn img() -> Image {
|
||
crate::util::gen_image(32, 24, |x, y| {
|
||
let a = if (4..28).contains(&x) && (4..20).contains(&y) { 1.0 } else { 0.0 };
|
||
[x as f32 / 32.0 * a, y as f32 / 24.0 * a, 0.4 * a, a]
|
||
})
|
||
}
|
||
|
||
struct Host(LayerPixels);
|
||
impl EffectHost for Host {
|
||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||
Some(self.0.clone())
|
||
}
|
||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||
None
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn environment_reflects_env_layer() {
|
||
// A flat layer reflects the environment straight back: with a uniform green environment,
|
||
// opaque pixels become green, transparent stay transparent.
|
||
let env = LayerPixels { buf: Buf { img: Image::filled(16, 8, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [16.0, 8.0] };
|
||
let host = Host(env);
|
||
let e = EffectEnv { host: Some(&host), ..Default::default() };
|
||
let o = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e);
|
||
let px = o.img.get(16, 12);
|
||
assert!((px[1] - 1.0).abs() < 1e-4 && px[0].abs() < 1e-4, "{px:?}");
|
||
assert_eq!(o.img.get(0, 0)[3], 0.0);
|
||
let again = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e);
|
||
assert_eq!(o.img, again.img);
|
||
for m in 0..3 {
|
||
let o = run_fx("ec.perspective.ccenvironment", &[("mapping", Value::Enum(m))], img(), 0.0, EffectEnv::default());
|
||
assert!(o.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn glasses_anaglyph_and_pairs() {
|
||
let im = img();
|
||
// Same view on both eyes: Red Blue LR puts the luminance in red and blue equally.
|
||
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(4))], im.clone(), 0.0, EffectEnv::default());
|
||
let px = o.img.get(16, 12);
|
||
assert!((px[0] - px[2]).abs() < 1e-5 && px[1] == 0.0 && px[0] > 0.0);
|
||
// Difference of identical views is black.
|
||
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8))], im.clone(), 0.0, EffectEnv::default());
|
||
assert!(o.img.data.iter().all(|p| p[0].abs() < 1e-6 && p[1].abs() < 1e-6));
|
||
// Convergence separates the eyes: difference becomes non-zero.
|
||
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8)), ("sceneConvergence", num(4.0))], im.clone(), 0.0, EffectEnv::default());
|
||
assert!(o.img.data.iter().any(|p| p[0] > 0.05));
|
||
// Every view is deterministic.
|
||
for v in 0..VIEWS.len() as u32 {
|
||
let a = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default());
|
||
let b = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default());
|
||
assert_eq!(a.img, b.img);
|
||
}
|
||
// Interlace: even rows = left view (self).
|
||
let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(2))], im.clone(), 0.0, EffectEnv::default());
|
||
assert_eq!(o.img.get(10, 10), im.get(10, 10));
|
||
}
|
||
}
|