mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
GPU render passes for CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise (fx_particles, chunked multi-pass raster); Shatter wireframe views, Foam extras, Timewarp with a Matte Layer, strong Warp Fisheye/Twist (CPU f64 inverse map) and custom .ocio configs no longer fall back to the CPU. CPU refactors pinned by particle_golden / timewarp_golden / sim_golden hashes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1647 lines
69 KiB
Rust
1647 lines
69 KiB
Rust
//! OCIO-style colour transforms (Color Correction ▸ OCIO CDL / Color Space / Display / File / Look
|
||
//! Transform), Color Profile Converter (Utility) and Color Stabilizer (Color Correction).
|
||
//!
|
||
//! The OCIO effects run on a **built-in minimal configuration** written from published
|
||
//! standards, not from OpenColorIO's source:
|
||
//!
|
||
//! * colour spaces are defined by primaries, white point and transfer function: ACES2065-1 (AP0)
|
||
//! and ACEScg / ACEScct (AP1) from SMPTE ST 2065-1 and Academy S-2014-004 / S-2016-001, sRGB
|
||
//! (IEC 61966-2-1), Rec. 709 / Rec. 2020 with the BT.1886 gamma 2.4 EOTF, Display P3 (SMPTE
|
||
//! EG 432-1 primaries, D65, sRGB curve), their linear variants and CIE XYZ D65;
|
||
//! * the reference space is CIE XYZ D65; spaces with another white point (the ACES white,
|
||
//! x 0.32168 y 0.33767) are adapted with the Bradford chromatic adaptation transform, as in the
|
||
//! published ACES configurations;
|
||
//! * displays (sRGB, Rec.1886 Rec.709, Display P3, Rec.2100-PQ via SMPTE ST 2084) and views
|
||
//! (Standard = clip-free encode, Tone Mapped = extended Reinhard roll-off, Raw);
|
||
//! * looks are ASC CDLs applied in ACEScct;
|
||
//! * CDL maths follows the ASC CDL v1.2 specification (slope, offset, power, then saturation with
|
||
//! Rec. 709 luma weights); the "No Clamp" style mirrors power around zero;
|
||
//! * File Transform reads `.cube` (Adobe/Resolve), `.3dl` (Autodesk Lustre/Flame integer
|
||
//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper) and ASC `.cc` / `.ccc` /
|
||
//! `.cdl` XML, with nearest, trilinear or tetrahedral interpolation and an inverse direction
|
||
//! (exact for 1D/CDL, iterative for 3D lattices).
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
|
||
use effectcraft_color::ColorSpace;
|
||
use effectcraft_color::space::{invert, mul, mul_vec, rgb_to_xyz};
|
||
use effectcraft_keyframe::Value;
|
||
use effectcraft_project::ParamUi;
|
||
use rayon::prelude::*;
|
||
|
||
use crate::util::unpremul;
|
||
use crate::utility::{Lut, parse_cube};
|
||
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
|
||
|
||
type M3 = [[f64; 3]; 3];
|
||
|
||
const D65: [f64; 2] = [0.3127, 0.3290];
|
||
const ACES_WHITE: [f64; 2] = [0.32168, 0.33767];
|
||
const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]];
|
||
const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]];
|
||
const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
|
||
const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
|
||
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
|
||
const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||
|
||
/// Transfer functions (encoded ↔ linear).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum Tf {
|
||
Linear,
|
||
Srgb,
|
||
Gamma(f64),
|
||
AcesCct,
|
||
/// SMPTE ST 2084 with 1.0 = 100 cd/m².
|
||
Pq,
|
||
}
|
||
|
||
impl Tf {
|
||
pub fn decode(self, v: f64) -> f64 {
|
||
match self {
|
||
Tf::Linear => v,
|
||
Tf::Srgb => {
|
||
let a = v.abs();
|
||
let l = if a <= 0.04045 { a / 12.92 } else { ((a + 0.055) / 1.055).powf(2.4) };
|
||
l.copysign(v)
|
||
}
|
||
Tf::Gamma(g) => v.abs().powf(g).copysign(v),
|
||
Tf::AcesCct => {
|
||
if v <= 0.155_251_141_552_511 {
|
||
(v - 0.072_905_534_195_835_5) / 10.540_237_741_654_5
|
||
} else if v < (65504f64.log2() + 9.72) / 17.52 {
|
||
2f64.powf(v * 17.52 - 9.72)
|
||
} else {
|
||
65504.0
|
||
}
|
||
}
|
||
Tf::Pq => {
|
||
let (m1, m2, c1, c2, c3) = pq_consts();
|
||
let e = v.max(0.0).powf(1.0 / m2);
|
||
let l = ((e - c1).max(0.0) / (c2 - c3 * e)).powf(1.0 / m1);
|
||
l * 100.0
|
||
}
|
||
}
|
||
}
|
||
pub fn encode(self, v: f64) -> f64 {
|
||
match self {
|
||
Tf::Linear => v,
|
||
Tf::Srgb => {
|
||
let a = v.abs();
|
||
let e = if a <= 0.003_130_8 { a * 12.92 } else { 1.055 * a.powf(1.0 / 2.4) - 0.055 };
|
||
e.copysign(v)
|
||
}
|
||
Tf::Gamma(g) => v.abs().powf(1.0 / g).copysign(v),
|
||
Tf::AcesCct => {
|
||
if v <= 0.007_812_5 {
|
||
10.540_237_741_654_5 * v + 0.072_905_534_195_835_5
|
||
} else {
|
||
(v.log2() + 9.72) / 17.52
|
||
}
|
||
}
|
||
Tf::Pq => {
|
||
let (m1, m2, c1, c2, c3) = pq_consts();
|
||
let y = (v / 100.0).max(0.0).powf(m1);
|
||
((c1 + c2 * y) / (1.0 + c3 * y)).powf(m2)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn pq_consts() -> (f64, f64, f64, f64, f64) {
|
||
(2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0, 3424.0 / 4096.0, 2413.0 / 4096.0 * 32.0, 2392.0 / 4096.0 * 32.0)
|
||
}
|
||
|
||
/// A colour space of the built-in config.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Space {
|
||
pub name: &'static str,
|
||
/// Primaries and white (None = CIE XYZ itself).
|
||
pub prims: Option<([[f64; 2]; 3], [f64; 2])>,
|
||
pub tf: Tf,
|
||
/// Passes data through untouched.
|
||
pub raw: bool,
|
||
}
|
||
|
||
const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space {
|
||
Space { name, prims: Some((prims, white)), tf, raw: false }
|
||
}
|
||
|
||
/// The built-in configuration's colour spaces, in popup order.
|
||
pub const SPACES: &[Space] = &[
|
||
sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear),
|
||
sp("ACEScg", AP1, ACES_WHITE, Tf::Linear),
|
||
sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct),
|
||
sp("Linear Rec.709 (sRGB)", REC709, D65, Tf::Linear),
|
||
sp("sRGB", REC709, D65, Tf::Srgb),
|
||
sp("Gamma 2.4 Rec.709", REC709, D65, Tf::Gamma(2.4)),
|
||
sp("Linear Rec.2020", REC2020, D65, Tf::Linear),
|
||
sp("Gamma 2.4 Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
|
||
sp("Linear P3-D65", P3, D65, Tf::Linear),
|
||
sp("Display P3", P3, D65, Tf::Srgb),
|
||
Space { name: "CIE-XYZ-D65", prims: None, tf: Tf::Linear, raw: false },
|
||
Space { name: "Raw", prims: None, tf: Tf::Linear, raw: true },
|
||
];
|
||
|
||
pub fn space_names() -> Vec<&'static str> {
|
||
SPACES.iter().map(|s| s.name).collect()
|
||
}
|
||
|
||
pub fn space_by_name(n: &str) -> Option<&'static Space> {
|
||
SPACES.iter().find(|s| s.name.eq_ignore_ascii_case(n))
|
||
}
|
||
|
||
/// Bradford chromatic adaptation from white `src` to white `dst` (xy).
|
||
pub fn bradford(src: [f64; 2], dst: [f64; 2]) -> M3 {
|
||
if src == dst {
|
||
return IDENTITY;
|
||
}
|
||
const MB: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]];
|
||
let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]];
|
||
let s = mul_vec(&MB, xyz(src));
|
||
let d = mul_vec(&MB, xyz(dst));
|
||
let diag = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]];
|
||
mul(&invert(&MB), &mul(&diag, &MB))
|
||
}
|
||
|
||
impl Space {
|
||
/// Linear RGB → XYZ D65 (Bradford-adapted when `adapt`, else the space's own white).
|
||
pub fn to_ref(self, adapt: bool) -> M3 {
|
||
match self.prims {
|
||
None => IDENTITY,
|
||
Some((p, w)) => {
|
||
let m = rgb_to_xyz(p, w);
|
||
if adapt { mul(&bradford(w, D65), &m) } else { m }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Linear-RGB matrix from space `a` to space `b`.
|
||
pub fn matrix(a: &Space, b: &Space) -> M3 {
|
||
mul(&invert(&b.to_ref(true)), &a.to_ref(true))
|
||
}
|
||
|
||
/// A compiled colour-space conversion.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Xform {
|
||
dec: Tf,
|
||
m: [[f32; 3]; 3],
|
||
ident: bool,
|
||
enc: Tf,
|
||
}
|
||
|
||
impl Xform {
|
||
pub fn new(a: &Space, b: &Space) -> Xform {
|
||
if a.raw || b.raw {
|
||
return Xform { dec: Tf::Linear, m: IDENTITY.map(|r| r.map(|v| v as f32)), ident: true, enc: Tf::Linear };
|
||
}
|
||
let m = matrix(a, b);
|
||
let ident = (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-12));
|
||
Xform { dec: a.tf, m: m.map(|r| r.map(|v| v as f32)), ident, enc: b.tf }
|
||
}
|
||
#[inline]
|
||
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
||
let l = c.map(|v| self.dec.decode(v as f64) as f32);
|
||
let l = if self.ident { l } else { [0, 1, 2].map(|i| self.m[i][0] * l[0] + self.m[i][1] * l[1] + self.m[i][2] * l[2]) };
|
||
l.map(|v| self.enc.encode(v as f64) as f32)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- ASC CDL
|
||
|
||
/// An ASC Colour Decision List correction.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Cdl {
|
||
pub slope: [f64; 3],
|
||
pub offset: [f64; 3],
|
||
pub power: [f64; 3],
|
||
pub sat: f64,
|
||
}
|
||
|
||
impl Default for Cdl {
|
||
fn default() -> Self {
|
||
Cdl { slope: [1.0; 3], offset: [0.0; 3], power: [1.0; 3], sat: 1.0 }
|
||
}
|
||
}
|
||
|
||
const LUMA: [f64; 3] = [0.2126, 0.7152, 0.0722];
|
||
|
||
impl Cdl {
|
||
/// Forward CDL. `clamp` = the v1.2 style (results clamped to 0..1 after SOP and saturation).
|
||
pub fn apply(&self, c: [f64; 3], clamp: bool) -> [f64; 3] {
|
||
let mut o = [0.0; 3];
|
||
for i in 0..3 {
|
||
let v = c[i] * self.slope[i] + self.offset[i];
|
||
o[i] = if clamp {
|
||
v.clamp(0.0, 1.0).powf(self.power[i])
|
||
} else if v >= 0.0 {
|
||
v.powf(self.power[i])
|
||
} else {
|
||
v
|
||
};
|
||
}
|
||
let l = o[0] * LUMA[0] + o[1] * LUMA[1] + o[2] * LUMA[2];
|
||
let o = o.map(|v| l + self.sat * (v - l));
|
||
if clamp { o.map(|v| v.clamp(0.0, 1.0)) } else { o }
|
||
}
|
||
/// Exact inverse of [`Cdl::apply`] (inside the clamped range).
|
||
pub fn invert(&self, c: [f64; 3], clamp: bool) -> [f64; 3] {
|
||
let c = if clamp { c.map(|v| v.clamp(0.0, 1.0)) } else { c };
|
||
let l = c[0] * LUMA[0] + c[1] * LUMA[1] + c[2] * LUMA[2];
|
||
let s = if self.sat.abs() < 1e-9 { 1e-9 } else { self.sat };
|
||
let o = c.map(|v| l + (v - l) / s);
|
||
let mut r = [0.0; 3];
|
||
for i in 0..3 {
|
||
let v = if clamp { o[i].clamp(0.0, 1.0) } else { o[i] };
|
||
let pw = self.power[i].max(1e-9);
|
||
let u = if v >= 0.0 { v.powf(1.0 / pw) } else { v };
|
||
let sl = if self.slope[i].abs() < 1e-9 { 1e-9 } else { self.slope[i] };
|
||
r[i] = (u - self.offset[i]) / sl;
|
||
}
|
||
if clamp { r.map(|v| v.clamp(0.0, 1.0)) } else { r }
|
||
}
|
||
}
|
||
|
||
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
|
||
let open = xml.find(&format!("<{name}"))?;
|
||
let gt = open + xml[open..].find('>')?;
|
||
if xml[..gt].ends_with('/') {
|
||
return Some("");
|
||
}
|
||
let close = xml[gt..].find(&format!("</{name}>"))? + gt;
|
||
Some(&xml[gt + 1..close])
|
||
}
|
||
|
||
fn nums3(s: &str) -> Option<[f64; 3]> {
|
||
let v: Vec<f64> = s.split_whitespace().filter_map(|t| t.parse().ok()).collect();
|
||
(v.len() >= 3).then(|| [v[0], v[1], v[2]])
|
||
}
|
||
|
||
fn parse_cc(block: &str) -> Cdl {
|
||
let mut c = Cdl::default();
|
||
if let Some(s) = tag(block, "Slope").and_then(nums3) {
|
||
c.slope = s;
|
||
}
|
||
if let Some(s) = tag(block, "Offset").and_then(nums3) {
|
||
c.offset = s;
|
||
}
|
||
if let Some(s) = tag(block, "Power").and_then(nums3) {
|
||
c.power = s;
|
||
}
|
||
if let Some(s) = tag(block, "Saturation").and_then(|t| t.trim().parse().ok()) {
|
||
c.sat = s;
|
||
}
|
||
c
|
||
}
|
||
|
||
/// Parse ASC CDL XML (`.cc`, `.ccc`, `.cdl`): every `<ColorCorrection>` with its `id`.
|
||
pub fn parse_cdl_xml(xml: &str) -> Vec<(String, Cdl)> {
|
||
let mut out = vec![];
|
||
let mut rest = xml;
|
||
while let Some(i) = rest.find("<ColorCorrection") {
|
||
let after = &rest[i..];
|
||
let head_end = after.find('>').unwrap_or(after.len());
|
||
let head = &after[..head_end];
|
||
let id = head
|
||
.find("id=")
|
||
.and_then(|k| {
|
||
let q = head[k + 3..].chars().next()?;
|
||
let s = &head[k + 4..];
|
||
s.find(q).map(|e| s[..e].to_string())
|
||
})
|
||
.unwrap_or_default();
|
||
let end = after.find("</ColorCorrection>").map(|e| e + "</ColorCorrection>".len()).unwrap_or(after.len());
|
||
out.push((id, parse_cc(&after[..end])));
|
||
rest = &after[end..];
|
||
}
|
||
out
|
||
}
|
||
|
||
// ---------------------------------------------------------------- LUT files
|
||
|
||
/// A loaded transform file.
|
||
#[derive(Clone, Debug)]
|
||
pub enum FileXform {
|
||
/// A lattice/1D LUT with an optional per-channel piecewise-linear shaper (cineSpace pre-LUT).
|
||
Lut { lut: Lut, shaper: Option<[Vec<(f32, f32)>; 3]> },
|
||
/// CDL corrections by id (in file order).
|
||
Cdl(Vec<(String, Cdl)>),
|
||
}
|
||
|
||
/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line).
|
||
pub fn parse_3dl(text: &str) -> Option<Lut> {
|
||
let mut mesh: Option<Vec<f64>> = None;
|
||
let mut rows: Vec<[f64; 3]> = vec![];
|
||
let mut out_bits: Option<u32> = None;
|
||
for line in text.lines() {
|
||
let l = line.trim();
|
||
if l.is_empty() || l.starts_with('#') || l.starts_with('<') {
|
||
continue;
|
||
}
|
||
if l.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||
// "Mesh <in bits> <out bits>" (Lustre header); other keywords are ignored.
|
||
let t: Vec<&str> = l.split_whitespace().collect();
|
||
if t[0].eq_ignore_ascii_case("mesh") && t.len() >= 3 {
|
||
out_bits = t[2].parse().ok();
|
||
}
|
||
continue;
|
||
}
|
||
let v: Vec<f64> = l.split_whitespace().filter_map(|t| t.parse().ok()).collect();
|
||
if v.len() > 3 && mesh.is_none() && rows.is_empty() {
|
||
mesh = Some(v);
|
||
} else if v.len() == 3 {
|
||
rows.push([v[0], v[1], v[2]]);
|
||
} else {
|
||
return None;
|
||
}
|
||
}
|
||
let is_cube = |k: usize| {
|
||
let n = (k as f64).cbrt().round() as usize;
|
||
n * n * n == k
|
||
};
|
||
// A 3-entry input mesh (3³ lattice) looks like a data row: tell them apart by the count.
|
||
if mesh.is_none() && !is_cube(rows.len()) && rows.len() == 28 {
|
||
mesh = Some(rows.remove(0).to_vec());
|
||
}
|
||
let n = match &mesh {
|
||
Some(m) => m.len(),
|
||
None => (rows.len() as f64).cbrt().round() as usize,
|
||
};
|
||
if n < 2 || rows.len() != n * n * n {
|
||
return None;
|
||
}
|
||
let maxv = rows.iter().flat_map(|r| r.iter()).fold(0.0f64, |m, &v| m.max(v));
|
||
let out_max = match out_bits {
|
||
Some(b) if b > 0 && b < 32 => (1u64 << b) as f64 - 1.0,
|
||
_ => [255.0, 1023.0, 4095.0, 65535.0].into_iter().find(|&m| maxv <= m).unwrap_or(maxv.max(1.0)),
|
||
};
|
||
// .3dl rows: red slowest, blue fastest. Our lattice is red fastest.
|
||
let mut d3 = vec![[0.0f32; 3]; n * n * n];
|
||
for (k, r) in rows.iter().enumerate() {
|
||
let (ri, gi, bi) = (k / (n * n), (k / n) % n, k % n);
|
||
d3[ri + gi * n + bi * n * n] = r.map(|v| (v / out_max) as f32);
|
||
}
|
||
Some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] })
|
||
}
|
||
|
||
/// Parse a cineSpace `.csp` LUT (1D or 3D, with its three pre-LUT shapers).
|
||
pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
|
||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||
if !lines.next()?.starts_with("CSPLUTV100") {
|
||
return None;
|
||
}
|
||
let kind = lines.next()?;
|
||
let mut lines = lines.peekable();
|
||
if lines.peek().is_some_and(|l| l.starts_with("BEGIN METADATA")) {
|
||
for l in lines.by_ref() {
|
||
if l.starts_with("END METADATA") {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
let nums = |l: &str| -> Option<Vec<f32>> { l.split_whitespace().map(|t| t.parse().ok()).collect() };
|
||
let mut shaper: [Vec<(f32, f32)>; 3] = Default::default();
|
||
for ch in shaper.iter_mut() {
|
||
let n: usize = lines.next()?.parse().ok()?;
|
||
let ins = nums(lines.next()?)?;
|
||
let outs = nums(lines.next()?)?;
|
||
if ins.len() != n || outs.len() != n || n < 2 {
|
||
return None;
|
||
}
|
||
*ch = ins.into_iter().zip(outs).collect();
|
||
}
|
||
if kind.starts_with("3D") {
|
||
let sz = nums(lines.next()?)?;
|
||
if sz.len() != 3 || sz[0] != sz[1] || sz[1] != sz[2] {
|
||
return None;
|
||
}
|
||
let n = sz[0] as usize;
|
||
let mut d3 = Vec::with_capacity(n * n * n);
|
||
for _ in 0..n * n * n {
|
||
let v = nums(lines.next()?)?;
|
||
if v.len() != 3 {
|
||
return None;
|
||
}
|
||
d3.push([v[0], v[1], v[2]]);
|
||
}
|
||
(n >= 2).then_some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
|
||
} else {
|
||
let n: usize = lines.next()?.parse().ok()?;
|
||
let mut d1 = Vec::with_capacity(n);
|
||
for _ in 0..n {
|
||
let v = nums(lines.next()?)?;
|
||
if v.len() != 3 {
|
||
return None;
|
||
}
|
||
d1.push([v[0], v[1], v[2]]);
|
||
}
|
||
(n >= 2).then_some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
|
||
}
|
||
}
|
||
|
||
fn shape(curve: &[(f32, f32)], x: f32) -> f32 {
|
||
if curve.len() < 2 {
|
||
return x;
|
||
}
|
||
let i = curve.partition_point(|p| p.0 < x).clamp(1, curve.len() - 1);
|
||
let (a, b) = (curve[i - 1], curve[i]);
|
||
let t = ((x - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0);
|
||
a.1 + (b.1 - a.1) * t
|
||
}
|
||
|
||
fn shape_inv(curve: &[(f32, f32)], y: f32) -> f32 {
|
||
let flipped: Vec<(f32, f32)> = curve.iter().map(|&(a, b)| (b, a)).collect();
|
||
shape(&flipped, y)
|
||
}
|
||
|
||
/// Parse transform text by its format (detected from `ext` or the content).
|
||
pub fn parse_file(text: &str, ext: &str) -> Option<FileXform> {
|
||
let ext = ext.to_ascii_lowercase();
|
||
if ext == "cc" || ext == "ccc" || ext == "cdl" || text.contains("<ColorCorrection") {
|
||
let v = parse_cdl_xml(text);
|
||
return (!v.is_empty()).then_some(FileXform::Cdl(v));
|
||
}
|
||
if ext == "csp" || text.trim_start().starts_with("CSPLUTV100") {
|
||
return parse_csp(text).map(|(lut, s)| FileXform::Lut { lut, shaper: Some(s) });
|
||
}
|
||
if ext == "cube" || text.contains("LUT_3D_SIZE") || text.contains("LUT_1D_SIZE") {
|
||
return parse_cube(text).map(|lut| FileXform::Lut { lut, shaper: None });
|
||
}
|
||
parse_3dl(text).map(|lut| FileXform::Lut { lut, shaper: None })
|
||
}
|
||
|
||
type FileCache = Mutex<HashMap<String, Option<Arc<FileXform>>>>;
|
||
|
||
/// Load (and cache) a transform from a file path or inline content.
|
||
pub fn load_file(src: &str) -> Option<Arc<FileXform>> {
|
||
let src_t = src.trim();
|
||
if src_t.is_empty() {
|
||
return None;
|
||
}
|
||
static CACHE: OnceLock<FileCache> = 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');
|
||
let parsed = if inline {
|
||
parse_file(src, "")
|
||
} else {
|
||
let ext = std::path::Path::new(src_t).extension().and_then(|e| e.to_str()).unwrap_or("");
|
||
std::fs::read_to_string(src_t).ok().and_then(|t| parse_file(&t, ext))
|
||
}
|
||
.map(Arc::new);
|
||
if let Ok(mut m) = cache.lock() {
|
||
m.insert(src.to_string(), parsed.clone());
|
||
}
|
||
parsed
|
||
}
|
||
|
||
impl FileXform {
|
||
pub fn apply(&self, c: [f32; 3], interp: u32, inverse: bool, ccc_id: &str) -> [f32; 3] {
|
||
match self {
|
||
FileXform::Cdl(list) => {
|
||
let cdl = list.iter().find(|(id, _)| !ccc_id.is_empty() && id == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default();
|
||
let c64 = c.map(|v| v as f64);
|
||
let o = if inverse { cdl.invert(c64, true) } else { cdl.apply(c64, true) };
|
||
o.map(|v| v as f32)
|
||
}
|
||
FileXform::Lut { lut, shaper } => {
|
||
let fwd = |c: [f32; 3]| {
|
||
let c = match shaper {
|
||
Some(s) => [shape(&s[0], c[0]), shape(&s[1], c[1]), shape(&s[2], c[2])],
|
||
None => c,
|
||
};
|
||
lut.apply_interp(c, interp)
|
||
};
|
||
if !inverse {
|
||
return fwd(c);
|
||
}
|
||
if lut.n3 == 0 {
|
||
// 1D: invert each channel exactly through the (monotone) tables.
|
||
let mut x = c;
|
||
if lut.n1 > 1 {
|
||
x = [0, 1, 2].map(|i| {
|
||
let curve: Vec<(f32, f32)> =
|
||
(0..lut.n1).map(|k| (lut.d1[k][i], lut.dmin[i] + (lut.dmax[i] - lut.dmin[i]) * k as f32 / (lut.n1 - 1) as f32)).collect();
|
||
shape(&curve, c[i])
|
||
});
|
||
}
|
||
if let Some(s) = shaper {
|
||
x = [shape_inv(&s[0], x[0]), shape_inv(&s[1], x[1]), shape_inv(&s[2], x[2])];
|
||
}
|
||
return x;
|
||
}
|
||
// 3D: damped fixed-point (Newton with an identity Jacobian) from the target.
|
||
let mut x = c;
|
||
for _ in 0..40 {
|
||
let y = fwd(x);
|
||
let e = [c[0] - y[0], c[1] - y[1], c[2] - y[2]];
|
||
if e.iter().all(|v| v.abs() < 1e-6) {
|
||
break;
|
||
}
|
||
x = [x[0] + e[0], x[1] + e[1], x[2] + e[2]];
|
||
}
|
||
x
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- displays, views and looks
|
||
|
||
/// Displays of the built-in config: (name, display primaries/white, encoding).
|
||
pub const DISPLAYS: &[(&str, [[f64; 2]; 3], Tf)] =
|
||
&[("sRGB", REC709, Tf::Srgb), ("Rec.1886 Rec.709", REC709, Tf::Gamma(2.4)), ("Display P3", P3, Tf::Srgb), ("Rec.2100-PQ", REC2020, Tf::Pq)];
|
||
|
||
pub const VIEWS: &[&str] = &["Standard", "Tone Mapped", "Raw"];
|
||
|
||
/// Configuration popup: the built-in config, or a custom `.ocio` file (Config File: a path or
|
||
/// the config's text; spaces chosen by name, see [`crate::ocio_config`]).
|
||
pub const CONFIGS: &[&str] = &["Built-in (EffectCraft minimal)", "Custom (.ocio file)"];
|
||
|
||
/// Built-in looks: CDLs applied in ACEScct.
|
||
pub fn looks() -> Vec<(&'static str, Cdl)> {
|
||
let grey = 0.413_588_402_492_442_1; // ACEScct of 0.18
|
||
let c = |slope: [f64; 3], offset: [f64; 3], sat: f64| Cdl { slope, offset, power: [1.0; 3], sat };
|
||
vec![
|
||
("Warm", c([1.04, 1.0, 0.94], [0.0; 3], 1.0)),
|
||
("Cool", c([0.95, 1.0, 1.06], [0.0; 3], 1.0)),
|
||
("High Contrast", c([1.25; 3], [grey * (1.0 - 1.25); 3], 1.0)),
|
||
("Low Contrast", c([0.8; 3], [grey * (1.0 - 0.8); 3], 1.0)),
|
||
("Desaturated", c([1.0; 3], [0.0; 3], 0.6)),
|
||
("Vivid", c([1.0; 3], [0.0; 3], 1.3)),
|
||
]
|
||
}
|
||
|
||
/// Extended Reinhard roll-off (white point 16): maps 0..∞ into 0..1, ≈ identity near 0.
|
||
fn tonemap(v: f64) -> f64 {
|
||
let w2 = 256.0;
|
||
if v <= 0.0 { v } else { v * (1.0 + v / w2) / (1.0 + v) }
|
||
}
|
||
|
||
fn tonemap_inv(y: f64) -> f64 {
|
||
if y <= 0.0 {
|
||
return y;
|
||
}
|
||
// Solve v² / w² + v (1 − y) − y = 0.
|
||
let (a, b, c) = (1.0 / 256.0, 1.0 - y, -y);
|
||
(-b + (b * b - 4.0 * a * c).sqrt()) / (2.0 * a)
|
||
}
|
||
|
||
/// Display transform of an input-space colour (`inverse`: display code values → input space).
|
||
pub fn display_xform(input: &Space, display: usize, view: usize, inverse: bool) -> impl Fn([f32; 3]) -> [f32; 3] + Sync {
|
||
let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)];
|
||
let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false };
|
||
let raw = view == 2 || input.raw;
|
||
let m_in = Xform::new(&Space { tf: Tf::Linear, ..*input }, &disp);
|
||
let m_out = Xform::new(&disp, &Space { tf: Tf::Linear, ..*input });
|
||
let in_tf = input.tf;
|
||
move |c: [f32; 3]| {
|
||
if raw {
|
||
return c;
|
||
}
|
||
if !inverse {
|
||
let lin = c.map(|v| in_tf.decode(v as f64) as f32);
|
||
let d = m_in.apply(lin);
|
||
d.map(|v| {
|
||
let v = v as f64;
|
||
let v = if view == 1 { tonemap(v) } else { v };
|
||
tf.encode(v) as f32
|
||
})
|
||
} else {
|
||
let d = c.map(|v| {
|
||
let l = tf.decode(v as f64);
|
||
(if view == 1 { tonemap_inv(l) } else { l }) as f32
|
||
});
|
||
m_out.apply(d).map(|v| in_tf.encode(v as f64) as f32)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- effects
|
||
|
||
fn spec(id: &'static str, name: &'static str, cat: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||
EffectSpec { id, name, category: cat, params, render, gpu: false, float: true }
|
||
}
|
||
|
||
fn space_param(id: &'static str, name: &'static str, default: u32) -> crate::ParamSpec {
|
||
p(id, name, Value::Enum(default), popup(&space_names()))
|
||
}
|
||
|
||
fn direction() -> crate::ParamSpec {
|
||
p("direction", "Direction", Value::Enum(0), popup(&["Forward", "Inverse"]))
|
||
}
|
||
|
||
fn map_px(b: &mut Buf, f: impl Fn([f32; 3]) -> [f32; 3] + Sync) {
|
||
b.img.data.par_iter_mut().for_each(|px| {
|
||
let (c, a) = unpremul(*px);
|
||
if a <= 0.0 {
|
||
return;
|
||
}
|
||
let o = f(c);
|
||
*px = [o[0] * a, o[1] * a, o[2] * a, a];
|
||
});
|
||
}
|
||
|
||
fn space_at(i: u32) -> &'static Space {
|
||
&SPACES[(i as usize).min(SPACES.len() - 1)]
|
||
}
|
||
|
||
/// The custom config chosen by an effect (Configuration ▸ Custom with a readable file).
|
||
fn custom_config(ctx: &EffectCtx) -> Option<std::sync::Arc<crate::ocio_config::Config>> {
|
||
if ctx.params.e("config") != 1 {
|
||
return None;
|
||
}
|
||
crate::ocio_config::load_config(ctx.params.s("configFile"))
|
||
}
|
||
|
||
/// A space of a custom config by its typed name, else by the built-in popup's name.
|
||
fn custom_space<'c>(cfg: &'c crate::ocio_config::Config, name: &str, popup: u32) -> Option<&'c crate::ocio_config::ConfigSpace> {
|
||
cfg.space(name).or_else(|| cfg.space(space_at(popup).name))
|
||
}
|
||
|
||
fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
// Custom configuration that can't be read: pixels pass through.
|
||
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
|
||
return b;
|
||
}
|
||
if let Some(cfg) = custom_config(ctx) {
|
||
let (Some(a), Some(z)) = (
|
||
custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")),
|
||
custom_space(&cfg, ctx.params.s("destinationName"), ctx.params.e("destination")),
|
||
) else {
|
||
return b;
|
||
};
|
||
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
|
||
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
|
||
return b;
|
||
}
|
||
let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination")));
|
||
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
|
||
if a.name == z.name {
|
||
return b;
|
||
}
|
||
let x = Xform::new(a, z);
|
||
map_px(&mut b, |c| x.apply(c));
|
||
b
|
||
}
|
||
|
||
fn cdl_from_params(pr: &crate::Params) -> Cdl {
|
||
Cdl {
|
||
slope: [pr.f("slopeRed"), pr.f("slopeGreen"), pr.f("slopeBlue")],
|
||
offset: [pr.f("offsetRed"), pr.f("offsetGreen"), pr.f("offsetBlue")],
|
||
power: [pr.f("powerRed").max(0.0), pr.f("powerGreen").max(0.0), pr.f("powerBlue").max(0.0)],
|
||
sat: pr.f("saturation").max(0.0),
|
||
}
|
||
}
|
||
|
||
fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let cdl = cdl_from_params(ctx.params);
|
||
if cdl == Cdl::default() {
|
||
return b;
|
||
}
|
||
let clamp = ctx.params.e("style") == 0;
|
||
let inv = ctx.params.e("direction") == 1;
|
||
map_px(&mut b, |c| {
|
||
let c = c.map(|v| v as f64);
|
||
let o = if inv { cdl.invert(c, clamp) } else { cdl.apply(c, clamp) };
|
||
o.map(|v| v as f32)
|
||
});
|
||
b
|
||
}
|
||
|
||
fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
// Custom configuration that can't be read: pixels pass through.
|
||
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
|
||
return b;
|
||
}
|
||
if let Some(cfg) = custom_config(ctx) {
|
||
// The display's view names a colour space of the config.
|
||
let display = DISPLAYS.get(ctx.params.e("display") as usize).map_or("", |d| d.0);
|
||
let view = VIEWS.get(ctx.params.e("view") as usize).copied().unwrap_or("");
|
||
let dn = if ctx.params.s("displayName").is_empty() { display } else { ctx.params.s("displayName") };
|
||
let vn = if ctx.params.s("viewName").is_empty() { view } else { ctx.params.s("viewName") };
|
||
let (Some(a), Some(z)) = (custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")), cfg.view_space(dn, vn)) else { return b };
|
||
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
|
||
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
|
||
return b;
|
||
}
|
||
let input = space_at(ctx.params.e("source"));
|
||
let f = display_xform(input, ctx.params.e("display") as usize, ctx.params.e("view") as usize, ctx.params.e("direction") == 1);
|
||
map_px(&mut b, f);
|
||
b
|
||
}
|
||
|
||
fn ocio_file(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let Some(x) = load_file(ctx.params.s("file")) else { return b };
|
||
let interp = match ctx.params.e("interpolation") {
|
||
0 => 0,
|
||
1 => 1,
|
||
_ => 2,
|
||
};
|
||
let inv = ctx.params.e("direction") == 1;
|
||
let id = ctx.params.s("cccId").to_string();
|
||
map_px(&mut b, |c| x.apply(c, interp, inv, &id));
|
||
b
|
||
}
|
||
|
||
/// Parse a looks string: comma/space separated names, `-Name` = inverse.
|
||
pub fn parse_looks(s: &str) -> Vec<(Cdl, bool)> {
|
||
let all = looks();
|
||
s.split([',', ':'])
|
||
.map(str::trim)
|
||
.filter(|t| !t.is_empty())
|
||
.filter_map(|t| {
|
||
let (inv, n) = match t.strip_prefix('-') {
|
||
Some(r) => (true, r.trim()),
|
||
None => (false, t.trim_start_matches('+').trim()),
|
||
};
|
||
all.iter().find(|(name, _)| name.eq_ignore_ascii_case(n)).map(|(_, c)| (*c, inv))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn ocio_look(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let mut list = parse_looks(ctx.params.s("looks"));
|
||
let pick = ctx.params.e("look");
|
||
if pick > 0
|
||
&& let Some((_, c)) = looks().get(pick as usize - 1)
|
||
{
|
||
list.insert(0, (*c, false));
|
||
}
|
||
if list.is_empty() {
|
||
return b;
|
||
}
|
||
let (src, dst) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination")));
|
||
let inv = ctx.params.e("direction") == 1;
|
||
let cct = space_by_name("ACEScct").expect("built-in");
|
||
let (a, z) = if inv { (dst, src) } else { (src, dst) };
|
||
let to_p = Xform::new(a, cct);
|
||
let from_p = Xform::new(cct, z);
|
||
if inv {
|
||
list.reverse();
|
||
}
|
||
map_px(&mut b, |c| {
|
||
let mut v = to_p.apply(c).map(|x| x as f64);
|
||
for (cdl, li) in &list {
|
||
v = if *li != inv { cdl.invert(v, false) } else { cdl.apply(v, false) };
|
||
}
|
||
from_p.apply(v.map(|x| x as f32))
|
||
});
|
||
b
|
||
}
|
||
|
||
// ---- Color Profile Converter (Utility)
|
||
|
||
pub const PROFILES: &[&str] = &["Project Working Space", "sRGB IEC61966-2.1", "HDTV (Rec. 709)", "Rec. 2020", "Display P3", "ACEScg", "ACES2065-1"];
|
||
|
||
fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) {
|
||
let ws = |cs: ColorSpace| match cs {
|
||
ColorSpace::Srgb => sp("sRGB", REC709, D65, Tf::Srgb),
|
||
ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)),
|
||
ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
|
||
ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb),
|
||
ColorSpace::AcesCg => *space_by_name("ACEScg").expect("built-in"),
|
||
ColorSpace::Aces2065 => *space_by_name("ACES2065-1").expect("built-in"),
|
||
// HDR encodings are output spaces, never a working space.
|
||
ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
|
||
};
|
||
match i {
|
||
0 => (ws(ctx.env.working_space.unwrap_or(ColorSpace::Srgb)), ctx.env.working_linear),
|
||
1 => (ws(ColorSpace::Srgb), false),
|
||
2 => (ws(ColorSpace::Rec709), false),
|
||
3 => (ws(ColorSpace::Rec2020), false),
|
||
4 => (ws(ColorSpace::DisplayP3), false),
|
||
5 => (*space_by_name("ACEScg").expect("built-in"), true),
|
||
_ => (*space_by_name("ACES2065-1").expect("built-in"), true),
|
||
}
|
||
}
|
||
|
||
/// Gamut compression for the perceptual intent: pull out-of-gamut (negative) colours toward
|
||
/// their luminance just enough to bring every channel to ≥ 0, rolling off smoothly.
|
||
fn compress_gamut(c: [f64; 3], luma: [f64; 3]) -> [f64; 3] {
|
||
let l = c[0] * luma[0] + c[1] * luma[1] + c[2] * luma[2];
|
||
if l <= 0.0 {
|
||
return c.map(|v| v.max(0.0));
|
||
}
|
||
let mn = c.iter().cloned().fold(f64::INFINITY, f64::min);
|
||
if mn >= 0.0 {
|
||
return c;
|
||
}
|
||
// Fraction of the way toward grey that reaches zero on the most negative channel.
|
||
let t = (l / (l - mn)).clamp(0.0, 1.0);
|
||
c.map(|v| l + (v - l) * t)
|
||
}
|
||
|
||
fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let (inp, in_lin_default) = profile(ctx, ctx.params.e("inputProfile"));
|
||
let (out, out_lin_default) = profile(ctx, ctx.params.e("outputProfile"));
|
||
let in_lin = ctx.params.b("linearizeInputProfile") || in_lin_default && ctx.params.e("inputProfile") == 0;
|
||
let out_lin = ctx.params.b("linearizeOutputProfile") || out_lin_default && ctx.params.e("outputProfile") == 0;
|
||
let intent = ctx.params.e("intent");
|
||
let dec = if in_lin { Tf::Linear } else { inp.tf };
|
||
let enc = if out_lin { Tf::Linear } else { out.tf };
|
||
// Absolute colorimetric keeps the source white (no adaptation); the others adapt.
|
||
let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) };
|
||
let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9));
|
||
if identity {
|
||
return b;
|
||
}
|
||
let out_luma = out.to_ref(true)[1];
|
||
map_px(&mut b, |c| {
|
||
let l = c.map(|v| dec.decode(v as f64));
|
||
let mut o = mul_vec(&m, l);
|
||
match intent {
|
||
0 => o = compress_gamut(o, out_luma),
|
||
2 => o = o.map(|v| v.max(0.0)),
|
||
_ => {}
|
||
}
|
||
o.map(|v| enc.encode(v) as f32)
|
||
});
|
||
b
|
||
}
|
||
|
||
// ---- Colour programs (the GPU compositor's colour-management kernels)
|
||
|
||
/// One per-pixel step of a colour transform, in the order the CPU effect applies them (see
|
||
/// [`color_program`]).
|
||
#[derive(Clone, Debug)]
|
||
pub enum ColorOp {
|
||
/// Transfer function, encoded → linear.
|
||
Decode(Tf),
|
||
/// Transfer function, linear → encoded.
|
||
Encode(Tf),
|
||
/// Linear-RGB matrix.
|
||
Matrix([[f32; 3]; 3]),
|
||
/// ASC CDL (forward or exact inverse; `clamp` = the v1.2 style).
|
||
Cdl { cdl: Cdl, clamp: bool, inverse: bool },
|
||
/// The Tone Mapped view's roll-off (or its inverse).
|
||
Tonemap { inverse: bool },
|
||
/// Color Profile Converter's perceptual gamut compression toward luminance `luma`.
|
||
Gamut([f64; 3]),
|
||
/// Negative channels to 0 (the saturation intent).
|
||
Max0,
|
||
/// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward
|
||
/// or inverse (as [`FileXform::apply`]).
|
||
Lut { lut: Arc<Lut>, shaper: Option<Arc<[Vec<(f32, f32)>; 3]>>, interp: u32, inverse: bool },
|
||
/// `m × (c + pre) + post` (a custom config's MatrixTransform and RangeTransform).
|
||
Affine { m: [[f32; 3]; 3], pre: [f32; 3], post: [f32; 3] },
|
||
/// `max(c, 0) ^ p` per channel (ExponentTransform).
|
||
Pow([f32; 3]),
|
||
/// LogTransform / LogAffineTransform, forward or inverse (as `ocio_config::Xf::apply`).
|
||
LogAffine { base: f32, log_slope: [f32; 3], log_offset: [f32; 3], lin_slope: [f32; 3], lin_offset: [f32; 3], inverse: bool },
|
||
}
|
||
|
||
/// The ops of a LUT / CDL file transform (as [`FileXform::apply`]).
|
||
fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec<ColorOp>) {
|
||
match x {
|
||
FileXform::Cdl(list) => {
|
||
let cdl = list.iter().find(|(i, _)| !ccc_id.is_empty() && i == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default();
|
||
ops.push(ColorOp::Cdl { cdl, clamp: true, inverse });
|
||
}
|
||
FileXform::Lut { lut, shaper } => {
|
||
ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse });
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The ops of a custom config's transform `x` (as `Xf::apply`).
|
||
fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorOp>) {
|
||
use crate::ocio_config::Xf;
|
||
let f3 = |v: &[f64; 3]| v.map(|x| x as f32);
|
||
match x {
|
||
Xf::Inverse(x) => config_xf_ops(x, !inverse, ops),
|
||
Xf::Matrix { m, offset } => {
|
||
if !inverse {
|
||
let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]);
|
||
ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] });
|
||
} else if let Some(inv) = crate::ocio_config::mat3_inverse(m) {
|
||
let m = inv.map(|r| r.map(|v| v as f32));
|
||
ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] });
|
||
}
|
||
}
|
||
Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops),
|
||
Xf::File { xf: None, .. } | Xf::Unsupported(_) => {}
|
||
Xf::Exponent(e) => ops.push(ColorOp::Pow([0, 1, 2].map(|k| (if inverse { 1.0 / e[k].max(1e-9) } else { e[k] }) as f32))),
|
||
Xf::Log { base } => {
|
||
ops.push(ColorOp::LogAffine { base: *base as f32, log_slope: [1.0; 3], log_offset: [0.0; 3], lin_slope: [1.0; 3], lin_offset: [0.0; 3], inverse })
|
||
}
|
||
Xf::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset } => ops.push(ColorOp::LogAffine {
|
||
base: *base as f32,
|
||
log_slope: f3(log_slope),
|
||
log_offset: f3(log_offset),
|
||
lin_slope: f3(lin_slope),
|
||
lin_offset: f3(lin_offset),
|
||
inverse,
|
||
}),
|
||
Xf::Cdl(cdl) => ops.push(ColorOp::Cdl { cdl: *cdl, clamp: true, inverse }),
|
||
Xf::Range { min_in, max_in, min_out, max_out } => {
|
||
let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) };
|
||
let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 };
|
||
let k = k as f32;
|
||
ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [-a0 as f32; 3], post: [b0 as f32; 3] });
|
||
}
|
||
Xf::Group(list) => {
|
||
if inverse {
|
||
list.iter().rev().for_each(|x| config_xf_ops(x, true, ops));
|
||
} else {
|
||
list.iter().for_each(|x| config_xf_ops(x, false, ops));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The ops of a custom config's conversion from space `a` to space `z` (as `Config::convert`).
|
||
fn config_ops(a: &crate::ocio_config::ConfigSpace, z: &crate::ocio_config::ConfigSpace, ops: &mut Vec<ColorOp>) {
|
||
if a.is_data || z.is_data || a.name == z.name {
|
||
return;
|
||
}
|
||
match (&a.to_ref, &a.from_ref) {
|
||
(Some(x), _) => config_xf_ops(x, false, ops),
|
||
(None, Some(x)) => config_xf_ops(x, true, ops),
|
||
_ => {}
|
||
}
|
||
match (&z.from_ref, &z.to_ref) {
|
||
(Some(x), _) => config_xf_ops(x, false, ops),
|
||
(None, Some(x)) => config_xf_ops(x, true, ops),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// How a colour program meets premultiplied pixels.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Straight {
|
||
/// `unpremul` (colour 0 below alpha 1e-6); fully transparent pixels untouched (OCIO effects).
|
||
Unpremul,
|
||
/// Colour ÷ alpha; fully transparent pixels untouched (`Image::map_straight`, Apply Color LUT).
|
||
Divide,
|
||
}
|
||
|
||
fn xform_ops(x: &Xform, ops: &mut Vec<ColorOp>) {
|
||
if x.dec != Tf::Linear {
|
||
ops.push(ColorOp::Decode(x.dec));
|
||
}
|
||
if !x.ident {
|
||
ops.push(ColorOp::Matrix(x.m));
|
||
}
|
||
if x.enc != Tf::Linear {
|
||
ops.push(ColorOp::Encode(x.enc));
|
||
}
|
||
}
|
||
|
||
/// Effect `id`'s per-pixel transform as a list of [`ColorOp`]s that reproduces the CPU effect
|
||
/// (empty = the layer passes through; custom OCIO configs compile their transforms too). `None`
|
||
/// for another effect.
|
||
pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straight)> {
|
||
let pr = ctx.params;
|
||
let mut ops = vec![];
|
||
match id {
|
||
"ec.utility.applylut" => {
|
||
if let Some(lut) = crate::utility::load_lut(pr.s("lut")) {
|
||
ops.push(ColorOp::Lut { lut, shaper: None, interp: 2, inverse: false });
|
||
}
|
||
return Some((ops, Straight::Divide));
|
||
}
|
||
"ec.color.ociocolorspace" => {
|
||
if pr.e("config") == 1 {
|
||
// A custom config (one that can't be read, or names unknown spaces: the pixels
|
||
// pass through).
|
||
if let Some(cfg) = custom_config(ctx)
|
||
&& let (Some(a), Some(z)) =
|
||
(custom_space(&cfg, pr.s("sourceName"), pr.e("source")), custom_space(&cfg, pr.s("destinationName"), pr.e("destination")))
|
||
{
|
||
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
|
||
config_ops(a, z, &mut ops);
|
||
}
|
||
return Some((ops, Straight::Unpremul));
|
||
}
|
||
let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination")));
|
||
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
|
||
if a.name != z.name {
|
||
xform_ops(&Xform::new(a, z), &mut ops);
|
||
}
|
||
}
|
||
"ec.color.ociocdl" => {
|
||
let cdl = cdl_from_params(pr);
|
||
if cdl != Cdl::default() {
|
||
ops.push(ColorOp::Cdl { cdl, clamp: pr.e("style") == 0, inverse: pr.e("direction") == 1 });
|
||
}
|
||
}
|
||
"ec.color.ociodisplay" => {
|
||
if pr.e("config") == 1 {
|
||
if let Some(cfg) = custom_config(ctx) {
|
||
let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0);
|
||
let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or("");
|
||
let dn = if pr.s("displayName").is_empty() { display } else { pr.s("displayName") };
|
||
let vn = if pr.s("viewName").is_empty() { view } else { pr.s("viewName") };
|
||
if let (Some(a), Some(z)) = (custom_space(&cfg, pr.s("sourceName"), pr.e("source")), cfg.view_space(dn, vn)) {
|
||
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
|
||
config_ops(a, z, &mut ops);
|
||
}
|
||
}
|
||
return Some((ops, Straight::Unpremul));
|
||
}
|
||
let input = space_at(pr.e("source"));
|
||
let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1);
|
||
let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)];
|
||
if view != 2 && !input.raw {
|
||
let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false };
|
||
let lin = Space { tf: Tf::Linear, ..*input };
|
||
if !inverse {
|
||
ops.push(ColorOp::Decode(input.tf));
|
||
xform_ops(&Xform::new(&lin, &disp), &mut ops);
|
||
if view == 1 {
|
||
ops.push(ColorOp::Tonemap { inverse: false });
|
||
}
|
||
ops.push(ColorOp::Encode(tf));
|
||
} else {
|
||
ops.push(ColorOp::Decode(tf));
|
||
if view == 1 {
|
||
ops.push(ColorOp::Tonemap { inverse: true });
|
||
}
|
||
xform_ops(&Xform::new(&disp, &lin), &mut ops);
|
||
ops.push(ColorOp::Encode(input.tf));
|
||
}
|
||
}
|
||
}
|
||
"ec.color.ociofile" => {
|
||
if let Some(x) = load_file(pr.s("file")) {
|
||
let interp = match pr.e("interpolation") {
|
||
0 => 0,
|
||
1 => 1,
|
||
_ => 2,
|
||
};
|
||
file_ops(&x, interp, pr.e("direction") == 1, pr.s("cccId"), &mut ops);
|
||
}
|
||
}
|
||
"ec.color.ociolook" => {
|
||
let mut list = parse_looks(pr.s("looks"));
|
||
let pick = pr.e("look");
|
||
if pick > 0
|
||
&& let Some((_, c)) = looks().get(pick as usize - 1)
|
||
{
|
||
list.insert(0, (*c, false));
|
||
}
|
||
if !list.is_empty() {
|
||
let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination")));
|
||
let inv = pr.e("direction") == 1;
|
||
let cct = space_by_name("ACEScct").expect("built-in");
|
||
let (a, z) = if inv { (dst, src) } else { (src, dst) };
|
||
if inv {
|
||
list.reverse();
|
||
}
|
||
xform_ops(&Xform::new(a, cct), &mut ops);
|
||
for (cdl, li) in &list {
|
||
ops.push(ColorOp::Cdl { cdl: *cdl, clamp: false, inverse: *li != inv });
|
||
}
|
||
xform_ops(&Xform::new(cct, z), &mut ops);
|
||
}
|
||
}
|
||
"ec.utility.colorprofileconverter" => {
|
||
let (inp, in_lin_default) = profile(ctx, pr.e("inputProfile"));
|
||
let (out, out_lin_default) = profile(ctx, pr.e("outputProfile"));
|
||
let in_lin = pr.b("linearizeInputProfile") || in_lin_default && pr.e("inputProfile") == 0;
|
||
let out_lin = pr.b("linearizeOutputProfile") || out_lin_default && pr.e("outputProfile") == 0;
|
||
let intent = pr.e("intent");
|
||
let dec = if in_lin { Tf::Linear } else { inp.tf };
|
||
let enc = if out_lin { Tf::Linear } else { out.tf };
|
||
let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) };
|
||
let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9));
|
||
if !identity {
|
||
ops.push(ColorOp::Decode(dec));
|
||
ops.push(ColorOp::Matrix(m.map(|r| r.map(|v| v as f32))));
|
||
match intent {
|
||
0 => ops.push(ColorOp::Gamut(out.to_ref(true)[1])),
|
||
2 => ops.push(ColorOp::Max0),
|
||
_ => {}
|
||
}
|
||
ops.push(ColorOp::Encode(enc));
|
||
}
|
||
}
|
||
_ => return None,
|
||
}
|
||
Some((ops, Straight::Unpremul))
|
||
}
|
||
|
||
// ---- Color Stabilizer
|
||
|
||
/// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`.
|
||
fn sample_mean(b: &Buf, pt: [f64; 2], r: f64) -> [f64; 3] {
|
||
let (cx, cy) = b.to_px(pt);
|
||
let rr = (r * b.scale).max(0.5);
|
||
let (x0, x1) = ((cx - rr).floor().max(0.0) as i64, ((cx + rr).ceil() as i64).min(b.img.width as i64));
|
||
let (y0, y1) = ((cy - rr).floor().max(0.0) as i64, ((cy + rr).ceil() as i64).min(b.img.height as i64));
|
||
let mut acc = [0.0f64; 4];
|
||
for y in y0..y1 {
|
||
for x in x0..x1 {
|
||
let p = b.img.get(x, y);
|
||
for k in 0..4 {
|
||
acc[k] += p[k] as f64;
|
||
}
|
||
}
|
||
}
|
||
if acc[3] <= 1e-9 {
|
||
return [0.0; 3];
|
||
}
|
||
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3]]
|
||
}
|
||
|
||
/// Monotone piecewise-quadratic curve through (0,0), three samples and (1,1) (linear between
|
||
/// them is enough for a stabiliser and stays monotone).
|
||
fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 {
|
||
let mut pts = vec![(0.0, 0.0)];
|
||
let mut pairs: Vec<(f64, f64)> = xs.iter().cloned().zip(ys.iter().cloned()).collect();
|
||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
for (x, y) in pairs {
|
||
if x > pts.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
|
||
pts.push((x, y));
|
||
}
|
||
}
|
||
pts.push((1.0, 1.0));
|
||
let i = pts.partition_point(|p| p.0 < v).clamp(1, pts.len() - 1);
|
||
let (a, c) = (pts[i - 1], pts[i]);
|
||
a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9)
|
||
}
|
||
|
||
/// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points
|
||
/// with increasing x; the end segments extend), the same maps [`color_stabilizer`] applies.
|
||
/// `None` without a host or reference frame (the layer passes through). (The GPU effect.)
|
||
pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> {
|
||
let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?;
|
||
let r = ctx.params.f("sampleSize").max(0.5);
|
||
let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")];
|
||
let cur = pts.map(|pt| sample_mean(b, pt, r));
|
||
let refv = pts.map(|pt| sample_mean(&reference, pt, r));
|
||
let mode = ctx.params.e("stabilize");
|
||
Some([0, 1, 2].map(|k| {
|
||
let offset = |c0: f64, r0: f64| vec![(0.0, r0 - c0), (1.0, 1.0 + r0 - c0)];
|
||
match mode {
|
||
0 => offset(cur[0][k], refv[0][k]),
|
||
1 => {
|
||
let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]);
|
||
if (c1 - c0).abs() < 1e-6 {
|
||
offset(c0, r0)
|
||
} else if c0 < c1 {
|
||
vec![(c0, r0), (c1, r1)]
|
||
} else {
|
||
vec![(c1, r1), (c0, r0)]
|
||
}
|
||
}
|
||
_ => {
|
||
let mut v = vec![(0.0, 0.0)];
|
||
let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect();
|
||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
for (x, y) in pairs {
|
||
if x > v.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
|
||
v.push((x, y));
|
||
}
|
||
}
|
||
v.push((1.0, 1.0));
|
||
v
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
|
||
fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||
let Some(host) = ctx.env.host else { return b };
|
||
let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b };
|
||
let r = ctx.params.f("sampleSize").max(0.5);
|
||
let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")];
|
||
let cur = pts.map(|pt| sample_mean(&b, pt, r));
|
||
let refv = pts.map(|pt| sample_mean(&reference, pt, r));
|
||
let mode = ctx.params.e("stabilize");
|
||
map_px(&mut b, |c| {
|
||
[0, 1, 2].map(|k| {
|
||
let v = c[k] as f64;
|
||
let o = match mode {
|
||
// Brightness: one sample (black point) — offset each channel.
|
||
0 => v + refv[0][k] - cur[0][k],
|
||
// Levels: black and white samples — linear map per channel.
|
||
1 => {
|
||
let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]);
|
||
if (c1 - c0).abs() < 1e-6 { v + r0 - c0 } else { r0 + (v - c0) * (r1 - r0) / (c1 - c0) }
|
||
}
|
||
// Curves: three samples — a curve through them per channel.
|
||
_ => curve3([cur[0][k], cur[1][k], cur[2][k]], [refv[0][k], refv[1][k], refv[2][k]], v),
|
||
};
|
||
o as f32
|
||
})
|
||
});
|
||
b
|
||
}
|
||
|
||
fn sl(lo: f64, hi: f64, smin: f64, smax: f64) -> ParamUi {
|
||
slider(lo, hi, smin, smax, 3)
|
||
}
|
||
|
||
pub fn specs() -> Vec<EffectSpec> {
|
||
let cc = "Color Correction";
|
||
let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap_or(0) as u32;
|
||
let lin709 = SPACES.iter().position(|s| s.name == "Linear Rec.709 (sRGB)").unwrap_or(0) as u32;
|
||
let acescg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap_or(0) as u32;
|
||
let mut look_opts = vec!["None"];
|
||
look_opts.extend(looks().iter().map(|(n, _)| *n));
|
||
vec![
|
||
spec(
|
||
"ec.color.ociocdl",
|
||
"OCIO CDL Transform",
|
||
cc,
|
||
vec![
|
||
p("slopeRed", "Slope Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
|
||
p("slopeGreen", "Slope Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
|
||
p("slopeBlue", "Slope Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
|
||
p("offsetRed", "Offset Red", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)),
|
||
p("offsetGreen", "Offset Green", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)),
|
||
p("offsetBlue", "Offset Blue", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)),
|
||
p("powerRed", "Power Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
|
||
p("powerGreen", "Power Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
|
||
p("powerBlue", "Power Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
|
||
p("saturation", "Saturation", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
|
||
p("style", "Style", Value::Enum(0), popup(&["ASC CDL v1.2", "No Clamp"])),
|
||
direction(),
|
||
],
|
||
ocio_cdl,
|
||
),
|
||
spec(
|
||
"ec.color.ociocolorspace",
|
||
"OCIO Color Space Transform",
|
||
cc,
|
||
vec![
|
||
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
|
||
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
|
||
space_param("source", "Source", lin709),
|
||
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
|
||
space_param("destination", "Destination", srgb),
|
||
p("destinationName", "Destination (config name)", Value::Str(String::new()), ParamUi::Text),
|
||
direction(),
|
||
],
|
||
ocio_cst,
|
||
),
|
||
spec(
|
||
"ec.color.ociodisplay",
|
||
"OCIO Display Transform",
|
||
cc,
|
||
vec![
|
||
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
|
||
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
|
||
space_param("source", "Input Color Space", lin709),
|
||
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
|
||
p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::<Vec<_>>())),
|
||
p("displayName", "Display (config name)", Value::Str(String::new()), ParamUi::Text),
|
||
p("view", "View Transform", Value::Enum(0), popup(VIEWS)),
|
||
p("viewName", "View (config name)", Value::Str(String::new()), ParamUi::Text),
|
||
direction(),
|
||
],
|
||
ocio_display,
|
||
),
|
||
// `file`: a path to a .cube/.3dl/.csp/.cc/.ccc/.cdl file, or the file's text itself.
|
||
spec(
|
||
"ec.color.ociofile",
|
||
"OCIO File Transform",
|
||
cc,
|
||
vec![
|
||
p("file", "File", Value::Str(String::new()), ParamUi::Text),
|
||
p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text),
|
||
p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])),
|
||
direction(),
|
||
],
|
||
ocio_file,
|
||
),
|
||
spec(
|
||
"ec.color.ociolook",
|
||
"OCIO Look Transform",
|
||
cc,
|
||
vec![
|
||
p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])),
|
||
space_param("source", "Source", acescg),
|
||
space_param("destination", "Destination", acescg),
|
||
p("look", "Look", Value::Enum(0), popup(&look_opts)),
|
||
p("looks", "Looks", Value::Str(String::new()), ParamUi::Text),
|
||
direction(),
|
||
],
|
||
ocio_look,
|
||
),
|
||
spec(
|
||
"ec.color.colorstabilizer",
|
||
"Color Stabilizer",
|
||
cc,
|
||
vec![
|
||
// The pivot frame (After Effects' Set Frame button stores it).
|
||
p("referenceFrame", "Reference Frame (s)", num(0.0), slider(-100000.0, 100000.0, 0.0, 60.0, 2)),
|
||
p("stabilize", "Stabilize", Value::Enum(0), popup(&["Brightness", "Levels", "Curves"])),
|
||
p("blackPoint", "Black Point", Value::Vec2([0.25, 0.75]), ParamUi::Point),
|
||
p("midPoint", "Mid Point", Value::Vec2([0.5, 0.5]), ParamUi::Point),
|
||
p("whitePoint", "White Point", Value::Vec2([0.75, 0.25]), ParamUi::Point),
|
||
p("sampleSize", "Sample Size", num(5.0), slider(1.0, 100.0, 1.0, 50.0, 0)),
|
||
],
|
||
color_stabilizer,
|
||
),
|
||
spec(
|
||
"ec.utility.colorprofileconverter",
|
||
"Color Profile Converter",
|
||
"Utility",
|
||
vec![
|
||
p("inputProfile", "Input Profile", Value::Enum(0), popup(PROFILES)),
|
||
p("linearizeInputProfile", "Linearize Input Profile", Value::Bool(false), ParamUi::Checkbox),
|
||
p("outputProfile", "Output Profile", Value::Enum(1), popup(PROFILES)),
|
||
p("linearizeOutputProfile", "Linearize Output Profile", Value::Bool(false), ParamUi::Checkbox),
|
||
p("intent", "Intent", Value::Enum(1), popup(&["Perceptual", "Relative Colorimetric", "Saturation", "Absolute Colorimetric"])),
|
||
// Our built-in profiles share a zero black point and none is scene-referred, so
|
||
// these two are kept for compatibility and do not change the result.
|
||
p("useBlackPointCompensation", "Use Black Point Compensation", Value::Bool(true), ParamUi::Checkbox),
|
||
p("sceneRefCompensation", "Scene-ref. Profile Compensation", Value::Enum(2), popup(&["On", "Off", "Use Project Setting"])),
|
||
],
|
||
profile_converter,
|
||
),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{EffectEnv, EffectHost, Image, LayerPixels, run_fx};
|
||
|
||
fn close(a: M3, b: M3, tol: f64) {
|
||
for i in 0..3 {
|
||
for j in 0..3 {
|
||
assert!((a[i][j] - b[i][j]).abs() < tol, "{a:?} vs {b:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn aces_matrices_match_published_values() {
|
||
let ap0 = space_by_name("ACES2065-1").unwrap();
|
||
let ap1 = space_by_name("ACEScg").unwrap();
|
||
// Academy S-2014-004 AP0 → AP1.
|
||
close(
|
||
matrix(ap0, ap1),
|
||
[
|
||
[1.451_439_316_1, -0.236_510_746_9, -0.214_928_569_3],
|
||
[-0.076_553_773_4, 1.176_229_699_8, -0.099_675_926_4],
|
||
[0.008_316_148_4, -0.006_032_449_8, 0.997_716_301_4],
|
||
],
|
||
2e-4,
|
||
);
|
||
// ACEScg → linear Rec.709 with Bradford adaptation (published ACES config value).
|
||
let lin = space_by_name("Linear Rec.709 (sRGB)").unwrap();
|
||
close(matrix(ap1, lin), [[1.705_05, -0.621_79, -0.083_26], [-0.130_26, 1.140_80, -0.010_55], [-0.024_00, -0.128_97, 1.152_97]], 2e-3);
|
||
// White maps to white.
|
||
let w = mul_vec(&matrix(ap1, lin), [1.0, 1.0, 1.0]);
|
||
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-3), "{w:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn transfer_functions_round_trip() {
|
||
for tf in [Tf::Srgb, Tf::Gamma(2.4), Tf::AcesCct, Tf::Pq] {
|
||
for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] {
|
||
let r = tf.decode(tf.encode(v));
|
||
assert!((r - v).abs() < 1e-6 * v.max(1.0), "{tf:?} {v} {r}");
|
||
}
|
||
}
|
||
// ACEScct of 18 % grey (S-2016-001).
|
||
assert!((Tf::AcesCct.encode(0.18) - 0.413_588).abs() < 1e-5);
|
||
// PQ: 100 nits (1.0) encodes to ≈ 0.508.
|
||
assert!((Tf::Pq.encode(1.0) - 0.508).abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn color_space_transform_effect_round_trips() {
|
||
let img = Image::filled(4, 4, [0.2, 0.5, 0.7, 1.0]);
|
||
let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap() as u32;
|
||
let cg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap() as u32;
|
||
let a = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg))], img.clone(), 0.0, EffectEnv::default());
|
||
assert_ne!(a.img.data, img.data);
|
||
let b = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(cg)), ("destination", Value::Enum(srgb))], a.img.clone(), 0.0, EffectEnv::default());
|
||
let c = run_fx(
|
||
"ec.color.ociocolorspace",
|
||
&[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg)), ("direction", Value::Enum(1))],
|
||
a.img,
|
||
0.0,
|
||
EffectEnv::default(),
|
||
);
|
||
for (x, y) in b.img.data.iter().zip(&img.data).chain(c.img.data.iter().zip(&img.data)) {
|
||
assert!((0..4).all(|k| (x[k] - y[k]).abs() < 1e-4), "{x:?} {y:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cdl_math() {
|
||
let cdl = Cdl { slope: [1.2, 1.0, 0.8], offset: [0.05, 0.0, -0.02], power: [1.1, 1.0, 0.9], sat: 0.8 };
|
||
let c = [0.3, 0.5, 0.7];
|
||
// Hand computation of the v1.2 formula.
|
||
let sop = [(0.3f64 * 1.2 + 0.05).powf(1.1), 0.5, (0.7f64 * 0.8 - 0.02).powf(0.9)];
|
||
let l = sop[0] * 0.2126 + sop[1] * 0.7152 + sop[2] * 0.0722;
|
||
let want = sop.map(|v| l + 0.8 * (v - l));
|
||
let got = cdl.apply(c, true);
|
||
for k in 0..3 {
|
||
assert!((got[k] - want[k]).abs() < 1e-12);
|
||
}
|
||
let back = cdl.invert(got, true);
|
||
for k in 0..3 {
|
||
assert!((back[k] - c[k]).abs() < 1e-9);
|
||
}
|
||
// v1.2 clamps, No Clamp keeps negatives.
|
||
let neg = Cdl { offset: [-0.5; 3], ..Cdl::default() };
|
||
assert_eq!(neg.apply([0.2; 3], true), [0.0; 3]);
|
||
assert!((neg.apply([0.2; 3], false)[0] + 0.3).abs() < 1e-12);
|
||
let img = Image::filled(2, 2, [0.3, 0.5, 0.7, 1.0]);
|
||
let out = run_fx("ec.color.ociocdl", &[("slopeRed", num(1.2)), ("offsetRed", num(0.05)), ("powerRed", num(1.1))], img, 0.0, EffectEnv::default());
|
||
let r = (0.3f64 * 1.2 + 0.05).powf(1.1);
|
||
assert!((out.img.get(0, 0)[0] as f64 - r).abs() < 1e-5);
|
||
}
|
||
|
||
fn identity_lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Vec<[f32; 3]> {
|
||
let mut v = vec![];
|
||
for b in 0..n {
|
||
for g in 0..n {
|
||
for r in 0..n {
|
||
let s = (n - 1) as f32;
|
||
v.push(f([r as f32 / s, g as f32 / s, b as f32 / s]));
|
||
}
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn lut_files_parse_and_interpolate() {
|
||
// .cube with a non-linear lattice: trilinear between lattice points.
|
||
let f = |c: [f32; 3]| [c[0] * c[0], c[1], 1.0 - c[2]];
|
||
let mut cube = String::from("LUT_3D_SIZE 3\n");
|
||
for r in identity_lattice(3, f) {
|
||
cube += &format!("{} {} {}\n", r[0], r[1], r[2]);
|
||
}
|
||
let Some(FileXform::Lut { lut, .. }) = parse_file(&cube, "cube") else { panic!() };
|
||
// Trilinear between 0.5 (0.25) and 1.0 (1.0) at 0.75 → 0.625.
|
||
let o = lut.apply_interp([0.75, 0.3, 0.2], 1);
|
||
assert!((o[0] - 0.625).abs() < 1e-6 && (o[1] - 0.3).abs() < 1e-6 && (o[2] - 0.8).abs() < 1e-6, "{o:?}");
|
||
// Nearest snaps.
|
||
assert_eq!(lut.apply_interp([0.74, 0.0, 0.0], 0)[0], 0.25);
|
||
// .3dl (blue fastest, 10-bit integers, mesh line).
|
||
let mut tdl = String::from("0 511 1023\n");
|
||
for r in 0..3 {
|
||
for g in 0..3 {
|
||
for b in 0..3 {
|
||
let v = [r, g, b].map(|i| i as f64 * 511.5);
|
||
tdl += &format!("{} {} {}\n", v[0].round(), v[1].round(), (1023.0 - v[2]).round());
|
||
}
|
||
}
|
||
}
|
||
let Some(FileXform::Lut { lut, .. }) = parse_file(&tdl, "3dl") else { panic!() };
|
||
let o = lut.apply_interp([0.25, 0.5, 0.25], 1);
|
||
assert!((o[0] - 0.25).abs() < 2e-3 && (o[1] - 0.5).abs() < 2e-3 && (o[2] - 0.75).abs() < 2e-3, "{o:?}");
|
||
// .csp with a squaring pre-LUT on red and an identity 2³ lattice.
|
||
let mut csp = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.25 1\n2\n0 1\n0 1\n2\n0 1\n0 1\n\n2 2 2\n");
|
||
for r in identity_lattice(2, |c| c) {
|
||
csp += &format!("{} {} {}\n", r[0], r[1], r[2]);
|
||
}
|
||
let x = parse_file(&csp, "csp").unwrap();
|
||
let o = x.apply([0.5, 0.4, 0.6], 2, false, "");
|
||
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.4).abs() < 1e-6, "{o:?}");
|
||
let inv = x.apply(o, 2, true, "");
|
||
assert!((inv[0] - 0.5).abs() < 1e-4, "{inv:?}");
|
||
// CDL collection: pick by id.
|
||
let ccc = r#"<ColorCorrectionCollection><ColorCorrection id="a"><SOPNode><Slope>2 2 2</Slope><Offset>0 0 0</Offset><Power>1 1 1</Power></SOPNode></ColorCorrection>
|
||
<ColorCorrection id='b'><SOPNode><Slope>0.5 0.5 0.5</Slope></SOPNode><SatNode><Saturation>1</Saturation></SatNode></ColorCorrection></ColorCorrectionCollection>"#;
|
||
let x = parse_file(ccc, "ccc").unwrap();
|
||
assert!((x.apply([0.4; 3], 2, false, "b")[0] - 0.2).abs() < 1e-6);
|
||
assert!((x.apply([0.4; 3], 2, false, "a")[0] - 0.8).abs() < 1e-6);
|
||
assert!((x.apply([0.4; 3], 2, false, "")[0] - 0.8).abs() < 1e-6);
|
||
// Effect with inline content; inverse of a 3D lattice.
|
||
let img = Image::filled(2, 2, [0.6, 0.3, 0.2, 1.0]);
|
||
let out = run_fx("ec.color.ociofile", &[("file", Value::Str(cube.clone()))], img.clone(), 0.0, EffectEnv::default());
|
||
let back = run_fx("ec.color.ociofile", &[("file", Value::Str(cube)), ("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default());
|
||
assert!((back.img.get(0, 0)[0] - 0.6).abs() < 1e-3, "{:?}", back.img.get(0, 0));
|
||
assert!(parse_file("garbage", "3dl").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn display_and_look_transforms() {
|
||
// Linear 0.18 through the sRGB display / Standard view = sRGB encoding of 0.18.
|
||
let img = Image::filled(2, 2, [0.18, 0.18, 0.18, 1.0]);
|
||
let out = run_fx("ec.color.ociodisplay", &[], img.clone(), 0.0, EffectEnv::default());
|
||
assert!((out.img.get(0, 0)[0] as f64 - Tf::Srgb.encode(0.18)).abs() < 1e-5);
|
||
let back = run_fx("ec.color.ociodisplay", &[("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default());
|
||
assert!((back.img.get(0, 0)[0] - 0.18).abs() < 1e-5);
|
||
// Tone mapped view keeps highlights below 1.
|
||
let hot = Image::filled(1, 1, [8.0, 8.0, 8.0, 1.0]);
|
||
let tm = run_fx("ec.color.ociodisplay", &[("view", Value::Enum(1))], hot, 0.0, EffectEnv::default());
|
||
assert!(tm.img.get(0, 0)[0] < 1.0);
|
||
assert!((tonemap_inv(tonemap(3.0)) - 3.0).abs() < 1e-9);
|
||
// Looks: Warm raises red over blue; its inverse undoes it.
|
||
let grey = Image::filled(1, 1, [0.18, 0.18, 0.18, 1.0]);
|
||
let w = run_fx("ec.color.ociolook", &[("looks", Value::Str("Warm".into()))], grey.clone(), 0.0, EffectEnv::default());
|
||
let px = w.img.get(0, 0);
|
||
assert!(px[0] > px[2]);
|
||
let u = run_fx("ec.color.ociolook", &[("looks", Value::Str("-Warm".into()))], w.img, 0.0, EffectEnv::default());
|
||
assert!((u.img.get(0, 0)[0] - 0.18).abs() < 1e-4 && (u.img.get(0, 0)[2] - 0.18).abs() < 1e-4);
|
||
assert_eq!(parse_looks("Warm, -Cool, nope").len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn profile_converter_converts_and_round_trips() {
|
||
let img = Image::filled(2, 2, [0.8, 0.4, 0.1, 1.0]);
|
||
// sRGB → Rec. 2020 (relative), then back.
|
||
let a = run_fx(
|
||
"ec.utility.colorprofileconverter",
|
||
&[("inputProfile", Value::Enum(1)), ("outputProfile", Value::Enum(3))],
|
||
img.clone(),
|
||
0.0,
|
||
EffectEnv::default(),
|
||
);
|
||
assert_ne!(a.img.data, img.data);
|
||
let b = run_fx(
|
||
"ec.utility.colorprofileconverter",
|
||
&[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))],
|
||
a.img,
|
||
0.0,
|
||
EffectEnv::default(),
|
||
);
|
||
assert!((0..3).all(|k| (b.img.get(0, 0)[k] - img.get(0, 0)[k]).abs() < 1e-4));
|
||
// Same profile both sides: identity. Working space defaults to sRGB.
|
||
let c = run_fx(
|
||
"ec.utility.colorprofileconverter",
|
||
&[("inputProfile", Value::Enum(0)), ("outputProfile", Value::Enum(1))],
|
||
img.clone(),
|
||
0.0,
|
||
EffectEnv::default(),
|
||
);
|
||
assert_eq!(c.img.data, img.data);
|
||
// Perceptual pulls out-of-gamut colours inside: saturated Rec.2020 green into sRGB.
|
||
let g = Image::filled(1, 1, [0.0, 1.0, 0.0, 1.0]);
|
||
let rel = run_fx(
|
||
"ec.utility.colorprofileconverter",
|
||
&[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))],
|
||
g.clone(),
|
||
0.0,
|
||
EffectEnv::default(),
|
||
);
|
||
assert!(rel.img.get(0, 0)[0] < 0.0);
|
||
let per = run_fx(
|
||
"ec.utility.colorprofileconverter",
|
||
&[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1)), ("intent", Value::Enum(0))],
|
||
g,
|
||
0.0,
|
||
EffectEnv::default(),
|
||
);
|
||
assert!(per.img.get(0, 0).iter().all(|v| *v >= -1e-6));
|
||
}
|
||
|
||
struct RefHost(Image);
|
||
impl EffectHost for RefHost {
|
||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||
None
|
||
}
|
||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||
None
|
||
}
|
||
fn self_at(&self, _: f64, _: usize) -> Option<Buf> {
|
||
Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 })
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn color_stabilizer_matches_the_reference_frame() {
|
||
let mk = |k: f32, o: f32| {
|
||
let mut img = Image::new(40, 40);
|
||
for y in 0..40 {
|
||
for x in 0..40 {
|
||
let v = (x as f32 / 40.0) * k + o;
|
||
img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]);
|
||
}
|
||
}
|
||
img
|
||
};
|
||
let reference = mk(1.0, 0.0);
|
||
let flicker = mk(0.8, 0.1);
|
||
let host = RefHost(reference.clone());
|
||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||
let pts = [("blackPoint", Value::Vec2([5.0, 20.0])), ("midPoint", Value::Vec2([20.0, 20.0])), ("whitePoint", Value::Vec2([35.0, 20.0]))];
|
||
let mut vals: Vec<(&str, Value)> = pts.to_vec();
|
||
vals.push(("stabilize", Value::Enum(1)));
|
||
vals.push(("sampleSize", num(1.0)));
|
||
let out = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env);
|
||
for x in [5i64, 20, 35] {
|
||
let (o, r) = (out.img.get(x, 20), reference.get(x, 20));
|
||
assert!((o[0] - r[0]).abs() < 0.01, "x {x}: {o:?} vs {r:?}");
|
||
}
|
||
// Deterministic, and a no-op without a host.
|
||
let again = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env);
|
||
assert_eq!(out.img.data, again.img.data);
|
||
let none = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, EffectEnv::default());
|
||
assert_eq!(none.img.data, flicker.data);
|
||
// Brightness mode corrects an offset at the black point.
|
||
let lifted = mk(1.0, 0.1);
|
||
let mut vals2: Vec<(&str, Value)> = pts.to_vec();
|
||
vals2.push(("sampleSize", num(1.0)));
|
||
let b = run_fx("ec.color.colorstabilizer", &vals2, lifted, 0.0, env);
|
||
assert!((b.img.get(5, 20)[0] - reference.get(5, 20)[0]).abs() < 0.01);
|
||
}
|
||
}
|