effectcraft/crates/effects/src/channel.rs

662 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Channel effects: channel routing (Set/Shift Channels, Channel Combiner, Set Matte), morphology
//! (Minimax), per-channel arithmetic, solid compositing and un-matting.
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::{Plane, SRC_NAMES, Src, join, layer_or_self, morph_plane, pick, premul, src_at, 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: "Channel", params, render, gpu: false, float: true }
}
/// Route four sources into R, G, B, A (straight colour), premultiplying the result.
fn route(b: &mut Buf, srcs: [Src; 4]) {
if srcs == [Src::Red, Src::Green, Src::Blue, Src::Alpha] {
return;
}
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = unpremul(*px);
let v = srcs.map(|s| pick(s, c, a));
*px = premul([v[0], v[1], v[2]], v[3].clamp(0.0, 1.0));
});
}
const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"];
fn set_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = ["setRedTo", "setGreenTo", "setBlueTo", "setAlphaTo"].map(|id| src_at(ctx.params.e(id)));
// Source layers default to the effect's own layer ("None").
let stretch = ctx.params.b("stretchLayersToFit");
let layers: Vec<Option<crate::Image>> =
SET_CHANNEL_LAYERS.iter().map(|id| ctx.layer_param(id, true).map(|o| crate::util::fit_layer(ctx, &b, &o, stretch))).collect();
if layers.iter().all(Option::is_none) {
route(&mut b, s);
return b;
}
let own = b.img.clone();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let v: [f32; 4] = std::array::from_fn(|k| {
let q = layers[k].as_ref().map(|l| l.data[i]).unwrap_or(own.data[i]);
let (c, a) = unpremul(q);
pick(s[k], c, a)
});
*px = premul([v[0], v[1], v[2]], v[3].clamp(0.0, 1.0));
});
b
}
const SHIFT_ORDER: [Src; 11] =
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off];
const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"];
fn shift_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(10)]);
route(&mut b, s);
b
}
fn rgb_to_yuv(c: [f32; 3]) -> [f32; 3] {
let y = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
[y, 0.492 * (c[2] - y) + 0.5, 0.877 * (c[0] - y) + 0.5]
}
fn yuv_to_rgb(c: [f32; 3]) -> [f32; 3] {
let (y, u, v) = (c[0], c[1] - 0.5, c[2] - 0.5);
let r = y + v / 0.877;
let b = y + u / 0.492;
let g = (y - 0.299 * r - 0.114 * b) / 0.587;
[r, g, b]
}
/// Channel Combiner's From options.
const COMBINER_FROM: [&str; 14] = [
"RGB to HLS",
"HLS to RGB",
"RGB to YUV",
"YUV to RGB",
"Red",
"Green",
"Blue",
"Alpha",
"Lightness",
"Hue",
"Saturation",
"Luminance",
"Max RGB",
"Min RGB",
];
/// Channel Combiner's To options (a single value goes into one channel).
const COMBINER_TO: [&str; 8] = ["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale"];
fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf {
let from = ctx.params.e("from");
let to = ctx.params.e("to");
let inv = ctx.params.b("invert");
let solid = ctx.params.b("solidAlpha");
// Use 2nd Layer: read the input values from the source layer instead.
let second = if ctx.params.b("useSecondLayer") { Some(layer_or_self(ctx, &b, "sourceLayer", true, true)) } else { None };
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, mut a) = unpremul(*px);
let (sc, sa) = match &second {
Some(img) => unpremul(img.data[i]),
None => (c, a),
};
if sa <= 0.0 && from != 7 && second.is_none() {
if solid {
*px = premul(c, 1.0);
}
return;
}
let iv = |v: f32| if inv { 1.0 - v } else { v };
let out = match from {
0..=3 => {
let o = match from {
0 => {
let (h, s, l) = rgb_to_hsl(sc[0], sc[1], sc[2]);
[h, l, s]
}
1 => {
let (r, g, bb) = hsl_to_rgb(sc[0], sc[2], sc[1]);
[r, g, bb]
}
2 => rgb_to_yuv(sc),
_ => yuv_to_rgb(sc),
};
o.map(iv)
}
_ => {
let v = iv(match from {
4 => sc[0],
5 => sc[1],
6 => sc[2],
7 => sa,
8 => rgb_to_hsl(sc[0], sc[1], sc[2]).2,
9 => rgb_to_hsl(sc[0], sc[1], sc[2]).0,
10 => rgb_to_hsl(sc[0], sc[1], sc[2]).1,
11 => luminance(sc[0], sc[1], sc[2]),
12 => sc[0].max(sc[1]).max(sc[2]),
_ => sc[0].min(sc[1]).min(sc[2]),
});
// The single value goes to one channel; the others are set to fixed values
// (zero for the other colour channels, white for Alpha Only, 50 % lightness /
// full saturation for Hue Only, …).
match to {
0 => [v, 0.0, 0.0],
1 => [0.0, v, 0.0],
2 => [0.0, 0.0, v],
3 => {
a = v.clamp(0.0, 1.0);
[1.0, 1.0, 1.0]
}
5 => {
let (r, g, bb) = hsl_to_rgb(v, 1.0, 0.5);
[r, g, bb]
}
6 => {
let (r, g, bb) = hsl_to_rgb(0.0, v.clamp(0.0, 1.0), 0.5);
[r, g, bb]
}
_ => [v, v, v],
}
}
};
if solid {
a = 1.0;
}
*px = premul(out.map(|v| v.max(0.0)), a);
});
b
}
/// Minimax's Direction options.
pub(crate) const MINIMAX_DIRECTIONS: [&str; 3] = ["Horizontal & Vertical", "Just Horizontal", "Just Vertical"];
fn minimax(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;
}
let op = ctx.params.e("operation");
let ch = ctx.params.e("channel");
let (rx, ry) = match ctx.params.e("direction") {
1 => (r, 0),
2 => (0, r),
_ => (r, r),
};
// Which straight channels (R, G, B, A) to process.
let sel: [bool; 4] = match ch {
1 => [false, false, false, true],
2 => [true, true, true, true],
3 => [true, false, false, false],
4 => [false, true, false, false],
5 => [false, false, true, false],
_ => [true, true, true, false],
};
if sel[3] && op != 0 && !ctx.adjustment {
b.pad((rx.max(ry)) as u32);
}
let mut planes = [0, 1, 2, 3].map(|k| Plane::from_image(&b.img, |px| if k == 3 { px[3] } else { unpremul(px).0[k] }));
for (k, pl) in planes.iter_mut().enumerate() {
if !sel[k] {
continue;
}
*pl = match op {
0 => morph_plane(pl, rx, ry, false),
1 => morph_plane(pl, rx, ry, true),
2 => morph_plane(&morph_plane(pl, rx, ry, false), rx, ry, true),
_ => morph_plane(&morph_plane(pl, rx, ry, true), rx, ry, false),
};
}
let mut img = join(&planes);
img.data.par_iter_mut().for_each(|px| {
let a = px[3].clamp(0.0, 1.0);
*px = premul([px[0], px[1], px[2]], a);
});
b.img = img;
b
}
fn arithmetic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let op = ctx.params.e("operator");
let k = ["redValue", "greenValue", "blueValue"].map(|id| ctx.params.f(id) as f32 / 255.0);
let clip = ctx.params.b("clip");
b.img.map_straight(|c| {
let mut o = [0.0f32; 3];
for i in 0..3 {
let (v, kv) = (c[i], k[i]);
let iv = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let ik = (kv * 255.0).round() as u8;
let r = match op {
0 => (iv & ik) as f32 / 255.0,
1 => (iv | ik) as f32 / 255.0,
2 => (iv ^ ik) as f32 / 255.0,
3 => v + kv,
4 => v - kv,
5 => (v - kv).abs(),
6 => v.min(kv),
7 => v.max(kv),
8 => {
if v > kv {
0.0
} else {
v
}
}
9 => {
if v < kv {
0.0
} else {
v
}
}
10 => {
if v >= kv {
1.0
} else {
0.0
}
}
11 => v * kv,
_ => 1.0 - (1.0 - v) * (1.0 - kv),
};
o[i] = if clip { r.clamp(0.0, 1.0) } else { r.max(0.0) };
}
o
});
b
}
const SOLID_MODES: [BlendMode; 12] = [
BlendMode::Normal,
BlendMode::Add,
BlendMode::Multiply,
BlendMode::Screen,
BlendMode::Overlay,
BlendMode::SoftLight,
BlendMode::HardLight,
BlendMode::Darken,
BlendMode::Lighten,
BlendMode::Difference,
BlendMode::Color,
BlendMode::Luminosity,
];
const SOLID_MODE_NAMES: [&str; 12] =
["Normal", "Add", "Multiply", "Screen", "Overlay", "Soft Light", "Hard Light", "Darken", "Lighten", "Difference", "Color", "Luminosity"];
fn solid_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
let so = ctx.params.f("sourceOpacity") as f32 / 100.0;
let c = ctx.params.color("color");
let op = (ctx.params.f("opacity") as f32 / 100.0 * c[3]).clamp(0.0, 1.0);
let mode = SOLID_MODES[(ctx.params.e("blendingMode") as usize).min(SOLID_MODES.len() - 1)];
let solid = premul([c[0], c[1], c[2]], op);
b.img.data.par_iter_mut().for_each(|px| {
let src = px.map(|v| v * so);
let mut o = blend_pixel(mode, solid, src, 0.5);
o[3] = o[3].clamp(0.0, 1.0);
for v in o.iter_mut().take(3) {
*v = v.max(0.0);
}
*px = o;
});
b
}
fn remove_color_matting(ctx: &EffectCtx, mut b: Buf) -> Buf {
let bg = ctx.params.color("backgroundColor");
let clip = ctx.params.b("clipHdr");
b.img.data.par_iter_mut().for_each(|px| {
let a = px[3];
if a <= 1e-6 {
*px = [0.0; 4];
return;
}
for i in 0..3 {
let s = px[i] / a;
let mut v = (s - bg[i] * (1.0 - a)).max(0.0);
if clip {
v = v.min(a);
}
px[i] = v;
}
});
b
}
const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off];
fn set_matte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let src = MATTE_ORDER[(ctx.params.e("takeMatteFrom") as usize).min(9)];
let inv = ctx.params.b("invertMatte");
let comp = ctx.params.b("compositeMatteWithOriginal");
let pre = ctx.params.b("premultiplyMatteLayer");
// Take Matte From Layer: another layer (stretched or centred), else the layer itself.
let matte = ctx.layer_param("takeMatteFromLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, ctx.params.b("stretchMatteToFit")));
let own_matte = matte.is_none();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, a) = unpremul(*px);
let (mc, ma) = match &matte {
Some(m) => unpremul(m.data[i]),
None => (c, a),
};
let mut m = pick(src, mc, ma);
if pre && !matches!(src, Src::Alpha | Src::Full | Src::Off) {
m *= ma;
}
let m = m.clamp(0.0, 1.0);
let m = if inv { 1.0 - m } else { m };
// The layer's own (non-inverted) alpha already is the original matte: don't square it.
let na = if comp && (!own_matte || src != Src::Alpha || inv) { a * m } else { m };
*px = premul(c, na.clamp(0.0, 1.0));
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let srcp = |id: &'static str, name: &'static str, d: u32| p(id, name, Value::Enum(d), popup(&SRC_NAMES));
let shp = |id: &'static str, name: &'static str, d: u32| p(id, name, Value::Enum(d), popup(&SHIFT_NAMES));
let v255 = || slider(0.0, 255.0, 0.0, 255.0, 0);
vec![
spec(
"ec.channel.setchannels",
"Set Channels",
vec![
p("sourceLayer1", "Source Layer 1", Value::Layer(None), ParamUi::Layer),
srcp("setRedTo", "Set Red To Source 1's", 0),
p("sourceLayer2", "Source Layer 2", Value::Layer(None), ParamUi::Layer),
srcp("setGreenTo", "Set Green To Source 2's", 1),
p("sourceLayer3", "Source Layer 3", Value::Layer(None), ParamUi::Layer),
srcp("setBlueTo", "Set Blue To Source 3's", 2),
p("sourceLayer4", "Source Layer 4", Value::Layer(None), ParamUi::Layer),
srcp("setAlphaTo", "Set Alpha To Source 4's", 3),
p("stretchLayersToFit", "Stretch Layers to Fit", Value::Bool(true), ParamUi::Checkbox),
],
set_channels,
),
spec(
"ec.channel.shiftchannels",
"Shift Channels",
vec![
shp("takeAlphaFrom", "Take Alpha From", 0),
shp("takeRedFrom", "Take Red From", 1),
shp("takeGreenFrom", "Take Green From", 2),
shp("takeBlueFrom", "Take Blue From", 3),
],
shift_channels,
),
spec(
"ec.channel.combiner",
"Channel Combiner",
vec![
p("useSecondLayer", "Use 2nd Layer", Value::Bool(false), ParamUi::Checkbox),
p("sourceLayer", "Source Layer", Value::Layer(None), ParamUi::Layer),
p("from", "From", Value::Enum(8), popup(&COMBINER_FROM)),
p("to", "To", Value::Enum(4), popup(&COMBINER_TO)),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("solidAlpha", "Solid Alpha", Value::Bool(false), ParamUi::Checkbox),
],
channel_combiner,
),
spec(
"ec.channel.minimax",
"Minimax",
vec![
p("operation", "Operation", Value::Enum(1), popup(&["Minimum", "Maximum", "Minimum Then Maximum", "Maximum Then Minimum"])),
p("radius", "Radius", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 0)),
p("channel", "Channel", Value::Enum(0), popup(&["Color", "Alpha", "Alpha and Color", "Red", "Green", "Blue"])),
p("direction", "Direction", Value::Enum(0), popup(&MINIMAX_DIRECTIONS)),
],
minimax,
),
spec(
"ec.channel.arithmetic",
"Arithmetic",
vec![
p(
"operator",
"Operator",
Value::Enum(3),
popup(&["And", "Or", "Xor", "Add", "Subtract", "Difference", "Min", "Max", "Block Above", "Block Below", "Slice", "Multiply", "Screen"]),
),
p("redValue", "Red Value", num(0.0), v255()),
p("greenValue", "Green Value", num(0.0), v255()),
p("blueValue", "Blue Value", num(0.0), v255()),
p("clip", "Clip Result Values", Value::Bool(true), ParamUi::Checkbox),
],
arithmetic,
),
spec(
"ec.channel.solidcomposite",
"Solid Composite",
vec![
p("sourceOpacity", "Source Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blendingMode", "Blending Mode", Value::Enum(0), popup(&SOLID_MODE_NAMES)),
],
solid_composite,
),
spec(
"ec.channel.removecolormatting",
"Remove Color Matting",
vec![
p("backgroundColor", "Background Color", col(0.0, 0.0, 0.0), ParamUi::Color),
p("clipHdr", "Clip HDR Values", Value::Bool(true), ParamUi::Checkbox),
],
remove_color_matting,
),
spec(
"ec.channel.setmatte",
"Set Matte",
vec![
p("takeMatteFromLayer", "Take Matte From Layer", Value::Layer(None), ParamUi::Layer),
p(
"takeMatteFrom",
"Use For Matte",
Value::Enum(3),
popup(&["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation", "Full", "Off"]),
),
p("invertMatte", "Invert Matte", Value::Bool(false), ParamUi::Checkbox),
p("stretchMatteToFit", "Stretch Matte to Fit", Value::Bool(true), ParamUi::Checkbox),
p("compositeMatteWithOriginal", "Composite Matte with Original", Value::Bool(true), ParamUi::Checkbox),
p("premultiplyMatteLayer", "Premultiply Matte Layer", Value::Bool(true), ParamUi::Checkbox),
],
set_matte,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Params, apply, find};
use effectcraft_raster::Image;
fn run(id: &str, over: &[(&str, Value)], img: Image) -> Image {
let s = find(id).unwrap();
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}
fn sample() -> Image {
let mut img = Image::new(8, 8);
for y in 0..8 {
for x in 0..8 {
let a = if x < 6 { 1.0 } else { 0.5 };
img.set(x, y, [0.2 * a, 0.5 * a, 0.9 * a, a]);
}
}
img
}
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-5)
}
#[test]
fn set_channels_defaults_are_identity_and_swap_works() {
let img = sample();
assert_eq!(run("ec.channel.setchannels", &[], img.clone()), img);
let out = run("ec.channel.setchannels", &[("setRedTo", Value::Enum(2)), ("setBlueTo", Value::Enum(0))], img);
assert!(close(out.get(0, 0), [0.9, 0.5, 0.2, 1.0]));
}
#[test]
fn shift_channels_takes_red_from_green() {
let out = run("ec.channel.shiftchannels", &[("takeRedFrom", Value::Enum(2))], sample());
assert!(close(out.get(7, 0), [0.25, 0.25, 0.45, 0.5]));
}
#[test]
fn channel_combiner_hls_round_trip() {
let img = sample();
let hls = run("ec.channel.combiner", &[("from", Value::Enum(0))], img.clone());
let back = run("ec.channel.combiner", &[("from", Value::Enum(1))], hls);
assert!(close(back.get(0, 0), img.get(0, 0)), "{:?}", back.get(0, 0));
}
#[test]
fn minimax_maximum_grows_bright_point() {
let mut img = Image::filled(9, 9, [0.0, 0.0, 0.0, 1.0]);
img.set(4, 4, [1.0, 1.0, 1.0, 1.0]);
let out = run("ec.channel.minimax", &[("radius", num(2.0))], img);
assert_eq!(out.get(2, 2)[0], 1.0);
assert_eq!(out.get(1, 4)[0], 0.0);
let n: usize = out.data.iter().filter(|p| p[0] > 0.5).count();
assert_eq!(n, 25);
}
#[test]
fn arithmetic_add_and_slice() {
let img = Image::filled(1, 1, [0.4, 0.4, 0.4, 1.0]);
let out = run("ec.channel.arithmetic", &[("redValue", num(51.0))], img.clone());
assert!((out.get(0, 0)[0] - 0.6).abs() < 1e-5 && (out.get(0, 0)[1] - 0.4).abs() < 1e-6);
let out = run("ec.channel.arithmetic", &[("operator", Value::Enum(10)), ("redValue", num(200.0)), ("greenValue", num(10.0))], img);
assert_eq!(&out.get(0, 0)[..3], &[0.0, 1.0, 1.0]);
}
#[test]
fn remove_color_matting_inverts_matting() {
// Foreground colour f, alpha a, matted against bg: stored straight value = f·a + bg·(1−a).
let (f, a, bg) = ([0.8f32, 0.3, 0.1], 0.4f32, [0.2f32, 0.6, 0.9]);
let s: Vec<f32> = (0..3).map(|i| f[i] * a + bg[i] * (1.0 - a)).collect();
let img = Image::filled(1, 1, [s[0] * a, s[1] * a, s[2] * a, a]);
let out = run("ec.channel.removecolormatting", &[("backgroundColor", Value::Color([0.2, 0.6, 0.9, 1.0]))], img);
let px = out.get(0, 0);
for i in 0..3 {
assert!((px[i] - f[i] * a).abs() < 1e-5, "{px:?}");
}
}
#[test]
fn set_matte_from_luminance() {
let mut img = Image::new(2, 1);
img.set(0, 0, [1.0, 1.0, 1.0, 1.0]);
img.set(1, 0, [0.0, 0.0, 0.0, 1.0]);
let out = run("ec.channel.setmatte", &[("takeMatteFrom", Value::Enum(4))], img);
assert!((out.get(0, 0)[3] - 1.0).abs() < 1e-5);
assert_eq!(out.get(1, 0)[3], 0.0);
}
/// A host whose every layer is `img`.
struct OneLayer(Image);
impl crate::EffectHost for OneLayer {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
let (w, h) = (self.0.width as f64, self.0.height as f64);
Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [w, h] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
fn run_host(id: &str, over: &[(&str, Value)], img: Image, other: Image) -> Image {
let s = find(id).unwrap();
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let host = OneLayer(other);
let env = crate::EffectEnv { host: Some(&host), ..Default::default() };
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}
#[test]
fn set_channels_reads_source_layers() {
let other = Image::filled(8, 8, [0.7, 0.1, 0.3, 1.0]);
let out = run_host("ec.channel.setchannels", &[("sourceLayer2", Value::Layer(Some(9)))], sample(), other);
// Green from the other layer's green; red, blue, alpha from the layer itself.
assert!(close(out.get(0, 0), [0.2, 0.1, 0.9, 1.0]), "{:?}", out.get(0, 0));
}
#[test]
fn set_matte_takes_matte_from_another_layer() {
let mut other = Image::new(2, 1);
other.set(0, 0, [1.0, 1.0, 1.0, 1.0]);
let img = Image::filled(2, 1, [0.5, 0.5, 0.5, 1.0]);
let out = run_host("ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(9)))], img, other);
assert!((out.get(0, 0)[3] - 1.0).abs() < 1e-5);
assert_eq!(out.get(1, 0)[3], 0.0);
}
#[test]
fn channel_combiner_single_channel_targets() {
let img = Image::filled(1, 1, [0.2, 0.5, 0.9, 1.0]);
// Red → Green Only: green gets red's value, the other colour channels go to zero.
let out = run("ec.channel.combiner", &[("from", Value::Enum(4)), ("to", Value::Enum(1))], img.clone());
assert!(close(out.get(0, 0), [0.0, 0.2, 0.0, 1.0]), "{:?}", out.get(0, 0));
// Lightness Only (the default) gives a grey image.
let out = run("ec.channel.combiner", &[], img.clone());
let px = out.get(0, 0);
assert!((px[0] - px[1]).abs() < 1e-6 && (px[1] - px[2]).abs() < 1e-6 && (px[0] - 0.55).abs() < 1e-5, "{px:?}");
// Alpha Only: white with the value as alpha; Solid Alpha forces full opacity.
let out = run("ec.channel.combiner", &[("from", Value::Enum(5)), ("to", Value::Enum(3))], img.clone());
assert!(close(out.get(0, 0), [0.5, 0.5, 0.5, 0.5]), "{:?}", out.get(0, 0));
let out = run("ec.channel.combiner", &[("from", Value::Enum(5)), ("to", Value::Enum(3)), ("solidAlpha", Value::Bool(true))], img);
assert!(close(out.get(0, 0), [1.0, 1.0, 1.0, 1.0]), "{:?}", out.get(0, 0));
}
#[test]
fn invert_hls_and_yiq_channels() {
let img = Image::filled(1, 1, [0.2, 0.4, 0.6, 1.0]);
let idx = |n: &str| Value::Enum(crate::INVERT_CHANNELS.iter().position(|o| *o == n).unwrap() as u32);
// Lightness 0.4 → 0.6 keeps hue and saturation: (0.4, 0.6, 0.8).
let out = run("ec.channel.invert", &[("channel", idx("Lightness"))], img.clone());
assert!(close(out.get(0, 0), [0.4, 0.6, 0.8, 1.0]), "{:?}", out.get(0, 0));
// Luminance inverts Y and keeps chrominance: Y + Y' = 1.
let out = run("ec.channel.invert", &[("channel", idx("Luminance"))], img.clone());
let y = |p: [f32; 4]| 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2];
assert!((y(out.get(0, 0)) + y(img.get(0, 0)) - 1.0).abs() < 1e-3, "{:?}", out.get(0, 0));
// Alpha keeps working at its new index.
let out = run("ec.channel.invert", &[("channel", Value::Enum(crate::INVERT_ALPHA))], img);
assert_eq!(out.get(0, 0)[3], 0.0);
}
#[test]
fn channel_combiner_uses_second_layer() {
let img = Image::filled(1, 1, [0.2, 0.5, 0.9, 1.0]);
let other = Image::filled(1, 1, [0.6, 0.0, 0.0, 1.0]);
let over = [("useSecondLayer", Value::Bool(true)), ("sourceLayer", Value::Layer(Some(3))), ("from", Value::Enum(4)), ("to", Value::Enum(0))];
let out = run_host("ec.channel.combiner", &over, img, other);
assert!(close(out.get(0, 0), [0.6, 0.0, 0.0, 1.0]), "{:?}", out.get(0, 0));
}
#[test]
fn solid_composite_fills_behind() {
let mut img = Image::new(2, 1);
img.set(0, 0, [0.0, 0.0, 1.0, 1.0]);
let out = run("ec.channel.solidcomposite", &[("color", Value::Color([1.0, 0.0, 0.0, 1.0]))], img);
assert!(close(out.get(0, 0), [0.0, 0.0, 1.0, 1.0]));
assert!(close(out.get(1, 0), [1.0, 0.0, 0.0, 1.0]));
}
}