Files
effectcraft/crates/effects/src/utility.rs
T

815 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Utility effects: bounds growth, HDR inspection/compression, Cineon log conversion and colour
//! lookup tables (.cube).
use std::sync::Arc;
use crate::file_cache::{self, Registered};
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;
/// `v^e` with the sign of `v` kept (negative input stays negative, as on the GPU).
#[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 }
})
}
/// HDR Highlight Compression: every straight-colour pixel through [`hdr_compress_pixel`] at
/// Amount / 100; Amount 0 or NaN returns the layer unchanged.
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]))
}
}
/// At most this many parsed LUTs (files and inline texts) are kept, and at most
/// [`MAX_CACHED_BYTES`] of them; the least recently used go first.
const MAX_CACHED_LUTS: usize = 64;
const MAX_CACHED_BYTES: usize = 512 << 20;
/// [`load_lut`]'s cache, by parameter text.
static LUTS: Registered<Option<Arc<Lut>>> = Registered::new(MAX_CACHED_LUTS, MAX_CACHED_BYTES);
/// The parameters (paths in the effect's group) of `effect` that name a file an effect reads
/// through a cache that follows it ([`load_lut`], the OCIO file transforms and configs).
pub fn lut_params(effect: &str) -> &'static [&'static str] {
match effect {
"ec.utility.applylut" => &["lut"],
"ec.color.lumetri" => &["basicCorrection/inputLutFile", "creative/lookFile"],
"ec.color.ociofile" => &["file"],
"ec.color.ociocolorspace" | "ec.color.ociodisplay" => &["configFile"],
_ => &[],
}
}
/// The file a LUT parameter names; `None` for inline `.cube` text or an empty parameter.
pub fn lut_file(src: &str) -> Option<&str> {
// `.cube` text always spans lines (a size header, then data rows); a path never does, even one
// whose file name contains `LUT_` (#3454). Trimmed, so a trailing newline is still a path.
let path = src.trim();
(!path.is_empty() && !path.contains('\n')).then_some(path)
}
/// The files a [`lut_params`] parameter set to `src` reads: the ones its cached result was read
/// from (an OCIO config's also the LUTs it names, and the places it looked for missing ones; an
/// `.ocio` config not read yet is read), else the file it names.
pub fn lut_files(src: &str) -> Vec<String> {
// A config not read yet is read now: the LUTs it names are its files from the first render.
if crate::ocio_config::cached_files(src).is_none()
&& lut_file(src).is_some_and(|p| std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("ocio")))
{
let _ = crate::ocio_config::load_config(src);
}
crate::ocio_config::cached_files(src)
.or_else(|| crate::ocio::cached_files(src))
.or_else(|| file_cache::lock(LUTS.get()).files(src))
.unwrap_or_else(|| lut_file(src).map(str::to_string).into_iter().collect())
}
/// Load (and cache) a LUT from inline `.cube` text or a file path. A file is stamped (one
/// `metadata` call) on every load and read again once its size or modification time moved (see
/// [`crate::file_cache`]); inline text is its own content.
pub fn load_lut(src: &str) -> Option<Arc<Lut>> {
if src.trim().is_empty() {
return None;
}
file_cache::cached(LUTS.get(), src, || {
let lut = match lut_file(src) {
Some(path) => file_cache::read(path).ok().and_then(|t| parse_cube(&t)),
None => parse_cube(src),
}
.map(Arc::new);
let bytes = lut.as_ref().map_or(0, |l| lut_bytes(l));
(lut, bytes)
})
}
/// The size of a LUT's tables.
pub(crate) fn lut_bytes(l: &Lut) -> usize {
l.d1.len().saturating_add(l.d3.len()).saturating_mul(std::mem::size_of::<[f32; 3]>())
}
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",
"Out-of-Range Display",
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);
}
/// #3454: a path containing `LUT_` was taken for `.cube` text and the file never loaded.
#[test]
fn a_path_whose_name_contains_lut_loads_the_file() {
let dir = std::env::temp_dir().join(format!("ec-fx-3454-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("LUT_x.cube");
std::fs::write(&path, identity_cube(2)).unwrap();
let path = path.to_string_lossy().into_owned();
assert!(load_lut(&path).is_some(), "{path}");
assert!(load_lut(&format!("{path}\r\n")).is_some(), "{path} with a trailing newline");
assert!(load_lut(&identity_cube(2)).is_some(), "inline text");
std::fs::remove_dir_all(&dir).unwrap();
}
/// #3493: a LUT file was read once per process; an edited or newly created file never loaded.
#[test]
fn a_lut_file_is_read_again_once_it_changes() {
const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n";
const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n";
let dir = std::env::temp_dir().join(format!("ec-fx-3493-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let write = |name: &str, text: &str, secs: u64| {
let path = dir.join(name);
std::fs::write(&path, text).unwrap();
let f = std::fs::File::options().write(true).open(&path).unwrap();
f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap();
path.to_string_lossy().into_owned()
};
let at = |src: &str| load_lut(src).map(|l| l.apply([0.5; 3]));
let edited = write("edited.cube", BLACK, 1_000);
assert_eq!(at(&edited), Some([0.0; 3]));
write("edited.cube", WHITE, 2_000);
assert_eq!(at(&edited), Some([1.0; 3]), "an edited file, same size");
let created = dir.join("created.cube").to_string_lossy().into_owned();
assert_eq!(at(&created), None);
write("created.cube", WHITE, 1_000);
assert_eq!(at(&created), Some([1.0; 3]), "a file created after a failed load");
let repaired = write("repaired.cube", "not a lut", 1_000);
assert_eq!(at(&repaired), None);
write("repaired.cube", WHITE, 2_000);
assert_eq!(at(&repaired), Some([1.0; 3]), "a repaired file");
std::fs::remove_file(&repaired).unwrap();
assert_eq!(at(&repaired), None, "a deleted file");
// Inline text is its own content: the same text is the same LUT.
let inline = load_lut(BLACK).unwrap();
assert!(Arc::ptr_eq(&inline, &load_lut(BLACK).unwrap()));
std::fs::remove_dir_all(&dir).unwrap();
}
/// A file that changed is noticed without a render loading it, and the generation that tells
/// frames made before it apart moves.
#[test]
fn check_lut_files_forgets_a_changed_file() {
let dir = std::env::temp_dir().join(format!("ec-fx-3493-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("grade.cube");
std::fs::write(&path, identity_cube(2)).unwrap();
let src = path.to_string_lossy().into_owned();
assert!(load_lut(&src).is_some());
let before = crate::check_lut_files();
assert!(file_cache::lock(LUTS.get()).contains(&src), "an unchanged file stays");
std::fs::write(&path, identity_cube(3)).unwrap();
assert!(crate::check_lut_files() > before);
assert!(!file_cache::lock(LUTS.get()).contains(&src), "a changed file is read again");
assert_eq!(load_lut(&src).map(|l| l.n3), Some(3));
std::fs::remove_dir_all(&dir).unwrap();
}
/// The cache keeps at most [`MAX_CACHED_LUTS`] LUTs, the least recently used going first.
#[test]
fn the_lut_cache_is_bounded() {
// (text no other test loads: a load elsewhere would make it recently used)
let oldest = format!("TITLE \"bounded first\"\n{}", identity_cube(2));
let first = load_lut(&oldest).unwrap();
for i in 0..MAX_CACHED_LUTS + 8 {
let text = format!("TITLE \"bounded {i}\"\n{}", identity_cube(2));
assert!(load_lut(&text).is_some());
assert!(file_cache::lock(LUTS.get()).len() <= MAX_CACHED_LUTS);
}
assert!(!Arc::ptr_eq(&first, &load_lut(&oldest).unwrap()), "the oldest was let go");
let (bytes, sum, max) = file_cache::lock(LUTS.get()).bytes();
assert_eq!(bytes, sum, "the bytes kept add up");
assert!(bytes <= max);
}
/// A LUT file whose LUT left the cache is still followed: its frames are dropped when it
/// changes, though no render loads it again first.
#[test]
fn a_file_whose_lut_left_the_cache_is_still_followed() {
let dir = std::env::temp_dir().join(format!("ec-fx-3493-evicted-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old.cube");
std::fs::write(&path, identity_cube(2)).unwrap();
let src = path.to_string_lossy().into_owned();
assert!(load_lut(&src).is_some());
for i in 0..MAX_CACHED_LUTS + 8 {
assert!(load_lut(&format!("TITLE \"evict {i}\"\n{}", identity_cube(2))).is_some());
}
assert!(!file_cache::lock(LUTS.get()).contains(&src), "its LUT left the cache");
std::fs::write(&path, identity_cube(3)).unwrap();
crate::check_lut_files();
assert_eq!(file_cache::followed_stamp(&src), Some(file_cache::file_stamp(&src)), "the change was noticed");
std::fs::remove_dir_all(&dir).unwrap();
}
#[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);
}
/// A linear value up to 1 as a code of After Effects' 16-bit PNG renders: the sRGB curve × 6553.5.
fn png_code(x: f32) -> f32 {
(if x <= 0.003_130_8 { 12.92 * x } else { 1.055 * x.powf(1.0 / 2.4) - 0.055 }) * 6553.5
}
/// 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];
// Up to 1: within 20 codes of the 16-bit PNG (the sRGB curve × 6553.5) the
// renders were read from. Above 1 the PNG's lookup table is not known: 0.1 %.
let ok = if w <= 1.0 { (png_code(got[c]) - png_code(w)).abs() <= 20.0 } else { (got[c] - w).abs() <= w * 1e-3 };
assert!(ok, "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]);
}
}