M9.13: Camera Lens Blur fringe / blur map, Colorama full controls, Selective Color Details

- Camera Lens Blur: Diffraction Fringe, Blur Map (layer, channel, placement, focal
  distance, invert) with per-pixel radius.
- Colorama: Add Phase (layer, from, mode), preset / custom Output Cycle palettes, Modify,
  Modify Alpha, Change Empty Pixels, Pixel Selection, Masking, Composite Over Layer.
- Selective Color: Colors popup and Details twirl-downs (older saves migrate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 04:43:41 -04:00
co-authored by Claude Opus 5.5
parent 3a7c9c5f61
commit 413e3ea97d
7 changed files with 516 additions and 83 deletions
+176 -52
View File
@@ -284,15 +284,9 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
let shape = ctx.params.e("irisProperties/irisShape");
let sides = if shape >= 8 { 0 } else { shape + 3 };
let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0);
let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0;
// Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1).
let aspect = ctx.params.f("irisProperties/irisAspectRatio");
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
for s in spans.iter_mut() {
s.1 = (s.1 as f64 * aspect).round() as i64;
s.2 = (s.2 as f64 * aspect).round() as i64;
}
}
let reach = r * aspect.max(1.0);
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
if !repeat {
@@ -342,58 +336,148 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
v
})
.collect();
let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum();
let norm = 1.0 / count as f64;
let mut out = Image::new(src.width, src.height);
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let mut acc = [0.0f64; 4];
for &(dy, l, rr) in &spans {
let mut yy = y as i64 + dy;
if yy < 0 || yy >= h as i64 {
if !repeat {
continue;
}
yy = yy.clamp(0, h as i64 - 1);
// Sum of the source over the iris spans centred on (x, y).
let span_sum = |spans: &[(i64, i64, i64)], x: usize, y: usize| -> [f64; 4] {
let mut acc = [0.0f64; 4];
for &(dy, l, rr) in spans {
let mut yy = y as i64 + dy;
if yy < 0 || yy >= h as i64 {
if !repeat {
continue;
}
let pre = &prefix[yy as usize];
let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr);
if repeat {
if a < 0 {
let n = (-a).min(bnd - a + 1) as f64;
let first = src.data[yy as usize * w];
for c in 0..4 {
acc[c] += first[c] as f64 * n;
}
a = 0;
}
if bnd >= w as i64 {
let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64;
let last = src.data[yy as usize * w + w - 1];
for c in 0..4 {
acc[c] += last[c] as f64 * n;
}
bnd = w as i64 - 1;
}
} else {
a = a.max(0);
bnd = bnd.min(w as i64 - 1);
}
if a <= bnd {
yy = yy.clamp(0, h as i64 - 1);
}
let pre = &prefix[yy as usize];
let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr);
if repeat {
if a < 0 {
let n = (-a).min(bnd - a + 1) as f64;
let first = src.data[yy as usize * w];
for c in 0..4 {
acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c];
acc[c] += first[c] as f64 * n;
}
a = 0;
}
if bnd >= w as i64 {
let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64;
let last = src.data[yy as usize * w + w - 1];
for c in 0..4 {
acc[c] += last[c] as f64 * n;
}
bnd = w as i64 - 1;
}
} else {
a = a.max(0);
bnd = bnd.min(w as i64 - 1);
}
if a <= bnd {
for c in 0..4 {
acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c];
}
}
let mut px = acc.map(|v| (v * norm) as f32);
px[3] = px[3].clamp(0.0, 1.0);
if linear {
let (c, a) = unpremul(px);
px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a);
}
*o = px;
}
acc
};
// Diffraction Fringe: the iris edge (outer 20 % of the radius) gets more of the energy:
// 0 = an even disc, 100 = a natural halo, 500 = all of it in the ring.
let fringe = (ctx.params.f("irisProperties/diffractionFringe") / 500.0).clamp(0.0, 1.0);
let (w_in, w_ring) = (1.0 - fringe, 1.0 + 4.0 * fringe);
let iris = |radius: f64| -> (Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>) {
let stretch = |mut s: Vec<(i64, i64, i64)>| {
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
for v in s.iter_mut() {
v.1 = (v.1 as f64 * aspect).round() as i64;
v.2 = (v.2 as f64 * aspect).round() as i64;
}
}
s
};
let outer = stretch(iris_spans(radius, sides, ctx.params.f("irisProperties/irisRotation"), roundness));
let inner = if fringe > 0.0 && radius * 0.8 >= 1.0 {
stretch(iris_spans(radius * 0.8, sides, ctx.params.f("irisProperties/irisRotation"), roundness))
} else {
vec![]
};
(outer, inner)
};
let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::<f64>();
// One blurred pixel with the given iris.
let blur_px = |sp: &(Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>), x: usize, y: usize| -> [f32; 4] {
let (outer, inner) = sp;
let acc = if inner.is_empty() {
let n = count(outer).max(1.0);
span_sum(outer, x, y).map(|v| v / n)
} else {
let (so, si) = (span_sum(outer, x, y), span_sum(inner, x, y));
let (no, ni) = (count(outer), count(inner));
let norm = (w_in * ni + w_ring * (no - ni)).max(1e-9);
std::array::from_fn(|c| (w_in * si[c] + w_ring * (so[c] - si[c])) / norm)
};
let mut px = acc.map(|v| v as f32);
px[3] = px[3].clamp(0.0, 1.0);
if linear {
let (c, a) = unpremul(px);
px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a);
}
px
};
// Blur Map: per-pixel radius from a map layer (no blur at Blur Focal Distance, full radius
// at the farthest value).
let map = ctx.layer_param("blurMap/blurMapLayer", true).map(|o| {
let stretch = ctx.params.e("blurMap/placement") == 1;
let img = crate::util::fit_layer(ctx, &b, &o, stretch);
let ch = ctx.params.e("blurMap/channel");
let invert = ctx.params.b("blurMap/invertBlurMap");
let focal = (ctx.params.f("blurMap/blurFocalDistance") / 255.0).clamp(0.0, 1.0) as f32;
let span = focal.max(1.0 - focal).max(1e-3);
img.data
.iter()
.map(|p| {
let (c, a) = unpremul(*p);
let mut v = match ch {
1 => a,
2 => c[0].max(c[1]).max(c[2]) * a,
_ => luminance(c[0], c[1], c[2]) * a,
};
if invert {
v = 1.0 - v;
}
((v - focal).abs() / span).clamp(0.0, 1.0)
})
.collect::<Vec<f32>>()
});
let mut out = Image::new(src.width, src.height);
match &map {
None => {
let sp = iris(r);
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
*o = blur_px(&sp, x, y);
}
});
}
Some(m) => {
// Radii quantised to levels; each pixel blends the two nearest.
let levels = (r.ceil() as usize).clamp(1, 8);
let irises: Vec<_> = (0..=levels).map(|k| iris(r * k as f64 / levels as f64)).collect();
let srcd = &b.img.data;
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let f = m[y * w + x] * levels as f32;
let k0 = (f.floor() as usize).min(levels);
let t = f - k0 as f32;
let at = |k: usize| if k == 0 || r * k as f64 / (levels as f64) < 0.5 { srcd[y * w + x] } else { blur_px(&irises[k], x, y) };
let a = at(k0);
*o = if t > 1e-4 && k0 < levels {
let bq = at(k0 + 1);
std::array::from_fn(|c| a[c] + (bq[c] - a[c]) * t)
} else {
a
};
}
});
}
}
b.img = out;
b
}
@@ -598,6 +682,12 @@ pub fn specs() -> Vec<EffectSpec> {
p("irisProperties/irisRoundness", "Roundness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("irisProperties/irisAspectRatio", "Aspect Ratio", num(1.0), slider(0.01, 100.0, 0.25, 4.0, 2)),
p("irisProperties/irisRotation", "Rotation", num(0.0), ParamUi::Angle),
p("irisProperties/diffractionFringe", "Diffraction Fringe", num(0.0), slider(0.0, 500.0, 0.0, 500.0, 1)),
p("blurMap/blurMapLayer", "Layer", Value::Layer(None), ParamUi::Layer),
p("blurMap/channel", "Channel", Value::Enum(0), popup(&["Luminance", "Alpha", "Color"])),
p("blurMap/placement", "Placement", Value::Enum(1), popup(&["Center", "Stretch"])),
p("blurMap/blurFocalDistance", "Blur Focal Distance", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("blurMap/invertBlurMap", "Invert Blur Map", Value::Bool(false), ParamUi::Checkbox),
p("highlight/specularBrightness", "Gain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("highlight/specularThreshold", "Threshold", num(255.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("highlight/highlightSaturation", "Saturation", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
@@ -816,4 +906,38 @@ mod tests {
let pad = (out.img.width - 16) / 2;
assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005);
}
#[test]
fn camera_lens_blur_diffraction_fringe_and_blur_map() {
use crate::{EffectEnv, EffectHost, LayerPixels};
// A single bright point spreads into a disc; with Diffraction Fringe its rim is brighter.
let mut dot = Image::filled(41, 41, [0.0, 0.0, 0.0, 1.0]);
dot.set(20, 20, [1.0, 1.0, 1.0, 1.0]);
let v = [("blurRadius", num(10.0)), ("irisProperties/irisShape", Value::Enum(8)), ("repeatEdge", Value::Bool(true))];
let plain = run("ec.blur.cameralens", &dot, &v).img;
let ring = run("ec.blur.cameralens", &dot, &[v.as_slice(), &[("irisProperties/diffractionFringe", num(300.0))]].concat()).img;
assert!((plain.get(20, 20)[0] - plain.get(29, 20)[0]).abs() < 1e-5, "even disc");
assert!(ring.get(29, 20)[0] > ring.get(20, 20)[0] * 2.0, "{:?} {:?}", ring.get(29, 20), ring.get(20, 20));
// Blur Map: black (focal distance 0) stays sharp, white gets the full radius.
struct Half;
impl EffectHost for Half {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
let mut m = Image::filled(40, 20, [0.0, 0.0, 0.0, 1.0]);
for y in 0..20 {
for x in 20..40 {
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
Some(LayerPixels { buf: Buf { img: m, offset: [0.0; 2], scale: 1.0 }, size: [40.0, 20.0] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
let stripes = crate::util::gen_image(40, 20, |x, _| if x % 4 < 2 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] });
let vals = [("blurRadius", num(6.0)), ("repeatEdge", Value::Bool(true)), ("blurMap/blurMapLayer", Value::Layer(Some(1)))];
let out = crate::run_fx("ec.blur.cameralens", &vals, stripes.clone(), 0.0, EffectEnv { host: Some(&Half), ..Default::default() }).img;
assert_eq!(out.get(4, 10), stripes.get(4, 10), "sharp where the map is at the focal distance");
assert!((out.get(30, 10)[0] - 0.5).abs() < 0.2, "{:?}", out.get(30, 10));
}
}
+35 -3
View File
@@ -8,9 +8,6 @@ use crate::{CATEGORIES, registry};
/// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far
/// as the public behaviour documentation describes it.
pub const PARTIAL: &[(&str, &str)] = &[
("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"),
("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"),
("ec.color.selectivecolor", "simplified Colors / Details layout"),
("ec.distort.reshape", "no correspondence points"),
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
@@ -61,6 +58,13 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option<eff
_ => true,
}
}
"ec.color.selectivecolor" => match param.strip_prefix("details/") {
Some(rest) => {
let g = rest.split('/').next().unwrap_or("");
crate::color2::SC_GROUPS.iter().position(|x| *x == g).is_none_or(|i| i == e("colors") as usize)
}
None => true,
},
"ec.sim.carddance" | "ec.transition.cardwipe" => crate::card3d::shown(param, &e).unwrap_or(true),
"ec.sim.shatter" => match param {
"shape/customShatterMap" | "shape/whiteTilesFixed" => e("shape/pattern") == 5,
@@ -143,6 +147,34 @@ mod tests {
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
}
#[test]
fn popups_choose_the_shown_controls() {
use effectcraft_keyframe::Value;
let with = |pairs: &'static [(&'static str, u32)]| move |id: &str| pairs.iter().find(|(k, _)| *k == id).map(|(_, v)| Value::Enum(*v));
let shown = |e: &str, p: &str, pairs: &'static [(&'static str, u32)]| super::param_shown(e, p, &with(pairs));
// Levels: RGB shows the master controls only; Red shows the red ones.
assert!(shown("ec.color.levels", "inBlack", &[]));
assert!(!shown("ec.color.levels", "redInBlack", &[]));
assert!(shown("ec.color.levels", "redInBlack", &[("channel", 1)]));
assert!(!shown("ec.color.levels", "inBlack", &[("channel", 1)]));
assert!(shown("ec.color.levels", "channel", &[("channel", 1)]));
// Hue/Saturation: Master or one colour range.
assert!(shown("ec.color.huesaturation", "hue", &[]));
assert!(shown("ec.color.huesaturation", "bluesHue", &[("channelControl", 5)]));
assert!(!shown("ec.color.huesaturation", "redsHue", &[("channelControl", 5)]));
// Card effects: the camera system's group.
assert!(shown("ec.sim.carddance", "cameraPosition", &[]));
assert!(!shown("ec.sim.carddance", "cornerPins", &[]));
assert!(shown("ec.transition.cardwipe", "cornerPins/upperLeftCorner", &[("cameraSystem", 1)]));
assert!(!shown("ec.sim.shatter", "shape/customShatterMap", &[]));
// Selective Color: the chosen range's Details group.
assert!(shown("ec.color.selectivecolor", "details/reds", &[]));
assert!(!shown("ec.color.selectivecolor", "details/blues/bluesCyan", &[]));
assert!(shown("ec.color.selectivecolor", "details", &[]));
// Everything else is always shown.
assert!(shown("ec.blur.gaussian", "blurriness", &[]));
}
#[test]
fn partial_notes_name_registered_effects() {
for (id, note) in super::PARTIAL {
+18 -6
View File
@@ -504,6 +504,16 @@ const SC_IDS: [[&str; 4]; 9] = [
["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"],
["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"],
];
/// Selective Color's Colors popup (the range whose controls Effect Controls shows) and the
/// Details twirl-down group of each range.
pub const SC_COLORS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"];
pub const SC_GROUPS: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"];
/// Spec id of range `r`'s control `leaf` (`details/reds/redsCyan`).
fn sc_param_id(r: usize, leaf: &str) -> String {
format!("details/{}/{leaf}", SC_GROUPS[r])
}
const SC_NAMES: [[&str; 4]; 9] = [
["Reds Cyan", "Reds Magenta", "Reds Yellow", "Reds Black"],
["Yellows Cyan", "Yellows Magenta", "Yellows Yellow", "Yellows Black"],
@@ -534,7 +544,7 @@ fn sc_weights(c: [f32; 3]) -> [f32; 9] {
}
fn selective_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let adj = SC_IDS.map(|ids| ids.map(|id| ctx.params.f(id) as f32 / 100.0));
let adj: [[f32; 4]; 9] = std::array::from_fn(|r| SC_IDS[r].map(|id| ctx.params.f(&sc_param_id(r, id)) as f32 / 100.0));
if adj.iter().all(|r| r.iter().all(|v| *v == 0.0)) {
return b;
}
@@ -882,10 +892,12 @@ pub fn specs() -> Vec<EffectSpec> {
}
lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"]))];
for (ids, names) in SC_IDS.iter().zip(SC_NAMES.iter()) {
let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"])), p("colors", "Colors", Value::Enum(0), popup(&SC_COLORS))];
// Details: every range's Cyan / Magenta / Yellow / Black in its own twirl-down.
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
for (r, (ids, names)) in SC_IDS.iter().zip(SC_NAMES.iter()).enumerate() {
for k in 0..4 {
sc_params.push(p(ids[k], names[k], num(0.0), pct()));
sc_params.push(p(leak(sc_param_id(r, ids[k])), names[k], num(0.0), pct()));
}
}
let clip_params = |extra: bool| {
@@ -1146,10 +1158,10 @@ mod tests {
let img = ramp();
assert_eq!(run("ec.color.selectivecolor", &img, &[]), img);
let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]);
let out = run("ec.color.selectivecolor", &red, &[("redsCyan", num(100.0)), ("method", Value::Enum(1))]);
let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]);
assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]);
let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]);
assert_eq!(run("ec.color.selectivecolor", &blue, &[("redsCyan", num(100.0))]), blue);
assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue);
}
#[test]
+254 -17
View File
@@ -634,28 +634,204 @@ fn colorama_phase(c: [f32; 3], a: f32, mode: u32) -> f32 {
v.clamp(0.0, 1.0)
}
/// Colorama with the built-in hue-cycle output palette: the chosen input attribute, shifted by
/// Phase Shift and repeated Cycle Repetitions times, picks a colour on the cycle (Interpolate
/// Palette off posterizes to the palette's six colours).
/// Colorama's Output Cycle presets (our own palettes; Custom uses the Palette parameter).
pub const COLORAMA_PRESETS: [&str; 9] = ["Hue Cycle", "Ramp Grayscale", "Fire", "Ice", "Ramp Red", "Ramp Green", "Ramp Blue", "Pastel Cycle", "Custom"];
/// Colorama's Modify options: which channels of the pixel take the palette colour.
const COLORAMA_MODIFY: [&str; 12] =
["All", "Red", "Green", "Blue", "Hue", "Lightness", "Saturation", "Value", "Red & Green", "Green & Blue", "Blue & Red", "None"];
/// An output cycle: colour stops around the wheel (position 0..1, straight RGBA), cyclic.
#[derive(Clone, Debug, PartialEq)]
pub struct ColoramaPalette {
pub stops: Vec<(f32, [f32; 4])>,
}
impl ColoramaPalette {
/// Built-in palette `i` of [`COLORAMA_PRESETS`].
pub fn preset(i: u32) -> ColoramaPalette {
let s = |v: &[(f32, [f32; 3])]| ColoramaPalette { stops: v.iter().map(|(p, c)| (*p, [c[0], c[1], c[2], 1.0])).collect() };
match i {
1 => s(&[(0.0, [0.0; 3]), (0.999, [1.0; 3])]),
2 => s(&[(0.0, [0.0, 0.0, 0.0]), (0.3, [0.7, 0.05, 0.0]), (0.6, [1.0, 0.55, 0.0]), (0.85, [1.0, 0.95, 0.4]), (0.999, [1.0, 1.0, 1.0])]),
3 => s(&[(0.0, [0.0, 0.0, 0.1]), (0.4, [0.1, 0.3, 0.8]), (0.75, [0.5, 0.85, 1.0]), (0.999, [1.0, 1.0, 1.0])]),
4 => s(&[(0.0, [0.0; 3]), (0.999, [1.0, 0.0, 0.0])]),
5 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 1.0, 0.0])]),
6 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 0.0, 1.0])]),
7 => s(&[(0.0, [1.0, 0.7, 0.7]), (1.0 / 3.0, [0.7, 1.0, 0.7]), (2.0 / 3.0, [0.7, 0.7, 1.0])]),
_ => s(&[
(0.0, [1.0, 0.0, 0.0]),
(1.0 / 6.0, [1.0, 1.0, 0.0]),
(2.0 / 6.0, [0.0, 1.0, 0.0]),
(3.0 / 6.0, [0.0, 1.0, 1.0]),
(4.0 / 6.0, [0.0, 0.0, 1.0]),
(5.0 / 6.0, [1.0, 0.0, 1.0]),
]),
}
}
/// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
pub fn parse(s: &str) -> Option<ColoramaPalette> {
let mut stops: Vec<(f32, [f32; 4])> = s
.split_whitespace()
.filter_map(|t| {
let (pos, c) = t.split_once(':')?;
let v: Vec<f32> = c.split(',').filter_map(|x| x.trim().parse().ok()).collect();
(v.len() >= 3).then(|| (pos.trim().parse::<f32>().ok().map(|p| p.rem_euclid(1.0)), [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]))
})
.filter_map(|(p, c)| p.map(|p| (p, c)))
.collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
(!stops.is_empty()).then_some(ColoramaPalette { stops })
}
/// The colour at phase `ph` (0..1). Interpolate Palette blends between neighbouring stops
/// (around the wheel); off, each phase takes the stop at or before it.
pub fn eval(&self, ph: f32, interpolate: bool) -> [f32; 4] {
let n = self.stops.len();
let ph = ph.rem_euclid(1.0);
let k = self.stops.iter().rposition(|s| s.0 <= ph).unwrap_or(n - 1);
let (p0, c0) = self.stops[k];
if !interpolate || n == 1 {
return c0;
}
let (p1, c1) = self.stops[(k + 1) % n];
let span = (p1 - p0).rem_euclid(1.0).max(1e-6);
let t = ((ph - p0).rem_euclid(1.0) / span).clamp(0.0, 1.0);
std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * t)
}
}
/// How well colour `c` matches `key` (1 = matches) for Pixel Selection's Matching Mode
/// (1 RGB, 2 Hue, 3 Chroma), with Matching Tolerance / Softness (0..1).
fn colorama_match(c: [f32; 3], key: [f32; 3], mode: u32, tol: f32, soft: f32) -> f32 {
let d = match mode {
2 => {
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();
dh.min(1.0 - dh) * 2.0 + (1.0 - s1.min(1.0)) * 0.5
}
3 => {
// Chroma: the colour without its brightness.
let n = |c: [f32; 3]| {
let s = c[0] + c[1] + c[2];
if s > 1e-5 { [c[0] / s, c[1] / s, c[2] / s] } else { [1.0 / 3.0; 3] }
};
let (a, b) = (n(c), n(key));
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() * 1.5
}
_ => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(),
};
if d <= tol {
1.0
} else if soft > 0.0 {
(1.0 - (d - tol) / soft).clamp(0.0, 1.0)
} else {
0.0
}
}
/// Colorama: a phase taken from the pixel (Get Phase From, optionally combined with an Add
/// Phase layer), shifted and repeated around the Output Cycle, picks a palette colour, which
/// replaces the channels Modify names. Pixel Selection limits it to colours near Matching
/// Color; Masking to where a mask layer allows; Blend With Original mixes the result back.
fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf {
let shift = ctx.params.f("inputPhase/phaseShift") as f32 / 360.0;
let mode = ctx.params.e("inputPhase/mode");
let cycles = ctx.params.f("outputCycle/cycles").max(0.0) as f32;
let smooth = ctx.params.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool());
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.data.iter_mut().for_each(|px| {
let pr = ctx.params;
let shift = pr.f("inputPhase/phaseShift") as f32 / 360.0;
let mode = pr.e("inputPhase/mode");
let cycles = pr.f("outputCycle/cycles").max(0.0) as f32;
let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool());
let preset = pr.e("outputCycle/usePresetPalette");
let palette = if preset as usize == COLORAMA_PRESETS.len() - 1 { ColoramaPalette::parse(pr.s("outputCycle/palette")) } else { None }
.unwrap_or_else(|| ColoramaPalette::preset(preset));
let blend = 1.0 - pr.f("blend") as f32 / 100.0;
// Add Phase.
let add = ctx.layer_param("inputPhase/addPhase", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true));
let add_from = pr.e("inputPhase/addPhaseFrom");
let add_mode = pr.e("inputPhase/addMode");
// Modify.
let modify = pr.e("modify/modify");
let modify_alpha = pr.b("modify/modifyAlpha");
let change_empty = pr.b("modify/changeEmptyPixels");
// Pixel Selection.
let match_mode = pr.e("pixelSelection/matchingMode");
let key = pr.color("pixelSelection/matchingColor");
let tol = (pr.f("pixelSelection/matchingTolerance") / 100.0) as f32;
let soft = (pr.f("pixelSelection/matchingSoftness") / 100.0) as f32;
// Masking.
let mask_mode = pr.e("masking/maskingMode");
let mask = (mask_mode != 0).then(|| ctx.layer_param("masking/maskLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true))).flatten();
let composite = pr.get("masking/compositeOverLayer").is_none_or(|v| v.as_bool());
use rayon::prelude::*;
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let a = px[3];
if a <= 0.0 {
if a <= 0.0 && !change_empty {
return;
}
let c = [px[0] / a, px[1] / a, px[2] / a];
let mut ph = (colorama_phase(c, a, mode) * cycles + shift).rem_euclid(1.0);
if !smooth {
ph = (ph * 6.0).floor() / 6.0;
let c = if a > 0.0 { [px[0] / a, px[1] / a, px[2] / a] } else { [0.0; 3] };
let mut p = colorama_phase(c, a, mode);
if let Some(img) = &add {
let (ac, aa) = crate::util::unpremul(img.data[i]);
let q = colorama_phase(ac, aa, add_from);
p = match add_mode {
1 => (p + q).min(1.0),
2 => (p + q) * 0.5,
3 => p + q - p * q,
_ => (p + q).rem_euclid(1.0),
};
}
let (r, g, bl) = effectcraft_color::hsv_to_rgb(ph, 1.0, 1.0);
let o = mix(c, [r, g, bl], blend);
*px = [o[0] * a, o[1] * a, o[2] * a, a];
let ph = (p * cycles + shift).rem_euclid(1.0);
let pc = palette.eval(ph, smooth);
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (ph_, ps, pl) = rgb_to_hsl(pc[0], pc[1], pc[2]);
let hl = |h: f32, s: f32, l: f32| {
let (r, g, bb) = hsl_to_rgb(h, s, l);
[r, g, bb]
};
let new: [f32; 3] = match modify {
1 => [pc[0], c[1], c[2]],
2 => [c[0], pc[1], c[2]],
3 => [c[0], c[1], pc[2]],
4 => hl(ph_, s, l),
5 => hl(h, s, pl),
6 => hl(h, ps, l),
7 => {
// Value: scale the colour to the palette colour's maximum.
let (m, pm) = (c[0].max(c[1]).max(c[2]), pc[0].max(pc[1]).max(pc[2]));
if m > 1e-6 { c.map(|v| v * pm / m) } else { [pm; 3] }
}
8 => [pc[0], pc[1], c[2]],
9 => [c[0], pc[1], pc[2]],
10 => [pc[0], c[1], pc[2]],
11 => c,
_ => [pc[0], pc[1], pc[2]],
};
let new_a = if modify_alpha {
pc[3]
} else if a > 0.0 {
a
} else {
1.0
};
// Where the effect applies: Pixel Selection × Masking.
let mut w = if match_mode == 0 { 1.0 } else { colorama_match(c, [key[0], key[1], key[2]], match_mode, tol, soft) };
if let Some(m) = &mask {
let (mc, ma) = crate::util::unpremul(m.data[i]);
let lum = luminance(mc[0], mc[1], mc[2]) * ma;
w *= match mask_mode {
1 => lum,
2 => 1.0 - lum,
3 => ma,
_ => 1.0 - ma,
}
.clamp(0.0, 1.0);
}
let k = w * blend;
let o = mix(c, new, k);
let oa = a + (new_a - a) * k;
// Composite Over Layer off: only the affected pixels remain.
let oa = if composite { oa } else { oa * w };
*px = [o[0] * oa, o[1] * oa, o[2] * oa, oa.clamp(0.0, 1.0)];
});
b
}
@@ -919,9 +1095,30 @@ pub fn specs() -> Vec<EffectSpec> {
"Colorama",
vec![
p("inputPhase/mode", "Get Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)),
p("inputPhase/addPhase", "Add Phase", Value::Layer(None), ParamUi::Layer),
p("inputPhase/addPhaseFrom", "Add Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)),
p("inputPhase/addMode", "Add Mode", Value::Enum(0), popup(&["Wrap", "Clamp", "Average", "Screen"])),
p("inputPhase/phaseShift", "Phase Shift", num(0.0), ParamUi::Angle),
p("outputCycle/usePresetPalette", "Use Preset Palette", Value::Enum(0), popup(&COLORAMA_PRESETS)),
// Use Preset Palette = Custom: "position:r,g,b[,a] …" around the wheel.
p(
"outputCycle/palette",
"Output Cycle",
Value::Str("0:1,0,0 0.1667:1,1,0 0.3333:0,1,0 0.5:0,1,1 0.6667:0,0,1 0.8333:1,0,1".into()),
ParamUi::Text,
),
p("outputCycle/cycles", "Cycle Repetitions", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
p("outputCycle/interpolatePalette", "Interpolate Palette", Value::Bool(true), ParamUi::Checkbox),
p("modify/modify", "Modify", Value::Enum(0), popup(&COLORAMA_MODIFY)),
p("modify/modifyAlpha", "Modify Alpha", Value::Bool(false), ParamUi::Checkbox),
p("modify/changeEmptyPixels", "Change Empty Pixels", Value::Bool(false), ParamUi::Checkbox),
p("pixelSelection/matchingColor", "Matching Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("pixelSelection/matchingTolerance", "Matching Tolerance", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("pixelSelection/matchingSoftness", "Matching Softness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("pixelSelection/matchingMode", "Matching Mode", Value::Enum(0), popup(&["Off", "RGB", "Hue", "Chroma"])),
p("masking/maskLayer", "Mask Layer", Value::Layer(None), ParamUi::Layer),
p("masking/maskingMode", "Masking Mode", Value::Enum(0), popup(&["Off", "Luminance", "Inverted Luminance", "Alpha", "Inverted Alpha"])),
p("masking/compositeOverLayer", "Composite Over Layer", Value::Bool(true), ParamUi::Checkbox),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
colorama,
@@ -1158,4 +1355,44 @@ mod tests {
let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img;
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn colorama_palettes_modify_selection_masking_and_add_phase() {
let grey = [0.5, 0.5, 0.5, 1.0];
let red = [1.0, 0.0, 0.0, 1.0];
// Ramp Grayscale: intensity 0.5 maps to mid grey.
let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey);
assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}");
// Custom palette: two stops, black at 0 and blue at 0.5.
let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))];
let c = px("ec.color.colorama", &custom, grey);
assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}");
// Modify Lightness keeps the hue of the pixel (red stays red).
let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red);
assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}");
// Pixel Selection: only colours near Matching Color change.
let sel = [
("pixelSelection/matchingMode", Value::Enum(1)),
("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)),
("pixelSelection/matchingTolerance", num(10.0)),
];
assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6));
assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3));
// Composite Over Layer off: unselected pixels vanish.
let mut off = sel.to_vec();
off.push(("masking/compositeOverLayer", Value::Bool(false)));
assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0);
// Modify Alpha takes the palette alpha.
let alpha = [
("outputCycle/usePresetPalette", Value::Enum(8)),
("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())),
("modify/modifyAlpha", Value::Bool(true)),
];
assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5);
// Palette parsing and evaluation.
let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap();
assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]);
assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around");
assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]);
}
}
+14
View File
@@ -514,6 +514,19 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("toleranceGroup", "Tolerance"),
("inputPhase", "Input Phase"),
("outputCycle", "Output Cycle"),
("modify", "Modify"),
("pixelSelection", "Pixel Selection"),
("masking", "Masking"),
("details", "Details"),
("reds", "Reds"),
("yellows", "Yellows"),
("greens", "Greens"),
("cyans", "Cyans"),
("blues", "Blues"),
("magentas", "Magentas"),
("whites", "Whites"),
("neutrals", "Neutrals"),
("blacks", "Blacks"),
("moreOptions", "More Options"),
("basicCorrection", "Basic Correction"),
("whiteBalance", "White Balance"),
@@ -532,6 +545,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
// Camera Lens Blur.
("irisProperties", "Iris Properties"),
("highlight", "Highlight"),
("blurMap", "Blur Map"),
// Numbers.
("format", "Format"),
("fillAndStroke", "Fill and Stroke"),
+14
View File
@@ -421,4 +421,18 @@ mod tests {
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed");
}
#[test]
fn selective_color_ranges_move_into_details() {
let spec = find("ec.color.selectivecolor").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Selective Color", [100.0, 50.0]);
let mut pr = take_at(&mut g, "details/reds/redsCyan").unwrap();
pr.value = Value::Scalar(40.0);
g.children.push(pr.into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert!(g.get("redsCyan").is_none());
assert_eq!(prop_at(&mut g, "details/reds/redsCyan").unwrap().value, Value::Scalar(40.0));
assert_eq!(g.sub("details").unwrap().sub("reds").unwrap().name, "Reds");
}
}
+5 -5
View File
@@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 303 effects with 2751 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
EffectCraft ships 303 effects with 2773 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
- **GPU**: 24 effects also run on the GPU compositor with identical results.
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 284 implemented in full, 19 partial (what is missing is listed).
- **Status**: 287 implemented in full, 16 partial (what is missing is listed).
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
@@ -51,7 +51,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented |
| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | | 32 | Implemented |
| CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented |
| Camera Lens Blur | `ec.blur.cameralens` | 10 | | 32 | Partial: no Diffraction Fringe or Blur Map layer |
| Camera Lens Blur | `ec.blur.cameralens` | 16 | | 32 | Implemented |
| Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented |
| Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented |
| Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented |
@@ -102,7 +102,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented |
| Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented |
| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented |
| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking |
| Colorama | `ec.color.colorama` | 20 | | 32 | Implemented |
| Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented |
| Equalize | `ec.color.equalize` | 2 | | 32 | Implemented |
| Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented |
@@ -119,7 +119,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented |
| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented |
| Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented |
| Selective Color | `ec.color.selectivecolor` | 37 | | 32 | Partial: simplified Colors / Details layout |
| Selective Color | `ec.color.selectivecolor` | 38 | | 32 | Implemented |
| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented |
| Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented |
| Tritone | `ec.color.tritone` | 4 | | 32 | Implemented |