Files
effectcraft/crates/effects/src/utility.rs
T
Claude 3ba43b20c7 Effect Controls: file parameters get Choose…, Apply Color LUT asks for its LUT on apply, and text parameters get a field (#514, #473)
Cause: Apply Color LUT's only parameter, `lut`, was ParamUi::Hidden (set only
through commands), so the effect could not be used from the UI. Effect
Controls also had no widget for any ParamUi::Text parameter (only EXtractoR's
channels had one), so Cryptomatte's Selection, the OCIO effects' config file /
file / config names and Colorama's Output Cycle could not be set either.

Fix: a new ParamUi::File { filters } for paths. Effect Controls shows the
file's name (None when empty) and a Choose... button that opens the host's
file dialog with those extensions and sets the path (prop.set, so agents use
the same command). As in After Effects, applying an effect with an empty file
parameter from the UI (Effect menu, Last Effect, drops, Effects & Presets)
opens the file dialog straight away; cancelling keeps the effect without a
file. Apply Color LUT's LUT (.cube) and the OCIO Config File (.ocio) and File
(.cube/.3dl/.csp/.cc/.ccc/.cdl/.spi1d/.spi3d/.spimtx) use it. Text
parameters get a single-line field that sets the value when it loses the
focus (Esc keeps the old text). Saved projects are upgraded on open: a
Hidden or Text parameter whose spec is now a File parameter takes the File
UI and keeps its path (effects::migrate). docs/effects.md regenerated.

This covers #473's first part (the Selection field); picking mattes in the
viewer, the layer popup from the file's metadata, the GPU path and quoted
commas are separate.

Tests: effects hidden_and_text_paths_become_file_parameters (migration); ui
file_and_text_parameters_have_widgets (the LUT is asked for on apply and
inverts red to cyan, the Choose... button picks another LUT, typing into
Cryptomatte's Selection sets it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit 403863f703999d19d3a83d9976f76bdc7577e682)
2026-10-10 19:48:25 +00:00

516 lines
18 KiB
Rust

//! Utility effects: bounds growth, HDR inspection/compression, Cineon log conversion and colour
//! lookup tables (.cube).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use rayon::prelude::*;
use crate::util::{premul, 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: "Utility", params, render, gpu: false, float: true }
}
fn grow_bounds(ctx: &EffectCtx, mut b: Buf) -> Buf {
let px = (ctx.params.f("pixels") * b.scale).round().max(0.0) as u32;
if !ctx.adjustment {
b.pad(px);
}
b
}
/// Visualise out-of-range values: in-range shown as dim grey, > 1 in the highlight colour,
/// < 0 in its complement.
fn overbrights(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ch = ctx.params.e("channel");
let hc = ctx.params.color("highlightColor");
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = unpremul(*px);
if a <= 0.0 {
return;
}
let v = match ch {
1 => c[0],
2 => c[1],
3 => c[2],
4 => a,
5 => luminance(c[0], c[1], c[2]),
_ => c[0].max(c[1]).max(c[2]),
};
let o = if v > 1.0 {
[hc[0], hc[1], hc[2]]
} else if v < 0.0 {
[1.0 - hc[0], 1.0 - hc[1], 1.0 - hc[2]]
} else {
let g = v * 0.35;
[g, g, g]
};
*px = premul(o, a);
});
b
}
/// Soft-knee, hue-preserving compression of the brightest channel into [0, 1).
fn hdr_compress_value(m: f32, knee: f32) -> f32 {
if m <= knee {
return m;
}
let w = 1.0 - knee;
knee + w * (m - knee) / ((m - knee) + w)
}
fn hdr_compression(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32;
if t <= 0.0 {
return b;
}
let knee = 1.0 - 0.5 * t;
b.img.map_straight(|c| {
let m = c[0].max(c[1]).max(c[2]);
if m <= knee {
return c;
}
let k = hdr_compress_value(m, knee) / m;
c.map(|v| v * k)
});
b
}
// ---- Cineon (Kodak printing-density log encoding: 0.002 density per code value, negative gamma 0.6) ----
#[derive(Clone, Copy, Debug)]
pub(crate) struct Cineon {
pub black: f32,
pub white: f32,
pub ib: f32,
pub iw: f32,
pub gamma: f32,
pub rolloff: f32,
}
impl Cineon {
fn offset(&self) -> f32 {
10f32.powf((self.black - self.white) * 0.002 / 0.6)
}
fn knee(&self) -> f32 {
1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)
}
/// Normalised code value (code / 1023) → linear.
pub fn log_to_lin(&self, v: f32) -> f32 {
let off = self.offset();
let code = v * 1023.0;
let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01));
let k = self.knee();
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
self.ib + (self.iw - self.ib) * lin
}
/// Linear → normalised code value.
pub fn lin_to_log(&self, v: f32) -> f32 {
let off = self.offset();
let d = self.iw - self.ib;
let mut lin = (v - self.ib) / if d.abs() < 1e-6 { 1e-6 } else { d };
let k = self.knee();
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
}
lin = lin.max(0.0).powf(self.gamma.max(0.01) / 1.7);
let code = self.white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002;
code / 1023.0
}
}
fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = Cineon {
black: ctx.params.f("tenBitBlackPoint") as f32,
white: ctx.params.f("tenBitWhitePoint") as f32,
ib: ctx.params.f("internalBlackPoint") as f32,
iw: ctx.params.f("internalWhitePoint") as f32,
gamma: ctx.params.f("gamma") as f32,
rolloff: ctx.params.f("highlightRolloff") as f32,
};
let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff };
let kind = ctx.params.e("conversionType");
b.img.map_straight(|v| {
v.map(|x| match kind {
0 => c.lin_to_log(x),
1 => c.log_to_lin(x),
_ => std.lin_to_log(c.log_to_lin(x)),
})
});
b
}
// ---- Colour LUTs (.cube) ----
#[derive(Clone, Debug, PartialEq)]
pub struct Lut {
pub n1: usize,
pub d1: Vec<[f32; 3]>,
pub n3: usize,
pub d3: Vec<[f32; 3]>,
pub dmin: [f32; 3],
pub dmax: [f32; 3],
}
/// Parse `.cube` text (TITLE, LUT_1D_SIZE, LUT_3D_SIZE, DOMAIN_MIN/MAX, data red-fastest).
pub fn parse_cube(text: &str) -> Option<Lut> {
let mut lut = Lut { n1: 0, d1: vec![], n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] };
let mut rows: Vec<[f32; 3]> = Vec::new();
let three = |it: &mut std::str::SplitWhitespace| -> Option<[f32; 3]> {
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
let c = it.next()?.parse().ok()?;
Some([a, b, c])
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut it = line.split_whitespace();
let key = it.clone().next()?;
match key {
"TITLE" => continue,
"LUT_1D_SIZE" => {
it.next();
lut.n1 = it.next()?.parse().ok()?;
}
"LUT_3D_SIZE" => {
it.next();
lut.n3 = it.next()?.parse().ok()?;
}
"DOMAIN_MIN" => {
it.next();
lut.dmin = three(&mut it)?;
}
"DOMAIN_MAX" => {
it.next();
lut.dmax = three(&mut it)?;
}
"LUT_1D_INPUT_RANGE" | "LUT_3D_INPUT_RANGE" => {
it.next();
let lo: f32 = it.next()?.parse().ok()?;
let hi: f32 = it.next()?.parse().ok()?;
lut.dmin = [lo; 3];
lut.dmax = [hi; 3];
}
k if k.starts_with(|c: char| c.is_ascii_alphabetic()) => continue,
_ => rows.push(three(&mut it)?),
}
}
let need1 = lut.n1;
let need3 = lut.n3 * lut.n3 * lut.n3;
if (lut.n1 == 0 && lut.n3 == 0) || lut.n1 == 1 || lut.n3 == 1 || rows.len() != need1 + need3 || lut.n3 > 256 {
return None;
}
lut.d1 = rows[..need1].to_vec();
lut.d3 = rows[need1..].to_vec();
Some(lut)
}
impl Lut {
fn norm(&self, c: [f32; 3]) -> [f32; 3] {
[0, 1, 2].map(|i| ((c[i] - self.dmin[i]) / (self.dmax[i] - self.dmin[i]).max(1e-9)).clamp(0.0, 1.0))
}
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let mut c = c;
if self.n1 > 1 {
let t = self.norm(c);
c = [0, 1, 2].map(|i| {
let x = t[i] * (self.n1 - 1) as f32;
let i0 = (x.floor() as usize).min(self.n1 - 2);
let f = x - i0 as f32;
self.d1[i0][i] + (self.d1[i0 + 1][i] - self.d1[i0][i]) * f
});
}
if self.n3 > 1 {
c = self.tetra(self.norm(c));
}
c
}
/// Apply with a choice of 3D interpolation: 0 nearest, 1 trilinear, otherwise tetrahedral.
pub fn apply_interp(&self, c: [f32; 3], interp: u32) -> [f32; 3] {
let mut c = c;
if self.n1 > 1 {
let t = self.norm(c);
c = [0, 1, 2].map(|i| {
let x = t[i] * (self.n1 - 1) as f32;
if interp == 0 {
return self.d1[(x.round() as usize).min(self.n1 - 1)][i];
}
let i0 = (x.floor() as usize).min(self.n1 - 2);
let f = x - i0 as f32;
self.d1[i0][i] + (self.d1[i0 + 1][i] - self.d1[i0][i]) * f
});
}
if self.n3 > 1 {
let t = self.norm(c);
c = match interp {
0 => self.nearest(t),
1 => self.trilinear(t),
_ => self.tetra(t),
};
}
c
}
fn nearest(&self, t: [f32; 3]) -> [f32; 3] {
let n = self.n3;
let s = (n - 1) as f32;
let i = |x: f32| ((x * s).round() as usize).min(n - 1);
self.d3[i(t[0]) + i(t[1]) * n + i(t[2]) * n * n]
}
/// Trilinear interpolation on the lattice (red fastest).
pub fn trilinear(&self, t: [f32; 3]) -> [f32; 3] {
let n = self.n3;
let s = (n - 1) as f32;
let idx = |x: f32| {
let v = x * s;
let i = (v.floor() as usize).min(n - 2);
(i, v - i as f32)
};
let (r, fr) = idx(t[0]);
let (g, fg) = idx(t[1]);
let (b, fb) = idx(t[2]);
let at = |dr: usize, dg: usize, db: usize| self.d3[(r + dr) + (g + dg) * n + (b + db) * n * n];
let l = |a: [f32; 3], b: [f32; 3], f: f32| [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
let c00 = l(at(0, 0, 0), at(1, 0, 0), fr);
let c10 = l(at(0, 1, 0), at(1, 1, 0), fr);
let c01 = l(at(0, 0, 1), at(1, 0, 1), fr);
let c11 = l(at(0, 1, 1), at(1, 1, 1), fr);
l(l(c00, c10, fg), l(c01, c11, fg), fb)
}
fn tetra(&self, t: [f32; 3]) -> [f32; 3] {
let n = self.n3;
let s = (n - 1) as f32;
let idx = |x: f32| {
let v = x * s;
let i = (v.floor() as usize).min(n - 2);
(i, v - i as f32)
};
let (r, fr) = idx(t[0]);
let (g, fg) = idx(t[1]);
let (b, fb) = idx(t[2]);
let at = |dr: usize, dg: usize, db: usize| self.d3[(r + dr) + (g + dg) * n + (b + db) * n * n];
let c000 = at(0, 0, 0);
let c111 = at(1, 1, 1);
let (w1, w2, w3, p1, p2) = if fr > fg {
if fg > fb {
(fr, fg, fb, at(1, 0, 0), at(1, 1, 0))
} else if fr > fb {
(fr, fb, fg, at(1, 0, 0), at(1, 0, 1))
} else {
(fb, fr, fg, at(0, 0, 1), at(1, 0, 1))
}
} else if fb > fg {
(fb, fg, fr, at(0, 0, 1), at(0, 1, 1))
} else if fb > fr {
(fg, fb, fr, at(0, 1, 0), at(0, 1, 1))
} else {
(fg, fr, fb, at(0, 1, 0), at(1, 1, 0))
};
// c = c000 + w1 (p1 - c000) + w2 (p2 - p1) + w3 (c111 - p2), w1 ≥ w2 ≥ w3.
[0, 1, 2].map(|i| c000[i] + w1 * (p1[i] - c000[i]) + w2 * (p2[i] - p1[i]) + w3 * (c111[i] - p2[i]))
}
}
type LutCache = Mutex<HashMap<String, Option<Arc<Lut>>>>;
/// Load (and cache) a LUT from inline `.cube` text or a file path.
pub fn load_lut(src: &str) -> Option<Arc<Lut>> {
if src.trim().is_empty() {
return None;
}
static CACHE: OnceLock<LutCache> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(m) = cache.lock()
&& let Some(v) = m.get(src)
{
return v.clone();
}
let inline = src.contains('\n') || src.contains("LUT_");
let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new);
if let Ok(mut m) = cache.lock() {
m.insert(src.to_string(), parsed.clone());
}
parsed
}
fn apply_lut(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(lut) = load_lut(ctx.params.s("lut")) else {
return b;
};
b.img.map_straight(|c| lut.apply(c));
b
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec("ec.utility.growbounds", "Grow Bounds", vec![p("pixels", "Pixels", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0))], grow_bounds),
spec(
"ec.utility.ccoverbrights",
"CC Overbrights",
vec![
p("channel", "Channel", Value::Enum(0), popup(&["Max RGB", "Red", "Green", "Blue", "Alpha", "Luminance"])),
p("highlightColor", "Highlight Color", col(1.0, 0.0, 0.0), ParamUi::Color),
],
overbrights,
),
spec(
"ec.utility.hdrcompression",
"HDR Highlight Compression",
vec![p("amount", "Amount", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1))],
hdr_compression,
),
spec(
"ec.utility.cineon",
"Cineon Converter",
vec![
p("conversionType", "Conversion Type", Value::Enum(1), popup(&["Linear to Log", "Log to Linear", "Log to Log"])),
p("tenBitBlackPoint", "10 Bit Black Point", num(95.0), slider(0.0, 1023.0, 0.0, 1023.0, 0)),
p("internalBlackPoint", "Internal Black Point", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("tenBitWhitePoint", "10 Bit White Point", num(685.0), slider(0.0, 1023.0, 0.0, 1023.0, 0)),
p("internalWhitePoint", "Internal White Point", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("gamma", "Gamma", num(1.7), slider(0.1, 5.0, 0.1, 5.0, 2)),
p("highlightRolloff", "Highlight Rolloff", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
],
cineon,
),
// `lut`: a path to a `.cube` file (Effect Controls' Choose…, asked for when the effect is
// applied, as in After Effects), or the `.cube` text itself (commands/automation).
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), crate::file_ui(&["cube"]))], apply_lut),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Image, Params};
fn identity_cube(n: usize) -> String {
let mut s = format!("TITLE \"id\"\n# comment\nLUT_3D_SIZE {n}\n");
for b in 0..n {
for g in 0..n {
for r in 0..n {
let f = |v: usize| v as f32 / (n - 1) as f32;
s += &format!("{} {} {}\n", f(r), f(g), f(b));
}
}
}
s
}
fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf {
let s = crate::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 vals {
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: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}
fn test_img() -> Image {
let mut img = Image::new(8, 8);
for y in 0..8 {
for x in 0..8 {
let a = 0.5 + (x as f32) / 16.0;
img.set(x, y, premul([x as f32 / 7.0, y as f32 / 7.0, ((x + y) % 5) as f32 / 4.0], a));
}
}
img
}
#[test]
fn identity_lut_is_identity() {
for n in [2, 17] {
let lut = parse_cube(&identity_cube(n)).unwrap();
for c in [[0.1, 0.5, 0.9], [0.33, 0.77, 0.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.42, 0.42, 0.9]] {
let o = lut.apply(c);
for i in 0..3 {
assert!((o[i] - c[i]).abs() < 1e-5, "n={n} {c:?} -> {o:?}");
}
}
let img = test_img();
let out = run("ec.utility.applylut", &[("lut", Value::Str(identity_cube(n)))], img.clone());
for (a, b) in out.img.data.iter().zip(img.data.iter()) {
for c in 0..4 {
assert!((a[c] - b[c]).abs() < 1e-5);
}
}
}
}
#[test]
fn swap_lut_swaps_channels() {
let mut s = String::from("LUT_3D_SIZE 2\n");
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
s += &format!("{b} {g} {r}\n");
}
}
}
let lut = parse_cube(&s).unwrap();
let o = lut.apply([0.2, 0.5, 0.8]);
assert!((o[0] - 0.8).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.2).abs() < 1e-5, "{o:?}");
}
#[test]
fn bad_cube_is_passthrough() {
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n1 1 1\n").is_none());
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 zz\n").is_none());
let img = test_img();
let out = run("ec.utility.applylut", &[("lut", Value::Str("LUT_3D_SIZE 3\n0 0 0\n".into()))], img.clone());
assert_eq!(out.img, img);
let out = run("ec.utility.applylut", &[("lut", Value::Str("/nonexistent/path.cube".into()))], img.clone());
assert_eq!(out.img, img);
}
#[test]
fn one_d_lut_with_domain() {
let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap();
let o = lut.apply([0.5, 1.0, 2.0]);
assert!((o[0] - 0.75).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && o[2].abs() < 1e-5, "{o:?}");
}
#[test]
fn cineon_round_trip() {
let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: 20.0 };
for code in [100.0f32, 200.0, 350.0, 500.0, 600.0] {
let v = code / 1023.0;
let back = c.lin_to_log(c.log_to_lin(v));
assert!((back - v).abs() < 1e-3, "{code}: {back}");
}
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
}
#[test]
fn hdr_compression_behaviour() {
let mut img = Image::new(2, 1);
img.set(0, 0, [0.2, 0.3, 0.4, 1.0]);
img.set(1, 0, [4.0, 2.0, 1.0, 1.0]);
let out = run("ec.utility.hdrcompression", &[], img);
assert_eq!(out.img.data[0], [0.2, 0.3, 0.4, 1.0]);
let o = out.img.data[1];
assert!(o[0] <= 1.0 && o[0] > 0.5 && (o[0] / o[1] - 2.0).abs() < 1e-4, "{o:?}");
}
#[test]
fn grow_bounds_pads() {
let out = run("ec.utility.growbounds", &[("pixels", num(5.0))], test_img());
assert_eq!((out.img.width, out.img.height), (18, 18));
assert_eq!(out.offset, [5.0, 5.0]);
}
}