Merge branch 'worktree-agent-a022a00e662ea8d21'

# Conflicts:
#	crates/media/src/pool.rs
#	crates/render/src/lib.rs
#	crates/render/src/tests.rs
This commit is contained in:
Brandon Thomas 2026-10-01 22:58:51 -04:00
commit 1e8768ad18
33 changed files with 11153 additions and 38 deletions

View File

@ -596,7 +596,15 @@ pub fn specs() -> Vec<EffectSpec> {
],
vector_blur,
),
spec("ec.blur.reduceflicker", "Reduce Interlace Flicker", vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))], reduce_flicker),
EffectSpec {
category: "Obsolete",
..spec(
"ec.blur.reduceflicker",
"Reduce Interlace Flicker",
vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))],
reduce_flicker,
)
},
spec(
"ec.blur.cccross",
"CC Cross Blur",
@ -621,7 +629,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -425,7 +425,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,448 @@
//! Channel effects, batch 2: layer-to-layer combinations (Blend, Calculations, Compound
//! Arithmetic) and CC Composite. Second layers come from layer parameters through the effect
//! host; with no layer chosen the effect uses the layer itself, as After Effects does.
use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use rayon::prelude::*;
use crate::util::{layer_or_self, lerp4, premul, unpremul};
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: "Channel", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
fn layer_param(id: &'static str, name: &'static str) -> crate::ParamSpec {
p(id, name, Value::Layer(None), ParamUi::Layer)
}
const SIZE_DIFF: [&str; 2] = ["Center", "Stretch to Fit"];
// ---------------------------------------------------------------- Blend
fn blend(ctx: &EffectCtx, mut b: Buf) -> Buf {
let mode = ctx.params.e("mode");
let orig = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0);
if orig >= 1.0 {
return b;
}
let other = layer_or_self(ctx, &b, "blendWithLayer", true, ctx.params.e("ifLayerSizesDiffer") == 1);
b.img.data.par_iter_mut().zip(other.data.par_iter()).for_each(|(px, &o)| {
let src = *px;
let blended = match mode {
// Crossfade: the other layer replaces this one.
0 => o,
// Color Only / Tint Only: hue (and saturation) from the other layer, lightness kept.
1 | 2 => {
let (c, a) = unpremul(src);
let (oc, oa) = unpremul(o);
if a <= 0.0 || oa <= 0.0 {
src
} else {
let (_, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (oh, os, _) = rgb_to_hsl(oc[0], oc[1], oc[2]);
let (h2, s2) = if mode == 1 {
(oh, os)
} else if s > 1e-4 {
(oh, s)
} else {
(0.0, 0.0)
};
let (r, g, bb) = hsl_to_rgb(h2, s2, l);
let t = oa;
lerp4(src, premul([r, g, bb], a), t)
}
}
3 => [src[0].min(o[0]), src[1].min(o[1]), src[2].min(o[2]), src[3]],
_ => [src[0].max(o[0]), src[1].max(o[1]), src[2].max(o[2]), src[3].max(o[3])],
};
*px = lerp4(blended, src, orig);
});
b
}
// ---------------------------------------------------------------- Calculations
/// Blending modes offered by Calculations / CC Composite (menu order).
const CALC_MODES: [BlendMode; 22] = [
BlendMode::Normal,
BlendMode::Darken,
BlendMode::Multiply,
BlendMode::ColorBurn,
BlendMode::LinearBurn,
BlendMode::Add,
BlendMode::Lighten,
BlendMode::Screen,
BlendMode::ColorDodge,
BlendMode::LinearDodge,
BlendMode::Overlay,
BlendMode::SoftLight,
BlendMode::HardLight,
BlendMode::LinearLight,
BlendMode::VividLight,
BlendMode::PinLight,
BlendMode::HardMix,
BlendMode::Difference,
BlendMode::Exclusion,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Luminosity,
];
fn calc_mode_names() -> Vec<&'static str> {
CALC_MODES.iter().map(|m| m.label()).collect()
}
const CHANNELS: [&str; 6] = ["RGBA", "Gray", "Red", "Green", "Blue", "Alpha"];
/// Extract channel `ch` (index into [`CHANNELS`]) of a premultiplied pixel as a premultiplied
/// pixel: RGBA unchanged, single channels as opaque grey (Alpha as grey too).
#[inline]
fn extract(px: [f32; 4], ch: u32, invert: bool) -> [f32; 4] {
let (c, a) = unpremul(px);
let g = match ch {
0 => {
if invert {
return premul([1.0 - c[0], 1.0 - c[1], 1.0 - c[2]], a);
}
return px;
}
1 => luminance(c[0], c[1], c[2]) * a,
2 => c[0] * a,
3 => c[1] * a,
4 => c[2] * a,
_ => a,
};
let g = if invert { 1.0 - g } else { g };
[g, g, g, 1.0]
}
fn calculations(ctx: &EffectCtx, mut b: Buf) -> Buf {
let in_ch = ctx.params.e("inputChannel");
let in_inv = ctx.params.b("invertInput");
let second = ctx.params.get("secondLayer").and_then(Value::as_layer).is_some();
let sec_ch = ctx.params.e("secondLayerChannel");
let sec_op = (ctx.params.f("secondLayerOpacity") as f32 / 100.0).clamp(0.0, 1.0);
let sec_inv = ctx.params.b("invertSecondLayer");
let mode = CALC_MODES[(ctx.params.e("blendingMode") as usize).min(CALC_MODES.len() - 1)];
let keep_alpha = ctx.params.b("preserveTransparency");
let other = if second { Some(layer_or_self(ctx, &b, "secondLayer", true, ctx.params.b("stretchSecondLayerToFit"))) } else { None };
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let orig_a = px[3];
let mut o = extract(*px, in_ch, in_inv);
if let Some(other) = &other {
let s = extract(other.data[i], sec_ch, sec_inv).map(|v| v * sec_op);
o = blend_pixel(mode, o, s, 0.5);
}
if keep_alpha && o[3] > 1e-6 {
let (c, _) = unpremul(o);
o = premul(c, orig_a);
}
o[3] = o[3].clamp(0.0, 1.0);
*px = o;
});
b
}
// ---------------------------------------------------------------- CC Composite
const CC_COMPOSITE_MODES: [&str; 16] = [
"In Front",
"In Back",
"Add",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Darken",
"Lighten",
"Difference",
"Hue",
"Saturation",
"Color",
"Luminosity",
"Stencil Alpha",
];
/// Composites the original over/under/with the effect result. The original is the image as it
/// enters this effect (the host does not expose the pre-effect source).
fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
let m = ctx.params.e("compositeOriginal");
let rgb_only = ctx.params.b("rgbOnly");
let mode = match m {
2 => BlendMode::Add,
3 => BlendMode::Multiply,
4 => BlendMode::Screen,
5 => BlendMode::Overlay,
6 => BlendMode::SoftLight,
7 => BlendMode::HardLight,
8 => BlendMode::Darken,
9 => BlendMode::Lighten,
10 => BlendMode::Difference,
11 => BlendMode::Hue,
12 => BlendMode::Saturation,
13 => BlendMode::Color,
14 => BlendMode::Luminosity,
15 => BlendMode::StencilAlpha,
_ => BlendMode::Normal,
};
b.img.data.par_iter_mut().for_each(|px| {
let result = *px;
let orig = result.map(|v| v * op);
let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) };
o[3] = o[3].clamp(0.0, 1.0);
if rgb_only {
let (c, _) = unpremul(o);
o = premul(c, result[3]);
}
*px = o;
});
b
}
// ---------------------------------------------------------------- Compound Arithmetic
const OPERATORS: [&str; 15] =
["Copy", "Add", "Subtract", "Multiply", "Difference", "And", "Or", "Xor", "Lighten", "Darken", "Minimum", "Maximum", "Screen", "Overlay", "Hard Light"];
/// One channel operation on straight values in 0..1 (may overflow).
#[inline]
fn arith(op: u32, a: f32, b: f32) -> f32 {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
match op {
0 => b,
1 => a + b,
2 => a - b,
3 => a * b,
4 => (a - b).abs(),
5 => (q(a) & q(b)) as f32 / 255.0,
6 => (q(a) | q(b)) as f32 / 255.0,
7 => (q(a) ^ q(b)) as f32 / 255.0,
8 | 11 => a.max(b),
9 | 10 => a.min(b),
12 => a + b - a * b,
13 => {
if a < 0.5 {
2.0 * a * b
} else {
1.0 - 2.0 * (1.0 - a) * (1.0 - b)
}
}
_ => {
if b < 0.5 {
2.0 * a * b
} else {
1.0 - 2.0 * (1.0 - a) * (1.0 - b)
}
}
}
}
/// Overflow handling: 0 Clip, 1 Wrap, 2 Scale (map the operator's full range to 0..1).
#[inline]
fn overflow(op: u32, v: f32, how: u32) -> f32 {
match how {
0 => v.clamp(0.0, 1.0),
1 => v.rem_euclid(1.0001).min(1.0),
_ => match op {
1 => v * 0.5,
2 => (v + 1.0) * 0.5,
_ => v.clamp(0.0, 1.0),
},
}
}
fn compound_arithmetic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let op = ctx.params.e("operator");
let chans = ctx.params.e("operateOnChannels");
let how = ctx.params.e("overflowBehavior");
let orig = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0);
if orig >= 1.0 {
return b;
}
let other = layer_or_self(ctx, &b, "secondSource", true, ctx.params.b("stretchSecondSourceToFit"));
b.img.data.par_iter_mut().zip(other.data.par_iter()).for_each(|(px, &o)| {
let src = *px;
let (c, a) = unpremul(src);
let (oc, oa) = unpremul(o);
let mut rc = c;
let mut ra = a;
if chans != 2 {
for k in 0..3 {
rc[k] = overflow(op, arith(op, c[k], oc[k]), how);
}
}
if chans != 0 {
ra = overflow(op, arith(op, a, oa), how);
}
let res = premul(rc, ra);
*px = lerp4(res, src, orig);
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let calc_modes = calc_mode_names();
vec![
spec(
"ec.channel.blend",
"Blend",
vec![
layer_param("blendWithLayer", "Blend With Layer"),
p("mode", "Mode", Value::Enum(0), popup(&["Crossfade", "Color Only", "Tint Only", "Darken Only", "Lighten Only"])),
p("blendWithOriginal", "Blend With Original", num(0.0), pct()),
p("ifLayerSizesDiffer", "If Layer Sizes Differ", Value::Enum(0), popup(&SIZE_DIFF)),
],
blend,
),
spec(
"ec.channel.calculations",
"Calculations",
vec![
p("inputChannel", "Input Channel", Value::Enum(0), popup(&CHANNELS)),
p("invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox),
layer_param("secondLayer", "Second Layer"),
p("secondLayerChannel", "Second Layer Channel", Value::Enum(0), popup(&CHANNELS)),
p("secondLayerOpacity", "Second Layer Opacity", num(100.0), pct()),
p("invertSecondLayer", "Invert Second Layer", Value::Bool(false), ParamUi::Checkbox),
p("stretchSecondLayerToFit", "Stretch Second Layer to Fit", Value::Bool(true), ParamUi::Checkbox),
p("blendingMode", "Blending Mode", Value::Enum(0), ParamUi::Popup { options: calc_modes.iter().map(|s| s.to_string()).collect() }),
p("preserveTransparency", "Preserve Transparency", Value::Bool(false), ParamUi::Checkbox),
],
calculations,
),
spec(
"ec.channel.cccomposite",
"CC Composite",
vec![
p("opacity", "Opacity", num(100.0), pct()),
p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)),
p("rgbOnly", "RGB Only", Value::Bool(false), ParamUi::Checkbox),
],
cc_composite,
),
spec(
"ec.channel.compoundarithmetic",
"Compound Arithmetic",
vec![
layer_param("secondSource", "Second Source Layer"),
p("operator", "Operator", Value::Enum(0), popup(&OPERATORS)),
p("operateOnChannels", "Operate On Channels", Value::Enum(0), popup(&["RGB", "ARGB", "Alpha"])),
p("overflowBehavior", "Overflow Behavior", Value::Enum(0), popup(&["Clip", "Wrap", "Scale"])),
p("stretchSecondSourceToFit", "Stretch Second Source to Fit", Value::Bool(true), ParamUi::Checkbox),
p("blendWithOriginal", "Blend With Original", num(0.0), pct()),
],
compound_arithmetic,
),
]
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
use effectcraft_raster::Image;
/// A host that knows one other layer (id 7).
pub(crate) struct FakeHost(pub Image);
impl EffectHost for FakeHost {
fn layer(&self, id: u64, _: bool) -> Option<LayerPixels> {
(id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
pub(crate) fn sample() -> Image {
let mut img = Image::new(8, 6);
for y in 0..6 {
for x in 0..8 {
let a = if x < 6 { 1.0 } else { 0.5 };
img.set(x, y, [x as f32 / 8.0 * a, y as f32 / 6.0 * a, 0.4 * a, a]);
}
}
img
}
fn close(a: &Image, b: &Image, tol: f32) -> bool {
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
}
fn with_host<R>(other: Image, f: impl FnOnce(EffectEnv) -> R) -> R {
let host = FakeHost(other);
f(EffectEnv { host: Some(&host), ..Default::default() })
}
#[test]
fn blend_self_crossfade_is_identity_and_layer_crossfades() {
let img = sample();
let out = run_fx("ec.channel.blend", &[], img.clone(), 0.0, EffectEnv::default());
assert!(close(&out.img, &img, 1e-6));
let red = Image::filled(8, 6, [1.0, 0.0, 0.0, 1.0]);
let out = with_host(red, |env| {
run_fx("ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(7))), ("blendWithOriginal", num(50.0))], img.clone(), 0.0, env)
});
let p = out.img.get(0, 0);
assert!((p[0] - 0.5).abs() < 1e-5 && (p[3] - 1.0).abs() < 1e-5);
let again = with_host(Image::filled(8, 6, [1.0, 0.0, 0.0, 1.0]), |env| {
run_fx("ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(7))), ("blendWithOriginal", num(50.0))], img.clone(), 0.0, env)
});
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn calculations_defaults_identity_and_gray() {
let img = sample();
let out = run_fx("ec.channel.calculations", &[], img.clone(), 0.0, EffectEnv::default());
assert!(close(&out.img, &img, 1e-6));
let g = run_fx("ec.channel.calculations", &[("inputChannel", Value::Enum(2))], img.clone(), 0.0, EffectEnv::default());
let p = g.img.get(4, 2);
assert!((p[0] - 0.5).abs() < 1e-5 && p[0] == p[1] && p[1] == p[2]);
let m = with_host(Image::filled(8, 6, [0.5, 0.5, 0.5, 1.0]), |env| {
run_fx("ec.channel.calculations", &[("secondLayer", Value::Layer(Some(7))), ("blendingMode", Value::Enum(2))], img.clone(), 0.0, env)
});
let q = m.img.get(4, 0);
assert!((q[0] - 0.25).abs() < 1e-4, "{q:?}");
}
#[test]
fn cc_composite_in_front_is_identity_on_opaque() {
let img = Image::filled(4, 4, [0.3, 0.6, 0.2, 1.0]);
let out = run_fx("ec.channel.cccomposite", &[], img.clone(), 0.0, EffectEnv::default());
assert!(close(&out.img, &img, 1e-6));
let add = run_fx("ec.channel.cccomposite", &[("compositeOriginal", Value::Enum(2))], img.clone(), 0.0, EffectEnv::default());
assert!((add.img.get(0, 0)[0] - 0.6).abs() < 1e-5);
}
#[test]
fn compound_arithmetic_copy_self_identity_and_add() {
let img = sample();
let out = run_fx("ec.channel.compoundarithmetic", &[], img.clone(), 0.0, EffectEnv::default());
assert!(close(&out.img, &img, 1e-5));
let other = Image::filled(8, 6, [0.25, 0.25, 0.25, 1.0]);
let add = with_host(other, |env| {
run_fx("ec.channel.compoundarithmetic", &[("secondSource", Value::Layer(Some(7))), ("operator", Value::Enum(1))], img.clone(), 0.0, env)
});
let p = add.img.get(4, 3);
assert!((p[0] - (0.5 + 0.25)).abs() < 1e-5 && (p[1] - 0.75).abs() < 1e-5, "{p:?}");
let sc = with_host(Image::filled(8, 6, [1.0, 1.0, 1.0, 1.0]), |env| {
run_fx(
"ec.channel.compoundarithmetic",
&[("secondSource", Value::Layer(Some(7))), ("operator", Value::Enum(1)), ("overflowBehavior", Value::Enum(2))],
img.clone(),
0.0,
env,
)
});
assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5);
}
}

View File

@ -886,7 +886,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,575 @@
//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer,
//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels,
//! Vignette).
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, layer_rect, lerp, premul, smoothstep, unpremul};
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: "Color Correction", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
fn bipolar() -> ParamUi {
slider(-100.0, 100.0, -100.0, 100.0, 1)
}
/// Apply `f(straight colour) -> straight colour` to every pixel, keeping alpha.
fn map_colour(b: &mut Buf, f: impl Fn(usize, [f32; 3]) -> [f32; 3] + Sync) {
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, a) = unpremul(*px);
if a <= 0.0 {
return;
}
*px = premul(f(i, c), a);
});
}
// ---------------------------------------------------------------- Change Color
/// Similarity mask (1 = matches) of colour `c` to `key`.
#[inline]
fn colour_match(c: [f32; 3], key: [f32; 3], using: u32, tol: f32, soft: f32) -> f32 {
let d = match using {
0 => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(),
1 => {
let (h1, s1, _) = rgb_to_hsl(c[0], c[1], c[2]);
let (h2, _, _) = rgb_to_hsl(key[0], key[1], key[2]);
let dh = (h1 - h2).abs();
let dh = dh.min(1.0 - dh) * 2.0;
// Greys have no reliable hue.
if s1 < 0.02 { 1.0 } else { dh }
}
_ => {
let chroma = |c: [f32; 3]| {
let s = (c[0] + c[1] + c[2]).max(1e-6);
[c[0] / s, c[1] / s]
};
let (a, k) = (chroma(c), chroma(key));
((a[0] - k[0]).powi(2) + (a[1] - k[1]).powi(2)).sqrt() * 1.5
}
};
if d <= tol {
1.0
} else if soft <= 1e-6 {
0.0
} else {
(1.0 - (d - tol) / soft).clamp(0.0, 1.0)
}
}
fn change_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let view_mask = ctx.params.e("view") == 1;
let dh = (ctx.params.f("hueTransform") / 360.0) as f32;
let dl = (ctx.params.f("lightnessTransform") / 100.0) as f32;
let ds = (ctx.params.f("saturationTransform") / 100.0) as f32;
let k = ctx.params.color("colorToChange");
let key = [k[0], k[1], k[2]];
let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32;
let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32;
let using = ctx.params.e("matchColors");
let invert = ctx.params.b("invertColorCorrectionMask");
if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 {
return b;
}
map_colour(&mut b, |_, c| {
let mut m = colour_match(c, key, using, tol, soft);
if invert {
m = 1.0 - m;
}
if view_mask {
return [m; 3];
}
if m <= 0.0 {
return c;
}
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let h2 = (h + dh).rem_euclid(1.0);
let s2 = (s + ds * if ds > 0.0 { 1.0 - s } else { s }).clamp(0.0, 1.0);
let l2 = (l + dl * if dl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0);
let (r, g, bb) = hsl_to_rgb(h2, s2, l2);
[lerp(c[0], r, m), lerp(c[1], g, m), lerp(c[2], bb, m)]
});
b
}
// ---------------------------------------------------------------- Video Limiter
const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09];
/// Soft knee: values above `start` approach `limit` smoothly.
#[inline]
fn knee(v: f32, start: f32, limit: f32) -> f32 {
if v <= start || limit <= start {
return v.min(limit);
}
let r = limit - start;
start + r * (1.0 - (-(v - start) / r).exp())
}
fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let level = CLIP_LEVELS[(ctx.params.e("clipLevel") as usize).min(CLIP_LEVELS.len() - 1)];
let method = ctx.params.e("clipMethod");
let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(ctx.params.e("compressionBeforeClipping") as usize).min(4)];
let warn = ctx.params.b("gamutWarning");
let wc = ctx.params.color("gamutWarningColor");
let start = level * (1.0 - comp);
map_colour(&mut b, |_, c| {
let y = luminance(c[0], c[1], c[2]);
let mut o = c;
// Luma: compress luminance, keep chroma offsets.
if method != 1 {
let y2 = knee(y.max(0.0), start, level);
o = [o[0] - y + y2, o[1] - y + y2, o[2] - y + y2];
}
// Chroma: scale chroma towards luma until every channel is within 0..level.
if method != 0 {
let yl = luminance(o[0], o[1], o[2]);
let mut t = 1.0f32;
for &v in &o {
if v > level && v - yl > 1e-6 {
t = t.min((level - yl) / (v - yl));
}
if v < 0.0 && yl - v > 1e-6 {
t = t.min(yl / (yl - v));
}
}
let t = t.clamp(0.0, 1.0);
o = [yl + (o[0] - yl) * t, yl + (o[1] - yl) * t, yl + (o[2] - yl) * t];
}
let o = o.map(|v| v.clamp(0.0, level));
if warn && (c.iter().zip(o.iter()).any(|(a, b)| (a - b).abs() > 1e-4)) {
return [wc[0], wc[1], wc[2]];
}
o
});
b
}
// ---------------------------------------------------------------- CC Color Neutralizer
fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")];
let off: Vec<[f32; 3]> = pairs
.iter()
.map(|(u, bal)| {
let (u, v) = (ctx.params.color(u), ctx.params.color(bal));
[v[0] - u[0], v[1] - u[1], v[2] - u[2]]
})
.collect();
let pin = (ctx.params.f("pinning") / 100.0) as f32;
let orig = (ctx.params.f("blendWOriginal") / 100.0) as f32;
let contrast = (ctx.params.f("contrast") / 100.0) as f32;
let darks = (ctx.params.f("darks") / 100.0) as f32;
let brights = (ctx.params.f("brights") / 100.0) as f32;
map_colour(&mut b, |_, c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let ws = 1.0 - smoothstep(0.0, 0.5, l);
let wh = smoothstep(0.5, 1.0, l);
let wm = 1.0 - ws - wh;
// Pinning keeps pure black and pure white fixed.
let keep = 1.0 - pin * (1.0 - 4.0 * l * (1.0 - l)).clamp(0.0, 1.0);
let mut o = [0.0; 3];
for k in 0..3 {
let d = off[0][k] * ws + off[1][k] * wm + off[2][k] * wh;
let mut v = c[k] + d * keep;
v = 0.5 + (v - 0.5) * (1.0 + contrast);
v += darks * 0.25 * (1.0 - l).powi(2) + brights * 0.25 * l * l;
o[k] = v.max(0.0);
}
[lerp(o[0], c[0], orig), lerp(o[1], c[1], orig), lerp(o[2], c[2], orig)]
});
b
}
// ---------------------------------------------------------------- PS Arbitrary Map
/// Parse a map string of 256 comma/space separated values (0..255, or 0..1 floats). An empty or
/// malformed map is the identity.
fn parse_map(s: &str) -> Vec<f32> {
let vals: Vec<f32> = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::<f32>().ok()).collect();
if vals.len() < 2 {
return (0..256).map(|i| i as f32 / 255.0).collect();
}
let max = vals.iter().cloned().fold(0.0f32, f32::max);
let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 };
vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect()
}
fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf {
let map = parse_map(ctx.params.s("map"));
let phase = ctx.params.f("phase");
let alpha = ctx.params.b("applyPhaseMapToAlpha");
let n = map.len();
let lookup = |v: f32| -> f32 {
let x = (v.clamp(0.0, 1.0) as f64 * (n - 1) as f64 + phase * (n - 1) as f64 / 255.0).rem_euclid(n as f64);
let i0 = x.floor() as usize % n;
let i1 = if i0 + 1 < n { i0 + 1 } else { i0 };
let t = (x - x.floor()) as f32;
lerp(map[i0], map[i1], t)
};
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = unpremul(*px);
let o = c.map(lookup);
let na = if alpha { lookup(a) } else { a };
*px = premul(o, na);
});
b
}
// ---------------------------------------------------------------- Lumetri Color
/// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the
/// end points fixed.
#[inline]
fn tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 {
if s == 0.0 && m == 0.0 && h == 0.0 {
return x;
}
let t = x.clamp(0.0, 1.0);
let bump = |c: f32| {
let d = (t - c) / 0.25;
(1.0 - d * d).max(0.0).powi(2)
};
x + 0.25 * (s * bump(0.25) + m * bump(0.5) + h * bump(0.75))
}
/// The colour part of a wheel/tint colour (offset from its own grey), 0 for neutral colours.
#[inline]
fn tint_offset(c: [f32; 4]) -> [f32; 3] {
let l = (c[0] + c[1] + c[2]) / 3.0;
[c[0] - l, c[1] - l, c[2] - l]
}
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let temp = (pr.f("temperature") / 100.0) as f32;
let tint = (pr.f("tint") / 100.0) as f32;
let expo = 2f32.powf(pr.f("exposure") as f32);
let contrast = (pr.f("contrast") / 100.0) as f32;
let (hi, sh, wh, bl) =
((pr.f("highlights") / 100.0) as f32, (pr.f("shadows") / 100.0) as f32, (pr.f("whites") / 100.0) as f32, (pr.f("blacks") / 100.0) as f32);
let sat = (pr.f("saturation") / 100.0) as f32;
let intensity = (pr.f("lookIntensity") / 100.0) as f32;
let faded = (pr.f("fadedFilm") / 100.0) as f32;
let vib = (pr.f("vibrance") / 100.0) as f32;
let csat = (pr.f("creativeSaturation") / 100.0) as f32;
let st = tint_offset(pr.color("shadowTint"));
let ht = tint_offset(pr.color("highlightTint"));
let tbal = (pr.f("tintBalance") / 100.0) as f32;
let curve = |k: &str| {
[(pr.f(&format!("{k}Shadows")) / 100.0) as f32, (pr.f(&format!("{k}Midtones")) / 100.0) as f32, (pr.f(&format!("{k}Highlights")) / 100.0) as f32]
};
let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue"));
let ws = tint_offset(pr.color("shadowsWheel"));
let wm = tint_offset(pr.color("midtonesWheel"));
let wh_ = tint_offset(pr.color("highlightsWheel"));
let vig = pr.f("vignetteAmount") as f32;
let vmid = (pr.f("vignetteMidpoint") / 100.0) as f32;
let vround = (pr.f("vignetteRoundness") / 100.0) as f32;
let vfeather = (pr.f("vignetteFeather") / 100.0) as f32;
let sharpen = (pr.f("sharpen") / 100.0) as f32;
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let w = b.img.width as usize;
let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5);
map_colour(&mut b, |i, c| {
let mut c = c;
// Basic Correction: white balance, exposure, contrast, tone, saturation.
if temp != 0.0 || tint != 0.0 {
c = [c[0] * (1.0 + 0.25 * temp), c[1] * (1.0 - 0.25 * tint), c[2] * (1.0 - 0.25 * temp)];
}
if expo != 1.0 {
c = c.map(|v| v * expo);
}
if contrast != 0.0 {
c = c.map(|v| 0.5 + (v - 0.5) * (1.0 + contrast));
}
if hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0 {
let l = luminance(c[0], c[1], c[2]).max(0.0);
let lt = l.min(1.0);
let d = 0.25 * hi * smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (1.0 - lt).powi(2);
c = c.map(|v| v + d);
}
let l = luminance(c[0], c[1], c[2]);
if sat != 1.0 {
c = c.map(|v| l + (v - l) * sat);
}
// Creative.
if intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) {
let mut d = c;
if faded != 0.0 {
d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded);
}
let l = luminance(d[0], d[1], d[2]);
if vib != 0.0 {
let mx = d[0].max(d[1]).max(d[2]);
let mn = d[0].min(d[1]).min(d[2]);
let s = if mx > 1e-6 { (mx - mn) / mx } else { 0.0 };
let k = 1.0 + vib * (1.0 - s.clamp(0.0, 1.0));
d = d.map(|v| l + (v - l) * k);
}
if csat != 1.0 {
d = d.map(|v| l + (v - l) * csat);
}
let pivot = (0.5 + 0.25 * tbal).clamp(0.05, 0.95);
let lt = l.clamp(0.0, 1.0);
let wsh = 1.0 - smoothstep(0.0, pivot, lt);
let whi = smoothstep(pivot, 1.0, lt);
for k in 0..3 {
d[k] += 0.5 * (st[k] * wsh + ht[k] * whi);
}
c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)];
}
// Curves.
c = c.map(|v| tone_curve(v, cm[0], cm[1], cm[2]));
c = [tone_curve(c[0], cr[0], cr[1], cr[2]), tone_curve(c[1], cg[0], cg[1], cg[2]), tone_curve(c[2], cb[0], cb[1], cb[2])];
// Color wheels.
if ws != [0.0; 3] || wm != [0.0; 3] || wh_ != [0.0; 3] {
let lt = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let a = 1.0 - smoothstep(0.0, 0.5, lt);
let z = smoothstep(0.5, 1.0, lt);
let m = 1.0 - a - z;
for k in 0..3 {
c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z);
}
}
// Vignette.
if vig != 0.0 {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let nx = ((x - cx) / (lw * 0.5).max(1e-6)) as f32;
let ny = ((y - cy) / (lh * 0.5).max(1e-6)) as f32;
// Roundness: 0 follows the frame's aspect, +1 a circle, -1 squarer.
let aspect = (lw / lh.max(1e-6)) as f32;
let (nx, ny) = if vround > 0.0 { (nx * lerp(1.0, aspect.max(1.0), vround), ny * lerp(1.0, (1.0 / aspect).max(1.0), vround)) } else { (nx, ny) };
let pw = 2.0 + (-vround).max(0.0) * 6.0;
let r = (nx.abs().powf(pw) + ny.abs().powf(pw)).powf(1.0 / pw) / std::f32::consts::SQRT_2;
let start = vmid * 0.9;
let f = smoothstep(start, start + 0.05 + vfeather * (1.05 - start), r);
let k = if vig < 0.0 { 1.0 + vig * 0.2 * f } else { 1.0 };
c = c.map(|v| if vig > 0.0 { v + (1.0 - v) * (vig * 0.2 * f).min(1.0) } else { v * k.max(0.0) });
}
c.map(|v| v.max(0.0))
});
if sharpen != 0.0 {
let luma = Plane::luma(&b.img);
let blur = gauss_plane(&luma, 1.0 * b.scale, 1.0 * b.scale);
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, a) = unpremul(*px);
if a <= 0.0 {
return;
}
let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0;
*px = premul(c.map(|v| (v + d).max(0.0)), a);
});
}
b
}
pub fn specs() -> Vec<EffectSpec> {
let grey = || col(0.5, 0.5, 0.5);
let mut lum = vec![
p("inputLut", "Input LUT", Value::Enum(0), popup(&["None"])),
p("temperature", "Temperature", num(0.0), bipolar()),
p("tint", "Tint", num(0.0), bipolar()),
p("exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
p("contrast", "Contrast", num(0.0), bipolar()),
p("highlights", "Highlights", num(0.0), bipolar()),
p("shadows", "Shadows", num(0.0), bipolar()),
p("whites", "Whites", num(0.0), bipolar()),
p("blacks", "Blacks", num(0.0), bipolar()),
p("saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("look", "Look", Value::Enum(0), popup(&["None"])),
p("lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("fadedFilm", "Faded Film", num(0.0), pct()),
p("sharpen", "Sharpen", num(0.0), bipolar()),
p("vibrance", "Vibrance", num(0.0), bipolar()),
p("creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("shadowTint", "Shadow Tint", grey(), ParamUi::Color),
p("highlightTint", "Highlight Tint", grey(), ParamUi::Color),
p("tintBalance", "Tint Balance", num(0.0), bipolar()),
];
const CURVES: [(&str, &str); 12] = [
("curveMasterShadows", "RGB Curve Shadows"),
("curveMasterMidtones", "RGB Curve Midtones"),
("curveMasterHighlights", "RGB Curve Highlights"),
("curveRedShadows", "Red Curve Shadows"),
("curveRedMidtones", "Red Curve Midtones"),
("curveRedHighlights", "Red Curve Highlights"),
("curveGreenShadows", "Green Curve Shadows"),
("curveGreenMidtones", "Green Curve Midtones"),
("curveGreenHighlights", "Green Curve Highlights"),
("curveBlueShadows", "Blue Curve Shadows"),
("curveBlueMidtones", "Blue Curve Midtones"),
("curveBlueHighlights", "Blue Curve Highlights"),
];
for (id, name) in CURVES {
lum.push(p(id, name, num(0.0), bipolar()));
}
lum.extend([
p("shadowsWheel", "Shadows", grey(), ParamUi::Color),
p("midtonesWheel", "Midtones", grey(), ParamUi::Color),
p("highlightsWheel", "Highlights", grey(), ParamUi::Color),
p("vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
p("vignetteMidpoint", "Midpoint", num(50.0), pct()),
p("vignetteRoundness", "Roundness", num(0.0), bipolar()),
p("vignetteFeather", "Feather", num(50.0), pct()),
]);
vec![
spec(
"ec.color.changecolor",
"Change Color",
vec![
p("view", "View", Value::Enum(0), popup(&["Corrected Layer", "Color Correction Mask"])),
p("hueTransform", "Hue Transform", num(0.0), slider(-360.0, 360.0, -180.0, 180.0, 1)),
p("lightnessTransform", "Lightness Transform", num(0.0), bipolar()),
p("saturationTransform", "Saturation Transform", num(0.0), bipolar()),
p("colorToChange", "Color To Change", col(0.8, 0.2, 0.2), ParamUi::Color),
p("matchingTolerance", "Matching Tolerance", num(15.0), pct()),
p("matchingSoftness", "Matching Softness", num(0.0), pct()),
p("matchColors", "Match colors", Value::Enum(1), popup(&["Using RGB", "Using Hue", "Using Chroma"])),
p("invertColorCorrectionMask", "Invert Color Correction Mask", Value::Bool(false), ParamUi::Checkbox),
],
change_color,
),
spec(
"ec.color.videolimiter",
"Video Limiter",
vec![
p("clipLevel", "Clip Level", Value::Enum(2), popup(&["90%", "95%", "100%", "105%", "109%"])),
p("clipMethod", "Clip Method", Value::Enum(2), popup(&["Luma", "Chroma", "Smart Limit"])),
p("compressionBeforeClipping", "Compression before Clipping", Value::Enum(2), popup(&["None", "3%", "5%", "10%", "20%"])),
p("gamutWarning", "Gamut Warning", Value::Bool(false), ParamUi::Checkbox),
p("gamutWarningColor", "Gamut Warning Color", col(1.0, 0.0, 0.0), ParamUi::Color),
],
video_limiter,
),
spec(
"ec.color.cccolorneutralizer",
"CC Color Neutralizer",
vec![
p("shadowsUnbalance", "Shadows Unbalance", col(0.0, 0.0, 0.0), ParamUi::Color),
p("shadowsBalance", "Shadows Balance", col(0.0, 0.0, 0.0), ParamUi::Color),
p("midtonesUnbalance", "Midtones Unbalance", grey(), ParamUi::Color),
p("midtonesBalance", "Midtones Balance", grey(), ParamUi::Color),
p("highlightsUnbalance", "Highlights Unbalance", col(1.0, 1.0, 1.0), ParamUi::Color),
p("highlightsBalance", "Highlights Balance", col(1.0, 1.0, 1.0), ParamUi::Color),
p("pinning", "Pinning", num(0.0), pct()),
p("blendWOriginal", "Blend w. Original", num(0.0), pct()),
p("darks", "Darks", num(0.0), bipolar()),
p("brights", "Brights", num(0.0), bipolar()),
p("contrast", "Contrast", num(0.0), bipolar()),
],
color_neutralizer,
),
spec(
"ec.color.psarbitrarymap",
"PS Arbitrary Map",
vec![
p("phase", "Phase", num(0.0), slider(-1000.0, 1000.0, 0.0, 255.0, 0)),
p("applyPhaseMapToAlpha", "Apply Phase Map To Alpha", Value::Bool(false), ParamUi::Checkbox),
p("map", "Map", Value::Str(String::new()), ParamUi::Hidden),
],
arbitrary_map,
),
spec("ec.color.lumetri", "Lumetri Color", lum, lumetri),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
use effectcraft_raster::Image;
fn ramp() -> Image {
let mut img = Image::new(16, 8);
for y in 0..8 {
for x in 0..16 {
let a = if y < 7 { 1.0 } else { 0.5 };
img.set(x, y, [x as f32 / 16.0 * a, (y as f32 / 8.0) * a, 0.3 * a, a]);
}
}
img
}
fn close(a: &Image, b: &Image, tol: f32) -> bool {
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
}
fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Image {
run_fx(id, vals, img, 0.0, EffectEnv::default()).img
}
#[test]
fn change_color_zero_is_identity_and_hue_shift_moves_red() {
let img = ramp();
assert!(close(&run("ec.color.changecolor", &[], img.clone()), &img, 0.0));
let red = Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0]);
let out = run("ec.color.changecolor", &[("hueTransform", num(120.0))], red.clone());
let p = out.get(1, 1);
assert!(p[1] > p[0] && p[1] > p[2], "{p:?}");
let blue = Image::filled(4, 4, [0.1, 0.2, 0.9, 1.0]);
assert!(close(&run("ec.color.changecolor", &[("hueTransform", num(120.0))], blue.clone()), &blue, 1e-6));
let mask = run("ec.color.changecolor", &[("view", Value::Enum(1))], red);
assert!((mask.get(0, 0)[0] - 1.0).abs() < 1e-6);
assert_eq!(out.data, run("ec.color.changecolor", &[("hueTransform", num(120.0))], Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0])).data);
}
#[test]
fn video_limiter_keeps_legal_and_limits_hot() {
let mid = Image::filled(4, 4, [0.4, 0.5, 0.3, 1.0]);
assert!(close(&run("ec.color.videolimiter", &[], mid.clone()), &mid, 1e-6));
let hot = Image::filled(4, 4, [1.6, 1.2, 0.9, 1.0]);
let out = run("ec.color.videolimiter", &[], hot);
assert!(out.data.iter().all(|p| p[0] <= 1.0 + 1e-6 && p[1] <= 1.0 + 1e-6));
let warn = run("ec.color.videolimiter", &[("gamutWarning", Value::Bool(true))], Image::filled(2, 2, [1.6, 1.2, 0.9, 1.0]));
assert_eq!(warn.get(0, 0), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn color_neutralizer_defaults_identity_and_removes_cast() {
let img = ramp();
assert!(close(&run("ec.color.cccolorneutralizer", &[], img.clone()), &img, 1e-6));
let cast = Image::filled(4, 4, [0.6, 0.5, 0.4, 1.0]);
let out = run("ec.color.cccolorneutralizer", &[("midtonesUnbalance", col(0.6, 0.5, 0.4)), ("midtonesBalance", col(0.5, 0.5, 0.5))], cast);
let p = out.get(0, 0);
assert!((p[0] - p[2]).abs() < 0.1, "{p:?}");
}
#[test]
fn arbitrary_map_identity_and_inverting_map() {
let img = ramp();
assert!(close(&run("ec.color.psarbitrarymap", &[], img.clone()), &img, 1e-5));
let inv: Vec<String> = (0..256).map(|i| (255 - i).to_string()).collect();
let out = run("ec.color.psarbitrarymap", &[("map", Value::Str(inv.join(",")))], Image::filled(2, 2, [0.25, 0.5, 1.0, 1.0]));
let p = out.get(0, 0);
assert!((p[0] - 0.75).abs() < 0.01 && p[2].abs() < 0.01, "{p:?}");
}
#[test]
fn lumetri_defaults_identity_and_controls_act() {
let img = ramp();
assert!(close(&run("ec.color.lumetri", &[], img.clone()), &img, 1e-5));
let g = Image::filled(8, 8, [0.4, 0.4, 0.4, 1.0]);
let e = run("ec.color.lumetri", &[("exposure", num(1.0))], g.clone());
assert!((e.get(0, 0)[0] - 0.8).abs() < 1e-5);
let warm = run("ec.color.lumetri", &[("temperature", num(50.0))], g.clone());
assert!(warm.get(0, 0)[0] > warm.get(0, 0)[2]);
let bw = run("ec.color.lumetri", &[("saturation", num(0.0))], img.clone());
let p = bw.get(5, 3);
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
let v = run("ec.color.lumetri", &[("vignetteAmount", num(-3.0))], g.clone());
assert!(v.get(0, 0)[0] < v.get(4, 4)[0]);
let c = run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g.clone());
assert!(c.get(0, 0)[0] > 0.4);
assert_eq!(c.data, run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g).data);
}
}

View File

@ -690,7 +690,7 @@ mod tests {
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

File diff suppressed because it is too large Load Diff

View File

@ -754,7 +754,7 @@ mod tests {
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

File diff suppressed because it is too large Load Diff

View File

@ -485,7 +485,7 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.key.luma",
"Luma Key",
"Keying",
"Obsolete",
vec![
p("keyType", "Key Type", Value::Enum(1), popup(&["Key Out Brighter", "Key Out Darker", "Key Out Similar", "Key Out Dissimilar"])),
p("threshold", "Threshold", num(0.0), b255()),
@ -560,7 +560,7 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.key.spill",
"Spill Suppressor",
"Keying",
"Obsolete",
vec![
p("colorToSuppress", "Color To Suppress", col(0.0, 0.0, 1.0), ParamUi::Color),
p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)),
@ -617,7 +617,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,419 @@
//! Keying effects, batch 2: Difference Matte (against another layer), Inner/Outer Key (mattes
//! from masks with colour-based edge estimation and decontamination), Unmult and CC Simple
//! Wire Removal.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::gaussian_blur;
use rayon::prelude::*;
use crate::util::{Plane, dist_to_poly, gauss_plane, layer_or_self, lerp4, morph_frac, point_in_poly, premul, smoothstep, unpremul};
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: "Keying", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
// ---------------------------------------------------------------- Difference Matte
fn difference_matte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let view = ctx.params.e("view");
if view == 1 {
return b;
}
let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32;
let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32;
let blur = ctx.params.f("blurBeforeDifference") * b.scale;
let other = layer_or_self(ctx, &b, "differenceLayer", true, ctx.params.e("ifLayerSizesDiffer") == 1);
let (src, other) = if blur > 0.05 {
(gaussian_blur(&b.img, blur * 0.5, blur * 0.5, true), gaussian_blur(&other, blur * 0.5, blur * 0.5, true))
} else {
(b.img.clone(), other)
};
let matte_only = view == 2;
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, a) = unpremul(src.data[i]);
let (oc, oa) = unpremul(other.data[i]);
let d = (c[0] - oc[0]).abs().max((c[1] - oc[1]).abs()).max((c[2] - oc[2]).abs()).max((a - oa).abs());
let m = if d <= tol {
0.0
} else if soft <= 1e-6 {
1.0
} else {
((d - tol) / soft).clamp(0.0, 1.0)
};
if matte_only {
*px = [m * px[3], m * px[3], m * px[3], px[3]];
} else {
*px = px.map(|v| v * m);
}
});
b
}
// ---------------------------------------------------------------- Inner/Outer Key
fn mask_options() -> ParamUi {
popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"])
}
/// Coverage plane (1 inside) of the selected closed masks, in buffer pixels.
fn mask_plane(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option<Plane> {
let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect();
if shapes.is_empty() {
return None;
}
let (w, h) = (b.img.width as usize, b.img.height as usize);
let mut pl = Plane::new(w, h);
let inv = 1.0 / b.scale.max(1e-9);
pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
let lx = (x as f64 + 0.5 - b.offset[0]) * inv;
let ly = (y as f64 + 0.5 - b.offset[1]) * inv;
*v = if shapes.iter().any(|s| point_in_poly(&s.points, lx, ly)) { 1.0 } else { 0.0 };
}
});
Some(pl)
}
/// Signed distance (pixels, positive inside) to the selected mask outlines.
fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option<Plane> {
let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect();
if shapes.is_empty() {
return None;
}
let (w, h) = (b.img.width as usize, b.img.height as usize);
let mut pl = Plane::new(w, h);
let inv = 1.0 / b.scale.max(1e-9);
pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
let lx = (x as f64 + 0.5 - b.offset[0]) * inv;
let ly = (y as f64 + 0.5 - b.offset[1]) * inv;
let inside = shapes.iter().any(|s| point_in_poly(&s.points, lx, ly));
let d = shapes.iter().map(|s| dist_to_poly(&s.points, true, lx, ly)).fold(f64::INFINITY, f64::min) * b.scale;
*v = if inside { d as f32 } else { -d as f32 };
}
});
Some(pl)
}
fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
let fg_idx = [ctx.params.e("foreground"), ctx.params.e("additionalForeground")];
let bg_idx = [ctx.params.e("background"), ctx.params.e("additionalBackground")];
let radius = (ctx.params.f("singleMaskHighlightRadius") * b.scale) as f32;
let thin = ctx.params.f("edgeThin") * b.scale;
let feather = ctx.params.f("edgeFeather") * b.scale;
let thr = (ctx.params.f("edgeThreshold") / 100.0) as f32;
let invert = ctx.params.b("invertExtraction");
let orig = (ctx.params.f("blendWithOriginal") / 100.0) as f32;
let (w, h) = (b.img.width as usize, b.img.height as usize);
// Trimap: 1 = known foreground, 0 = known background, 0.5 = unknown.
let fg = mask_plane(ctx, &b, &fg_idx);
let bg = mask_plane(ctx, &b, &bg_idx);
let tri: Plane = match (fg, bg) {
(None, _) => return b,
(Some(f), Some(bk)) => f.zip_map(&bk, |f, bk| {
if f > 0.5 {
1.0
} else if bk > 0.5 {
0.5
} else {
0.0
}
}),
(Some(f), None) => {
if radius <= 0.0 {
f
} else {
let sdf = mask_sdf(ctx, &b, &fg_idx).unwrap_or(f);
sdf.map(|d| {
if d > radius {
1.0
} else if d < -radius {
0.0
} else {
0.5
}
})
}
}
};
let unknown = tri.data.par_iter().any(|&t| t > 0.25 && t < 0.75);
let mut alpha = tri.clone();
let mut colours: Option<Vec<[f32; 3]>> = None;
if unknown {
// Local foreground / background colour estimates: normalised blurs over known regions.
let sigma = (radius.max(4.0) as f64).max(w.min(h) as f64 * 0.02);
let known_f = tri.map(|t| if t > 0.75 { 1.0 } else { 0.0 });
let known_b = tri.map(|t| if t < 0.25 { 1.0 } else { 0.0 });
let straight: Vec<[f32; 3]> = b.img.data.par_iter().map(|&px| unpremul(px).0).collect();
let est = |known: &Plane| -> Vec<[f32; 3]> {
let wsum = gauss_plane(known, sigma, sigma);
let chans: Vec<Plane> = (0..3)
.map(|k| {
let pl = Plane { w, h, data: straight.par_iter().zip(known.data.par_iter()).map(|(c, &m)| c[k] * m).collect() };
gauss_plane(&pl, sigma, sigma)
})
.collect();
(0..w * h).into_par_iter().map(|i| [0, 1, 2].map(|k| chans[k].data[i] / wsum.data[i].max(1e-6))).collect()
};
let fc = est(&known_f);
let bc = est(&known_b);
alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| {
if *a > 0.25 && *a < 0.75 {
let c = straight[i];
let d = [fc[i][0] - bc[i][0], fc[i][1] - bc[i][1], fc[i][2] - bc[i][2]];
let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
*a = if dd < 1e-6 { 0.5 } else { (((c[0] - bc[i][0]) * d[0] + (c[1] - bc[i][1]) * d[1] + (c[2] - bc[i][2]) * d[2]) / dd).clamp(0.0, 1.0) };
}
});
// Decontaminate edge colours: remove the background contribution.
colours = Some(
(0..w * h)
.into_par_iter()
.map(|i| {
let a = alpha.data[i];
let c = straight[i];
if tri.data[i] > 0.25 && tri.data[i] < 0.75 && a > 0.02 {
[0, 1, 2].map(|k| ((c[k] - (1.0 - a) * bc[i][k]) / a).clamp(0.0, 1.0))
} else {
c
}
})
.collect(),
);
}
if thin.abs() > 0.01 {
alpha = morph_frac(&alpha, thin.abs(), thin < 0.0);
}
if feather > 0.05 {
alpha = gauss_plane(&alpha, feather * 0.5, feather * 0.5);
}
let src = b.img.clone();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let mut m = alpha.data[i];
if thr > 0.0 {
m = if m < thr { 0.0 } else { m };
}
if invert {
m = 1.0 - m;
}
let (c0, a) = unpremul(src.data[i]);
let c = colours.as_ref().map(|v| v[i]).unwrap_or(c0);
let keyed = premul(c, a * m);
*px = lerp4(keyed, src.data[i], orig);
});
b
}
// ---------------------------------------------------------------- Unmult
/// Turns black into transparency: alpha becomes the brightest straight channel and colour is
/// divided by it (premultiplied colour is unchanged).
fn unmult(_: &EffectCtx, mut b: Buf) -> Buf {
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = unpremul(*px);
let m = c[0].max(c[1]).max(c[2]).clamp(0.0, 1.0);
if m <= 1e-6 {
*px = [0.0; 4];
return;
}
*px = premul([c[0] / m, c[1] / m, c[2] / m], a * m);
});
b
}
// ---------------------------------------------------------------- CC Simple Wire Removal
fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pa = ctx.params.v2("pointA");
let pb = ctx.params.v2("pointB");
let style = ctx.params.e("removalStyle");
let half = (ctx.params.f("thickness") * b.scale * 0.5).max(0.0);
if half <= 0.0 {
return b;
}
let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32;
let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32;
let (ax, ay) = b.to_px(pa);
let (bx, by) = b.to_px(pb);
let (dx, dy) = (bx - ax, by - ay);
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-6 {
return b;
}
let (tx, ty) = (dx / len, dy / len);
let (nx, ny) = (-ty, tx);
let src = b.img.clone();
let soft_w = half * slope as f64;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay);
let d = qx * nx + qy * ny;
let ad = d.abs();
if ad > half + soft_w {
continue;
}
// Positions just outside the wire on both sides.
let base = (x as f64 + 0.5 - d * nx, y as f64 + 0.5 - d * ny);
let edge = half + 1.0;
let pos = |s: f64| src.sample_bilinear_clamped(base.0 + nx * s, base.1 + ny * s);
let repl = match style {
0 => {
let t = ((d + edge) / (2.0 * edge)) as f32;
lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0))
}
// Displace: shift the image across the wire from the nearer side.
2 | 1 => {
let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 };
let sh = pos(s);
if mirror > 0.0 { lerp4(sh, pos(-s), mirror * 0.5) } else { sh }
}
_ => {
// Displace Horizontal: sample left/right of the wire along x.
let sx = if nx.abs() > 1e-6 { (half - ad + 1.0) / nx.abs() } else { 0.0 };
let dir = if d * nx >= 0.0 { 1.0 } else { -1.0 };
let sh = src.sample_bilinear_clamped(x as f64 + 0.5 + dir * sx, y as f64 + 0.5);
let mi = src.sample_bilinear_clamped(x as f64 + 0.5 - dir * sx, y as f64 + 0.5);
if mirror > 0.0 { lerp4(sh, mi, mirror * 0.5) } else { sh }
}
};
let k = if ad <= half { 1.0 } else { 1.0 - smoothstep(half as f32, (half + soft_w) as f32, ad as f32) };
*px = lerp4(*px, repl, k);
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec(
"ec.key.differencematte",
"Difference Matte",
vec![
p("view", "View", Value::Enum(0), popup(&["Final Output", "Source Only", "Matte Only"])),
p("differenceLayer", "Difference Layer", Value::Layer(None), ParamUi::Layer),
p("ifLayerSizesDiffer", "If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])),
p("matchingTolerance", "Matching Tolerance", num(15.0), pct()),
p("matchingSoftness", "Matching Softness", num(0.0), pct()),
p("blurBeforeDifference", "Blur Before Difference", num(0.0), slider(0.0, 1000.0, 0.0, 20.0, 1)),
],
difference_matte,
),
spec(
"ec.key.innerouter",
"Inner/Outer Key",
vec![
p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options()),
p("additionalForeground", "Additional Foreground", Value::Enum(0), mask_options()),
p("background", "Background (Outside)", Value::Enum(0), mask_options()),
p("additionalBackground", "Additional Background", Value::Enum(0), mask_options()),
p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)),
p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
p("edgeThreshold", "Edge Threshold", num(0.0), pct()),
p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox),
p("blendWithOriginal", "Blend with Original", num(0.0), pct()),
],
inner_outer_key,
),
spec("ec.key.unmult", "Unmult", vec![], unmult),
spec(
"ec.key.ccsimplewireremoval",
"CC Simple Wire Removal",
vec![
p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point),
p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point),
p("removalStyle", "Removal Style", Value::Enum(2), popup(&["Fade", "Frame Offset", "Displace", "Displace Horizontal"])),
p("thickness", "Thickness", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 1)),
p("slope", "Slope", num(0.0), pct()),
p("mirrorBlend", "Mirror Blend", num(0.0), pct()),
p("frameOffset", "Frame Offset", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 0)),
],
wire_removal,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel2::tests::{FakeHost, sample};
use crate::{EffectEnv, MaskShape, run_fx};
use effectcraft_raster::Image;
#[test]
fn difference_matte_self_is_transparent_and_other_keeps() {
let img = sample();
let out = run_fx("ec.key.differencematte", &[], img.clone(), 0.0, EffectEnv::default());
assert!(out.img.data.iter().all(|p| p[3] == 0.0));
let host = FakeHost(Image::filled(8, 6, [0.0, 0.0, 0.4, 1.0]));
let env = EffectEnv { host: Some(&host), ..Default::default() };
let out = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env);
assert_eq!(out.img.get(5, 5), img.get(5, 5));
assert_eq!(out.img.get(0, 0)[3], 0.0, "matching pixel keyed out");
let again = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env);
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn inner_outer_key_inside_mask() {
let img = Image::filled(20, 20, [0.8, 0.2, 0.2, 1.0]);
let masks = vec![MaskShape { name: "Mask 1".into(), points: vec![[5.0, 5.0], [15.0, 5.0], [15.0, 15.0], [5.0, 15.0]], closed: true, inverted: false }];
let env = EffectEnv { masks: &masks, ..Default::default() };
let out = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1))], img.clone(), 0.0, env);
assert!((out.img.get(10, 10)[3] - 1.0).abs() < 1e-6);
assert_eq!(out.img.get(1, 1)[3], 0.0);
// Two masks: unknown band estimated from colour.
let mut img2 = Image::filled(20, 20, [0.0, 0.0, 1.0, 1.0]);
for y in 0..20 {
for x in 7..13 {
img2.set(x, y, [1.0, 0.0, 0.0, 1.0]);
}
}
let masks2 = vec![
MaskShape { name: "in".into(), points: vec![[8.0, 0.0], [12.0, 0.0], [12.0, 20.0], [8.0, 20.0]], closed: true, inverted: false },
MaskShape { name: "out".into(), points: vec![[4.0, 0.0], [16.0, 0.0], [16.0, 20.0], [4.0, 20.0]], closed: true, inverted: false },
];
let env2 = EffectEnv { masks: &masks2, ..Default::default() };
let o2 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2.clone(), 0.0, env2);
assert!(o2.img.get(7, 10)[3] > 0.8, "{:?}", o2.img.get(7, 10));
assert!(o2.img.get(5, 10)[3] < 0.2, "{:?}", o2.img.get(5, 10));
assert_eq!(o2.img.get(1, 10)[3], 0.0);
let o3 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2, 0.0, env2);
assert_eq!(o2.img.data, o3.img.data);
}
#[test]
fn unmult_black_becomes_transparent() {
let mut img = Image::filled(4, 4, [0.0, 0.0, 0.0, 1.0]);
img.set(1, 1, [0.5, 0.25, 0.0, 1.0]);
let out = run_fx("ec.key.unmult", &[], img, 0.0, EffectEnv::default());
assert_eq!(out.img.get(0, 0), [0.0; 4]);
let p = out.img.get(1, 1);
assert!((p[3] - 0.5).abs() < 1e-6 && (p[0] - 0.5).abs() < 1e-6);
}
#[test]
fn wire_removal_hides_a_wire() {
let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]);
for y in 0..20 {
img.set(10, y, [0.0, 0.0, 0.0, 1.0]);
}
for style in 0..4u32 {
let out = run_fx(
"ec.key.ccsimplewireremoval",
&[("pointA", Value::Vec2([10.5, 0.0])), ("pointB", Value::Vec2([10.5, 20.0])), ("removalStyle", Value::Enum(style)), ("thickness", num(3.0))],
img.clone(),
0.0,
EffectEnv::default(),
);
assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "style {style}: {:?}", out.img.get(10, 10));
assert_eq!(out.img.get(2, 10), img.get(2, 10));
}
}
}

View File

@ -8,22 +8,36 @@
mod blur2;
mod channel;
mod channel2;
mod color2;
mod color3;
mod color_fx;
mod controls;
mod distort;
mod distort2;
mod distort3;
mod generate;
mod generate2;
mod generate3;
mod keying;
mod keying2;
mod matte;
mod misc;
mod noise;
mod noise2;
mod obsolete;
mod perspective;
mod perspective2;
mod sim;
mod sim2;
mod stylize2;
mod stylize3;
mod textfx;
mod transition;
mod transition2;
pub mod util;
mod utility;
mod utility2;
use std::collections::HashMap;
use std::sync::OnceLock;
@ -122,6 +136,45 @@ impl Buf {
}
}
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MaskShape {
pub name: String,
pub points: Vec<[f64; 2]>,
pub closed: bool,
pub inverted: bool,
}
/// Pixels of another layer referenced by a layer parameter: its buffer (layer space, see
/// [`Buf`]) and its source size in layer pixels.
#[derive(Clone, Debug, Default)]
pub struct LayerPixels {
pub buf: Buf,
pub size: [f64; 2],
}
/// Services the renderer offers effects that look beyond their own pixels.
pub trait EffectHost: Sync {
/// Layer `id` of the same composition at the current comp time. `masks_and_effects`
/// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references.
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels>;
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>>;
}
/// Extra context the renderer may supply (all optional; `Default` is "nothing known").
#[derive(Clone, Copy, Default)]
pub struct EffectEnv<'a> {
/// The layer's masks (enabled ones, top to bottom).
pub masks: &'a [MaskShape],
pub host: Option<&'a dyn EffectHost>,
/// Composition time in seconds.
pub comp_time: f64,
/// Composition frame rate (0 when unknown).
pub frame_rate: f64,
}
/// What an effect gets to render with.
pub struct EffectCtx<'a> {
pub params: &'a Params,
@ -133,6 +186,20 @@ pub struct EffectCtx<'a> {
pub seed: u32,
/// Rendering for an adjustment layer (operating on the comp below).
pub adjustment: bool,
/// Masks, other layers, audio, comp timing.
pub env: EffectEnv<'a>,
}
impl EffectCtx<'_> {
/// The layer chosen in layer parameter `id`, via the host.
pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option<LayerPixels> {
let lid = self.params.get(id)?.as_layer()?;
self.env.host?.layer(lid, masks_and_effects)
}
/// Frame rate for frame-based maths (comp rate, else 30).
pub fn fps(&self) -> f64 {
if self.env.frame_rate > 0.0 { self.env.frame_rate } else { 30.0 }
}
}
pub type RenderFn = fn(&EffectCtx, Buf) -> Buf;
@ -187,6 +254,20 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(perspective::specs());
v.extend(transition::specs());
v.extend(utility::specs());
v.extend(sim::specs());
v.extend(sim2::specs());
v.extend(distort3::specs());
v.extend(perspective2::specs());
v.extend(utility2::specs());
v.extend(generate3::specs());
v.extend(textfx::specs());
v.extend(stylize3::specs());
v.extend(transition2::specs());
v.extend(channel2::specs());
v.extend(keying2::specs());
v.extend(noise2::specs());
v.extend(color3::specs());
v.extend(obsolete::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
v
})
@ -235,11 +316,16 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.noise.addgrain",
"ec.noise.noisealpha",
"ec.noise.noisehlsauto",
"ec.obsolete.lightning",
"ec.text.timecode",
"ec.text.numbers",
];
/// See [`TIME_DEPENDENT`].
pub fn is_time_dependent(id: &str) -> bool {
TIME_DEPENDENT.contains(&id)
// Simulations replay from layer time 0, so every frame differs even when the defaults
// happen to look static at the test's two sample times.
id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id)
}
/// Run an effect on a buffer.
@ -247,10 +333,39 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
(spec.render)(ctx, buf)
}
/// Test helper: run effect `id` on `img` (scale 1, layer size = image size) with its defaults
/// overridden by `vals`, at layer time `time`, with an optional environment.
#[cfg(test)]
pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf {
let s = find(id).unwrap_or_else(|| panic!("no effect {id}"));
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in vals {
assert!(params.values.contains_key(*k), "{id}: unknown param {k}");
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_fx_passthrough() {
let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]);
let out = run_fx("ec.control.slider", &[("slider", num(3.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(out.img.data, img.data);
}
#[test]
#[ignore]
fn dump_registry() {
for s in registry() {
println!("{} | {} | {}", s.category, s.name, s.id);
}
}
#[test]
fn registry_is_consistent() {
let r = registry();
@ -279,7 +394,7 @@ mod tests {
}
for s in registry() {
let params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false, env: Default::default() };
let out = apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 });
assert!(!out.img.is_empty(), "{}", s.id);
assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id);
@ -298,6 +413,7 @@ mod tests {
img.set(x, y, [((x * 7 + y * 3) % 11) as f32 / 11.0 * a, y as f32 / 30.0 * a, 0.4 * a, a]);
}
}
let mut missing = vec![];
for s in registry() {
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in params.values.iter_mut() {
@ -306,13 +422,14 @@ mod tests {
}
}
let run = |t: f64| {
let ctx = EffectCtx { params: &params, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false };
let ctx = EffectCtx { params: &params, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false, env: Default::default() };
apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
};
let (a, b) = (run(0.0), run(1.37));
if a != b {
assert!(is_time_dependent(s.id), "{} depends on time but is not in TIME_DEPENDENT", s.id);
if a != b && !is_time_dependent(s.id) {
missing.push(s.id);
}
}
assert!(missing.is_empty(), "these depend on time but are not in TIME_DEPENDENT: {missing:?}");
}
}

View File

@ -200,7 +200,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -531,7 +531,7 @@ mod tests {
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false };
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,369 @@
//! Noise & Grain effects, batch 2: Match Grain (grain statistics measured from another layer
//! and synthesised onto this one), Median (Legacy) and Curl Noise (divergence-free flow
//! distortion from the curl of a fractal noise potential).
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::noise::{fbm, median_image};
use crate::util::{Plane, gauss_plane, layer_or_self, premul, smoothstep, unpremul};
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: "Noise & Grain", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
// ---------------------------------------------------------------- Match Grain
/// Grain statistics of an image: per-channel standard deviation of the high-pass residual
/// (over opaque pixels) and a grain size from the lag-1 autocorrelation.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct GrainStats {
pub std: [f32; 3],
pub size: f32,
}
pub(crate) fn grain_stats(img: &Image) -> GrainStats {
let (w, h) = (img.width as usize, img.height as usize);
if w < 3 || h < 3 {
return GrainStats::default();
}
let straight: Vec<([f32; 3], f32)> = img.data.par_iter().map(|&p| unpremul(p)).collect();
let mut std = [0.0f32; 3];
let mut rho_num = 0.0f64;
let mut rho_den = 0.0f64;
for (k, s) in std.iter_mut().enumerate() {
let pl = Plane { w, h, data: straight.iter().map(|(c, _)| c[k]).collect() };
let low = gauss_plane(&pl, 1.5, 1.5);
let hp: Vec<f32> = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect();
let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64);
for (i, v) in hp.iter().enumerate() {
if straight[i].1 > 0.5 {
sum += *v as f64;
sq += (*v as f64) * (*v as f64);
n += 1.0;
}
}
if n > 1.0 {
let mean = sum / n;
*s = ((sq / n - mean * mean).max(0.0)).sqrt() as f32;
}
for y in 0..h {
for x in 0..w - 1 {
let i = y * w + x;
if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 {
rho_num += (hp[i] * hp[i + 1]) as f64;
rho_den += (hp[i] * hp[i]) as f64;
}
}
}
}
let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 };
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
}
fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() {
return b;
}
let view = ctx.params.e("viewingMode");
let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false);
let src_stats = grain_stats(&source);
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() };
let intensity = ctx.params.f("intensity") as f32;
let chan = [ctx.params.f("redIntensity") as f32, ctx.params.f("greenIntensity") as f32, ctx.params.f("blueIntensity") as f32];
let amp: [f32; 3] = [0, 1, 2].map(|k| {
let s = src_stats.std[k];
let e = own.std[k] * comp;
(s * s - e * e).max(0.0).sqrt() * intensity * chan[k]
});
let size = (src_stats.size.max(0.5) * ctx.params.f("size") as f32 * b.scale as f32).max(0.05);
let aspect = ctx.params.f("aspectRatio").max(0.05) as f32;
let soft = ctx.params.f("softness") * b.scale;
let mono = ctx.params.b("monochromatic");
let sat = ctx.params.f("saturation") as f32;
let blend_mode = ctx.params.e("blendingMode");
let (ws, wm, wh) = (ctx.params.f("shadows") as f32, ctx.params.f("midtones") as f32, ctx.params.f("highlights") as f32);
let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37);
let ft = ctx.time * 24.0 * ctx.params.f("animationSpeed");
let frame = if ctx.params.b("animateSmoothly") { ft as f32 } else { ft.floor() as f32 };
let (w, h) = (b.img.width as usize, b.img.height as usize);
let nch = if mono { 1 } else { 3 };
let mut planes: Vec<Plane> = (0..nch)
.map(|k| {
let mut pl = Plane::new(w, h);
pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
*v = value_noise(x as f32 / size, y as f32 / (size * aspect), frame, seed.wrapping_add(k as u32 * 131)) - 0.5;
}
});
pl
})
.collect();
if soft > 0.05 {
planes = planes.iter().map(|pl| gauss_plane(pl, soft, soft * aspect as f64)).collect();
}
// Normalise each synthetic plane to unit standard deviation so the measured stats transfer.
for pl in planes.iter_mut() {
let n = pl.data.len().max(1) as f64;
let mean = pl.data.iter().map(|&v| v as f64).sum::<f64>() / n;
let var = pl.data.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / n;
let k = if var > 1e-12 { 1.0 / var.sqrt() } else { 0.0 };
*pl = pl.map(|v| ((v as f64 - mean) * k) as f32);
}
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, a) = unpremul(*px);
let mut g = if mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] };
let gm = (g[0] + g[1] + g[2]) / 3.0;
g = g.map(|v| gm + (v - gm) * sat);
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let s = 1.0 - smoothstep(0.0, 0.5, l);
let hi = smoothstep(0.5, 1.0, l);
let weight = ws * s + wm * (1.0 - s - hi) + wh * hi;
match view {
1 => {
let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0));
*px = [o[0], o[1], o[2], 1.0];
}
2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0],
_ => {
if a <= 0.0 {
return;
}
let o = [0, 1, 2].map(|k| {
let n = g[k] * amp[k] * weight;
let v = match blend_mode {
1 => c[k] * (1.0 + n * 2.0),
2 => 1.0 - (1.0 - c[k]) * (1.0 - n * 2.0),
_ => c[k] + n,
};
v.max(0.0)
});
*px = premul(o, a);
}
}
});
b
}
// ---------------------------------------------------------------- Median (Legacy)
fn median_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
if r == 0 {
return b;
}
if ctx.params.b("operateOnAlphaChannel") {
b.img = median_image(&b.img, r);
return b;
}
// Colour only: median of straight colour, alpha kept.
let mut straight = b.img.clone();
straight.data.par_iter_mut().for_each(|px| {
let (c, _) = unpremul(*px);
*px = [c[0], c[1], c[2], 1.0];
});
let m = median_image(&straight, r);
b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, q)| {
*px = premul([q[0], q[1], q[2]], px[3]);
});
b
}
// ---------------------------------------------------------------- Curl Noise
fn curl_noise(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amount = ctx.params.f("displacementAmount") * b.scale;
let view = ctx.params.e("view");
if amount.abs() < 1e-6 && view == 0 {
return b;
}
let scale = (ctx.params.f("scale").max(1.0) * b.scale) as f32;
let complexity = ctx.params.f("complexity") as f32;
let rot = ctx.params.f("rotation").to_radians() as f32;
let off = ctx.params.v2("offset");
let (ox, oy) = ((off[0] * b.scale + b.offset[0]) as f32, (off[1] * b.scale + b.offset[1]) as f32);
let evo = (ctx.params.f("evolution") / 360.0) as f32;
let seed = ctx.params.f("randomSeed") as i64 as u32 ^ 0x51_7cc1;
let steps = ctx.params.f("steps").clamp(1.0, 16.0) as usize;
let edge_wrap = ctx.params.e("edgeBehavior") == 1;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let (cr, sr) = (rot.cos(), rot.sin());
// Potential field on the pixel grid.
let mut pot = Plane::new(w, h);
pot.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f32 + 0.5 - ox, y as f32 + 0.5 - oy);
let u = (dx * cr + dy * sr) / scale;
let vv = (-dx * sr + dy * cr) / scale;
*v = fbm(u, vv, evo * 4.0, seed, complexity);
}
});
// Curl: v = (dψ/dy, -dψ/dx), normalised so the typical speed is ~1 px per unit amount.
let mut vx = Plane::new(w, h);
let mut vy = Plane::new(w, h);
vx.data.par_chunks_mut(w.max(1)).zip(vy.data.par_chunks_mut(w.max(1))).enumerate().for_each(|(y, (rx, ry))| {
for x in 0..w {
let (xi, yi) = (x as i64, y as i64);
let gx = (pot.get_clamped(xi + 1, yi) - pot.get_clamped(xi - 1, yi)) * 0.5;
let gy = (pot.get_clamped(xi, yi + 1) - pot.get_clamped(xi, yi - 1)) * 0.5;
rx[x] = gy * scale;
ry[x] = -gx * scale;
}
});
if view == 1 {
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (a, c) = (vx.data[i], vy.data[i]);
*px = [(0.5 + a * 0.5).clamp(0.0, 1.0), (0.5 + c * 0.5).clamp(0.0, 1.0), 0.5, 1.0];
});
return b;
}
let src = b.img.clone();
let dt = amount as f32 / steps as f32;
let (wf, hf) = (w as f64, h as f64);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (mut sx, mut sy) = (x as f64 + 0.5, y as f64 + 0.5);
for _ in 0..steps {
// Trace the streamline backwards (semi-Lagrangian advection).
let (u, v) = (vx.sample(sx, sy), vy.sample(sx, sy));
sx -= (u * dt) as f64;
sy -= (v * dt) as f64;
}
*px = if edge_wrap { src.sample_bilinear_clamped(sx.rem_euclid(wf), sy.rem_euclid(hf)) } else { src.sample_bilinear_clamped(sx, sy) };
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let mult = || slider(0.0, 10.0, 0.0, 2.0, 2);
vec![
spec(
"ec.noise.matchgrain",
"Match Grain",
vec![
p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])),
p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer),
p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()),
p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)),
p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("aspectRatio", "Aspect Ratio", num(1.0), slider(0.05, 20.0, 0.25, 4.0, 2)),
p("redIntensity", "Red Intensity", num(1.0), mult()),
p("greenIntensity", "Green Intensity", num(1.0), mult()),
p("blueIntensity", "Blue Intensity", num(1.0), mult()),
p("monochromatic", "Monochromatic", Value::Bool(false), ParamUi::Checkbox),
p("saturation", "Saturation", num(1.0), mult()),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Multiply", "Screen"])),
p("shadows", "Shadows", num(1.0), mult()),
p("midtones", "Midtones", num(1.0), mult()),
p("highlights", "Highlights", num(1.0), mult()),
p("animationSpeed", "Animation Speed", num(1.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("animateSmoothly", "Animate Smoothly", Value::Bool(true), ParamUi::Checkbox),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
],
match_grain,
),
spec(
"ec.noise.medianlegacy",
"Median (Legacy)",
vec![
p("radius", "Radius", num(1.0), slider(0.0, 255.0, 0.0, 20.0, 0)),
p("operateOnAlphaChannel", "Operate On Alpha Channel", Value::Bool(false), ParamUi::Checkbox),
],
median_legacy,
),
spec(
"ec.noise.curlnoise",
"Curl Noise",
vec![
p("view", "View", Value::Enum(0), popup(&["Final Output", "Flow Field"])),
p("displacementAmount", "Displacement Amount", num(20.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("offset", "Offset Turbulence", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("complexity", "Complexity", num(3.0), slider(1.0, 20.0, 1.0, 10.0, 1)),
p("evolution", "Evolution", num(0.0), ParamUi::Angle),
p("steps", "Flow Steps", num(4.0), slider(1.0, 16.0, 1.0, 16.0, 0)),
p("edgeBehavior", "Edge Behavior", Value::Enum(0), popup(&["Clamp", "Wrap"])),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
],
curl_noise,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel2::tests::FakeHost;
use crate::{EffectEnv, run_fx};
fn noisy(w: u32, h: u32, amp: f32, seed: u32) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let n = (crate::util::hash1(x, y, seed) - 0.5) * 2.0 * amp;
img.set(x, y, [0.5 + n, 0.5 + n, 0.5 + n, 1.0]);
}
}
img
}
#[test]
fn match_grain_transfers_grain_strength() {
let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]);
let none = run_fx("ec.noise.matchgrain", &[], flat.clone(), 0.0, EffectEnv::default());
assert_eq!(none.img.data, flat.data);
let host = FakeHost(noisy(48, 48, 0.1, 3));
let env = EffectEnv { host: Some(&host), ..Default::default() };
let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat.clone(), 0.5, env);
let target = grain_stats(&host.0).std[0];
let got = grain_stats(&out.img).std[0];
assert!(got > target * 0.4 && got < target * 2.5, "target {target} got {got}");
let again = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat, 0.5, env);
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn median_legacy_radius_zero_identity_and_removes_speck() {
let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]);
img.set(4, 4, [1.0, 1.0, 1.0, 1.0]);
let id = run_fx("ec.noise.medianlegacy", &[("radius", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(id.img.data, img.data);
let out = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img.clone(), 0.0, EffectEnv::default());
assert!((out.img.get(4, 4)[0] - 0.2).abs() < 0.01);
let again = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img, 0.0, EffectEnv::default());
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn curl_noise_zero_is_identity_and_flow_preserves_flat() {
let img = crate::channel2::tests::sample();
let id = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(id.img.data, img.data);
let flat = Image::filled(16, 16, [0.3, 0.6, 0.9, 1.0]);
let out = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], flat.clone(), 0.0, EffectEnv::default());
assert!(out.img.data.iter().all(|p| (p[1] - 0.6).abs() < 1e-5));
let mut grad = Image::new(32, 32);
for y in 0..32 {
for x in 0..32 {
grad.set(x, y, [x as f32 / 32.0, y as f32 / 32.0, 0.0, 1.0]);
}
}
let a = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default());
let b = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default());
assert_eq!(a.img.data, b.img.data);
assert!(a.img.data.iter().zip(grad.data.iter()).any(|(p, q)| (p[0] - q[0]).abs() > 0.01));
}
}

