mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 11:07:54 -06:00
464 lines
16 KiB
Rust
464 lines
16 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(crate) 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(crate) 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
|
|
}
|
|
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(crate) 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, or the `.cube` text itself (set via commands/automation).
|
|
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], 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: ¶ms, 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]);
|
|
}
|
|
}
|