From 8ba936f87bfc48ff60a2274c4bb3c72310efe2fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:02:03 -0400 Subject: [PATCH] M13.9: gpu keying, matte and channel effects (fx_key) Color Key, Luma Key, Color Range, Extract, Difference Matte, Color Difference Key, Screen Key, Spill Suppressor, Advanced Spill Suppressor, Key Cleaner, Unmult, Simple Choker, Matte Choker, Set Matte, Set Channels, Shift Channels, Remove Color Matting, Arithmetic, Solid Composite, Channel Combiner, Blend, Calculations, Compound Arithmetic on the GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 23 + crates/gpu/src/fx_color.rs | 6 +- crates/gpu/src/fx_key.rs | 548 +++++++++++++++++++- crates/gpu/src/shaders/fx_key.wgsl | 799 ++++++++++++++++++++++++++++- crates/gpu/src/tests_fx_key.rs | 220 +++++++- 5 files changed, 1580 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bc1cd520..ebb44d9e 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -807,6 +807,29 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.key.linearcolor", "ec.keying.keylight", // effectcraft-gpu fx_key (keying, matte, channel) + "ec.key.colorkey", + "ec.key.luma", + "ec.key.colorrange", + "ec.key.extract", + "ec.key.differencematte", + "ec.key.colordifference", + "ec.key.screen", + "ec.key.spill", + "ec.key.advancedspill", + "ec.key.keycleaner", + "ec.key.unmult", + "ec.matte.simplechoker", + "ec.matte.mattechoker", + "ec.channel.setmatte", + "ec.channel.setchannels", + "ec.channel.shiftchannels", + "ec.channel.removecolormatting", + "ec.channel.arithmetic", + "ec.channel.solidcomposite", + "ec.channel.combiner", + "ec.channel.blend", + "ec.channel.calculations", + "ec.channel.compoundarithmetic", // // effectcraft-gpu fx_stylize (stylize, distort) // diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index ee6ab7e8..f411e97e 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -235,7 +235,7 @@ fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage { } /// util::morph_frac (blend between the two nearest integer radii). -fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { +pub(crate) fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { if radius <= 0.0 { return img.clone(); } @@ -253,8 +253,8 @@ fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuI out } -const ERODE_X: [u32; 4] = [0, 2, 2, 2]; -const DILATE_X: [u32; 4] = [1, 2, 2, 2]; +pub(crate) const ERODE_X: [u32; 4] = [0, 2, 2, 2]; +pub(crate) const DILATE_X: [u32; 4] = [1, 2, 2, 2]; // ---------------------------------------------------------------- Key Light diff --git a/crates/gpu/src/fx_key.rs b/crates/gpu/src/fx_key.rs index 05625d4c..b4709520 100644 --- a/crates/gpu/src/fx_key.rs +++ b/crates/gpu/src/fx_key.rs @@ -1,19 +1,551 @@ -//! GPU effects, keying, matte and channel family (kernels in `shaders/fx_key.wgsl`, every entry point prefixed -//! `fxk_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! GPU effects, keying, matte and channel family (kernels in `shaders/fx_key.wgsl`, every entry +//! point prefixed `fxk_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! +//! The keyers (Color Key, Luma Key, Color Range, Extract, Difference Matte, Color Difference +//! Key, Screen Key) build their matte in a plane pass, run the CPU's edge thin (morphology) and +//! feather (Gaussian) on it, and apply it in a second pass; spill suppression, Unmult and the +//! channel effects are one per-pixel pass (`fxk_point`). Simple / Matte Choker and Key Cleaner +//! work on the alpha plane and put it back with `util::set_alpha` (straight colour kept, the +//! transparent pixels' colour from a small blur). Advanced Spill Suppressor's Standard method +//! finds the dominant key primary with a GPU sum reduction (`fxk_reduce`). Other layers (layer +//! parameters) are resampled into the buffer like `util::fit_layer` (`fxk_fit`). +use effectcraft_color::rgb_to_hsl; use effectcraft_effects::EffectCtx; +use effectcraft_effects::util::{SRC_ORDER, Src}; -use crate::context::Enc; -use crate::effects::GBuf; +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; +use crate::fx_color::{DILATE_X, ERODE_X, morph_frac}; /// Compute entry points in `fx_key.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &["fxk_point", "fxk_fit", "fxk_reduce"]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.key.colorkey", + "ec.key.luma", + "ec.key.colorrange", + "ec.key.extract", + "ec.key.differencematte", + "ec.key.colordifference", + "ec.key.screen", + "ec.key.spill", + "ec.key.advancedspill", + "ec.key.keycleaner", + "ec.key.unmult", + "ec.matte.simplechoker", + "ec.matte.mattechoker", + "ec.channel.setmatte", + "ec.channel.setchannels", + "ec.channel.shiftchannels", + "ec.channel.removecolormatting", + "ec.channel.arithmetic", + "ec.channel.solidcomposite", + "ec.channel.combiner", + "ec.channel.blend", + "ec.channel.calculations", + "ec.channel.compoundarithmetic", +]; /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { - let _ = (e, id, ctx, b); - None + match id { + "ec.key.colorkey" | "ec.key.luma" | "ec.key.colorrange" | "ec.key.extract" => matte_key(e, id, ctx, b), + "ec.key.differencematte" => difference_matte(e, ctx, b), + "ec.key.screen" => screen_key(e, ctx, b), + "ec.key.advancedspill" => advanced_spill(e, ctx, b), + "ec.key.keycleaner" => key_cleaner(e, ctx, b), + "ec.matte.simplechoker" => simple_choker(e, ctx, b), + "ec.matte.mattechoker" => matte_choker(e, ctx, b), + "ec.channel.setchannels" => set_channels(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +// ---------------------------------------------------------------- helpers + +/// One `fxk_point` pass over `src`'s size. +fn run(e: &mut Enc, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.image(src.width, src.height); + e.pixels("fxk_point", p, src, aux, &out, data); + out +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn src_index(s: Src) -> u32 { + SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32 +} + +/// Layer parameter `id` resampled into the buffer's pixel grid (`util::fit_layer`): +/// `Some(None)` when no layer is chosen (the effect then uses its own layer). +fn fitted(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, masks_and_effects: bool, stretch: bool) -> Option> { + let Some(o) = ctx.layer_param(id, masks_and_effects) else { return Some(None) }; + let (w, h) = (b.img.width, b.img.height); + if o.buf.img.is_empty() { + return Some(Some(e.image(w, h))); + } + let ls = ctx.layer_size; + let os = o.size; + let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) }; + let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) }; + let inv = 1.0 / b.scale.max(1e-9); + // Per column / row: the other layer's buffer coordinate. + let mut data = Vec::with_capacity((w + h) as usize); + data.extend((0..w).map(|x| (((x as f64 + 0.5 - b.offset[0]) * inv * sx + dx) * o.buf.scale + o.buf.offset[0]) as f32)); + data.extend((0..h).map(|y| (((y as f64 + 0.5 - b.offset[1]) * inv * sy + dy) * o.buf.scale + o.buf.offset[1]) as f32)); + let src = e.g.upload_image(&o.buf.img)?; + let buf = e.data(&data); + let out = e.image(w, h); + e.pixels("fxk_fit", &Params::default(), &src, None, &out, Some(&buf)); + Some(Some(out)) +} + +/// `util::layer_or_self`. +fn layer_or_self(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, stretch: bool) -> Option { + Some(fitted(e, ctx, b, id, true, stretch)?.unwrap_or_else(|| b.img.clone())) +} + +/// keying::thin_feather on a matte plane (x channel). +fn thin_feather(e: &mut Enc, m: GpuImage, thin: f64, feather: f64) -> GpuImage { + let m = if thin > 0.0 { + morph_frac(e, &m, thin, ERODE_X) + } else if thin < 0.0 { + morph_frac(e, &m, -thin, DILATE_X) + } else { + m + }; + if feather > 0.0 { gaussian_blur(e, &m, feather * 0.5, feather * 0.5, true) } else { m } +} + +/// keying::apply_matte (view 0 final, 1 source, 2 matte). +fn apply_matte(e: &mut Enc, b: GBuf, m: &GpuImage, view: u32) -> GBuf { + if view == 1 { + return b; + } + let mut p = op(5); + p.u[0][1] = view; + let img = run(e, &p, &b.img, Some(m), None); + GBuf { img, ..b } +} + +/// keying::primary. +fn primary(c: [f32; 4]) -> [u32; 3] { + if c[1] >= c[0] && c[1] >= c[2] { + [1, 0, 2] + } else if c[2] >= c[0] && c[2] >= c[1] { + [2, 0, 1] + } else { + [0, 1, 2] + } +} + +/// util::set_alpha with the new alpha in `na`'s x channel. +fn set_alpha(e: &mut Enc, img: &GpuImage, na: &GpuImage) -> GpuImage { + let fill = gaussian_blur(e, img, 3.0, 3.0, true); + let (rows, row) = e.image_rows(&fill); + let mut p = op(27); + p.u[3][0] = row; + run(e, &p, img, Some(na), Some(&rows)) +} + +// ---------------------------------------------------------------- keyers + +fn matte_key(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut thin = 0.0; + let mut feather = 0.0; + match id { + "ec.key.colorkey" => { + let k = pr.color("keyColor"); + p.u[0][0] = 1; + p.f[0] = [k[0], k[1], k[2], f("colorTolerance") / 255.0]; + thin = pr.f("edgeThin") * b.scale; + feather = pr.f("edgeFeather") * b.scale; + } + "ec.key.luma" => { + p.u[0] = [2, pr.e("keyType"), 0, 0]; + p.f[0] = [f("threshold") / 255.0, f("tolerance") / 255.0, 0.0, 0.0]; + thin = pr.f("edgeThin") * b.scale; + feather = pr.f("edgeFeather") * b.scale; + } + "ec.key.colorrange" => { + p.u[0] = [3, pr.e("colorSpace"), 0, 0]; + p.f[0] = [f("minL"), f("minA"), f("minB"), 0.0]; + p.f[1] = [f("maxL"), f("maxA"), f("maxB"), 0.0]; + p.f[2][0] = f("fuzziness"); + } + _ => { + p.u[0] = [4, pr.e("channel"), pr.b("invert") as u32, 0]; + p.f[0] = [f("blackPoint"), f("whitePoint"), f("blackSoftness"), f("whiteSoftness")]; + } + } + let m = run(e, &p, &b.img, None, None); + let m = thin_feather(e, m, thin, feather); + Some(apply_matte(e, b, &m, 0)) +} + +fn difference_matte(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 1 { + return Some(b); + } + let blur = pr.f("blurBeforeDifference") * b.scale; + let other = layer_or_self(e, ctx, &b, "differenceLayer", pr.e("ifLayerSizesDiffer") == 1)?; + let (src, other) = if blur > 0.05 { + (gaussian_blur(e, &b.img, blur * 0.5, blur * 0.5, true), gaussian_blur(e, &other, blur * 0.5, blur * 0.5, true)) + } else { + (b.img.clone(), other) + }; + let mut p = op(6); + p.f[0] = [(pr.f("matchingTolerance") / 100.0) as f32, (pr.f("matchingSoftness") / 100.0) as f32, 0.0, 0.0]; + let m = run(e, &p, &src, Some(&other), None); + Some(apply_matte(e, b, &m, if view == 2 { 3 } else { 4 })) +} + +/// keying::secondary's screen-key matte and composite. +fn screen_key(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 3 { + return Some(b); + } + let sc = pr.color("screenColor"); + let ix = primary(sc); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let gain = pr.f("screenGain") as f32 / 100.0; + let bal = pr.f("screenBalance") as f32 / 100.0; + let cb = pr.f("clipBlack") as f32 / 100.0; + let cw = pr.f("clipWhite") as f32 / 100.0; + let preblur = pr.f("screenPreblur") * b.scale; + let sg = pr.f("screenShrinkGrow") * b.scale; + let soft = pr.f("screenSoftness") * b.scale; + let (hi, lo) = if sc[o1] > sc[o2] { (sc[o1], sc[o2]) } else { (sc[o2], sc[o1]) }; + let sd = (sc[pi] - (bal * hi + (1.0 - bal) * lo)).max(1e-3); + let key_src = if preblur > 0.0 { gaussian_blur(e, &b.img, preblur * 0.5, preblur * 0.5, true) } else { b.img.clone() }; + let mut p = op(22); + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [sd, gain, bal, cb]; + p.f[1][0] = cw; + let mut m = run(e, &p, &key_src, None, None); + if sg < 0.0 { + m = morph_frac(e, &m, -sg, ERODE_X); + } else if sg > 0.0 { + m = morph_frac(e, &m, sg, DILATE_X); + } + if soft > 0.0 { + m = gaussian_blur(e, &m, soft * 0.5, soft * 0.5, true); + } + let mut p = op(23); + p.u[0] = [23, view, (pr.e("edgeColor") == 1) as u32, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [sc[0], sc[1], sc[2], bal]; + p.f[1][0] = pr.f("despill") as f32 / 100.0; + let img = run(e, &p, &b.img, Some(&m), None); + Some(GBuf { img, ..b }) +} + +fn advanced_spill(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let ultra = pr.e("method") == 1; + let key = pr.color("ultraSettings/keyColor"); + let mut p = op(8); + let sums = if ultra { + let ix = primary(key); + p.u[1] = [ix[0], ix[1], ix[2], 0]; + None + } else { + // Standard: the dominant key primary over the whole buffer (sum reduction). + p.u[1][3] = 1; + let mut cur = b.img.clone(); + let mut first = true; + while first || cur.width > 1 || cur.height > 1 { + let out = e.image(cur.width.div_ceil(16), cur.height.div_ceil(16)); + let mut rp = Params::default(); + rp.u[0][0] = first as u32; + e.pixels("fxk_reduce", &rp, &cur, None, &out, None); + cur = out; + first = false; + } + Some(cur) + }; + let tol = if ultra { pr.get("ultraSettings/tolerance").map(|v| v.as_f64()).unwrap_or(100.0) as f32 / 100.0 } else { 1.0 }; + let f = |k: &str| pr.f(k) as f32 / 100.0; + let (desat, neutral) = if ultra { (f("ultraSettings/desaturate"), f("ultraSettings/spillColorCorrection")) } else { (0.0, 0.0) }; + p.f[0] = [f("suppression"), f("ultraSettings/spillRange"), f("ultraSettings/lumaCorrection"), tol]; + p.f[1] = [desat, neutral, rgb_to_hsl(key[0], key[1], key[2]).0, 0.0]; + let img = run(e, &p, &b.img, sums.as_ref(), None); + Some(GBuf { img, ..b }) +} + +fn key_cleaner(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let r = (pr.f("additionalEdgeRadius") * b.scale).max(0.0); + let strength = pr.f("strength") as f32 / 100.0; + let chatter = pr.b("reduceChatter"); + let mut a = run(e, &op(24), &b.img, None, None); + if chatter { + a = gaussian_blur(e, &a, 1.0, 1.0, true); + } + let est = (r > 0.0 && strength > 0.0).then(|| gaussian_blur(e, &b.img, r * 0.5, r * 0.5, true)); + let rows = est.as_ref().map(|img| e.image_rows(img)); + let mut p = op(25); + p.u[0][1] = chatter as u32; + p.u[3] = [rows.as_ref().map_or(0, |r| r.1), rows.is_some() as u32, 0, 0]; + p.f[0] = [pr.f("alphaContrast") as f32 / 100.0, strength, 0.0, 0.0]; + let img = run(e, &p, &b.img, Some(&a), rows.as_ref().map(|r| &r.0)); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- mattes + +fn simple_choker(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let c = ctx.params.f("chokeMatte") * b.scale; + if c.abs() > 1e-6 { + if c < 0.0 && !ctx.adjustment { + b.pad(e, (-c).ceil() as u32 + 1)?; + } + let a = run(e, &op(24), &b.img, None, None); + let na = if c > 0.0 { morph_frac(e, &a, c, ERODE_X) } else { morph_frac(e, &a, -c, DILATE_X) }; + b.img = set_alpha(e, &b.img, &na); + } + if ctx.params.e("view") == 1 { + b.img = run(e, &op(28), &b.img, None, None); + } + Some(b) +} + +fn matte_choker(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let pr = ctx.params; + let g1 = pr.f("geometricSoftness1") * b.scale; + let c1 = pr.f("choke1") as f32; + let s1 = pr.f("grayLevelSoftness1") as f32 / 100.0; + let g2 = pr.f("geometricSoftness2") * b.scale; + let c2 = pr.f("choke2") as f32; + let s2 = pr.f("grayLevelSoftness2") as f32 / 100.0; + let it = pr.f("iterations").round().clamp(1.0, 100.0) as usize; + if (c1 < 0.0 || c2 < 0.0 || g1 > 0.0 || g2 > 0.0) && !ctx.adjustment { + b.pad(e, ((g1 + g2) * 1.5 * it as f64).ceil() as u32 + 1)?; + } + let mut a = run(e, &op(24), &b.img, None, None); + let stage = |e: &mut Enc, a: GpuImage, geo: f64, choke: f32, gray: f32| -> GpuImage { + let a = if geo > 0.0 { gaussian_blur(e, &a, geo * 0.5, geo * 0.5, true) } else { a }; + let t = 0.5 + choke / 255.0 * 0.5; + let w = gray.max(1e-3); + if (t - 0.5).abs() < 1e-6 && (w - 1.0).abs() < 1e-6 { + return a; + } + let mut p = op(26); + p.f[0] = [t, w, 0.0, 0.0]; + run(e, &p, &a, None, None) + }; + for _ in 0..it { + a = stage(e, a, g1, c1, s1); + a = stage(e, a, g2, c2, s2); + } + b.img = set_alpha(e, &b.img, &a); + Some(b) +} + +// ---------------------------------------------------------------- channel + +const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"]; + +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 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_channels(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let s = ["setRedTo", "setGreenTo", "setBlueTo", "setAlphaTo"].map(|id| src_index(effectcraft_effects::util::src_at(pr.e(id)))); + let stretch = pr.b("stretchLayersToFit"); + let mut layers = vec![]; + for id in SET_CHANNEL_LAYERS { + layers.push(fitted(e, ctx, &b, id, true, stretch)?); + } + if layers.iter().all(Option::is_none) { + return route(e, b, s); + } + let mut vals = e.image(b.img.width, b.img.height); + for (k, layer) in layers.iter().enumerate() { + let mut p = op(20); + p.u[0] = [20, k as u32, s[k], 0]; + vals = run(e, &p, &vals, Some(layer.as_ref().unwrap_or(&b.img)), None); + } + let img = run(e, &op(21), &vals, None, None); + Some(GBuf { img, ..b }) +} + +/// channel::route. +fn route(e: &mut Enc, b: GBuf, s: [u32; 4]) -> Option { + if s == [0, 1, 2, 3] { + return Some(b); + } + let mut p = op(11); + p.u[1] = s; + let img = run(e, &p, &b.img, None, None); + Some(GBuf { img, ..b }) +} + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut aux = None; + match id { + "ec.key.spill" => { + let k = pr.color("colorToSuppress"); + let amt = f("suppression") / 100.0; + if pr.e("colorAccuracy") == 1 { + let kg = (k[0] + k[1] + k[2]) / 3.0; + let kc = [k[0] - kg, k[1] - kg, k[2] - kg]; + let kn = (kc[0] * kc[0] + kc[1] * kc[1] + kc[2] * kc[2]).sqrt(); + if kn < 1e-5 { + return Some(b); + } + p.u[0] = [7, 1, 0, 0]; + p.f[0] = [kc[0] / kn, kc[1] / kn, kc[2] / kn, amt]; + } else { + let ix = primary(k); + p.u[0] = [7, 0, 0, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0][3] = amt; + } + } + "ec.key.colordifference" => { + let view = pr.e("view"); + if view == 0 { + return Some(b); + } + let accurate = pr.e("colorMatchingAccuracy") == 1; + let lin = |c: [f32; 3]| if accurate { c.map(|v| effectcraft_color::srgb_to_linear(v.clamp(0.0, 1.0))) } else { c }; + let k = pr.color("keyColor"); + let key = lin([k[0], k[1], k[2]]); + let ix = primary([key[0], key[1], key[2], 1.0]); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let lv = |pre: &str| ["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| f(&format!("{pre}{s}"))); + let (pa, pb) = (lv("partialA"), lv("partialB")); + p.u[0] = [9, view, accurate as u32, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [(key[pi] - key[o1]).max(1e-3), (key[pi] - key[o2]).max(1e-3), f("blackLevel"), f("whiteLevel")]; + p.f[1] = [pa[0], pa[1], pa[2], pa[3]]; + p.f[2] = [pa[4], pb[4], f("gamma"), 0.0]; + p.f[3] = [pb[0], pb[1], pb[2], pb[3]]; + } + "ec.key.unmult" => { + let white = pr.e("backgroundColor") == 1; + let level = if white { pr.f("whiteLevel") } else { pr.f("blackLevel") }.clamp(0.0, 100.0) as f32 / 100.0; + p.u[0] = [10, white as u32, pr.b("removeColorMatting") as u32, pr.b("clipHdrResults") as u32]; + p.f[0] = [level, pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0, 0.0, 0.0]; + } + "ec.channel.shiftchannels" => { + let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(10)])); + return route(e, b, s); + } + "ec.channel.removecolormatting" => { + p.u[0] = [12, pr.b("clipHdr") as u32, 0, 0]; + p.f[0] = pr.color("backgroundColor"); + } + "ec.channel.arithmetic" => { + p.u[0] = [13, pr.e("operator"), pr.b("clip") as u32, 0]; + p.f[0] = [f("redValue") / 255.0, f("greenValue") / 255.0, f("blueValue") / 255.0, 0.0]; + } + "ec.channel.solidcomposite" => { + const MODES: [effectcraft_color::BlendMode; 12] = { + use effectcraft_color::BlendMode::*; + [Normal, Add, Multiply, Screen, Overlay, SoftLight, HardLight, Darken, Lighten, Difference, Color, Luminosity] + }; + let c = pr.color("color"); + let opa = (f("opacity") / 100.0 * c[3]).clamp(0.0, 1.0); + p.u[0] = [14, crate::ops::mode_id(MODES[(pr.e("blendingMode") as usize).min(11)]), 0, 0]; + p.f[0] = [c[0] * opa, c[1] * opa, c[2] * opa, opa]; + p.f[1][0] = f("sourceOpacity") / 100.0; + } + "ec.channel.setmatte" => { + let src = MATTE_ORDER[(pr.e("takeMatteFrom") as usize).min(9)]; + aux = fitted(e, ctx, &b, "takeMatteFromLayer", true, pr.b("stretchMatteToFit"))?; + let pre_ok = !matches!(src, Src::Alpha | Src::Full | Src::Off); + let flags = pr.b("invertMatte") as u32 + | (pr.b("compositeMatteWithOriginal") as u32) << 1 + | ((pr.b("premultiplyMatteLayer") && pre_ok) as u32) << 2 + | (aux.is_none() as u32) << 3 + | ((src == Src::Alpha) as u32) << 4; + p.u[0] = [15, src_index(src), flags, 0]; + } + "ec.channel.combiner" => { + if pr.b("useSecondLayer") { + aux = Some(layer_or_self(e, ctx, &b, "sourceLayer", true)?); + } + let flags = pr.b("invert") as u32 | (pr.b("solidAlpha") as u32) << 1 | (aux.is_some() as u32) << 2; + p.u[0] = [16, pr.e("from"), pr.e("to"), flags]; + } + "ec.channel.blend" => { + let orig = (f("blendWithOriginal") / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return Some(b); + } + aux = Some(layer_or_self(e, ctx, &b, "blendWithLayer", pr.e("ifLayerSizesDiffer") == 1)?); + p.u[0] = [17, pr.e("mode"), 0, 0]; + p.f[0][0] = orig; + } + "ec.channel.calculations" => { + const MODES: [effectcraft_color::BlendMode; 22] = { + use effectcraft_color::BlendMode::*; + [ + Normal, + Darken, + Multiply, + ColorBurn, + LinearBurn, + Add, + Lighten, + Screen, + ColorDodge, + LinearDodge, + Overlay, + SoftLight, + HardLight, + LinearLight, + VividLight, + PinLight, + HardMix, + Difference, + Exclusion, + Hue, + Saturation, + Luminosity, + ] + }; + if pr.get("secondLayer").and_then(|v| v.as_layer()).is_some() { + aux = Some(layer_or_self(e, ctx, &b, "secondLayer", pr.b("stretchSecondLayerToFit"))?); + } + let flags = + pr.b("invertInput") as u32 | (pr.b("invertSecondLayer") as u32) << 1 | (pr.b("preserveTransparency") as u32) << 2 | (aux.is_some() as u32) << 3; + let mode = MODES[(pr.e("blendingMode") as usize).min(MODES.len() - 1)]; + p.u[0] = [18, pr.e("inputChannel"), pr.e("secondLayerChannel"), crate::ops::mode_id(mode)]; + p.u[1][0] = flags; + p.f[0][0] = (f("secondLayerOpacity") / 100.0).clamp(0.0, 1.0); + } + "ec.channel.compoundarithmetic" => { + let orig = (f("blendWithOriginal") / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return Some(b); + } + aux = Some(layer_or_self(e, ctx, &b, "secondSource", pr.b("stretchSecondSourceToFit"))?); + p.u[0] = [19, pr.e("operator"), pr.e("operateOnChannels"), pr.e("overflowBehavior")]; + p.f[0][0] = orig; + } + _ => return None, + } + let img = run(e, &p, &b.img, aux.as_ref(), None); + Some(GBuf { img, ..b }) } diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index 4b251f3a..0ac62639 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -1,3 +1,796 @@ -// GPU effects (keying, matte and channel family): see src/fx_key.rs. Each entry point mirrors the CPU effect in -// effectcraft-effects operation for operation. Every name here is prefixed `fxk_` (the -// family files share one module). +// GPU effects (keying, matte and channel family): see src/fx_key.rs. Each entry point mirrors +// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed +// `fxk_` (the family files share one module). + +// util::unpremul: (straight colour, alpha); colour 0 when alpha ≤ 1e-6. +fn fxk_straight(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +fn fxk_premul(c: vec3, a: f32) -> vec4 { + return vec4(c * a, a); +} + +// Pixel `p` of the third image, copied into `data` as RGBA f32 rows of P.u[3].x pixels. +fn fxk_rows(p: vec2) -> vec4 { + let i = (u32(p.y) * P.u[3].x + u32(p.x)) * 4u; + return vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); +} + +// keying::to_lab. +fn fxk_cbrt(t: f32) -> f32 { + return sign(t) * pow(abs(t), 1.0 / 3.0); +} + +fn fxk_lab_f(t: f32) -> f32 { + if (t > 0.008856) { + return fxk_cbrt(t); + } + return 7.787 * t + 16.0 / 116.0; +} + +// keying::range_components. +fn fxk_range_components(space: u32, c: vec3) -> vec3 { + if (space == 1u) { + let y = 0.299 * c.x + 0.587 * c.y + 0.114 * c.z; + let u = -0.14713 * c.x - 0.28886 * c.y + 0.436 * c.z; + let v = 0.615 * c.x - 0.51499 * c.y - 0.10001 * c.z; + return vec3(y * 255.0, u * 255.0 + 128.0, v * 255.0 + 128.0); + } + if (space == 2u) { + return c * 255.0; + } + let cl = clamp(c, vec3(0.0), vec3(1.0)); + let l = vec3(srgb_to_linear(cl.x), srgb_to_linear(cl.y), srgb_to_linear(cl.z)); + let x = (0.4124 * l.x + 0.3576 * l.y + 0.1805 * l.z) / 0.95047; + let yy = 0.2126 * l.x + 0.7152 * l.y + 0.0722 * l.z; + let z = (0.0193 * l.x + 0.1192 * l.y + 0.9505 * l.z) / 1.08883; + let fx = fxk_lab_f(x); + let fy = fxk_lab_f(yy); + let fz = fxk_lab_f(z); + return vec3((116.0 * fy - 16.0) * 2.55, 500.0 * (fx - fy) + 128.0, 200.0 * (fy - fz) + 128.0); +} + +// keying::matte_levels. +fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { + var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); + t = powz(t, 1.0 / max(gamma, 0.01)); + return clamp(out_b / 255.0 + t * (out_w - out_b) / 255.0, 0.0, 1.0); +} + +// keying::secondary. +fn fxk_secondary(c: vec3, o1: u32, o2: u32, bal: f32) -> f32 { + var hi = c[o2]; + var lo = c[o1]; + if (c[o1] > c[o2]) { + hi = c[o1]; + lo = c[o2]; + } + return bal * hi + (1.0 - bal) * lo; +} + +// channel::arithmetic's 8-bit quantisation. +fn fxk_q(v: f32) -> u32 { + return u32(round_away(clamp(v, 0.0, 1.0) * 255.0)); +} + +// channel2::extract. +fn fxk_extract(px: vec4, ch: u32, inv: bool) -> vec4 { + let c = fxk_straight(px); + let a = max(px.w, 0.0); + var g: f32; + switch ch { + case 0u: { + if (inv) { + return fxk_premul(1.0 - c, a); + } + return px; + } + case 1u: { g = luminance(c) * a; } + case 2u: { g = c.x * a; } + case 3u: { g = c.y * a; } + case 4u: { g = c.z * a; } + default: { g = a; } + } + if (inv) { + g = 1.0 - g; + } + return vec4(g, g, g, 1.0); +} + +// channel2::arith. +fn fxk_arith(op: u32, a: f32, b: f32) -> f32 { + switch op { + case 0u: { return b; } + case 1u: { return a + b; } + case 2u: { return a - b; } + case 3u: { return a * b; } + case 4u: { return abs(a - b); } + case 5u: { return f32(fxk_q(a) & fxk_q(b)) / 255.0; } + case 6u: { return f32(fxk_q(a) | fxk_q(b)) / 255.0; } + case 7u: { return f32(fxk_q(a) ^ fxk_q(b)) / 255.0; } + case 8u, 11u: { return max(a, b); } + case 9u, 10u: { return min(a, b); } + case 12u: { return a + b - a * b; } + case 13u: { + if (a < 0.5) { + return 2.0 * a * b; + } + return 1.0 - 2.0 * (1.0 - a) * (1.0 - b); + } + default: { + if (b < 0.5) { + return 2.0 * a * b; + } + return 1.0 - 2.0 * (1.0 - a) * (1.0 - b); + } + } +} + +// channel2::overflow. +fn fxk_overflow(op: u32, v: f32, how: u32) -> f32 { + if (how == 0u) { + return clamp(v, 0.0, 1.0); + } + if (how == 1u) { + let m = 1.0001; + let r = v - m * floor(v / m); + return min(select(r, 0.0, r >= m || r < 0.0), 1.0); + } + if (op == 1u) { + return v * 0.5; + } + if (op == 2u) { + return (v + 1.0) * 0.5; + } + return clamp(v, 0.0, 1.0); +} + +// Per-pixel keying / matte / channel operations. u[0].x = op (see src/fx_key.rs for each +// effect's parameter layout): +// 1 Color Key matte 2 Luma Key matte 3 Color Range matte 4 Extract matte +// 5 apply a matte (aux.x; u[0].y = 0 final, 2 matte view, 3 Difference Matte's matte view, +// 4 Difference Matte's output) +// 6 Difference Matte matte (src and aux blurred) +// 7 Spill Suppressor 8 Advanced Spill Suppressor (aux = 1×1 excess sums when u[1].w) +// 9 Color Difference Key 10 Unmult 11 route channels (u[1] = sources) +// 12 Remove Color Matting 13 Arithmetic 14 Solid Composite 15 Set Matte +// 16 Channel Combiner 17 Blend 18 Calculations 19 Compound Arithmetic +// 20 Set Channels: channel u[0].y of the value image from aux; 21 Set Channels: premultiply +// 22 Screen Key matte 23 Screen Key output 24 alpha plane 25 Key Cleaner output +// 26 Matte Choker ramp 27 util::set_alpha (aux.x = alpha, data = fill) +// 28 matte view (alpha as opaque grey) +@compute @workgroup_size(16, 16) +fn fxk_point(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let c = fxk_straight(px); + let a = max(px.w, 0.0); + let u0 = P.u[0]; + let u1 = P.u[1]; + let f0 = P.f[0]; + var o = px; + switch u0.x { + case 1u: { + let k = f0.xyz; + let d = max(max(abs(c.x - k.x), abs(c.y - k.y)), abs(c.z - k.z)); + o = vec4(select(1.0, 0.0, d <= f0.w)); + } + case 2u: { + let l = luminance(c); + let thr = f0.x; + let tol = f0.y; + var m: f32; + switch u0.y { + case 0u: { + if (tol > 0.0) { + m = clamp((thr + tol - l) / tol, 0.0, 1.0); + } else { + m = select(1.0, 0.0, l > thr); + } + } + case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); } + case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); } + default: { + if (tol > 0.0) { + m = clamp((l - (thr - tol)) / tol, 0.0, 1.0); + } else { + m = select(1.0, 0.0, l < thr); + } + } + } + o = vec4(m); + } + case 3u: { + let v = fxk_range_components(u0.y, c); + var out_d = 0.0; + for (var i = 0u; i < 3u; i++) { + out_d = max(max(out_d, P.f[0][i] - v[i]), v[i] - P.f[1][i]); + } + let fuzz = P.f[2].x; + var m = 1.0; + if (out_d <= 0.0) { + m = 0.0; + } else if (fuzz > 0.0) { + m = clamp(out_d / fuzz, 0.0, 1.0); + } + o = vec4(m); + } + case 4u: { + var v: f32; + switch u0.y { + case 1u: { v = c.x; } + case 2u: { v = c.y; } + case 3u: { v = c.z; } + case 4u: { v = a; } + default: { v = luminance(c); } + } + v = 255.0 * v; + let bp = f0.x; + let wp = f0.y; + let bs = f0.z; + let ws = f0.w; + var k = 1.0; + if (v < bp) { + k = select(0.0, clamp(1.0 - (bp - v) / bs, 0.0, 1.0), bs > 0.0); + } else if (v > wp) { + k = select(0.0, clamp(1.0 - (v - wp) / ws, 0.0, 1.0), ws > 0.0); + } + if (u0.z != 0u) { + k = 1.0 - k; + } + o = vec4(k); + } + case 5u: { + let m = textureLoad(aux, p, 0).x; + switch u0.y { + case 2u: { + let k = clamp(m, 0.0, 1.0); + let v = px.w * k; + o = vec4(v, v, v, 1.0); + } + case 3u: { + let v = m * px.w; + o = vec4(v, v, v, px.w); + } + case 4u: { o = px * m; } + default: { o = px * clamp(m, 0.0, 1.0); } + } + } + case 6u: { + let q = textureLoad(aux, p, 0); + let oc = fxk_straight(q); + let oa = max(q.w, 0.0); + let d = max(max(max(abs(c.x - oc.x), abs(c.y - oc.y)), abs(c.z - oc.z)), abs(a - oa)); + var m: f32; + if (d <= f0.x) { + m = 0.0; + } else if (f0.y <= 1e-6) { + m = 1.0; + } else { + m = clamp((d - f0.x) / f0.y, 0.0, 1.0); + } + o = vec4(m); + } + case 7u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + var s = px.xyz / px.w; + if (u0.y == 1u) { + let g = (s.x + s.y + s.z) / 3.0; + let dir = f0.xyz; + let proj = (s.x - g) * dir.x + (s.y - g) * dir.y + (s.z - g) * dir.z; + if (proj > 0.0) { + s = s - proj * dir * f0.w; + } + } else { + let spill = max(s[u1.x] - max(s[u1.y], s[u1.z]), 0.0); + s[u1.x] = s[u1.x] - spill * f0.w; + } + o = vec4(s * px.w, px.w); + } + case 8u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + var pi = u1.x; + var o1 = u1.y; + var o2 = u1.z; + if (u1.w != 0u) { + let ex = textureLoad(aux, vec2(0, 0), 0); + if (ex.y >= ex.x && ex.y >= ex.z) { + pi = 1u; + o1 = 0u; + o2 = 2u; + } else if (ex.z >= ex.x) { + pi = 2u; + o1 = 0u; + o2 = 1u; + } else { + pi = 0u; + o1 = 1u; + o2 = 2u; + } + } + var s = px.xyz / px.w; + let amt = f0.x; + let spill_range = f0.y; + let luma = f0.z; + let tol = f0.w; + let desat = P.f[1].x; + let neutral = P.f[1].y; + let key_hue = P.f[1].z; + var w = 1.0; + if (tol < 1.0) { + let h = rgb_to_hsl(s).x; + let d0 = abs(h - key_hue); + let d = min(d0, 1.0 - d0); + w = clamp(1.0 - d / max(tol * 0.5, 1e-4), 0.0, 1.0); + } + let mx = max(s[o1], s[o2]); + let avg = 0.5 * (s[o1] + s[o2]); + let lim = mx + (avg - mx) * spill_range; + let spill = max(s[pi] - lim, 0.0) * amt * w; + s[pi] = s[pi] - spill; + let lw = vec3(0.2126, 0.7152, 0.0722); + let back = spill * lw[pi] * luma + spill * neutral / 3.0; + s = s + back; + if (desat > 0.0 && spill > 0.0) { + let l = luminance(s); + let k = min(desat * min(spill * 4.0, 1.0), 1.0); + s = s + (vec3(l) - s) * k; + } + o = vec4(s * px.w, px.w); + } + case 9u: { + var cc = c; + if (u0.z != 0u) { + let cl = clamp(cc, vec3(0.0), vec3(1.0)); + cc = vec3(srgb_to_linear(cl.x), srgb_to_linear(cl.y), srgb_to_linear(cl.z)); + } + let pa = P.f[1]; + let pb = P.f[3]; + let ua = 1.0 - clamp((cc[u1.x] - cc[u1.y]) / f0.x, 0.0, 1.0); + let ub = 1.0 - clamp((cc[u1.x] - cc[u1.z]) / f0.y, 0.0, 1.0); + let ca = fxk_matte_levels(ua, pa.x, pa.y, pa.z, pa.w, P.f[2].x); + let cb = fxk_matte_levels(ub, pb.x, pb.y, pb.z, pb.w, P.f[2].y); + let um = max(ua, ub); + let cm = fxk_matte_levels(max(ca, cb), f0.z, f0.w, P.f[2].z, 0.0, 255.0); + var view = u0.y; + if (view == 8u) { + let right = p.x * 2 >= dims.x; + let bottom = p.y * 2 >= dims.y; + if (!right && !bottom) { + view = 2u; + } else if (right && !bottom) { + view = 4u; + } else if (!right && bottom) { + view = 6u; + } else { + view = 7u; + } + } + var g = -1.0; + switch view { + case 1u: { g = ua; } + case 2u: { g = ca; } + case 3u: { g = ub; } + case 4u: { g = cb; } + case 5u: { g = um; } + case 6u: { g = cm; } + default: {} + } + if (g >= 0.0) { + let m = g * a; + o = vec4(m, m, m, 1.0); + } else { + o = px * cm; + } + } + case 10u: { + let white = u0.y != 0u; + var d: f32; + var start: f32; + if (white) { + d = 1.0 - min(min(c.x, c.y), c.z); + start = 1.0 - f0.x; + } else { + d = max(max(c.x, c.y), c.z); + start = f0.x; + } + d = clamp(d, 0.0, 1.0); + let width = f0.y * (1.0 - start); + var m: f32; + if (width > 1e-6) { + m = clamp((d - start) / width, 0.0, 1.0); + } else { + m = select(0.0, 1.0, d > start); + } + if (m <= 1e-6) { + o = vec4(0.0); + } else { + let bg = select(0.0, 1.0, white); + var oc = c; + if (u0.z != 0u) { + oc = (c - bg * (1.0 - m)) / m; + } + if (u0.w != 0u) { + oc = clamp(oc, vec3(0.0), vec3(1.0)); + } + o = fxk_premul(oc, a * m); + } + } + case 11u: { + let v = vec4(dst_pick(u1.x, c, a), dst_pick(u1.y, c, a), dst_pick(u1.z, c, a), dst_pick(u1.w, c, a)); + o = fxk_premul(v.xyz, clamp(v.w, 0.0, 1.0)); + } + case 12u: { + if (px.w <= 1e-6) { + o = vec4(0.0); + } else { + for (var i = 0; i < 3; i++) { + let s = px[i] / px.w; + var v = max(s - f0[i] * (1.0 - px.w), 0.0); + if (u0.y != 0u) { + v = min(v, px.w); + } + o[i] = v; + } + } + } + case 13u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + let s = px.xyz / px.w; + var r = vec3(0.0); + for (var i = 0u; i < 3u; i++) { + let v = s[i]; + let kv = f0[i]; + let iv = fxk_q(v); + let ik = u32(round_away(kv * 255.0)); + var x: f32; + switch u0.y { + case 0u: { x = f32(iv & ik) / 255.0; } + case 1u: { x = f32(iv | ik) / 255.0; } + case 2u: { x = f32(iv ^ ik) / 255.0; } + case 3u: { x = v + kv; } + case 4u: { x = v - kv; } + case 5u: { x = abs(v - kv); } + case 6u: { x = min(v, kv); } + case 7u: { x = max(v, kv); } + case 8u: { x = select(v, 0.0, v > kv); } + case 9u: { x = select(v, 0.0, v < kv); } + case 10u: { x = select(0.0, 1.0, v >= kv); } + case 11u: { x = v * kv; } + default: { x = 1.0 - (1.0 - v) * (1.0 - kv); } + } + if (u0.z != 0u) { + r[i] = clamp(x, 0.0, 1.0); + } else { + r[i] = max(x, 0.0); + } + } + o = vec4(r * px.w, px.w); + } + case 14u: { + var b = blend_pixel(u0.y, f0, px * P.f[1].x, 0.5); + b.w = clamp(b.w, 0.0, 1.0); + o = vec4(max(b.xyz, vec3(0.0)), b.w); + } + case 15u: { + var mc = c; + var ma = a; + if ((u0.z & 8u) == 0u) { + let q = textureLoad(aux, p, 0); + mc = fxk_straight(q); + ma = max(q.w, 0.0); + } + var m = dst_pick(u0.y, mc, ma); + if ((u0.z & 4u) != 0u) { + m = m * ma; + } + m = clamp(m, 0.0, 1.0); + let inv = (u0.z & 1u) != 0u; + if (inv) { + m = 1.0 - m; + } + let own_m = (u0.z & 8u) != 0u; + let is_alpha = (u0.z & 16u) != 0u; + var na = m; + if ((u0.z & 2u) != 0u && (!own_m || !is_alpha || inv)) { + na = a * m; + } + o = fxk_premul(c, clamp(na, 0.0, 1.0)); + } + case 16u: { + let ch_from = u0.y; + let ch_to = u0.z; + let inv = (u0.w & 1u) != 0u; + let solid = (u0.w & 2u) != 0u; + let second = (u0.w & 4u) != 0u; + var sc = c; + var sa = a; + if (second) { + let q = textureLoad(aux, p, 0); + sc = fxk_straight(q); + sa = max(q.w, 0.0); + } + if (sa <= 0.0 && ch_from != 7u && !second) { + if (solid) { + o = fxk_premul(c, 1.0); + } + textureStore(out, p, o); + return; + } + var na = a; + var r: vec3; + if (ch_from <= 3u) { + switch ch_from { + case 0u: { + let hsl = rgb_to_hsl(sc); + r = vec3(hsl.x, hsl.z, hsl.y); + } + case 1u: { r = hsl_to_rgb(sc.x, sc.z, sc.y); } + case 2u: { + let y = 0.299 * sc.x + 0.587 * sc.y + 0.114 * sc.z; + r = vec3(y, 0.492 * (sc.z - y) + 0.5, 0.877 * (sc.x - y) + 0.5); + } + default: { + let y = sc.x; + let uu = sc.y - 0.5; + let vv = sc.z - 0.5; + let rr = y + vv / 0.877; + let bb = y + uu / 0.492; + let gg = (y - 0.299 * rr - 0.114 * bb) / 0.587; + r = vec3(rr, gg, bb); + } + } + if (inv) { + r = 1.0 - r; + } + } else { + var v: f32; + switch ch_from { + case 4u: { v = sc.x; } + case 5u: { v = sc.y; } + case 6u: { v = sc.z; } + case 7u: { v = sa; } + case 8u: { v = rgb_to_hsl(sc).z; } + case 9u: { v = rgb_to_hsl(sc).x; } + case 10u: { v = rgb_to_hsl(sc).y; } + case 11u: { v = luminance(sc); } + case 12u: { v = max(max(sc.x, sc.y), sc.z); } + default: { v = min(min(sc.x, sc.y), sc.z); } + } + if (inv) { + v = 1.0 - v; + } + switch ch_to { + case 0u: { r = vec3(v, 0.0, 0.0); } + case 1u: { r = vec3(0.0, v, 0.0); } + case 2u: { r = vec3(0.0, 0.0, v); } + case 3u: { + na = clamp(v, 0.0, 1.0); + r = vec3(1.0); + } + case 5u: { r = hsl_to_rgb(v, 1.0, 0.5); } + case 6u: { r = hsl_to_rgb(0.0, clamp(v, 0.0, 1.0), 0.5); } + case 8u: { + let hsl = rgb_to_hsl(c); + r = hsl_to_rgb(hsl.x, clamp(hsl.y * v, 0.0, 1.0), hsl.z); + } + default: { r = vec3(v); } + } + } + if (solid) { + na = 1.0; + } + o = fxk_premul(max(r, vec3(0.0)), na); + } + case 17u: { + let q = textureLoad(aux, p, 0); + var bl: vec4; + switch u0.y { + case 0u: { bl = q; } + case 1u, 2u: { + let oa = q.w; + if (px.w <= 0.0 || oa <= 0.0) { + bl = px; + } else { + let hsl = rgb_to_hsl(c); + let ohsl = rgb_to_hsl(fxk_straight(q)); + var h2 = ohsl.x; + var s2 = ohsl.y; + if (u0.y == 2u) { + if (hsl.y > 1e-4) { + s2 = hsl.y; + } else { + h2 = 0.0; + s2 = 0.0; + } + } + let rgb = hsl_to_rgb(h2, s2, hsl.z); + bl = px + (fxk_premul(rgb, a) - px) * oa; + } + } + case 3u: { bl = vec4(min(px.xyz, q.xyz), px.w); } + default: { bl = max(px, q); } + } + o = bl + (px - bl) * f0.x; + } + case 18u: { + let flags = u1.x; + var r = fxk_extract(px, u0.y, (flags & 1u) != 0u); + if ((flags & 8u) != 0u) { + let s = fxk_extract(textureLoad(aux, p, 0), u0.z, (flags & 2u) != 0u) * f0.x; + r = blend_pixel(u0.w, r, s, 0.5); + } + if ((flags & 4u) != 0u && r.w > 1e-6) { + r = fxk_premul(fxk_straight(r), px.w); + } + r.w = clamp(r.w, 0.0, 1.0); + o = r; + } + case 19u: { + let q = textureLoad(aux, p, 0); + let oc = fxk_straight(q); + let oa = max(q.w, 0.0); + var rc = c; + var ra = a; + if (u0.z != 2u) { + for (var i = 0u; i < 3u; i++) { + rc[i] = fxk_overflow(u0.y, fxk_arith(u0.y, c[i], oc[i]), u0.w); + } + } + if (u0.z != 0u) { + ra = fxk_overflow(u0.y, fxk_arith(u0.y, a, oa), u0.w); + } + let res = fxk_premul(rc, ra); + o = res + (px - res) * f0.x; + } + case 20u: { + let q = textureLoad(aux, p, 0); + o = px; + o[u0.y] = dst_pick(u0.z, fxk_straight(q), max(q.w, 0.0)); + } + case 21u: { + o = fxk_premul(px.xyz, clamp(px.w, 0.0, 1.0)); + } + case 22u: { + let d = c[u1.x] - fxk_secondary(c, u1.y, u1.z, f0.z); + let t = clamp(d / f0.x * f0.y, 0.0, 1.0); + o = vec4(clamp((1.0 - t - f0.w) / max(P.f[1].x - f0.w, 1e-3), 0.0, 1.0)); + } + case 23u: { + let k = clamp(textureLoad(aux, p, 0).x, 0.0, 1.0); + let alpha = a * k; + if (u0.y == 1u) { + o = vec4(alpha, alpha, alpha, 1.0); + } else if (u0.y == 2u) { + var v = 0.5; + if (alpha <= 1e-3) { + v = 0.0; + } else if (alpha >= 0.999) { + v = 1.0; + } + o = vec4(v, v, v, 1.0); + } else { + let screen = f0.xyz; + var fg = c; + if (!(u0.z != 0u || k <= 1e-4)) { + let s = 1.0 - k; + for (var i = 0; i < 3; i++) { + fg[i] = min(max(c[i] - s * screen[i], 0.0) / k, max(c[i], 1.0)); + } + } + let spill = max(fg[u1.x] - fxk_secondary(fg, u1.y, u1.z, f0.w), 0.0); + fg[u1.x] = fg[u1.x] - spill * P.f[1].x; + o = fxk_premul(fg, alpha); + } + } + case 24u: { + o = vec4(px.w); + } + case 25u: { + var na = textureLoad(aux, p, 0).x; + if (u0.y != 0u) { + na = clamp((na - 0.5) * 1.5 + 0.5, 0.0, 1.0); + } + var cc = c; + let a0 = a; + if (P.u[3].y != 0u && a0 > 1e-4 && a0 < 0.999) { + let q = fxk_rows(p); + if (q.w > 1e-6) { + let ea = q.w; + if (ea > 1e-4) { + let t = f0.y * (1.0 - a0); + cc = cc + (q.xyz / ea - cc) * t; + } + } + } + na = clamp((na - 0.5) * f0.x + 0.5, 0.0, 1.0); + if (a0 <= 1e-6) { + na = 0.0; + } + o = fxk_premul(cc, na); + } + case 26u: { + o = vec4(clamp((px.x - f0.x) / f0.y + 0.5, 0.0, 1.0)); + } + case 27u: { + let na = clamp(textureLoad(aux, p, 0).x, 0.0, 1.0); + var cc = c; + if (px.w <= 1e-4) { + cc = fxk_straight(fxk_rows(p)); + } + o = fxk_premul(cc, na); + } + case 28u: { + let v = clamp(px.w, 0.0, 1.0); + o = vec4(v, v, v, 1.0); + } + default: {} + } + textureStore(out, p, o); +} + +// util::fit_layer: bilinear sample of the other layer (src) at the buffer coordinates in data +// (one per column, then one per row). +@compute @workgroup_size(16, 16) +fn fxk_fit(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let x = data[u32(p.x)]; + let y = data[u32(dims.x) + u32(p.y)]; + textureStore(out, p, sample_bilinear(src, x, y)); +} + +// Sum of 16×16 blocks; u[0].x = first pass: sum Advanced Spill Suppressor's per-channel excess +// of each primary over the other two (straight colour × alpha), else sum the values. +@compute @workgroup_size(16, 16) +fn fxk_reduce(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let sd = vec2(textureDimensions(src)); + var acc = vec4(0.0); + for (var j = 0; j < 16; j++) { + for (var i = 0; i < 16; i++) { + let q = vec2(p.x * 16 + i, p.y * 16 + j); + if (q.x >= sd.x || q.y >= sd.y) { + continue; + } + let v = textureLoad(src, q, 0); + if (P.u[0].x != 0u) { + let c = fxk_straight(v); + let a = max(v.w, 0.0); + acc += vec4( + max(c.x - max(c.y, c.z), 0.0) * a, + max(c.y - max(c.x, c.z), 0.0) * a, + max(c.z - max(c.x, c.y), 0.0) * a, + 0.0, + ); + } else { + acc += v; + } + } + } + textureStore(out, p, acc); +} diff --git a/crates/gpu/src/tests_fx_key.rs b/crates/gpu/src/tests_fx_key.rs index ae615fe7..25f2d9fe 100644 --- a/crates/gpu/src/tests_fx_key.rs +++ b/crates/gpu/src/tests_fx_key.rs @@ -1,2 +1,218 @@ -//! GPU keying, matte and channel family effects vs the CPU effects (the oracle): direct on a buffer at full and half -//! resolution, as adjustment, and composited at 8 and 32 bpc. +//! GPU keying, matte and channel family effects vs the CPU effects (the oracle): direct on a +//! buffer at full and half resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +/// [`effect_case`] for hard thresholds (8-bit quantisation, bitwise operators): the composited +/// renders may flip a few pixels sitting on the threshold after the layer transform's float +/// rounding, so up to 0.1 % of them may differ by any amount. +fn discontinuous_case(id: &str, vals: &[(&str, Value)]) { + crate::tests::effect_direct(id, vals, false); + crate::tests::effect_direct(id, vals, true); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(70, 44); + s.effect(&mut l, id, vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.001); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.001); + } +} + +#[test] +fn colour_luma_range_extract_keys() { + effect_case("ec.key.colorkey", &[("keyColor", c(0.7, 0.6, 0.5)), ("colorTolerance", n(60.0))]); + effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(1.5)), ("edgeFeather", n(4.0))]); + effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(-2.0))]); + effect_case("ec.key.luma", &[("threshold", n(120.0)), ("tolerance", n(40.0))]); + effect_case("ec.key.luma", &[("keyType", e(0)), ("threshold", n(140.0)), ("tolerance", n(30.0)), ("edgeFeather", n(3.0))]); + effect_case("ec.key.luma", &[("keyType", e(2)), ("threshold", n(128.0)), ("tolerance", n(25.0)), ("edgeThin", n(1.0))]); + effect_case("ec.key.luma", &[("keyType", e(3)), ("threshold", n(100.0)), ("tolerance", n(50.0))]); + effect_case("ec.key.colorrange", &[("fuzziness", n(20.0))]); + effect_case("ec.key.colorrange", &[("colorSpace", e(1)), ("minL", n(60.0)), ("maxA", n(150.0)), ("minB", n(90.0)), ("fuzziness", n(15.0))]); + effect_case("ec.key.colorrange", &[("colorSpace", e(2)), ("minL", n(80.0)), ("maxA", n(180.0)), ("minB", n(40.0)), ("fuzziness", n(30.0))]); + effect_case("ec.key.extract", &[("blackPoint", n(60.0)), ("whitePoint", n(200.0)), ("blackSoftness", n(30.0)), ("whiteSoftness", n(20.0))]); + discontinuous_case("ec.key.extract", &[("channel", e(1)), ("blackPoint", n(90.0)), ("invert", on())]); + effect_case("ec.key.extract", &[("channel", e(4)), ("whitePoint", n(200.0)), ("whiteSoftness", n(40.0))]); +} + +#[test] +fn difference_colour_difference_and_screen_keys() { + effect_case("ec.key.differencematte", &[]); + effect_case("ec.key.differencematte", &[("view", e(2)), ("matchingTolerance", n(0.0)), ("blurBeforeDifference", n(3.0))]); + effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8))]); + effect_case( + "ec.key.colordifference", + &[ + ("keyColor", c(0.3, 0.8, 0.2)), + ("colorMatchingAccuracy", e(1)), + ("partialAInBlack", n(20.0)), + ("partialAGamma", n(1.4)), + ("partialBOutWhite", n(200.0)), + ("blackLevel", n(10.0)), + ("gamma", n(0.8)), + ], + ); + for view in [1, 2, 3, 4, 5, 6, 8] { + effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8)), ("view", e(view)), ("partialBInWhite", n(180.0))]); + } + effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3))]); + effect_case( + "ec.key.screen", + &[ + ("screenColor", c(0.3, 0.4, 0.8)), + ("screenGain", n(130.0)), + ("screenBalance", n(30.0)), + ("despill", n(60.0)), + ("clipBlack", n(10.0)), + ("clipWhite", n(85.0)), + ("screenPreblur", n(2.0)), + ("screenShrinkGrow", n(-1.5)), + ("screenSoftness", n(3.0)), + ], + ); + effect_case("ec.key.screen", &[("screenColor", c(0.8, 0.4, 0.3)), ("screenShrinkGrow", n(2.0)), ("edgeColor", e(1))]); + for view in [1, 2] { + effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3)), ("view", e(view))]); + } +} + +#[test] +fn spill_unmult_and_key_cleaner() { + effect_case("ec.key.spill", &[("colorToSuppress", c(0.2, 0.9, 0.1))]); + effect_case("ec.key.spill", &[("colorToSuppress", c(0.9, 0.8, 0.1)), ("colorAccuracy", e(1)), ("suppression", n(150.0))]); + effect_case("ec.key.advancedspill", &[]); + effect_case("ec.key.advancedspill", &[("suppression", n(80.0)), ("ultraSettings/spillRange", n(20.0)), ("ultraSettings/lumaCorrection", n(50.0))]); + effect_case( + "ec.key.advancedspill", + &[ + ("method", e(1)), + ("ultraSettings/keyColor", c(0.9, 0.2, 0.3)), + ("ultraSettings/tolerance", n(40.0)), + ("ultraSettings/desaturate", n(50.0)), + ("ultraSettings/spillColorCorrection", n(30.0)), + ], + ); + effect_case("ec.key.unmult", &[]); + effect_case("ec.key.unmult", &[("backgroundColor", e(1)), ("whiteLevel", n(90.0)), ("softness", n(40.0)), ("clipHdrResults", off())]); + effect_case("ec.key.unmult", &[("blackLevel", n(30.0)), ("softness", n(0.0)), ("removeColorMatting", off())]); + effect_case("ec.key.keycleaner", &[]); + effect_case("ec.key.keycleaner", &[("additionalEdgeRadius", n(6.0)), ("reduceChatter", on()), ("alphaContrast", n(250.0)), ("strength", n(60.0))]); +} + +#[test] +fn chokers() { + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(1.5))]); + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-2.5))]); + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-1.0)), ("view", e(1))]); + effect_case("ec.matte.mattechoker", &[]); + effect_case( + "ec.matte.mattechoker", + &[ + ("geometricSoftness1", n(2.0)), + ("choke1", n(-40.0)), + ("geometricSoftness2", n(3.0)), + ("choke2", n(30.0)), + ("grayLevelSoftness2", n(40.0)), + ("iterations", n(2.0)), + ], + ); +} + +#[test] +fn channel_effects() { + effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]); + effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]); + effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]); + effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]); + for op in [0, 2, 3, 4, 8, 10, 12] { + discontinuous_case("ec.channel.arithmetic", &[("operator", e(op)), ("redValue", n(120.0)), ("greenValue", n(60.0)), ("blueValue", n(200.0))]); + } + effect_case("ec.channel.arithmetic", &[("operator", e(3)), ("redValue", n(200.0)), ("clip", off())]); + for mode in [0, 2, 4, 9, 11] { + effect_case("ec.channel.solidcomposite", &[("blendingMode", e(mode)), ("color", c(0.8, 0.3, 0.2)), ("opacity", n(70.0)), ("sourceOpacity", n(80.0))]); + } + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(4))]); + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(3)), ("invertMatte", on())]); + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(1)), ("compositeMatteWithOriginal", off()), ("premultiplyMatteLayer", off())]); + for (from, to) in [(0, 0), (1, 0), (2, 0), (3, 0), (8, 4), (9, 5), (10, 6), (11, 3), (12, 8), (13, 7), (7, 1)] { + effect_case("ec.channel.combiner", &[("from", e(from)), ("to", e(to))]); + } + effect_case("ec.channel.combiner", &[("from", e(4)), ("to", e(2)), ("invert", on()), ("solidAlpha", on())]); + effect_case("ec.channel.blend", &[("mode", e(1)), ("blendWithOriginal", n(30.0))]); + effect_case("ec.channel.calculations", &[("inputChannel", e(1))]); + effect_case("ec.channel.calculations", &[("inputChannel", e(0)), ("invertInput", on())]); + effect_case("ec.channel.compoundarithmetic", &[("operator", e(3)), ("operateOnChannels", e(1))]); +} + +/// The channel and keying effects that read another layer, composited. +#[test] +fn layer_inputs() { + let cases: Vec<(&str, Vec<(&str, Value)>)> = vec![ + ("ec.key.differencematte", vec![("matchingTolerance", n(10.0)), ("matchingSoftness", n(20.0))]), + ("ec.key.differencematte", vec![("ifLayerSizesDiffer", e(1)), ("blurBeforeDifference", n(2.0)), ("view", e(2))]), + ("ec.channel.setmatte", vec![("takeMatteFrom", e(4)), ("stretchMatteToFit", off())]), + ("ec.channel.setmatte", vec![("takeMatteFrom", e(3))]), + ("ec.channel.setchannels", vec![("setGreenTo", e(0)), ("setAlphaTo", e(4))]), + ("ec.channel.combiner", vec![("useSecondLayer", on()), ("from", e(11)), ("to", e(7))]), + ("ec.channel.blend", vec![("mode", e(0)), ("blendWithOriginal", n(40.0))]), + ("ec.channel.blend", vec![("mode", e(2))]), + ("ec.channel.blend", vec![("mode", e(3)), ("ifLayerSizesDiffer", e(1))]), + ("ec.channel.blend", vec![("mode", e(4))]), + ("ec.channel.calculations", vec![("secondLayerChannel", e(2)), ("blendingMode", e(2)), ("secondLayerOpacity", n(70.0))]), + ("ec.channel.calculations", vec![("inputChannel", e(4)), ("blendingMode", e(10)), ("preserveTransparency", on()), ("invertSecondLayer", on())]), + ("ec.channel.compoundarithmetic", vec![("operator", e(1)), ("overflowBehavior", e(2))]), + ("ec.channel.compoundarithmetic", vec![("operator", e(4)), ("operateOnChannels", e(1)), ("blendWithOriginal", n(25.0))]), + ("ec.channel.compoundarithmetic", vec![("operator", e(13)), ("operateOnChannels", e(2))]), + ]; + let layer_param = |id: &str| match id { + "ec.key.differencematte" => vec!["differenceLayer"], + "ec.channel.setmatte" => vec!["takeMatteFromLayer"], + "ec.channel.setchannels" => vec!["sourceLayer2", "sourceLayer4"], + "ec.channel.combiner" => vec!["sourceLayer"], + "ec.channel.blend" => vec!["blendWithLayer"], + "ec.channel.calculations" => vec!["secondLayer"], + _ => vec!["secondSource"], + }; + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (id, vals) in &cases { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut other = s.footage(50, 36); + other.switches.video = false; + let oid = s.push(other); + let mut l = s.footage(70, 44); + let mut vals = vals.clone(); + for p in layer_param(id) { + vals.push((p, Value::Layer(Some(oid.0)))); + } + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("{id} {vals:?} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +}