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:
commit
1e8768ad18
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -425,7 +425,8 @@ mod tests {
|
|||
for (k, v) in over {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -886,7 +886,8 @@ mod tests {
|
|||
for (k, v) in over {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -690,7 +690,7 @@ mod tests {
|
|||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
|
||||
let ctx = EffectCtx { params: ¶ms, 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
|
|
@ -754,7 +754,7 @@ mod tests {
|
|||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
|
||||
let ctx = EffectCtx { params: ¶ms, 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
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, 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: ¶ms, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false };
|
||||
let ctx = EffectCtx { params: ¶ms, 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: ¶ms, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false };
|
||||
let ctx = EffectCtx { params: ¶ms, 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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,7 +200,8 @@ mod tests {
|
|||
for (k, v) in over {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ mod tests {
|
|||
for (k, v) in vals {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false };
|
||||
let ctx = EffectCtx { params: ¶ms, 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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -698,7 +698,7 @@ mod tests {
|
|||
for (k, v) in vals {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
let ctx = EffectCtx { params: ¶ms, 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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -486,7 +486,8 @@ mod tests {
|
|||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¢re(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -363,7 +363,8 @@ mod tests {
|
|||
for (k, v) in vals {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, 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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment };
|
||||
let ectx = EffectCtx { params: ¶ms, 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() {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue