mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 18:31:30 -06:00
New After Effects 2026 renders of the same EXR at Amount 25, 50 and 75 (our own 1110-extra sample, 16-bit PNGs) confirm the transfer curve that was fitted at 84, 90 and 100. The worst channel error over all pixels is 4.0, 5.5 and 4.3 of 65535, against 997, 2415 and 3867 for the old soft knee. The code is unchanged. The measured-reference test now covers all six Amounts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
652 lines
26 KiB
Rust
652 lines
26 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
|
||
}
|
||
|
||
/// Luma weights HDR Highlight Compression divides by (measured against After Effects 2026).
|
||
const HDR_LUMA: [f32; 3] = [0.30, 0.59, 0.11];
|
||
/// Exponent of the domain the Amount blend happens in (out^0.4 is mixed linearly).
|
||
const HDR_BLEND_GAMMA: f32 = 0.4;
|
||
|
||
#[inline]
|
||
fn hdr_spow(v: f32, e: f32) -> f32 {
|
||
v.signum() * v.abs().powf(e)
|
||
}
|
||
|
||
/// One straight-colour pixel through HDR Highlight Compression at blend `t` (Amount / 100, > 0).
|
||
///
|
||
/// Measured behaviour (16-bit renders of a 0–4 HDR ramp and coloured patches at Amount 100,
|
||
/// 90, 84, 75, 50 and 25): every channel is divided by `1 + Y` (`Y` = 0.30 R + 0.59 G + 0.11 B, so a grey
|
||
/// value `x` lands on `x / (1 + x)`), clipped at 1, then mixed with the input as `x^0.4` values.
|
||
fn hdr_compress_pixel(c: [f32; 3], t: f32) -> [f32; 3] {
|
||
let y = (HDR_LUMA[0] * c[0] + HDR_LUMA[1] * c[1] + HDR_LUMA[2] * c[2]).max(0.0);
|
||
let s = 1.0 / (1.0 + y);
|
||
let g = HDR_BLEND_GAMMA;
|
||
c.map(|x| {
|
||
let comp = (x * s).min(1.0);
|
||
let mixed = (1.0 - t) * hdr_spow(x, g) + t * hdr_spow(comp, g);
|
||
let o = hdr_spow(mixed, 1.0 / g);
|
||
if o.is_finite() { o } else { x }
|
||
})
|
||
}
|
||
|
||
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 || !t.is_finite() {
|
||
return b;
|
||
}
|
||
b.img.map_straight(|c| hdr_compress_pixel(c, t));
|
||
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)
|
||
}
|
||
/// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
|
||
/// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
|
||
/// also uses with `1 / gamma`).
|
||
pub fn log_to_lin(&self, v: f32) -> f32 {
|
||
let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff);
|
||
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: ¶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);
|
||
}
|
||
|
||
/// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same
|
||
/// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times
|
||
/// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give
|
||
/// linear light (code 400 is 0.10251).
|
||
#[test]
|
||
fn cineon_converter_matches_interpret_footage() {
|
||
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
|
||
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] {
|
||
let mut img = Image::new(codes.len() as u32, 1);
|
||
for (x, code) in codes.iter().enumerate() {
|
||
let v = code / 1023.0;
|
||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||
}
|
||
let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))];
|
||
let out = run("ec.utility.cineon", &vals, img);
|
||
let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() };
|
||
for (x, code) in codes.iter().enumerate() {
|
||
let footage = settings.convert(code / 1023.0);
|
||
let effect = out.img.data[x][0];
|
||
assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}");
|
||
}
|
||
}
|
||
let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() };
|
||
assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4);
|
||
}
|
||
|
||
/// The Cineon Converter gives the same values as before Cineon Settings shared its
|
||
/// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three
|
||
/// conversion types.
|
||
#[test]
|
||
fn cineon_converter_output_is_unchanged() {
|
||
fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
|
||
let off = 10f32.powf((black - white) * 0.002 / 0.6);
|
||
let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
|
||
lin = lin.max(0.0).powf(1.7 / gamma.max(0.01));
|
||
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
|
||
if k < 1.0 && lin > k {
|
||
let w = 1.0 - k;
|
||
lin = k + w * (1.0 - (-(lin - k) / w).exp());
|
||
}
|
||
lin
|
||
}
|
||
fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
|
||
let off = 10f32.powf((black - white) * 0.002 / 0.6);
|
||
let mut lin = v;
|
||
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
|
||
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(gamma.max(0.01) / 1.7);
|
||
(white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0
|
||
}
|
||
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
|
||
for gamma in [None, Some(2.4)] {
|
||
for kind in 0..3 {
|
||
let mut img = Image::new(codes.len() as u32, 1);
|
||
for (x, code) in codes.iter().enumerate() {
|
||
let v = code / 1023.0;
|
||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||
}
|
||
let mut vals = vec![("conversionType", Value::Enum(kind))];
|
||
if let Some(g) = gamma {
|
||
vals.push(("gamma", num(g)));
|
||
}
|
||
let out = run("ec.utility.cineon", &vals, img);
|
||
let g = gamma.unwrap_or(1.7) as f32;
|
||
for (x, code) in codes.iter().enumerate() {
|
||
let v = code / 1023.0;
|
||
let want = match kind {
|
||
0 => lin_to_log(v, g, 20.0, 95.0, 685.0),
|
||
1 => log_to_lin(v, g, 20.0, 95.0, 685.0),
|
||
_ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0),
|
||
};
|
||
let got = out.img.data[x][0];
|
||
assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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", &[("amount", num(0.0))], img.clone());
|
||
assert_eq!(out.img.data, img.data, "Amount 0 leaves the image alone");
|
||
let out = run("ec.utility.hdrcompression", &[], img);
|
||
for o in &out.img.data {
|
||
assert!(o.iter().all(|v| v.is_finite() && *v <= 1.0), "{o:?}");
|
||
}
|
||
// Negative and huge input stays finite.
|
||
let mut img = Image::new(2, 1);
|
||
img.set(0, 0, [-0.5, 0.2, 3.0, 1.0]);
|
||
img.set(1, 0, [1e30, -1e30, 0.0, 1.0]);
|
||
let out = run("ec.utility.hdrcompression", &[("amount", num(50.0))], img);
|
||
assert!(out.img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", out.img.data);
|
||
}
|
||
|
||
/// Our own measurements of After Effects 2026 (32 bpc, no working space; 16-bit PNG renders
|
||
/// decoded back to linear): input pixel, then the output at Amount 100, 90, 84, 75, 50 and 25.
|
||
#[test]
|
||
fn hdr_compression_matches_measured_reference() {
|
||
const AMOUNTS: [f64; 6] = [100.0, 90.0, 84.0, 75.0, 50.0, 25.0];
|
||
#[rustfmt::skip]
|
||
let table: [([f32; 3], [[f32; 3]; 6]); 9] = [
|
||
([0.18005371; 3], [[0.1524; 3], [0.1550; 3], [0.1566; 3], [0.1590; 3], [0.1660; 3], [0.1729; 3]]),
|
||
([0.49365234; 3], [[0.3302; 3], [0.3450; 3], [0.3540; 3], [0.3674; 3], [0.4073; 3], [0.4492; 3]]),
|
||
([1.0058594; 3], [[0.5014; 3], [0.5427; 3], [0.5683; 3], [0.6085; 3], [0.7280; 3], [0.8602; 3]]),
|
||
([2.0058594; 3], [[0.6673; 3], [0.7637; 3], [0.8256; 3], [0.9232; 3], [1.2292; 3], [1.5892; 3]]),
|
||
([2.9941406; 3], [[0.7496; 3], [0.8962; 3], [0.9922; 3], [1.1465; 3], [1.6475; 3], [2.2604; 3]]),
|
||
([3.9941406; 3], [[0.7998; 3], [0.9929; 3], [1.1213; 3], [1.3303; 3], [2.0312; 3], [2.9141; 3]]),
|
||
([0.7998047, 0.099975586, 0.099975586], [
|
||
[0.6101, 0.0762, 0.0762], [0.6279, 0.0784, 0.0784], [0.6386, 0.0798, 0.0798],
|
||
[0.6547, 0.0817, 0.0817], [0.7012, 0.0877, 0.0877], [0.7496, 0.0937, 0.0937],
|
||
]),
|
||
([0.099975586, 0.7001953, 0.19995117], [
|
||
[0.0681, 0.4776, 0.1363], [0.0711, 0.4976, 0.1420], [0.0727, 0.5097, 0.1456],
|
||
[0.0754, 0.5287, 0.1508], [0.0832, 0.5829, 0.1662], [0.0912, 0.6397, 0.1825],
|
||
]),
|
||
([0.30004883, 0.5, 2.5], [
|
||
[0.1807, 0.3012, 0.9998], [0.1911, 0.3184, 1.1147], [0.1972, 0.3288, 1.1860],
|
||
[0.2071, 0.3453, 1.2995], [0.2359, 0.3931, 1.6484], [0.2669, 0.4444, 2.0482],
|
||
]),
|
||
];
|
||
let mut img = Image::new(table.len() as u32, 1);
|
||
for (x, (c, _)) in table.iter().enumerate() {
|
||
img.set(x as u32, 0, [c[0], c[1], c[2], 1.0]);
|
||
}
|
||
for (i, amount) in AMOUNTS.into_iter().enumerate() {
|
||
let out = run("ec.utility.hdrcompression", &[("amount", num(amount))], img.clone());
|
||
for (x, (input, want)) in table.iter().enumerate() {
|
||
let got = out.img.data[x];
|
||
for c in 0..3 {
|
||
let w = want[i][c];
|
||
let tol = 2e-3_f32.max(w * 2e-3);
|
||
assert!((got[c] - w).abs() <= tol, "amount {amount}, input {input:?}, channel {c}: {} vs AE {w}", got[c]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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]);
|
||
}
|
||
}
|