diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 529a8dee..639f57b6 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -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::(); + // 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::>() }); + 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 { 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 { + 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> { + 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)); + } } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 31e786c4..9966bfa9 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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 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 { diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 6e7475ea..67063d31 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -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 { } 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] diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 7427e9d2..0a6663a5 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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 { + let mut stops: Vec<(f32, [f32; 4])> = s + .split_whitespace() + .filter_map(|t| { + let (pos, c) = t.split_once(':')?; + let v: Vec = c.split(',').filter_map(|x| x.trim().parse().ok()).collect(); + (v.len() >= 3).then(|| (pos.trim().parse::().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 { "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]); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 28db9c15..90a85a6d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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"), diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 1e2c25e0..cba69cbe 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -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"); + } } diff --git a/docs/effects.md b/docs/effects.md index f54f62f6..8bb8d9e4 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -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..`) 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..`) 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..`) 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..`) 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 |