View File

@ -0,0 +1,184 @@
//! Obsolete-category effects: Basic 3D (tilt/swivel the layer in a simple perspective space
//! with an optional specular highlight) and Gaussian Blur (Legacy).
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::gaussian_blur;
use rayon::prelude::*;
use crate::util::layer_rect;
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: "Obsolete", params, render, gpu: false, float: true }
}
// ---------------------------------------------------------------- Basic 3D
fn basic_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
let swivel = ctx.params.f("swivel").to_radians();
let tilt = ctx.params.f("tilt").to_radians();
let dist = ctx.params.f("distanceToImage") * b.scale;
let spec_on = ctx.params.b("specularHighlight");
let wire = ctx.params.b("drawPreviewWireframe");
if swivel == 0.0 && tilt == 0.0 && dist == 0.0 && !spec_on && !wire {
return b;
}
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5);
// Eye on the -z axis at a focal distance comparable to a 50 mm lens.
let f = lw.max(lh).max(1.0) * 1.4;
// Plane basis after Y (swivel) then X (tilt) rotations.
let (cs, ss) = (swivel.cos(), swivel.sin());
let (ct, st) = (tilt.cos(), tilt.sin());
let ux = [cs, 0.0, -ss];
let uy = [ss * st, ct, cs * st];
let n = [ss * ct, -st, cs * ct];
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let src = b.img.clone();
let light = {
let l = [-0.5f64, -0.6, -1.0];
let k = 1.0 / dot(l, l).sqrt();
l.map(|v| v * k)
};
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
// Ray from the eye (0, 0, -f) through the pixel on the z = 0 plane (centre-relative).
let d = [x as f64 + 0.5 - cx, y as f64 + 0.5 - cy, f];
let o = [0.0, 0.0, -f];
let denom = dot(d, n);
if denom.abs() < 1e-9 {
*px = [0.0; 4];
continue;
}
let pc = [0.0, 0.0, dist];
let t = dot([pc[0] - o[0], pc[1] - o[1], pc[2] - o[2]], n) / denom;
if t <= 0.0 {
*px = [0.0; 4];
continue;
}
let hit = [o[0] + d[0] * t - pc[0], o[1] + d[1] * t - pc[1], o[2] + d[2] * t - pc[2]];
let (u, v) = (dot(hit, ux), dot(hit, uy));
let mut c = src.sample_bilinear(cx + u, cy + v);
if spec_on && c[3] > 0.0 {
// Reflect the view direction about the normal and compare with the light.
let dl = dot(d, d).sqrt();
let dv = d.map(|v| v / dl);
let k = 2.0 * dot(dv, n);
let r = [dv[0] - k * n[0], dv[1] - k * n[1], dv[2] - k * n[2]];
let s = (-dot(r, light)).max(0.0).powi(40) as f32;
let a = c[3];
for ch in c.iter_mut().take(3) {
*ch += s * a * (1.0 - *ch / a.max(1e-6)).max(0.0);
}
}
if wire {
let e = (u.abs() - lw * 0.5).abs().min((v.abs() - lh * 0.5).abs());
if e < 0.75 && u.abs() <= lw * 0.5 + 0.75 && v.abs() <= lh * 0.5 + 0.75 {
c = [1.0, 1.0, 1.0, 1.0];
}
}
*px = c;
}
});
b
}
// ---------------------------------------------------------------- Gaussian Blur (Legacy)
fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = ctx.params.f("blurriness").max(0.0) * 0.5 * b.scale;
if s <= 0.0 {
return b;
}
let (kx, ky) = match ctx.params.e("blurDimensions") {
1 => (1.0, 0.0),
2 => (0.0, 1.0),
_ => (1.0, 1.0),
};
if !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
}
b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment);
b
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec(
"ec.obsolete.basic3d",
"Basic 3D",
vec![
p("swivel", "Swivel", num(0.0), ParamUi::Angle),
p("tilt", "Tilt", num(0.0), ParamUi::Angle),
p("distanceToImage", "Distance to Image", num(0.0), slider(-1000.0, 30000.0, -100.0, 100.0, 1)),
p("specularHighlight", "Specular Highlight", Value::Bool(false), ParamUi::Checkbox),
p("drawPreviewWireframe", "Draw Preview Wireframe", Value::Bool(false), ParamUi::Checkbox),
],
basic_3d,
),
spec(
"ec.obsolete.gaussianlegacy",
"Gaussian Blur (Legacy)",
vec![
p("blurriness", "Blurriness", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)),
p("blurDimensions", "Blur Dimensions", Value::Enum(0), popup(&["Horizontal and Vertical", "Horizontal", "Vertical"])),
],
gaussian_legacy,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
use effectcraft_raster::Image;
fn ramp() -> Image {
let mut img = Image::new(20, 12);
for y in 0..12 {
for x in 0..20 {
img.set(x, y, [x as f32 / 20.0, y as f32 / 12.0, 0.5, 1.0]);
}
}
img
}
#[test]
fn basic_3d_zero_identity_swivel_narrows_and_is_deterministic() {
let img = ramp();
let id = run_fx("ec.obsolete.basic3d", &[], img.clone(), 0.0, EffectEnv::default());
assert_eq!(id.img.data, img.data);
let flat =
run_fx("ec.obsolete.basic3d", &[("distanceToImage", num(0.0)), ("specularHighlight", Value::Bool(false))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(flat.img.data, img.data);
let sw = run_fx("ec.obsolete.basic3d", &[("swivel", num(60.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(sw.img.get(0, 6)[3], 0.0, "edges uncovered after swivel");
assert!(sw.img.get(10, 6)[3] > 0.99);
let again = run_fx("ec.obsolete.basic3d", &[("swivel", num(60.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(sw.img.data, again.img.data);
let far = run_fx("ec.obsolete.basic3d", &[("distanceToImage", num(20.0))], img, 0.0, EffectEnv::default());
assert_eq!(far.img.get(0, 0)[3], 0.0, "farther image is smaller");
}
#[test]
fn gaussian_legacy_zero_identity_and_blurs() {
let mut img = Image::new(15, 15);
img.set(7, 7, [1.0, 1.0, 1.0, 1.0]);
let id = run_fx("ec.obsolete.gaussianlegacy", &[], img.clone(), 0.0, EffectEnv::default());
assert_eq!(id.img.data, img.data);
let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(4.0))], img.clone(), 0.0, EffectEnv::default());
let total: f32 = out.img.data.iter().map(|p| p[3]).sum();
assert!((total - 1.0).abs() < 1e-3, "{total}");
let c = out.img.width / 2;
assert!(out.img.get(c as i64, c as i64)[3] < 0.5);
}
#[test]
fn moved_effects_are_obsolete() {
for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] {
assert_eq!(crate::find(id).unwrap().category, "Obsolete", "{id}");
}
}
}

View File

@ -452,7 +452,8 @@ mod tests {
params.values.insert(ps.id.to_string(), Value::Vec2([f[0] * img.width as f64, f[1] * img.height as f64]));
}
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,286 @@
//! 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", "Left Right Swap", 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));
}
}

1530
crates/effects/src/sim.rs Normal file

File diff suppressed because it is too large Load Diff

1610
crates/effects/src/sim2.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -698,7 +698,7 @@ mod tests {
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,659 @@
//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie
//! and CC Plastic.
//!
//! Written from the public descriptions of what these effects do (progressive block loading,
//! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling,
//! a colour map sampled from the image itself); the maths is our own.
use effectcraft_color::rgb_to_hsl;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::noise::fbm;
use crate::util::{Plane, gauss_plane, layer_or_self, lerp4, premul, smoothstep, unpremul};
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: "Stylize", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
// ------------------------------------------------------------------ CC Block Load
/// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`).
fn block_means(img: &Image, s: usize) -> (usize, usize, Vec<Px>) {
let (w, h) = (img.width as usize, img.height as usize);
let gw = w.div_ceil(s).max(1);
let gh = h.div_ceil(s).max(1);
let mut out = vec![[0.0f32; 4]; gw * gh];
out.par_chunks_mut(gw).enumerate().for_each(|(by, row)| {
for (bx, o) in row.iter_mut().enumerate() {
let mut acc = [0.0f32; 4];
let mut n = 0.0f32;
for y in by * s..((by + 1) * s).min(h) {
for x in bx * s..((bx + 1) * s).min(w) {
let p = img.data[y * w + x];
for c in 0..4 {
acc[c] += p[c];
}
n += 1.0;
}
}
if n > 0.0 {
*o = acc.map(|v| v / n);
}
}
});
(gw, gh, out)
}
/// Value of pixel (x, y) at block size `s` (nearest block, or bilinear between block centres).
fn block_at(g: &(usize, usize, Vec<Px>), s: usize, x: usize, y: usize, bilinear: bool) -> Px {
let (gw, gh, d) = g;
if !bilinear || s == 1 {
return d[(y / s).min(gh - 1) * gw + (x / s).min(gw - 1)];
}
let fx = (x as f32 + 0.5) / s as f32 - 0.5;
let fy = (y as f32 + 0.5) / s as f32 - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let (tx, ty) = (fx - x0, fy - y0);
let gx = |v: f32| (v as i64).clamp(0, *gw as i64 - 1) as usize;
let gy = |v: f32| (v as i64).clamp(0, *gh as i64 - 1) as usize;
let a = d[gy(y0) * gw + gx(x0)];
let b = d[gy(y0) * gw + gx(x0 + 1.0)];
let c = d[gy(y0 + 1.0) * gw + gx(x0)];
let e = d[gy(y0 + 1.0) * gw + gx(x0 + 1.0)];
lerp4(lerp4(a, b, tx), lerp4(c, e, tx), ty)
}
fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if c >= 1.0 {
return b;
}
let scan = ctx.params.b("scanlines");
let smooth = ctx.params.b("smoothing");
let cleared = ctx.params.b("startCleared");
let bilinear = ctx.params.b("bilinear");
let (w, h) = (b.img.width as usize, b.img.height as usize);
let nlev = (w.max(h).max(2) as f64).log2().ceil() as u32;
// Levels 0..=nlev with block sizes 2^(nlev-k); level k is "loading" while p is in [k, k+1).
let p = c * (nlev + 1) as f64;
let done = p.floor() as u32;
let frac = (p - done as f64) as f32;
let size = |k: u32| 1usize << (nlev - k.min(nlev));
let s_new = size(done);
let new = block_means(&b.img, s_new);
let old = if done > 0 { Some((size(done - 1), block_means(&b.img, size(done - 1)))) } else { None };
let rows_new = h.div_ceil(s_new) as f32;
let soft = if smooth { 1.5f32 } else { 0.0 };
let mut out = Image::new(w as u32, h as u32);
out.rows_mut().for_each(|(y, row)| {
// Fraction of the new level present on this row.
let t = if scan {
let front = frac * (rows_new + soft) - soft * 0.5;
let r = (y / s_new) as f32 + 0.5;
if soft > 0.0 {
smoothstep(r - soft * 0.5, r + soft * 0.5, front)
} else if r < front {
1.0
} else {
0.0
}
} else if smooth {
frac
} else {
0.0
};
for (x, o) in row.iter_mut().enumerate() {
let n = if t > 0.0 { block_at(&new, s_new, x, y, bilinear) } else { [0.0; 4] };
let prev = match &old {
Some((s, g)) => block_at(g, *s, x, y, bilinear),
None if cleared => [0.0; 4],
None => block_at(&new, s_new, x, y, bilinear),
};
*o = if t >= 1.0 {
n
} else if t <= 0.0 {
prev
} else {
lerp4(prev, n, t)
};
}
});
b.img = out;
b
}
// ------------------------------------------------------------------ CC Burn Film
fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32;
if burn <= 0.0 {
return b;
}
let (cx, cy) = b.to_px(ctx.params.v2("center"));
let seed = ctx.params.f("randomSeed").max(0.0) as u32;
let s = b.scale;
let diag = (ctx.layer_size[0].hypot(ctx.layer_size[1]) * s).max(1.0);
let feat = (60.0 * s).max(1e-3);
let th = 1.0 - burn * 1.25;
let band = 0.12 * (burn * 10.0).min(1.0);
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 d = ((fx - cx).hypot(fy - cy) / diag) as f32;
let n = fbm((fx / feat) as f32, (fy / feat) as f32, 0.0, seed.wrapping_add(17), 4.0);
// High near the centre and where the noise is high; burning eats from the top down.
let f = n * 0.65 + (1.0 - d.min(1.0)) * 0.35;
if f > th + band {
*px = [0.0; 4];
continue;
}
if f > th - band * 2.0 {
let (c, a) = unpremul(*px);
// Charred rim (scorched dark) with a hot orange edge right next to the hole.
let k = smoothstep(th - band * 2.0, th, f);
let hot = smoothstep(th, th + band, f);
let char = 1.0 - k * 0.85;
let mut o = [c[0] * char, c[1] * char, c[2] * char];
o = [o[0] + (1.0 - o[0]) * hot * 0.9, o[1] + (0.45 - o[1]) * hot * 0.9, o[2] * (1.0 - hot)];
let na = a * (1.0 - hot * 0.6);
*px = premul(o, na);
}
}
});
b
}
// ------------------------------------------------------------------ bump-map surfaces (Glass, Plastic)
const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"];
/// Height field (0..1) from the bump-map layer (or the layer itself), softened.
fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane {
let src = layer_or_self(ctx, b, "bumpMap", true, false);
let prop = ctx.params.e("property");
let mut pl = Plane::from_image(&src, |p| {
let (c, a) = unpremul(p);
let v = match prop {
0 => c[0],
1 => c[1],
2 => c[2],
3 => return a,
4 => effectcraft_color::luminance(c[0], c[1], c[2]),
_ => rgb_to_hsl(c[0], c[1], c[2]).2,
};
v * a
});
let soft = ctx.params.f("softness") * b.scale * 0.5;
if soft > 0.05 {
pl = gauss_plane(&pl, soft, soft);
}
pl
}
struct Light {
intensity: f32,
color: [f32; 3],
point: bool,
pos: (f64, f64),
/// Distant light direction (towards the light).
dir: [f32; 3],
height: f32,
ambient: f32,
diffuse: f32,
specular: f32,
shininess: f32,
metal: f32,
}
impl Light {
fn from(ctx: &EffectCtx, b: &Buf) -> Light {
let pr = &ctx.params;
let elev = (pr.f("lightHeight").clamp(-100.0, 100.0) / 100.0 * 90.0).to_radians();
let az = pr.f("lightDirection").to_radians();
let dir = [(az.sin() * elev.cos()) as f32, (-az.cos() * elev.cos()) as f32, elev.sin() as f32];
let c = pr.color("lightColor");
let rough = pr.f("roughness").clamp(0.001, 1.0) as f32;
Light {
intensity: (pr.f("lightIntensity") / 100.0) as f32,
color: [c[0], c[1], c[2]],
point: pr.e("lightType") == 1,
pos: b.to_px(pr.v2("lightPosition")),
dir,
height: (pr.f("lightHeight") / 100.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale * 0.5) as f32,
ambient: (pr.f("ambient") / 100.0) as f32,
diffuse: (pr.f("diffuse") / 100.0) as f32,
specular: (pr.f("specular") / 100.0) as f32,
shininess: 1.0 / rough,
metal: (pr.f("metal") / 100.0) as f32,
}
}
fn to_light(&self, x: f64, y: f64) -> [f32; 3] {
if self.point {
let v = [(self.pos.0 - x) as f32, (self.pos.1 - y) as f32, self.height.max(1.0)];
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
[v[0] / l, v[1] / l, v[2] / l]
} else {
self.dir
}
}
/// Lit straight colour for surface normal `n`. Diffuse is normalised so that a flat surface
/// keeps its colour; highlights add on top.
fn shade(&self, c: [f32; 3], n: [f32; 3], l: [f32; 3]) -> [f32; 3] {
let ndl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0);
let flat = l[2].max(0.0);
let base = self.ambient + self.diffuse * flat;
let lit = if base > 1e-4 { (self.ambient + self.diffuse * ndl) / base } else { 1.0 };
let lit = 1.0 + (lit - 1.0) * self.intensity;
// Blinn half vector with the viewer straight above.
let hv = [l[0], l[1], l[2] + 1.0];
let hl = (hv[0] * hv[0] + hv[1] * hv[1] + hv[2] * hv[2]).sqrt().max(1e-6);
let ndh = ((n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]) / hl).max(0.0);
let spec = self.specular * self.intensity * ndh.powf(self.shininess);
let mut o = [0.0f32; 3];
for i in 0..3 {
let hl_col = self.color[i] * (1.0 - self.metal) + c[i] * self.color[i] * self.metal;
o[i] = c[i] * lit * (1.0 + (self.color[i] - 1.0) * self.intensity.min(1.0)) + spec * hl_col;
}
o
}
}
/// Surface normal from a height field at pixel (x, y) with height scale `k`.
#[inline]
fn normal(h: &Plane, x: usize, y: usize, k: f32) -> [f32; 3] {
let (xi, yi) = (x as i64, y as i64);
let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) * 0.5 * k;
let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) * 0.5 * k;
let l = (gx * gx + gy * gy + 1.0).sqrt();
[-gx / l, -gy / l, 1.0 / l]
}
fn glass(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hf = height_field(ctx, &b);
let height = ctx.params.f("height") as f32 / 100.0;
let k = height * 25.0;
let disp = ctx.params.f("displacement") * b.scale * 0.25;
let light = Light::from(ctx, &b);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let n = normal(&hf, x, y, k);
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let s = if disp != 0.0 && height != 0.0 {
src.sample_bilinear_clamped(fx + n[0] as f64 * disp, fy + n[1] as f64 * disp)
} else {
src.data[y * src.width as usize + x]
};
let a = src.data[y * src.width as usize + x][3];
if a <= 0.0 {
*px = [0.0; 4];
continue;
}
let (c, sa) = unpremul(s);
let c = if sa > 0.0 { c } else { unpremul(src.data[y * src.width as usize + x]).0 };
let o = light.shade(c, n, light.to_light(fx, fy));
*px = premul(o, a);
}
});
b
}
fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let mut hf = height_field(ctx, &b);
let lo = (ctx.params.f("cutMin") / 100.0) as f32;
let hi = (ctx.params.f("cutMax") / 100.0) as f32;
if lo > 0.0 || hi < 1.0 {
hf = hf.map(|v| v.clamp(lo.min(hi), hi.max(lo)));
}
let k = ctx.params.f("height") as f32 / 100.0 * 25.0;
let light = Light::from(ctx, &b);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
if px[3] <= 0.0 {
continue;
}
let (c, a) = unpremul(*px);
let n = normal(&hf, x, y, k);
let o = light.shade(c, n, light.to_light(x as f64 + 0.5, y as f64 + 0.5));
*px = premul(o, a);
}
});
b
}
// ------------------------------------------------------------------ CC HexTile
/// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre
/// and integer cell coordinates.
fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) {
let q = (3f64.sqrt() / 3.0 * x - y / 3.0) / r;
let rr = (2.0 / 3.0 * y) / r;
// Cube rounding.
let (cx, cz) = (q, rr);
let cy = -cx - cz;
let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round());
let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs());
if dx > dy && dx > dz {
rx = -ry - rz;
} else if dy <= dz {
rz = -rx - ry;
}
let px = r * 3f64.sqrt() * (rx + rz / 2.0);
let py = r * 1.5 * rz;
((px, py), (rx as i64, rz as i64))
}
fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).max(0.5);
let (cx, cy) = b.to_px(ctx.params.v2("center"));
let rot = ctx.params.f("rotate").to_radians();
let (sn, cs) = rot.sin_cos();
let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0);
let mode = ctx.params.e("render");
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
// Into the rotated tiling frame.
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
let ((hx, hy), (qi, ri)) = hex_cell(u, v, r);
let (mut lu, lv) = (u - hx, v - hy);
let odd = (qi + ri).rem_euclid(2) == 1;
match mode {
1 if odd => lu = -lu,
2 if odd => lu = -lu,
_ => {}
}
let lv = if mode == 2 && odd { -lv } else { lv };
// Every tile shows the hexagon around the centre point.
let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs);
let (tx, ty) = (cx + ox, cy + oy);
let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear);
*px = src.sample_bilinear_clamped(sx, sy);
}
});
b
}
// ------------------------------------------------------------------ CC Mr. Smoothie
const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"];
fn smoothie(ctx: &EffectCtx, mut b: Buf) -> Buf {
let flow = layer_or_self(ctx, &b, "flowLayer", true, false);
let prop = ctx.params.e("property");
let mut pl = Plane::from_image(&flow, |p| {
let (c, a) = unpremul(p);
match prop {
0 => c[0],
1 => c[1],
2 => c[2],
3 => a,
4 => effectcraft_color::luminance(c[0], c[1], c[2]),
5 => rgb_to_hsl(c[0], c[1], c[2]).2,
6 => rgb_to_hsl(c[0], c[1], c[2]).0,
_ => rgb_to_hsl(c[0], c[1], c[2]).1,
}
});
let sm = ctx.params.f("smoothness") * b.scale * 0.5;
if sm > 0.05 {
pl = gauss_plane(&pl, sm, sm);
}
let (ax, ay) = b.to_px(ctx.params.v2("sampleA"));
let (bx, by) = b.to_px(ctx.params.v2("sampleB"));
// Colour palette sampled from the image along A→B.
const N: usize = 256;
let pal: Vec<Px> = (0..N)
.map(|i| {
let t = i as f64 / (N - 1) as f64;
let p = b.img.sample_bilinear_clamped(ax + (bx - ax) * t, ay + (by - ay) * t);
let (c, _) = unpremul(p);
[c[0], c[1], c[2], 1.0]
})
.collect();
let phase = (ctx.params.f("phase") / 360.0) as f32;
let loops = (ctx.params.e("colorLoop") + 1) as f32;
b.img.data.par_iter_mut().zip(pl.data.par_iter()).for_each(|(px, &v)| {
if px[3] <= 0.0 {
return;
}
let t = (v * loops + phase).rem_euclid(1.0);
// Ping-pong so the palette loops without seams.
let t = if t < 0.5 { t * 2.0 } else { 2.0 - t * 2.0 };
let f = t * (N - 1) as f32;
let i = (f as usize).min(N - 2);
let c = lerp4(pal[i], pal[i + 1], f - i as f32);
*px = premul([c[0], c[1], c[2]], px[3]);
});
b
}
// ------------------------------------------------------------------ specs
fn light_params() -> Vec<crate::ParamSpec> {
vec![
p("using", "Using", Value::Enum(0), popup(&["Effect Light", "AE Lights"])),
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])),
p("lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point),
p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
]
}
fn shading_params(ambient: f64, diffuse: f64, specular: f64, roughness: f64, metal: f64) -> Vec<crate::ParamSpec> {
vec![
p("ambient", "Ambient", num(ambient), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("diffuse", "Diffuse", num(diffuse), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("specular", "Specular", num(specular), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("roughness", "Roughness", num(roughness), slider(0.001, 0.5, 0.001, 0.5, 3)),
p("metal", "Metal", num(metal), pct()),
]
}
pub fn specs() -> Vec<EffectSpec> {
let mut glass_p = vec![
p("bumpMap", "Bump Map", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)),
p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("height", "Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("displacement", "Displacement", num(100.0), slider(-500.0, 500.0, -200.0, 200.0, 1)),
];
glass_p.extend(light_params());
glass_p.extend(shading_params(75.0, 32.0, 26.0, 0.05, 100.0));
let mut plastic_p = vec![
p("bumpLayer", "Bump Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)),
p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("height", "Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("cutMin", "Cut Min", num(0.0), pct()),
p("cutMax", "Cut Max", num(100.0), pct()),
];
plastic_p.extend(light_params());
plastic_p.extend(shading_params(100.0, 70.0, 50.0, 0.05, 50.0));
vec![
spec(
"ec.stylize.ccblockload",
"CC Block Load",
vec![
p("completion", "Completion", num(0.0), pct()),
p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox),
p("smoothing", "Smoothing", Value::Bool(true), ParamUi::Checkbox),
p("startCleared", "Start Cleared", Value::Bool(true), ParamUi::Checkbox),
p("bilinear", "Bilinear", Value::Bool(false), ParamUi::Checkbox),
],
block_load,
),
spec(
"ec.stylize.ccburnfilm",
"CC Burn Film",
vec![
p("burn", "Burn", num(0.0), pct()),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
],
burn_film,
),
spec("ec.stylize.ccglass", "CC Glass", glass_p, glass),
spec(
"ec.stylize.cchextile",
"CC HexTile",
vec![
p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])),
p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("rotate", "Rotate", num(0.0), ParamUi::Angle),
p("smearing", "Smearing", num(0.0), pct()),
],
hextile,
),
spec(
"ec.stylize.ccmrsmoothie",
"CC Mr. Smoothie",
vec![
p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)),
p("smoothness", "Smoothness", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("sampleA", "Sample A", pt(0.25, 0.5), ParamUi::Point),
p("sampleB", "Sample B", pt(0.75, 0.5), ParamUi::Point),
p("phase", "Phase", num(0.0), ParamUi::Angle),
p("colorLoop", "Color Loop", Value::Enum(0), popup(&["1x", "2x", "3x", "4x"])),
],
smoothie,
),
spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
fn ramp(w: u32, h: u32) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]);
}
}
img
}
fn bumpy(w: u32, h: u32) -> Image {
let mut img = ramp(w, h);
for y in 0..h {
for x in 0..w {
let a = if (x as i32 - 20).pow(2) + (y as i32 - 16).pow(2) < 64 { 1.0 } else { 0.5 };
let p = img.data[(y * w + x) as usize];
img.set(x, y, [p[0] * a, p[1] * a, p[2] * a, a]);
}
}
img
}
fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf {
run_fx(id, vals, img, 0.0, EffectEnv::default())
}
fn max_diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
const ALL: [&str; 6] =
["ec.stylize.ccblockload", "ec.stylize.ccburnfilm", "ec.stylize.ccglass", "ec.stylize.cchextile", "ec.stylize.ccmrsmoothie", "ec.stylize.ccplastic"];
#[test]
fn all_deterministic() {
for id in ALL {
let vals: &[(&str, Value)] = match id {
"ec.stylize.ccblockload" => &[("completion", num(40.0))],
"ec.stylize.ccburnfilm" => &[("burn", num(50.0))],
_ => &[],
};
let a = run(id, vals, bumpy(40, 32));
let b = run(id, vals, bumpy(40, 32));
assert_eq!(a.img.data, b.img.data, "{id}");
}
}
#[test]
fn block_load_complete_is_identity_and_start_cleared() {
let img = ramp(40, 30);
assert_eq!(run("ec.stylize.ccblockload", &[("completion", num(100.0))], img.clone()).img.data, img.data);
let o = run("ec.stylize.ccblockload", &[("completion", num(0.0))], img.clone());
assert!(o.img.data.iter().all(|p| p[3] == 0.0));
// Midway: blocky (fewer distinct colours than the source).
let o = run("ec.stylize.ccblockload", &[("completion", num(50.0)), ("smoothing", Value::Bool(false))], img.clone());
let mut cols: Vec<u32> = o.img.data.iter().map(|p| (p[0] * 1000.0) as u32).collect();
cols.sort();
cols.dedup();
assert!(cols.len() < 40, "{}", cols.len());
}
#[test]
fn burn_film_zero_identity_full_burns() {
let img = ramp(40, 30);
assert_eq!(run("ec.stylize.ccburnfilm", &[], img.clone()).img.data, img.data);
let o = run("ec.stylize.ccburnfilm", &[("burn", num(100.0))], img);
assert!(o.img.data.iter().all(|p| p[3] == 0.0));
}
#[test]
fn glass_flat_without_highlight_is_identity() {
let img = bumpy(40, 32);
let o = run("ec.stylize.ccglass", &[("height", num(0.0)), ("specular", num(0.0))], img.clone());
assert!(max_diff(&o.img, &img) < 1e-4, "{}", max_diff(&o.img, &img));
let o = run("ec.stylize.ccglass", &[], img.clone());
assert!(max_diff(&o.img, &img) > 0.01);
}
#[test]
fn plastic_shades_bumps() {
let img = bumpy(40, 32);
let o = run("ec.stylize.ccplastic", &[("height", num(0.0)), ("specular", num(0.0))], img.clone());
assert!(max_diff(&o.img, &img) < 1e-4);
let o = run("ec.stylize.ccplastic", &[("height", num(80.0))], img.clone());
assert!(max_diff(&o.img, &img) > 0.01);
// Alpha is untouched.
assert!(o.img.data.iter().zip(&img.data).all(|(a, b)| (a[3] - b[3]).abs() < 1e-6));
}
#[test]
fn hextile_repeats_centre() {
let img = ramp(64, 64);
let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("center", pt(32.0, 32.0))], img.clone());
assert!(max_diff(&o.img, &img) > 0.1);
// Pixels at the centre are unchanged (inside the central tile).
let c = (32 * 64 + 32) as usize;
assert!((0..4).all(|i| (o.img.data[c][i] - img.data[c][i]).abs() < 1e-4));
// Full smearing restores the source.
let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("smearing", num(100.0))], img.clone());
assert!(max_diff(&o.img, &img) < 1e-4);
}
#[test]
fn smoothie_maps_to_sampled_palette() {
let img = ramp(40, 30);
let o = run("ec.stylize.ccmrsmoothie", &[("smoothness", num(0.0)), ("sampleA", pt(10.0, 15.0)), ("sampleB", pt(30.0, 15.0))], img.clone());
// Every output colour lies on the palette line between A and B (blue stays 0.3, green ≈ 0.5).
assert!(o.img.data.iter().all(|p| (p[2] - 0.3).abs() < 1e-3 && (p[1] - 0.5).abs() < 0.03));
assert!(max_diff(&o.img, &img) > 0.1);
}
}

View File

@ -0,0 +1,981 @@
//! Text effects (Numbers, Timecode) and the obsolete generators Basic Text, Path Text and
//! Lightning.
//!
//! Effects sit below the text engine in the crate layering, so text here is drawn with a small
//! built-in single-stroke font (original work: capitals, digits and common punctuation laid out
//! on a 4 × 6 unit grid; lowercase letters use the capitals) and rasterised as anti-aliased round
//! strokes.
use std::f64::consts::PI;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate3::{Seg, mask_px, poly_segs, raster_segs};
use crate::util::{Plane, gauss_plane, hash1, poly_length, poly_point_at};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category, params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
// ---------------------------------------------------------------- stroke font
/// Glyph strokes on a grid x ∈ [0, 4], y ∈ [0, 6] (y down, baseline at 6): polylines separated
/// by `;`, points by spaces. A single point is a dot.
fn glyph_src(c: char) -> &'static str {
match c.to_ascii_uppercase() {
'0' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 3.6,0.6 0.4,5.4",
'1' => "1,1 2,0 2,6; 1,6 3,6",
'2' => "0,1 1,0 3,0 4,1 4,2 0,6 4,6",
'3' => "0,0 4,0 2,2.5 3,2.5 4,3.5 4,5 3,6 1,6 0,5",
'4' => "3,6 3,0 0,4 4,4",
'5' => "4,0 0,0 0,2.5 3,2.5 4,3.5 4,5 3,6 0,6",
'6' => "3.5,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 3,2.5 0,2.5",
'7' => "0,0 4,0 1.5,6",
'8' => "1,0 3,0 4,1 4,2 3,3 1,3 0,2 0,1 1,0; 1,3 0,4 0,5 1,6 3,6 4,5 4,4 3,3",
'9' => "4,3.5 1,3.5 0,2.5 0,1 1,0 3,0 4,1 4,5 3,6 0.5,6",
'A' => "0,6 2,0 4,6; 0.7,4 3.3,4",
'B' => "0,0 0,6 3,6 4,5 4,4 3,3 0,3; 0,0 3,0 4,1 4,2 3,3",
'C' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5",
'D' => "0,0 0,6 2.5,6 4,4.5 4,1.5 2.5,0 0,0",
'E' => "4,0 0,0 0,6 4,6; 0,3 3,3",
'F' => "4,0 0,0 0,6; 0,3 3,3",
'G' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 2.5,3.5",
'H' => "0,0 0,6; 4,0 4,6; 0,3 4,3",
'I' => "1,0 3,0; 2,0 2,6; 1,6 3,6",
'J' => "4,0 4,5 3,6 1,6 0,5",
'K' => "0,0 0,6; 4,0 0,4; 1.3,3 4,6",
'L' => "0,0 0,6 4,6",
'M' => "0,6 0,0 2,3 4,0 4,6",
'N' => "0,6 0,0 4,6 4,0",
'O' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0",
'P' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3",
'Q' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 2.5,4.5 4,6.3",
'R' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3; 2,3 4,6",
'S' => "4,1 3,0 1,0 0,1 0,2 1,3 3,3 4,4 4,5 3,6 1,6 0,5",
'T' => "0,0 4,0; 2,0 2,6",
'U' => "0,0 0,5 1,6 3,6 4,5 4,0",
'V' => "0,0 2,6 4,0",
'W' => "0,0 1,6 2,2 3,6 4,0",
'X' => "0,0 4,6; 4,0 0,6",
'Y' => "0,0 2,3 4,0; 2,3 2,6",
'Z' => "0,0 4,0 0,6 4,6",
':' => "2,2; 2,5",
';' => "2,2; 2,5 1.5,6.8",
'.' => "2,6",
',' => "2,5.6 1.5,6.8",
'-' => "1,3.5 3,3.5",
'+' => "0.5,3.5 3.5,3.5; 2,2 2,5",
'/' => "4,0 0,6",
'(' => "2.5,-0.3 1.5,1.2 1.5,4.8 2.5,6.3",
')' => "1.5,-0.3 2.5,1.2 2.5,4.8 1.5,6.3",
'\'' => "2,0 2,1.5",
'"' => "1.4,0 1.4,1.5; 2.6,0 2.6,1.5",
'!' => "2,0 2,4.2; 2,6",
'?' => "0,1 1,0 3,0 4,1 4,2 2,3.5 2,4.4; 2,6",
'%' => "0,6 4,0; 0.6,0.6; 3.4,5.4",
'=' => "0.5,2.5 3.5,2.5; 0.5,4.5 3.5,4.5",
'#' => "1.3,0.5 1,5.5; 3,0.5 2.7,5.5; 0,2 4,2; 0,4 4,4",
'&' => "4,6 1,2 1,0.8 1.8,0 2.8,0.8 2.8,1.8 0,4 0,5.2 1,6 2.5,6 4,4",
'*' => "2,1 2,4; 0.7,1.8 3.3,3.2; 3.3,1.8 0.7,3.2",
'_' => "0,6.5 4,6.5",
'<' => "4,1 0,3.5 4,6",
'>' => "0,1 4,3.5 0,6",
'@' => "3,4 3,2 1.8,2 1.2,3 1.8,4 3,4 4,3.5 4,1 3,0 1,0 0,1 0,5 1,6 3.5,6",
'$' => "4,1 3,0.5 1,0.5 0,1.5 1,3 3,3 4,4.5 3,5.5 1,5.5 0,5; 2,-0.3 2,6.3",
_ => "",
}
}
fn parse_glyph(c: char) -> Vec<Vec<[f64; 2]>> {
glyph_src(c)
.split(';')
.map(|s| {
s.split_whitespace()
.filter_map(|pt| {
let (x, y) = pt.split_once(',')?;
Some([x.parse().ok()?, y.parse().ok()?])
})
.collect::<Vec<[f64; 2]>>()
})
.filter(|v| !v.is_empty())
.collect()
}
/// Advance width in grid units (glyph ink + 1.6 units of side bearing).
fn advance(c: char, proportional: bool) -> f64 {
if c == ' ' {
return 3.0;
}
if !proportional {
return 5.6;
}
let g = parse_glyph(c);
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for s in &g {
for q in s {
lo = lo.min(q[0]);
hi = hi.max(q[0]);
}
}
if lo > hi { 3.0 } else { (hi - lo) + 1.6 }
}
/// Left ink edge of a glyph (so proportional glyphs start at their ink).
fn ink_left(c: char, proportional: bool) -> f64 {
if !proportional {
return 0.8;
}
parse_glyph(c).iter().flatten().map(|q| q[0]).fold(f64::INFINITY, f64::min).min(4.0) - 0.8
}
/// Text metrics: grid unit in px for a font size (cap height ≈ 0.7 em) and stroke radius.
fn unit(size: f64) -> f64 {
size * 0.7 / 6.0
}
fn weight(size: f64) -> f64 {
(size * 0.045).max(0.35)
}
/// Width of a line of text in px (tracking in px between characters).
fn line_width(text: &str, size: f64, tracking: f64, proportional: bool) -> f64 {
let u = unit(size);
let n = text.chars().count();
if n == 0 {
return 0.0;
}
text.chars().map(|c| advance(c, proportional) * u).sum::<f64>() + tracking * (n as f64 - 1.0) - 1.6 * u
}
/// Polylines (px) of a line of text whose baseline starts at `origin`.
fn layout_line(text: &str, origin: [f64; 2], size: f64, tracking: f64, proportional: bool, out: &mut Vec<Vec<[f64; 2]>>) {
let u = unit(size);
let mut x = origin[0];
for c in text.chars() {
let left = ink_left(c, proportional);
for s in parse_glyph(c) {
out.push(s.iter().map(|q| [x + (q[0] - left - 0.8) * u, origin[1] + (q[1] - 6.0) * u]).collect());
}
x += advance(c, proportional) * u + tracking;
}
}
/// How text paints: fill/stroke display option, colours, and compositing.
struct TextLook {
display: u32,
fill: [f32; 4],
stroke: [f32; 4],
stroke_w: f64,
on_original: bool,
opacity: f32,
}
fn over(dst: Px, src: Px) -> Px {
let k = 1.0 - src[3];
[src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k]
}
fn tint(c: [f32; 4], a: f32) -> Px {
let a = (a * c[3]).clamp(0.0, 1.0);
[c[0] * a, c[1] * a, c[2] * a, a]
}
/// Draw text polylines with the given look (`r` = glyph stroke radius in px).
fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) {
let (w, h) = (b.img.width as usize, b.img.height as usize);
let segs_at = |rad: f64| {
let mut s = Vec::new();
for pl in polys {
poly_segs(pl, false, rad.max(0.05), 1.0, &mut s);
}
s
};
let fill = raster_segs(w, h, &segs_at(r), 1.0);
let sw = look.stroke_w.max(0.0);
let ring = if look.display == 0 || sw <= 0.0 {
None
} else {
let outer = raster_segs(w, h, &segs_at(r + sw * 0.5), 1.0);
let inner = if r - sw * 0.5 > 0.05 { raster_segs(w, h, &segs_at(r - sw * 0.5), 1.0) } else { Plane::new(w, h) };
Some(outer.zip_map(&inner, |a, b| (a - b).max(0.0)))
};
let op = look.opacity;
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let mut out = if look.on_original { *px } else { [0.0; 4] };
let f = fill.data[i] * op;
let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op;
match look.display {
1 => out = over(out, tint(look.stroke, s)),
2 => {
out = over(out, tint(look.stroke, s));
out = over(out, tint(look.fill, f));
}
3 => {
out = over(out, tint(look.fill, f));
out = over(out, tint(look.stroke, s));
}
_ => out = over(out, tint(look.fill, f)),
}
*px = out;
});
}
fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook {
let pr = ctx.params;
TextLook {
display: pr.e("displayOptions"),
fill: pr.color("fillColor"),
stroke: pr.color("strokeColor"),
stroke_w: pr.f("strokeWidth") * b.scale,
on_original: pr.b(on_original_id),
opacity: 1.0,
}
}
// ---------------------------------------------------------------- Numbers
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday).
fn date_from_days(days: i64) -> (i64, u32, u32, usize) {
// Days since 0000-03-01 based civil calendar conversion.
let z = days + 730_425; // 2000-01-01 relative to 0000-03-01
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d, days.rem_euclid(7) as usize)
}
fn timecode(frames: i64, fps: f64) -> String {
let fr = fps.round().max(1.0) as i64;
let neg = frames < 0;
let f = frames.abs();
let s = format!("{:02}:{:02}:{:02}:{:02}", f / (fr * 3600), (f / (fr * 60)) % 60, (f / fr) % 60, f % fr);
if neg { format!("-{s}") } else { s }
}
fn numbers_text(ctx: &EffectCtx) -> String {
let pr = ctx.params;
let kind = pr.e("type");
let dp = pr.f("decimalPlaces").round().clamp(0.0, 10.0) as usize;
let mut v = pr.f("value");
if pr.b("randomValues") {
let frame = (ctx.time * ctx.fps()).floor() as i64 as u32;
v = hash1(frame, 17, ctx.seed) as f64 * pr.f("value");
}
let cur = pr.b("currentTimeDate");
let t = if cur { ctx.time + v } else { v };
match kind {
1 => {
let s = format!("{:.*}", dp, v.abs());
let (ip, fp) = s.split_once('.').map(|(a, b)| (a.to_string(), format!(".{b}"))).unwrap_or((s.clone(), String::new()));
format!("{}{:0>5}{}", if v < 0.0 { "-" } else { "" }, ip, fp)
}
2..=4 => {
let fps = [30.0, 25.0, 24.0][(kind - 2) as usize];
timecode((t * fps + 1e-6).floor() as i64, fps)
}
5 => {
let s = t.floor() as i64;
let a = s.abs();
format!("{}{:02}:{:02}:{:02}", if s < 0 { "-" } else { "" }, a / 3600, (a / 60) % 60, a % 60)
}
6..=8 => {
let (y, m, d, wd) = date_from_days(v.floor() as i64);
match kind {
6 => format!("{:02}/{:02}/{:02}", m, d, y.rem_euclid(100)),
7 => format!("{} {}, {}", &MONTHS[m as usize - 1][..3], d, y),
_ => format!("{}, {} {}, {}", DAYS[wd], MONTHS[m as usize - 1], d, y),
}
}
9 => {
let i = v.round() as i64;
if i < 0 { format!("-{:X}", -i) } else { format!("{i:X}") }
}
_ => format!("{:.*}", dp, if cur { t } else { v }),
}
}
fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let text = numbers_text(ctx);
let size = (pr.f("size") * b.scale).max(0.5);
let tracking = pr.f("tracking") * b.scale;
let prop = pr.b("proportionalSpacing");
let pos = b.to_px(pr.v2("position"));
let wdt = line_width(&text, size, tracking, prop);
let mut polys = Vec::new();
layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, tracking, prop, &mut polys);
let look = look_from(ctx, &b, "compositeOnOriginal");
draw_text(&mut b, &polys, weight(size), &look);
b
}
// ---------------------------------------------------------------- Timecode
/// SMPTE drop-frame label for nominal 30/60 fps rates.
fn drop_frame(frames: i64, nominal: i64) -> String {
let drop = nominal / 15; // 2 at 30, 4 at 60
let per_min = nominal * 60 - drop;
let per_10 = per_min * 10 + drop;
let f = frames.max(0);
let d = f / per_10;
let m = f % per_10;
let adj = if m > drop { f + drop * 9 * d + drop * ((m - drop) / per_min) } else { f + drop * 9 * d };
format!("{:02};{:02};{:02};{:02}", adj / (nominal * 3600), (adj / (nominal * 60)) % 60, (adj / nominal) % 60, adj % nominal)
}
fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let src = pr.e("timeSource");
let (t, fps) = match src {
1 => (ctx.env.comp_time, ctx.fps()),
2 => (ctx.time, pr.f("timeUnits").max(1.0)),
_ => (ctx.time, ctx.fps()),
};
let mut frame = (t * fps + 1e-6).floor() as i64;
if src == 2 {
frame += pr.f("startingFrame").round() as i64;
}
let nominal = fps.round() as i64;
let text = match pr.e("displayFormat") {
1 => format!("{frame}"),
2 => format!("{}+{:02}", frame.div_euclid(16), frame.rem_euclid(16)),
3 => format!("{}+{:02}", frame.div_euclid(40), frame.rem_euclid(40)),
_ => {
if pr.b("dropFrame") && (nominal == 30 || nominal == 60) {
drop_frame(frame, nominal)
} else {
timecode(frame, fps)
}
}
};
let size = (pr.f("textSize") * b.scale).max(0.5);
let pos = b.to_px(pr.v2("textPosition"));
let wdt = line_width(&text, size, 0.0, false);
let op = (pr.f("opacity") / 100.0) as f32;
if !pr.b("renderOnOriginal") {
b.img = Image::new(b.img.width, b.img.height);
}
if pr.b("showBox") {
let bc = pr.color("boxColor");
let pad = size * 0.25;
let (x0, x1) = (pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad);
let (y0, y1) = (pos.1 - size * 0.5, pos.1 + size * 0.5);
let a = op * bc[3];
b.img.rows_mut().for_each(|(y, row)| {
let fy = y as f64 + 0.5;
let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32;
if cy <= 0.0 {
return;
}
for (x, px) in row.iter_mut().enumerate() {
let fx = x as f64 + 0.5;
let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32;
let k = cx * cy * a;
if k > 0.0 {
*px = over(*px, [bc[0] * k, bc[1] * k, bc[2] * k, k]);
}
}
});
}
let mut polys = Vec::new();
layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, 0.0, false, &mut polys);
let look = TextLook { display: 0, fill: pr.color("textColor"), stroke: [0.0; 4], stroke_w: 0.0, on_original: true, opacity: op };
draw_text(&mut b, &polys, weight(size), &look);
b
}
// ---------------------------------------------------------------- Basic Text
fn basic_text(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let size = (pr.f("size") * b.scale).max(0.5);
let tracking = pr.f("tracking") * b.scale;
let lead = size * pr.f("lineSpacing") / 100.0 * 1.2;
let pos = b.to_px(pr.v2("position"));
let align = pr.e("alignment");
let lines: Vec<&str> = pr.s("text").split(['\n', '\r']).collect();
let n = lines.len() as f64;
let mut polys = Vec::new();
for (i, l) in lines.iter().enumerate() {
let wdt = line_width(l, size, tracking, true);
let x = match align {
0 => pos.0,
2 => pos.0 - wdt,
_ => pos.0 - wdt * 0.5,
};
let y = pos.1 + unit(size) * 3.0 + (i as f64 - (n - 1.0) * 0.5) * lead;
layout_line(l, [x, y], size, tracking, true, &mut polys);
}
let look = look_from(ctx, &b, "compositeOnOriginal");
draw_text(&mut b, &polys, weight(size), &look);
b
}
// ---------------------------------------------------------------- Path Text
fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let px = |id: &str| {
let q = b.to_px(pr.v2(id));
[q.0, q.1]
};
let (mut path, mut closed) = match mask_px(ctx, &b, pr.f("customPath").round() as usize) {
Some((p, c, _)) => (p, c),
None => match pr.e("shapeType") {
1 | 2 => {
let c = px("vertex1");
let s = px("tangent1");
let r = ((s[0] - c[0]).powi(2) + (s[1] - c[1]).powi(2)).sqrt().max(1.0);
let a0 = (s[1] - c[1]).atan2(s[0] - c[0]);
let n = 128;
(
(0..n)
.map(|i| {
let a = a0 + 2.0 * PI * i as f64 / n as f64;
[c[0] + a.cos() * r, c[1] + a.sin() * r]
})
.collect(),
true,
)
}
3 => (vec![px("vertex1"), px("vertex2")], false),
_ => {
let (v1, t1, t2, v2) = (px("vertex1"), px("tangent1"), px("tangent2"), px("vertex2"));
(
(0..=64)
.map(|i| {
let t = i as f64 / 64.0;
let u = 1.0 - t;
let f = |k: usize| u * u * u * v1[k] + 3.0 * u * u * t * t1[k] + 3.0 * u * t * t * t2[k] + t * t * t * v2[k];
[f(0), f(1)]
})
.collect(),
false,
)
}
},
};
if pr.b("reversePath") {
path.reverse();
}
if path.len() < 2 {
closed = false;
}
let len = poly_length(&path, closed);
let text: String = pr.s("text").chars().take(pr.f("visibleCharacters").max(0.0) as usize).collect();
let size = (pr.f("size") * b.scale).max(0.5);
let u = unit(size);
let tracking = pr.f("tracking") * b.scale;
let lm = pr.f("leftMargin") * b.scale;
let rm = pr.f("rightMargin") * b.scale;
let shift = pr.f("baselineShift") * b.scale;
let rot = pr.f("characterRotation").to_radians();
let n = text.chars().count();
let advs: Vec<f64> = text.chars().map(|c| advance(c, true) * u).collect();
let natural: f64 = advs.iter().sum::<f64>() + tracking * (n.max(1) as f64 - 1.0);
let avail = (len - lm - rm).max(0.0);
let (mut cursor, extra) = match pr.e("alignment") {
1 => (len - rm - natural, 0.0),
2 => (lm + (avail - natural) * 0.5, 0.0),
3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }),
_ => (lm, 0.0),
};
let mut polys = Vec::new();
for (c, adv) in text.chars().zip(advs) {
let mid = cursor + adv * 0.5;
cursor += adv + tracking + extra;
if !closed && (mid < 0.0 || mid > len) {
continue;
}
let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid });
let ang = d[1].atan2(d[0]) + rot;
let (s, co) = ang.sin_cos();
let left = ink_left(c, true);
for st in parse_glyph(c) {
polys.push(
st.iter()
.map(|g| {
let lx = (g[0] - left - 0.8) * u - adv * 0.5 + 0.8 * u;
let ly = (g[1] - 6.0) * u - shift;
[q[0] + lx * co - ly * s, q[1] + lx * s + ly * co]
})
.collect(),
);
}
}
let look = look_from(ctx, &b, "compositeOnOriginal");
draw_text(&mut b, &polys, weight(size), &look);
b
}
// ---------------------------------------------------------------- Lightning (obsolete)
/// Smooth 1-D value noise in [-1, 1].
fn vnoise(i: u32, t: f64, seed: u32) -> f64 {
let k = t.floor();
let f = t - k;
let a = hash1(i, k as i64 as u32, seed) as f64 * 2.0 - 1.0;
let c = hash1(i, (k as i64 + 1) as u32, seed) as f64 * 2.0 - 1.0;
let s = f * f * (3.0 - 2.0 * f);
a + (c - a) * s
}
struct Bolt<'a> {
seed: u32,
phase: f64,
stability: f64,
detail: u32,
detail_amp: f64,
pull: [f64; 2],
out: &'a mut Vec<Seg>,
counter: u32,
}
impl Bolt<'_> {
/// One bolt from `a` to `z` with `n` segments of amplitude `amp`; returns its points.
fn path(&mut self, a: [f64; 2], z: [f64; 2], n: usize, amp: f64, fixed_end: bool) -> Vec<[f64; 2]> {
let (dx, dy) = (z[0] - a[0], z[1] - a[1]);
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let nrm = [-dy / len, dx / len];
let id = self.counter;
self.counter += 1;
let mut pts = Vec::with_capacity(n + 1);
for i in 0..=n {
let t = i as f64 / n as f64;
let env = if fixed_end { (t * PI).sin() } else { t.sqrt() };
let off = vnoise(id * 1000 + i as u32, self.phase * (1.0 - self.stability * 0.9) + i as f64 * 0.37, self.seed) * amp * env;
let pull = t * t * len * 0.25;
pts.push([a[0] + dx * t + nrm[0] * off + self.pull[0] * pull, a[1] + dy * t + nrm[1] * off + self.pull[1] * pull]);
}
// Detail: midpoint displacement.
for lvl in 0..self.detail {
let mut nx = Vec::with_capacity(pts.len() * 2);
for (j, w) in pts.windows(2).enumerate() {
let (p0, p1) = (w[0], w[1]);
let (ex, ey) = (p1[0] - p0[0], p1[1] - p0[1]);
let l = (ex * ex + ey * ey).sqrt();
let o = vnoise(id * 7919 + lvl * 131 + j as u32, self.phase * 1.7 + j as f64 * 0.61, self.seed ^ 0x55) * l * self.detail_amp;
nx.push(p0);
nx.push([(p0[0] + p1[0]) * 0.5 - ey / l.max(1e-9) * o, (p0[1] + p1[1]) * 0.5 + ex / l.max(1e-9) * o]);
}
nx.push(*pts.last().unwrap_or(&a));
pts = nx;
}
pts
}
}
fn lightning(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let s = b.to_px(pr.v2("startPoint"));
let e = b.to_px(pr.v2("endPoint"));
let sc = b.scale;
let rerun = pr.b("rerunAtEachFrame");
let speed = pr.f("speed");
let frame = (ctx.time * ctx.fps()).floor();
let seed = (pr.f("randomSeed") as i64 as u32) ^ ctx.seed ^ if rerun { (frame as i64 as u32).wrapping_mul(2_654_435_761) } else { 0 };
let phase = if rerun { 0.0 } else { ctx.time * speed * 0.25 };
let pd = (pr.f("pullDirection") - 90.0).to_radians();
let pf = pr.f("pullForce") / 100.0;
let width = (pr.f("width") * sc).max(0.2);
let wvar = pr.f("widthVariation").clamp(0.0, 1.0);
let core = pr.f("coreWidth").clamp(0.0, 1.0);
let mut segs: Vec<Seg> = Vec::new();
let mut bolt = Bolt {
seed,
phase,
stability: pr.f("stability").clamp(0.0, 1.0),
detail: pr.f("detailLevel").round().clamp(0.0, 6.0) as u32,
detail_amp: pr.f("detailAmplitude").clamp(0.0, 1.0) * 0.5,
pull: [pd.cos() * pf, pd.sin() * pf],
out: &mut segs,
counter: 0,
};
let nseg = pr.f("segments").round().clamp(1.0, 64.0) as usize;
let amp = pr.f("amplitude") * sc;
let main = bolt.path([s.0, s.1], [e.0, e.1], nseg, amp, pr.b("fixedEndpoint"));
let branching = pr.f("branching").clamp(0.0, 1.0);
let rebranch = pr.f("rebranching").clamp(0.0, 1.0);
let bang = pr.f("branchAngle").to_radians();
let bseg_len = pr.f("branchSegLength").max(0.0);
let bsegs = pr.f("branchSegments").round().clamp(1.0, 32.0) as usize;
let bwidth = pr.f("branchWidth").clamp(0.0, 1.0);
let main_len = poly_length(&main, false);
let seg_len = main_len / nseg as f64;
let mut lines: Vec<(Vec<[f64; 2]>, f64)> = vec![(main.clone(), 1.0)];
let mut queue: Vec<(Vec<[f64; 2]>, f64, u32)> = vec![(main, 1.0, 0)];
while let Some((pts, w, depth)) = queue.pop() {
if depth >= 3 || lines.len() > 256 {
continue;
}
let prob = if depth == 0 { branching } else { rebranch };
for (i, wpair) in pts.windows(2).enumerate() {
if hash1(i as u32 + depth * 977, lines.len() as u32, seed ^ 0xB7) as f64 >= prob * 0.35 {
continue;
}
let (p0, p1) = (wpair[0], wpair[1]);
let base = (p1[1] - p0[1]).atan2(p1[0] - p0[0]);
let side = if hash1(i as u32, depth + 3, seed) < 0.5 { -1.0 } else { 1.0 };
let a = base + side * bang * (0.5 + hash1(i as u32, depth + 9, seed) as f64);
let l = seg_len * bseg_len * bsegs as f64;
let z = [p0[0] + a.cos() * l, p0[1] + a.sin() * l];
let br = bolt.path(p0, z, bsegs, amp * 0.5, false);
let bw = w * bwidth;
lines.push((br.clone(), bw));
queue.push((br, bw, depth + 1));
}
}
for (li, (pts, w)) in lines.iter().enumerate() {
for (i, wpair) in pts.windows(2).enumerate() {
let v = 1.0 - wvar * hash1(i as u32, li as u32, seed ^ 0x77) as f64;
bolt.out.push(Seg { a: wpair[0], b: wpair[1], r: width * 0.5 * w * v, v: 1.0 });
}
}
let (wd, ht) = (b.img.width as usize, b.img.height as usize);
let outer = raster_segs(wd, ht, &segs, 0.0);
let core_segs: Vec<Seg> = segs.iter().map(|g| Seg { r: (g.r * core).max(0.3), ..*g }).collect();
let inner = raster_segs(wd, ht, &core_segs, 0.6);
let glow = gauss_plane(&outer, width * 0.5, width * 0.5);
let oc = pr.color("outsideColor");
let ic = pr.color("insideColor");
let mode = pr.e("blendingMode");
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let o = outer.data[i].max(glow.data[i] * 0.8);
let c = inner.data[i];
let a = o.max(c).min(1.0);
if a <= 0.0 {
return;
}
let t = if a > 0.0 { c / a } else { 0.0 };
let colr = [oc[0] + (ic[0] - oc[0]) * t, oc[1] + (ic[1] - oc[1]) * t, oc[2] + (ic[2] - oc[2]) * t];
*px = match mode {
1 => [px[0] + colr[0] * a, px[1] + colr[1] * a, px[2] + colr[2] * a, (px[3] + a * (1.0 - px[3])).min(1.0)],
2 => {
let sc = |d: f32, s: f32| d + s - d * s;
[sc(px[0], colr[0] * a), sc(px[1], colr[1] * a), sc(px[2], colr[2] * a), sc(px[3], a)]
}
_ => over(*px, [colr[0] * a, colr[1] * a, colr[2] * a, a]),
};
});
b
}
// ---------------------------------------------------------------- registry
pub fn specs() -> Vec<EffectSpec> {
let ang = || ParamUi::Angle;
let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox);
let pt = |x: f64, y: f64| Value::Vec2([x, y]);
let display = || popup(&["Fill Only", "Stroke Only", "Fill Over Stroke", "Stroke Over Fill"]);
let size = || slider(0.0, 512.0, 0.0, 200.0, 1);
vec![
spec(
"ec.text.numbers",
"Numbers",
"Text",
vec![
p(
"type",
"Format: Type",
Value::Enum(0),
popup(&[
"Number",
"Number [Leading Zeros]",
"Timecode [30]",
"Timecode [25]",
"Timecode [24]",
"Time",
"Numerical Date",
"Short Date",
"Long Date",
"Hexadecimal",
]),
),
check("randomValues", "Format: Random Values", false),
p("value", "Format: Value/Offset/Random Max", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 3)),
p("decimalPlaces", "Format: Decimal Places", num(2.0), slider(0.0, 10.0, 0.0, 10.0, 0)),
check("currentTimeDate", "Format: Current Time/Date", false),
p("position", "Fill and Stroke: Position", pt(0.5, 0.5), ParamUi::Point),
p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()),
p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
p("size", "Size", num(36.0), size()),
p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)),
check("proportionalSpacing", "Proportional Spacing", true),
check("compositeOnOriginal", "Composite On Original", true),
],
numbers,
),
spec(
"ec.text.timecode",
"Timecode",
"Text",
vec![
p("displayFormat", "Display Format", Value::Enum(0), popup(&["SMPTE HH:MM:SS:FF", "Frames", "Feet + Frames (35mm)", "Feet + Frames (16mm)"])),
p("timeSource", "Time Source", Value::Enum(0), popup(&["Layer Source", "Composition", "Custom"])),
p("timeUnits", "Time Units", num(30.0), slider(1.0, 120.0, 1.0, 60.0, 2)),
check("dropFrame", "Drop Frame", false),
p("startingFrame", "Starting Frame", num(0.0), slider(-1_000_000.0, 1_000_000.0, 0.0, 10_000.0, 0)),
p("textPosition", "Text Position", pt(0.5, 0.9), ParamUi::Point),
p("textSize", "Text Size", num(36.0), size()),
p("textColor", "Text Color", col(1.0, 1.0, 1.0), ParamUi::Color),
check("showBox", "Show Box", true),
p("boxColor", "Box Color", col(0.0, 0.0, 0.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), pct()),
check("renderOnOriginal", "Render On Original", true),
],
timecode_fx,
),
spec(
"ec.obsolete.basictext",
"Basic Text",
"Obsolete",
vec![
p("text", "Text", Value::Str("Text".into()), ParamUi::Text),
p("alignment", "Alignment", Value::Enum(1), popup(&["Left", "Center", "Right"])),
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()),
p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
p("size", "Size", num(36.0), size()),
p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)),
p("lineSpacing", "Line Spacing", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)),
check("compositeOnOriginal", "Composite On Original", true),
],
basic_text,
),
spec(
"ec.obsolete.pathtext",
"Path Text",
"Obsolete",
vec![
p("text", "Text", Value::Str("Text".into()), ParamUi::Text),
p("shapeType", "Path Options: Shape Type", Value::Enum(0), popup(&["Bezier", "Circle", "Loop", "Line"])),
p("tangent1", "Control Points: Tangent 1/Circle Point", pt(0.3, 0.3), ParamUi::Point),
p("vertex1", "Control Points: Vertex 1/Circle Center", pt(0.1, 0.6), ParamUi::Point),
p("tangent2", "Control Points: Tangent 2", pt(0.7, 0.3), ParamUi::Point),
p("vertex2", "Control Points: Vertex 2/Line Right", pt(0.9, 0.6), ParamUi::Point),
p("customPath", "Path Options: Custom Path", num(0.0), slider(0.0, 32.0, 0.0, 8.0, 0)),
check("reversePath", "Path Options: Reverse Path", false),
p("displayOptions", "Fill and Stroke: Options", Value::Enum(0), display()),
p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
p("size", "Character: Size", num(36.0), size()),
p("tracking", "Character: Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)),
p("characterRotation", "Character: Orientation: Character Rotation", num(0.0), ang()),
p("alignment", "Paragraph: Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])),
p("leftMargin", "Paragraph: Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
p("rightMargin", "Paragraph: Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
p("baselineShift", "Paragraph: Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
p("visibleCharacters", "Advanced: Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)),
check("compositeOnOriginal", "Composite On Original", true),
],
path_text,
),
spec(
"ec.obsolete.lightning",
"Lightning",
"Obsolete",
vec![
p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point),
p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point),
p("segments", "Segments", num(7.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
p("amplitude", "Amplitude", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("detailLevel", "Detail Level", num(2.0), slider(0.0, 6.0, 0.0, 6.0, 0)),
p("detailAmplitude", "Detail Amplitude", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("branching", "Branching", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("rebranching", "Rebranching", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("branchAngle", "Branch Angle", num(20.0), ang()),
p("branchSegLength", "Branch Seg. Length", num(0.5), slider(0.0, 2.0, 0.0, 2.0, 3)),
p("branchSegments", "Branch Segments", num(4.0), slider(1.0, 32.0, 1.0, 16.0, 0)),
p("branchWidth", "Branch Width", num(0.7), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("speed", "Speed", num(10.0), slider(-100.0, 100.0, -50.0, 50.0, 1)),
p("stability", "Stability", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)),
check("fixedEndpoint", "Fixed Endpoint", true),
p("width", "Width", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
p("widthVariation", "Width Variation", num(0.25), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("coreWidth", "Core Width", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("outsideColor", "Outside Color", col(0.36, 0.25, 0.9), ParamUi::Color),
p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("pullForce", "Pull Force", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("pullDirection", "Pull Direction", num(0.0), ang()),
p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Add", "Screen"])),
check("rerunAtEachFrame", "Rerun At Each Frame", false),
],
lightning,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, MaskShape, Params};
fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf {
let s = crate::find(id).unwrap();
let ls = [img.width as f64, img.height as f64];
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for p in &s.params {
if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) {
params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
}
}
for (k, v) in set {
assert!(params.values.contains_key(*k), "{id}: unknown {k}");
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: ls, seed: 3, adjustment: false, env };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}
fn run(id: &str, set: &[(&str, Value)], img: Image, time: f64) -> Buf {
run_env(id, set, img, time, EffectEnv::default())
}
fn ink(img: &Image) -> f32 {
img.data.iter().map(|p| p[3]).sum()
}
#[test]
fn every_glyph_parses_within_grid() {
for c in (b'!'..=b'~').map(|c| c as char) {
for s in parse_glyph(c) {
for q in s {
assert!((-0.5..=4.5).contains(&q[0]) && (-0.5..=7.0).contains(&q[1]), "{c}: {q:?}");
}
}
}
assert!(!parse_glyph('A').is_empty() && !parse_glyph('a').is_empty() && parse_glyph(' ').is_empty());
}
#[test]
fn numbers_formats() {
let params = |vals: &[(&str, Value)]| {
let s = crate::find("ec.text.numbers").unwrap();
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in vals {
p.values.insert(k.to_string(), v.clone());
}
p
};
let txt = |vals: &[(&str, Value)], t: f64| {
let pr = params(vals);
let ctx = EffectCtx { params: &pr, time: t, layer_size: [10.0, 10.0], seed: 1, adjustment: false, env: Default::default() };
numbers_text(&ctx)
};
assert_eq!(txt(&[("value", num(8.0))], 0.0), "8.00");
assert_eq!(txt(&[("value", num(42.5)), ("type", Value::Enum(1))], 0.0), "00042.50");
assert_eq!(txt(&[("value", num(3661.5)), ("type", Value::Enum(2))], 0.0), "01:01:01:15");
assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF");
assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00");
assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000");
assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0");
assert_ne!(
txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0),
txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 1.0)
);
}
#[test]
fn numbers_draws_glyph_at_position() {
let img = Image::new(80, 60);
let a = run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0);
assert_eq!(a.img.data, run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0).img.data);
// Ink only around the centre.
let mut inside = 0.0;
let mut outside = 0.0;
for y in 0..60 {
for x in 0..80 {
let v = a.img.get(x, y)[3];
if (25..55).contains(&x) && (10..50).contains(&y) {
inside += v;
} else {
outside += v;
}
}
}
assert!(inside > 20.0 && outside < 0.5, "{inside} {outside}");
let stroke = run("ec.text.numbers", &[("value", num(8.0)), ("displayOptions", Value::Enum(2))], img, 0.0);
assert!(stroke.img.data.iter().any(|p| p[3] > 0.5 && p[1] > 0.5 * p[3]), "white stroke visible");
}
#[test]
fn timecode_changes_over_time() {
let img = Image::filled(120, 60, [0.2, 0.2, 0.2, 1.0]);
let a = run("ec.text.timecode", &[], img.clone(), 0.0);
let b = run("ec.text.timecode", &[], img.clone(), 1.0);
assert_eq!(a.img.data, run("ec.text.timecode", &[], img.clone(), 0.0).img.data);
assert_ne!(a.img.data, b.img.data);
assert_eq!(timecode(30 * 61 + 5, 30.0), "00:01:01:05");
assert_eq!(drop_frame(1800, 30), "00;01;00;02");
assert_eq!(drop_frame(17982, 30), "00;10;00;00");
}
#[test]
fn basic_text_draws_and_respects_composite() {
let img = Image::filled(100, 50, [0.0, 0.0, 1.0, 1.0]);
let a = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into()))], img.clone(), 0.0);
assert_eq!(a.img.get(1, 1), [0.0, 0.0, 1.0, 1.0]);
assert!(a.img.data.iter().any(|p| p[0] > 0.9));
let t = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into())), ("compositeOnOriginal", Value::Bool(false))], img, 0.0);
assert_eq!(t.img.get(1, 1)[3], 0.0);
assert!(ink(&t.img) > 10.0);
}
#[test]
fn path_text_follows_line_and_mask() {
let img = Image::new(120, 60);
let line = run(
"ec.obsolete.pathtext",
&[("shapeType", Value::Enum(3)), ("vertex1", Value::Vec2([5.0, 50.0])), ("vertex2", Value::Vec2([115.0, 50.0])), ("size", num(20.0))],
img.clone(),
0.0,
);
assert!(ink(&line.img) > 10.0);
// Text sits above the baseline at y = 50.
let below: f32 = (52..60).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| line.img.get(x, y)[3]).sum();
assert!(below < 1.0);
let masks = vec![MaskShape { name: "M".into(), points: vec![[10.0, 20.0], [110.0, 20.0]], closed: false, inverted: false }];
let env = EffectEnv { masks: &masks, ..Default::default() };
let m = run_env("ec.obsolete.pathtext", &[("customPath", num(1.0)), ("size", num(20.0))], img.clone(), 0.0, env);
let near: f32 = (0..24).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| m.img.get(x, y)[3]).sum();
assert!(near > 10.0 && (ink(&m.img) - near).abs() < 1.0);
let circle = run("ec.obsolete.pathtext", &[("shapeType", Value::Enum(1)), ("text", Value::Str("CIRCLE TEXT".into()))], img, 0.0);
assert!(ink(&circle.img) > 10.0);
}
#[test]
fn lightning_deterministic_and_animates() {
let img = Image::new(96, 64);
let a = run("ec.obsolete.lightning", &[], img.clone(), 0.5);
let b = run("ec.obsolete.lightning", &[], img.clone(), 0.5);
assert_eq!(a.img.data, b.img.data);
assert!(a.img.get(48, 32)[3] > 0.0 || ink(&a.img) > 50.0);
let c = run("ec.obsolete.lightning", &[], img.clone(), 1.5);
assert_ne!(a.img.data, c.img.data);
let r1 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img.clone(), 0.5);
let r2 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img, 0.5);
assert_eq!(r1.img.data, r2.img.data);
}
}

View File

@ -486,7 +486,8 @@ mod tests {
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,544 @@
//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep,
//! CC Twister and CC WarpoMatic.
//!
//! Written from the public descriptions of these transitions (gradient-driven wipes with glass
//! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that
//! reveals a back side); the maths is our own. Completion 0 leaves the layer untouched;
//! completion 100 leaves it fully transparent (or showing the revealed / back-side layer).
use effectcraft_color::rgb_to_hsl;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::util::{Plane, fit_layer, gauss_plane, layer_or_self, lerp4, smoothstep, unpremul};
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: "Transition", params, render, gpu: false, float: true }
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
fn completion(ctx: &EffectCtx) -> f32 {
(ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32
}
/// The layer in layer parameter `id` fitted to `b`, if one is chosen.
fn reveal(ctx: &EffectCtx, b: &Buf, id: &str) -> Option<Image> {
ctx.layer_param(id, true).map(|o| fit_layer(ctx, b, &o, false))
}
const GRAD_PROPS: [&str; 7] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Saturation"];
fn prop_plane(img: &Image, prop: u32) -> Plane {
Plane::from_image(img, |p| {
let (c, a) = unpremul(p);
match prop {
0 => c[0] * a,
1 => c[1] * a,
2 => c[2] * a,
3 => a,
4 => effectcraft_color::luminance(c[0], c[1], c[2]) * a,
5 => rgb_to_hsl(c[0], c[1], c[2]).2 * a,
_ => rgb_to_hsl(c[0], c[1], c[2]).1 * a,
}
})
}
/// Fraction of a pixel with gradient value `g` that has transitioned, for a soft band of
/// half-width `w`: none at completion 0, all at completion 1.
#[inline]
fn wipe_t(g: f32, c: f32, w: f32) -> f32 {
let w = w.max(1e-3);
let edge = c * (1.0 + 2.0 * w) - w;
1.0 - smoothstep(edge - w, edge + w, g)
}
#[inline]
fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) {
let (xi, yi) = (x as i64, y as i64);
((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5)
}
// ------------------------------------------------------------------ CC Glass Wipe
fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx);
if c <= 0.0 {
return b;
}
let gimg = layer_or_self(ctx, &b, "gradientLayer", true, false);
let mut g = prop_plane(&gimg, 4);
let soft = (ctx.params.f("softness") / 100.0) as f32;
let sm = ctx.params.f("softness") * b.scale * 0.2;
if sm > 0.05 {
g = gauss_plane(&g, sm, sm);
}
let disp = (ctx.params.f("displacementAmount") * b.scale * 4.0) as f32;
let rev = reveal(ctx, &b, "layerToReveal");
let src = b.img.clone();
let w = soft * 0.5 + 0.01;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let t = wipe_t(g.get(x, y), c, w);
let band = 4.0 * t * (1.0 - t);
let (gx, gy) = grad(&g, x, y);
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let (ox, oy) = ((gx * disp * band) as f64 * 10.0, (gy * disp * band) as f64 * 10.0);
let a = if band > 1e-4 { src.sample_bilinear(fx + ox, fy + oy) } else { src.data[y * src.width as usize + x] };
let r = match &rev {
Some(r) if band > 1e-4 => r.sample_bilinear(fx - ox, fy - oy),
Some(r) => r.data[y * r.width as usize + x],
None => [0.0; 4],
};
*px = lerp4(a, r, t);
}
});
b
}
// ------------------------------------------------------------------ CC Image Wipe
fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx);
if c <= 0.0 {
return b;
}
let gimg = layer_or_self(ctx, &b, "layer", true, false);
let mut g = prop_plane(&gimg, ctx.params.e("property"));
let bl = ctx.params.f("blur") * b.scale * 0.5;
if bl > 0.05 {
g = gauss_plane(&g, bl, bl);
}
if ctx.params.b("inverseGradient") {
g = g.map(|v| 1.0 - v);
}
let mut w = (ctx.params.f("borderSoftness") / 100.0) as f32 * 0.5;
if ctx.params.b("autoSoftness") {
w = w.max(0.02);
}
b.img.data.par_iter_mut().zip(g.data.par_iter()).for_each(|(px, &gv)| {
let k = 1.0 - wipe_t(gv, c, w);
*px = px.map(|v| v * k);
});
b
}
// ------------------------------------------------------------------ CC Jaws
fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx) as f64;
if c <= 0.0 {
return b;
}
let s = b.scale;
let (cx, cy) = b.to_px(ctx.params.v2("center"));
let ang = ctx.params.f("direction").to_radians();
let (sn, cs) = ang.sin_cos();
let width = (ctx.params.f("width") * s).max(1.0);
let amp = ctx.params.f("height") / 100.0 * width * 0.5;
let shape = ctx.params.e("shape");
// Far enough at completion 1 that both halves (teeth included) have left the buffer.
let (bw, bh) = (b.img.width as f64, b.img.height as f64);
let vmax = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|&(x, y)| (-(x - cx) * sn + (y - cy) * cs).abs()).fold(0.0, f64::max);
let d = c * (vmax + 2.0 * amp + 2.0);
// Tooth profile in [-amp, amp] along the cut.
let profile = move |u: f64| -> f64 {
let ph = (u / width).rem_euclid(1.0);
match shape {
0 => amp * (1.0 - 4.0 * (ph - 0.5).abs()),
1 => {
// RoboJaw: square teeth with sloped flanks.
let t = (1.0 - 4.0 * (ph - 0.5).abs()) * 2.0;
amp * t.clamp(-1.0, 1.0)
}
2 => {
if ph < 0.5 {
amp
} else {
-amp
}
}
_ => amp * (ph * std::f64::consts::TAU).sin(),
}
};
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
// u along the cut, v across it.
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
let f = profile(u);
let back = |vs: f64| {
let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy);
src.sample_bilinear(sx, sy)
};
*px = if v + d < f {
back(v + d)
} else if v - d >= f {
back(v - d)
} else {
[0.0; 4]
};
}
});
b
}
// ------------------------------------------------------------------ CC Line Sweep
fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx) as f64;
if c <= 0.0 {
return b;
}
let s = b.scale;
let ang = ctx.params.f("direction").to_radians();
let (sn, cs) = ang.sin_cos();
let thick = (ctx.params.f("thickness") * s).max(1.0);
let slant = ctx.params.f("slant") * s;
let flip = ctx.params.b("flipDirection");
// Projected extents of the buffer along the sweep (u) and across it (v).
let (w, h) = (b.img.width as f64, b.img.height as f64);
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)];
let proj = |x: f64, y: f64| (x * cs + y * sn, -x * sn + y * cs);
let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for (x, y) in corners {
let (u, v) = proj(x, y);
u0 = u0.min(u);
u1 = u1.max(u);
v0 = v0.min(v);
v1 = v1.max(v);
}
let nstr = ((v1 - v0) / thick).ceil().max(1.0);
let total = (u1 - u0) + slant.abs() * nstr + 1.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (u, v) = proj(x as f64 + 0.5, y as f64 + 0.5);
let k = ((v - v0) / thick).floor();
let k = if flip { nstr - 1.0 - k } else { k };
let stagger = if slant >= 0.0 { k } else { nstr - 1.0 - k };
let front = u0 + c * total - slant.abs() * stagger;
if u < front {
*px = [0.0; 4];
}
}
});
b
}
// ------------------------------------------------------------------ CC Twister
fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx) as f64;
if c <= 0.0 {
return b;
}
let (cx, cy) = b.to_px(ctx.params.v2("center"));
let ang = ctx.params.f("axis").to_radians();
let (sn, cs) = ang.sin_cos();
let shading = ctx.params.b("shading");
let half = (b.img.width as f64).hypot(b.img.height as f64) * 0.5;
let src = b.img.clone();
let back = reveal(ctx, &b, "backside");
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
// u along the twist axis, v across it.
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
let s01 = ((u / half) * 0.5 + 0.5).clamp(0.0, 1.0);
let th = std::f64::consts::PI * (c * 2.0 - s01).clamp(0.0, 1.0);
let k = th.cos();
if k.abs() < 1e-4 {
*px = [0.0; 4];
continue;
}
let vs = v / k;
let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy);
let mut o: Px = if k > 0.0 {
src.sample_bilinear(sx, sy)
} else {
// Back side: the layer itself is seen from behind (mirrored across the axis); a
// Backside layer is mapped so it reads upright when fully flipped.
match &back {
Some(bk) => bk.sample_bilinear(u * cs + vs * sn + cx, u * sn - vs * cs + cy),
None => src.sample_bilinear(sx, sy),
}
};
if shading {
let l = (0.4 + 0.6 * k.abs()) as f32;
o = [o[0] * l, o[1] * l, o[2] * l, o[3]];
}
*px = o;
}
});
b
}
// ------------------------------------------------------------------ CC WarpoMatic
fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = completion(ctx);
if c <= 0.0 {
return b;
}
let rimg = layer_or_self(ctx, &b, "reactorLayer", true, false);
let mut m = prop_plane(&rimg, 4);
let sm = ctx.params.f("smoothness") * b.scale * 0.5;
if sm > 0.05 {
m = gauss_plane(&m, sm, sm);
}
m = match ctx.params.e("reactor") {
1 => {
// Contrast differences: distance from the local mean.
let mean = gauss_plane(&m, 8.0 * b.scale, 8.0 * b.scale);
m.zip_map(&mean, |a, b| ((a - b).abs() * 4.0).min(1.0))
}
2 => {
// Local differences: gradient magnitude.
let mut o = Plane::new(m.w, m.h);
o.data.par_chunks_mut(m.w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
let (gx, gy) = grad(&m, x, y);
*v = ((gx * gx + gy * gy).sqrt() * 8.0).min(1.0);
}
});
o
}
_ => m,
};
let amt = (ctx.params.f("warpAmount") * b.scale) as f32 * 20.0;
let dir = ctx.params.e("warpDirection");
let span = (ctx.params.f("blendSpan") / 100.0) as f32 * 0.5;
let rev = reveal(ctx, &b, "layerToReveal");
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let q = wipe_t(m.get(x, y), c, span);
let (gx, gy) = grad(&m, x, y);
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let (ox, oy) = ((gx * amt * q) as f64, (gy * amt * q) as f64);
let (ix, iy) = ((gx * amt * (1.0 - q)) as f64, (gy * amt * (1.0 - q)) as f64);
let (ix, iy) = match dir {
1 => (-ix, -iy),
2 => (-iy, ix),
_ => (ix, iy),
};
let out = if q < 1.0 { src.sample_bilinear(fx + ox, fy + oy) } else { [0.0; 4] };
let inc = match &rev {
Some(r) if q > 0.0 => r.sample_bilinear(fx + ix, fy + iy),
_ => [0.0; 4],
};
*px = lerp4(out, inc, q);
}
});
b
}
// ------------------------------------------------------------------ specs
pub fn specs() -> Vec<EffectSpec> {
let done = || p("completion", "Completion", num(0.0), pct());
vec![
spec(
"ec.transition.ccglasswipe",
"CC Glass Wipe",
vec![
done(),
p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer),
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
p("softness", "Softness", num(10.0), pct()),
p("displacementAmount", "Displacement Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
],
glass_wipe,
),
spec(
"ec.transition.ccimagewipe",
"CC Image Wipe",
vec![
done(),
p("borderSoftness", "Border Softness", num(0.0), pct()),
p("autoSoftness", "Auto Softness", Value::Bool(true), ParamUi::Checkbox),
p("layer", "Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(4), popup(&GRAD_PROPS)),
p("blur", "Blur", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("inverseGradient", "Inverse Gradient", Value::Bool(false), ParamUi::Checkbox),
],
image_wipe,
),
spec(
"ec.transition.ccjaws",
"CC Jaws",
vec![
done(),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("direction", "Direction", num(0.0), ParamUi::Angle),
p("height", "Height", num(50.0), pct()),
p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
p("shape", "Shape", Value::Enum(0), popup(&["Spikes", "RoboJaw", "Block", "Waves"])),
],
jaws,
),
spec(
"ec.transition.cclinesweep",
"CC Line Sweep",
vec![
done(),
p("direction", "Direction", num(0.0), ParamUi::Angle),
p("thickness", "Thickness", num(20.0), slider(1.0, 500.0, 1.0, 100.0, 1)),
p("slant", "Slant", num(20.0), slider(-500.0, 500.0, -100.0, 100.0, 1)),
p("flipDirection", "Flip Direction", Value::Bool(false), ParamUi::Checkbox),
],
line_sweep,
),
spec(
"ec.transition.cctwister",
"CC Twister",
vec![
done(),
p("backside", "Backside", Value::Layer(None), ParamUi::Layer),
p("shading", "Shading", Value::Bool(true), ParamUi::Checkbox),
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("axis", "Axis", num(45.0), ParamUi::Angle),
],
twister,
),
spec(
"ec.transition.ccwarpomatic",
"CC WarpoMatic",
vec![
done(),
p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer),
p("reactorLayer", "Reactor Layer", Value::Layer(None), ParamUi::Layer),
p("reactor", "Reactor", Value::Enum(0), popup(&["Brightness", "Contrast Differences", "Local Differences"])),
p("smoothness", "Smoothness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("warpAmount", "Warp Amount", num(50.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)),
p("warpDirection", "Warp Direction", Value::Enum(0), popup(&["Joint", "Opposing", "Twisting"])),
p("blendSpan", "Blend Span", num(10.0), pct()),
],
warpomatic,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
fn ramp(w: u32, h: u32) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]);
}
}
img
}
fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf {
run_fx(id, vals, img, 0.0, EffectEnv::default())
}
const ALL: [&str; 6] = [
"ec.transition.ccglasswipe",
"ec.transition.ccimagewipe",
"ec.transition.ccjaws",
"ec.transition.cclinesweep",
"ec.transition.cctwister",
"ec.transition.ccwarpomatic",
];
fn centre(id: &str) -> Vec<(&'static str, Value)> {
match id {
"ec.transition.ccjaws" => vec![("center", pt(20.0, 16.0)), ("width", num(10.0))],
"ec.transition.cctwister" => vec![("center", pt(20.0, 16.0))],
_ => vec![],
}
}
#[test]
fn zero_completion_is_identity() {
let img = ramp(40, 32);
for id in ALL {
let o = run(id, &centre(id), img.clone());
assert_eq!(o.img.data, img.data, "{id}");
}
}
#[test]
fn full_completion_is_transparent() {
let img = ramp(40, 32);
for id in ALL {
if id == "ec.transition.cctwister" {
continue;
}
let mut v = centre(id);
v.push(("completion", num(100.0)));
let o = run(id, &v, img.clone());
assert!(o.img.data.iter().all(|p| p[3].abs() < 1e-5), "{id}");
}
}
#[test]
fn midway_deterministic_and_partial() {
let img = ramp(40, 32);
for id in ALL {
let mut v = centre(id);
v.push(("completion", num(50.0)));
let a = run(id, &v, img.clone());
let b = run(id, &v, img.clone());
assert_eq!(a.img.data, b.img.data, "{id}");
let opaque = a.img.data.iter().filter(|p| p[3] > 0.99).count();
let clear = a.img.data.iter().filter(|p| p[3] < 0.01).count();
if id != "ec.transition.cctwister" {
assert!(opaque > 0 && clear > 0, "{id}: {opaque} {clear}");
}
assert_ne!(a.img.data, img.data, "{id}");
}
}
#[test]
fn twister_full_shows_mirrored_back() {
let img = ramp(40, 32);
let o = run(
"ec.transition.cctwister",
&[("completion", num(100.0)), ("shading", Value::Bool(false)), ("axis", num(0.0)), ("center", pt(20.0, 16.0))],
img.clone(),
);
// Axis horizontal through the centre: the back is the source flipped vertically.
for (x, y) in [(5usize, 4usize), (30, 20), (12, 27)] {
let got = o.img.data[y * 40 + x];
let want = img.data[(31 - y) * 40 + x];
assert!((got[1] - want[1]).abs() < 1e-3 && (got[0] - want[0]).abs() < 1e-3, "{x},{y} {got:?} {want:?}");
}
}
#[test]
fn line_sweep_flip_reverses_stagger() {
let img = ramp(40, 32);
let a = run("ec.transition.cclinesweep", &[("completion", num(40.0))], img.clone());
let b = run("ec.transition.cclinesweep", &[("completion", num(40.0)), ("flipDirection", Value::Bool(true))], img.clone());
let cleared = |o: &Buf, y: usize| (0..40).filter(|&x| o.img.data[y * 40 + x][3] < 0.01).count();
// Unflipped: lower stripes trail; flipped: upper stripes trail.
assert!(cleared(&a, 2) > cleared(&a, 30));
assert!(cleared(&b, 2) < cleared(&b, 30));
}
#[test]
fn image_wipe_follows_gradient() {
let img = ramp(40, 32);
let o = run("ec.transition.ccimagewipe", &[("completion", num(50.0)), ("property", Value::Enum(0))], img.clone());
// Red ramps left→right: dark (left) side goes first.
assert!(o.img.data[16 * 40 + 2][3] < 0.01);
assert!(o.img.data[16 * 40 + 37][3] > 0.99);
}
}

View File

@ -427,10 +427,253 @@ pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64
(b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale)
}
/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other
/// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred.
pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image {
let ls = ctx.layer_size;
let os = other.size;
let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) };
let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) };
let inv = 1.0 / b.scale.max(1e-9);
gen_image(b.img.width, b.img.height, |x, y| {
let px = (x as f64 + 0.5 - b.offset[0]) * inv;
let py = (y as f64 + 0.5 - b.offset[1]) * inv;
let qx = px * sx + dx;
let qy = py * sy + dy;
other.buf.img.sample_bilinear(qx * other.buf.scale + other.buf.offset[0], qy * other.buf.scale + other.buf.offset[1])
})
}
/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of
/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour).
pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image {
match ctx.layer_param(id, masks_and_effects) {
Some(o) => fit_layer(ctx, b, &o, stretch),
None => b.img.clone(),
}
}
/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer
/// scale (cache key for simulations).
pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut keys: Vec<&String> = ctx.params.values.keys().collect();
keys.sort();
for k in keys {
k.hash(&mut h);
format!("{:?}", ctx.params.values[k]).hash(&mut h);
}
ctx.seed.hash(&mut h);
ctx.layer_size[0].to_bits().hash(&mut h);
ctx.layer_size[1].to_bits().hash(&mut h);
b.scale.to_bits().hash(&mut h);
salt.hash(&mut h);
h.finish()
}
/// Checkpointed, deterministic, time-seekable simulation cache.
///
/// A simulation is a state advanced in fixed steps from layer time 0. [`SimCache::run`] returns
/// the state after `target` steps, starting from the nearest cached checkpoint at or before it
/// (so scrubbing forward is incremental and seeking anywhere gives the same answer as
/// simulating from scratch). Entries are keyed by [`params_key`]; the least recently used key
/// is evicted beyond `cap` keys.
pub struct SimCache<S> {
inner: std::sync::Mutex<Vec<SimEntry<S>>>,
cap: usize,
}
struct SimEntry<S> {
key: u64,
used: u64,
checkpoints: Vec<(u64, std::sync::Arc<S>)>,
}
/// Checkpoint every this many steps.
pub const SIM_CHECKPOINT: u64 = 15;
impl<S: Clone> SimCache<S> {
pub const fn new(cap: usize) -> SimCache<S> {
SimCache { inner: std::sync::Mutex::new(Vec::new()), cap }
}
/// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step
/// `i` to `i + 1`.
pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc<S> {
use std::sync::Arc;
let start = {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
g.iter_mut().find(|e| e.key == key).and_then(|e| {
e.used = tick;
e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone()))
})
};
let had_start = start.is_some();
let (mut i, mut st) = match start {
Some((s, st)) if s == target => return st,
Some((s, st)) => (s, (*st).clone()),
None => (0, init()),
};
let mut fresh: Vec<(u64, Arc<S>)> = Vec::new();
if !had_start {
fresh.push((0, Arc::new(st.clone())));
}
while i < target {
step(&mut st, i);
i += 1;
if i % SIM_CHECKPOINT == 0 && i != target {
fresh.push((i, Arc::new(st.clone())));
}
}
let out = Arc::new(st);
fresh.push((target, out.clone()));
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
let idx = match g.iter().position(|e| e.key == key) {
Some(ix) => ix,
None => {
if g.len() >= self.cap.max(1) {
let old = g.iter().enumerate().min_by_key(|(_, e)| e.used).map(|(ix, _)| ix).unwrap_or(0);
g.remove(old);
}
g.push(SimEntry { key, used: tick, checkpoints: Vec::new() });
g.len() - 1
}
};
let e = &mut g[idx];
e.used = tick;
for (s, st) in fresh {
if !e.checkpoints.iter().any(|(c, _)| *c == s) {
e.checkpoints.push((s, st));
}
}
// Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin.
if e.checkpoints.len() > 256 {
e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0);
e.checkpoints.sort_by_key(|(s, _)| *s);
let mut k = 0;
e.checkpoints.retain(|_| {
k += 1;
k % 2 == 1
});
}
out
}
}
/// Point-in-polygon (even-odd) for a closed polyline.
pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool {
let n = pts.len();
if n < 3 {
return false;
}
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (a, b) = (pts[i], pts[j]);
if (a[1] > y) != (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] {
inside = !inside;
}
j = i;
}
inside
}
/// Distance from a point to a polyline (closed adds the last→first segment).
pub fn dist_to_poly(pts: &[[f64; 2]], closed: bool, x: f64, y: f64) -> f64 {
let n = pts.len();
if n == 0 {
return f64::INFINITY;
}
if n == 1 {
return ((pts[0][0] - x).powi(2) + (pts[0][1] - y).powi(2)).sqrt();
}
let segs = if closed { n } else { n - 1 };
let mut best = f64::INFINITY;
for i in 0..segs {
let a = pts[i];
let b = pts[(i + 1) % n];
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (px, py) = (a[0] + dx * t, a[1] + dy * t);
best = best.min((px - x).powi(2) + (py - y).powi(2));
}
best.sqrt()
}
/// Total length of a polyline.
pub fn poly_length(pts: &[[f64; 2]], closed: bool) -> f64 {
let n = pts.len();
if n < 2 {
return 0.0;
}
let segs = if closed { n } else { n - 1 };
(0..segs)
.map(|i| {
let (a, b) = (pts[i], pts[(i + 1) % n]);
((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt()
})
.sum()
}
/// Point and unit tangent at arc length `s` along a polyline.
pub fn poly_point_at(pts: &[[f64; 2]], closed: bool, s: f64) -> ([f64; 2], [f64; 2]) {
let n = pts.len();
if n == 0 {
return ([0.0; 2], [1.0, 0.0]);
}
if n == 1 {
return (pts[0], [1.0, 0.0]);
}
let segs = if closed { n } else { n - 1 };
let mut rem = s.max(0.0);
for i in 0..segs {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
if rem <= l || i == segs - 1 {
let t = if l > 0.0 { (rem / l).min(1.0) } else { 0.0 };
let d = if l > 0.0 { [(b[0] - a[0]) / l, (b[1] - a[1]) / l] } else { [1.0, 0.0] };
return ([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], d);
}
rem -= l;
}
(pts[n - 1], [1.0, 0.0])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sim_cache_is_seek_consistent() {
static C: SimCache<u64> = SimCache::new(2);
let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i);
let direct = *C.run(1, 100, || 7, step);
let fresh: SimCache<u64> = SimCache::new(2);
let a = *fresh.run(1, 40, || 7, step);
let b = *fresh.run(1, 100, || 7, step);
let c = *fresh.run(1, 40, || 7, step);
let d = *fresh.run(1, 77, || 7, step);
let e = *C.run(1, 77, || 7, step);
assert_eq!(b, direct);
assert_eq!(a, c);
assert_eq!(d, e);
}
#[test]
fn poly_helpers() {
let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
assert!(point_in_poly(&sq, 5.0, 5.0));
assert!(!point_in_poly(&sq, 15.0, 5.0));
assert!((poly_length(&sq, true) - 40.0).abs() < 1e-9);
assert!((dist_to_poly(&sq, true, 5.0, 5.0) - 5.0).abs() < 1e-9);
let (pt, _) = poly_point_at(&sq, true, 15.0);
assert!((pt[0] - 10.0).abs() < 1e-9 && (pt[1] - 5.0).abs() < 1e-9);
}
#[test]
fn minmax_matches_brute_force() {
let src: Vec<f32> = (0..37).map(|i| ((i * 7919) % 31) as f32).collect();

View File

@ -363,7 +363,8 @@ mod tests {
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,85 @@
//! Utility effects, batch 2: HDR Compander.
//!
//! HDR Compander squeezes high-dynamic-range values into 0–1 (Compress Range) so 8/16 bpc
//! effects can process them, and restores them afterwards (Expand Range) with the same Gain and
//! Gamma; the two modes are exact inverses.
use effectcraft_keyframe::Value;
use rayon::prelude::*;
use crate::util::{premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
#[inline]
fn spow(v: f32, e: f32) -> f32 {
v.signum() * v.abs().powf(e)
}
fn hdr_compander(ctx: &EffectCtx, mut b: Buf) -> Buf {
let expand = ctx.params.e("mode") == 1;
let gain = ctx.params.f("gain").max(1e-6) as f32;
let gamma = ctx.params.f("gamma").max(1e-3) as f32;
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = unpremul(*px);
if a <= 0.0 {
return;
}
let c = c.map(|v| if expand { spow(v, gamma) * gain } else { spow(v / gain, 1.0 / gamma) });
*px = premul(c, a);
});
b
}
pub fn specs() -> Vec<EffectSpec> {
vec![EffectSpec {
id: "ec.utility.hdrcompander",
name: "HDR Compander",
category: "Utility",
params: vec![
p("mode", "Mode", Value::Enum(0), popup(&["Compress Range", "Expand Range"])),
p("gain", "Gain", num(1.0), slider(1.0, 100.0, 1.0, 100.0, 2)),
p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)),
],
render: hdr_compander,
gpu: false,
float: true,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
use effectcraft_raster::Image;
fn hdr() -> Image {
crate::util::gen_image(8, 4, |x, y| {
let v = x as f32 * 0.6 + y as f32 * 0.1;
[v * 0.8, v * 0.4, v * 0.2, 0.8]
})
}
#[test]
fn defaults_are_identity_and_deterministic() {
let im = hdr();
let a = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default());
let b = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default());
assert_eq!(a.img, b.img);
assert!(a.img.data.iter().zip(&im.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() < 1e-5)));
}
#[test]
fn compress_then_expand_round_trips() {
let im = hdr();
let set = [("gain", num(5.0)), ("gamma", num(2.2))];
let c = run_fx("ec.utility.hdrcompander", &set, im.clone(), 0.0, EffectEnv::default());
// Compressed values fit in 0–1.
assert!(c.img.data.iter().all(|p| p[0] <= p[3] + 1e-5));
let e = run_fx("ec.utility.hdrcompander", &[set[0].clone(), set[1].clone(), ("mode", Value::Enum(1))], c.img, 0.0, EffectEnv::default());
for (p, q) in e.img.data.iter().zip(&im.data) {
for i in 0..4 {
assert!((p[i] - q[i]).abs() < 1e-3, "{p:?} vs {q:?}");
}
}
}
}

View File

@ -454,8 +454,12 @@ impl FootageSource for MediaPool {
}
}
}
fn audio(&self, _item: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
self.audio_samples(footage, start, frames, rate)
fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
if !footage.has_audio || footage.missing {
return None;
}
Some(self.audio_samples(footage, t, frames, rate))
}
}

View File

@ -103,7 +103,7 @@ fn mix_into(
let lt = l.layer_time(t_a);
let buf = match &l.source {
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate),
Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]),
_ => continue,
},
LayerSource::Comp { item } => {
@ -152,8 +152,8 @@ mod tests {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Vec<f32> {
(0..frames).flat_map(|_| if start >= Tick::ZERO { [0.25, -0.25] } else { [0.0, 0.0] }).collect()
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Option<Vec<f32>> {
Some((0..frames).flat_map(|_| if start >= Tick::ZERO { [0.25, -0.25] } else { [0.0, 0.0] }).collect())
}
}

View File

@ -17,7 +17,7 @@ use std::sync::Arc;
pub use cache::{CacheStats, LayerCache};
use effectcraft_color::BlendMode;
use effectcraft_effects::{Buf, EffectCtx, Params};
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling};
pub use effectcraft_raster::Image;
@ -30,10 +30,43 @@ use rayon::prelude::*;
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
/// `frames` stereo sample frames of `item`'s audio from source time `start` at `rate` Hz,
/// interleaved L R L R… (silence where there is none). Default: no audio.
fn audio(&self, _item: ItemId, _footage: &Footage, _start: Tick, frames: usize, _rate: u32) -> Vec<f32> {
vec![0.0; frames * 2]
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
/// `rate` Hz (`None` when unavailable).
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
}
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).
struct FxHost<'r, 'a, 'c> {
r: &'r Renderer<'a>,
ctx: &'c EvalCtx<'a>,
layer: &'c Layer,
}
impl EffectHost for FxHost<'_, '_, '_> {
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > 8 {
return None;
}
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
// The cached layer buffer is shared; effects get their own copy.
let buf = if masks_and_effects { (*sub.layer_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
Some(LayerPixels { buf, size })
}
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
let LayerSource::Footage { item } = &other.source else { return None };
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
if !f.has_audio {
return None;
}
let st = other.layer_time(Tick::from_seconds_f64(start));
self.r.footage.audio(*item, f, st, frames, rate)
}
}
@ -165,6 +198,9 @@ impl<'a> Renderer<'a> {
let lt = layer.layer_time(ctx.time);
let size = source_size(self.project, layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let mask_shapes = masks::shapes(ctx, layer);
let host = FxHost { r: self, ctx, layer };
let env = EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64() };
for g in fx.groups() {
if !g.enabled {
continue;
@ -172,7 +208,7 @@ impl<'a> Renderer<'a> {
let GroupKind::Effect { effect } = &g.kind else { continue };
let Some(spec) = effectcraft_effects::find(effect) else { continue };
let params = self.effect_params(ctx, layer, g);
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment };
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
let t0 = std::time::Instant::now();
buf = effectcraft_effects::apply(spec, &ectx, buf);
if let Some(v) = timing.as_deref_mut() {

View File

@ -39,6 +39,34 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu
Some(cov)
}
/// The layer's enabled masks at the context time, flattened to polylines in layer space (for
/// effects that use masks as paths: Stroke, Scribble, Inner/Outer Key, Reshape…).
pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShape> {
let Some(masks) = layer.masks() else { return Vec::new() };
let mut out = Vec::new();
for g in masks.groups().filter(|g| g.enabled) {
let GroupKind::Mask { inverted, .. } = g.kind else { continue };
let Some(Value::Path(sp)) = ctx.group_value(layer, g, "path") else { continue };
let path = effectcraft_path::to_kurbo(&sp);
let mut pts: Vec<[f64; 2]> = Vec::new();
let mut closed = false;
kurbo::flatten(path.iter(), 0.25, |el| match el {
effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) => {
if pts.last() != Some(&[p.x, p.y]) {
pts.push([p.x, p.y]);
}
}
effectcraft_path::PathEl::ClosePath => closed = true,
_ => {}
});
if closed && pts.len() > 1 && pts.first() == pts.last() {
pts.pop();
}
out.push(effectcraft_effects::MaskShape { name: g.name.clone(), points: pts, closed: closed || sp.closed, inverted });
}
out
}
/// Apply the layer's masks to its buffer.
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) {
let Some(masks) = layer.masks() else { return };

View File

@ -169,12 +169,17 @@ fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerC
r.comp_frame(cid, t)
}
fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, params: &[(&str, Value)]) {
/// [`add_effect`] for a 200×100 layer.
fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) {
add_effect(p, l, id, [200.0, 100.0], vals);
}
fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, size: [f64; 2], vals: &[(&str, Value)]) {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
p.next_id = next;
for (k, v) in params {
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v.clone();
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
@ -184,7 +189,7 @@ fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, par
fn cache_scene() -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut bg = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100);
add_effect(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]);
add_effect_200(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]);
let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
{
@ -202,7 +207,7 @@ fn cache_scene() -> (Project, ItemId) {
text.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "AB".into(), size: 40.0, ..Default::default() }));
text.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))];
add_effect(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]);
add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]);
let c = p.comp_mut(cid).unwrap();
c.layers = vec![text, shape, bg];
(p, cid)
@ -269,7 +274,7 @@ fn edits_invalidate_cached_layers() {
"effect added",
Box::new(|p, cid| {
let mut l = p.comp_mut(cid).unwrap().layers[2].clone();
add_effect(p, &mut l, "ec.channel.invert", &[]);
add_effect_200(p, &mut l, "ec.channel.invert", &[]);
p.comp_mut(cid).unwrap().layers[2] = l;
}),
),
@ -323,7 +328,7 @@ fn edits_invalidate_cached_layers() {
fn time_dependent_effects_rerender_every_frame() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
add_effect(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]);
add_effect_200(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]);
p.comp_mut(cid).unwrap().layers.push(l);
let cache = crate::LayerCache::default();
let a = render_cached(&p, cid, Tick::from_seconds_f64(0.1), Some(&cache));
@ -331,3 +336,35 @@ fn time_dependent_effects_rerender_every_frame() {
assert_same(&b, &render_cached(&p, cid, Tick::from_seconds_f64(0.2), None), "noise at 0.2");
assert!(a != b, "noise must animate");
}
#[test]
fn effects_see_layer_masks() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::None, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
add_effect(&mut p, &mut l, "ec.generate.stroke", [200.0, 100.0], &[("brushSize", Value::Scalar(6.0))]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(30, 50)[0] > 0.5, "stroke on the mask edge: {:?}", img.get(30, 50));
assert!(img.get(50, 50)[0] < 0.01, "inside untouched");
assert!(img.get(150, 50)[3] > 0.99, "mode None mask does not cut the layer");
}
#[test]
fn effects_read_layer_params() {
let (mut p, cid, comp) = setup();
let mut src = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
src.switches.video = false;
let src_id = src.id.0;
let mut top = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
add_effect(&mut p, &mut top, "ec.channel.blend", [200.0, 100.0], &[("blendWithLayer", Value::Layer(Some(src_id)))]);
let c = p.comp_mut(cid).unwrap();
c.layers.push(top);
c.layers.push(src);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let px = img.get(100, 50);
assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}");
}