diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index af180240..2daa9857 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -37,1483 +37,31 @@ use crate::util::unpremul; use crate::utility::{Lut, parse_cube}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; -type M3 = [[f64; 3]; 3]; - -pub(crate) 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]]; -pub(crate) const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; -pub(crate) const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; -pub(crate) 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, - /// ACEScc (Academy S-2014-003), the pure-log grading encoding. - AcesCc, - /// SMPTE ST 2084 with 1.0 = 100 cd/m². - Pq, -} - -/// ACEScc: the lowest code value (linear 0 and below) and the end of its toe (linear 2⁻¹⁵). -const ACESCC_MIN: f64 = (-16.0 + 9.72) / 17.52; -const ACESCC_TOE: f64 = (-15.0 + 9.72) / 17.52; - -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::AcesCc => { - if v < ACESCC_TOE { - (2f64.powf(v * 17.52 - 9.72) - 2f64.powi(-16)) * 2.0 - } 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::AcesCc => { - if v <= 0.0 { - ACESCC_MIN - } else if v < 2f64.powi(-15) { - ((2f64.powi(-16) + v * 0.5).log2() + 9.72) / 17.52 - } 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, -} - -pub(crate) 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(crate) const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear); -pub(crate) const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear); -const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct); - -pub const SPACES: &[Space] = &[ - ACES2065_1, - ACES_CG, - ACES_CCT, - 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() -} +mod cdl; +mod effects; +mod lut; +mod program; +mod registry; +mod space; +pub use cdl::Cdl; +pub(crate) use effects::CUSTOM_CONFIG_FX; #[cfg(test)] -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!(""))? + gt; - Some(&xml[gt + 1..close]) -} - -fn nums3(s: &str) -> Option<[f64; 3]> { - let v: Vec = 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 `` 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("').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("").map(|e| e + "".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)>), - /// A matrix with offset (Sony Imageworks `.spimtx`), as a MatrixTransform's. - Matrix { m: [f64; 16], offset: [f64; 4] }, -} - -/// Parse a Sony Imageworks `.spimtx` matrix: three rows of four numbers, three matrix columns -/// and an offset in 16-bit code values (÷ 65535). -pub fn parse_spimtx(text: &str) -> Option { - let v: Vec = text.split_whitespace().map(|t| t.parse().ok()).collect::>()?; - if v.len() != 12 || v.iter().any(|x| !x.is_finite()) { - return None; - } - let mut m = [0.0; 16]; - let mut offset = [0.0; 4]; - for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { - m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); - offset[r] = row[3] / 65535.0; - } - m[15] = 1.0; - Some(FileXform::Matrix { m, offset }) -} - -/// Parse a Sony Imageworks `.spi1d` 1D LUT: `From `, `Length `, `Components -/// <1 | 3>`, then `{` the values `}`. -pub fn parse_spi1d(text: &str) -> Option { - let (mut from, mut len, mut comps) = ([0.0f32, 1.0], 0usize, 1usize); - let mut values: Vec = vec![]; - let mut body = false; - for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { - if body { - if line.starts_with('}') { - break; - } - for t in line.split_whitespace() { - values.push(t.parse().ok()?); - } - continue; - } - let mut t = line.split_whitespace(); - match t.next()? { - "{" => body = true, - "From" => from = [t.next()?.parse().ok()?, t.next()?.parse().ok()?], - "Length" => len = t.next()?.parse().ok()?, - "Components" => comps = t.next()?.parse().ok()?, - _ => {} - } - } - // A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries). - if !(2..=1 << 24).contains(&len) - || !matches!(comps, 1 | 3) - || values.len() != len.checked_mul(comps)? - || from[1] <= from[0] - || from.iter().any(|v| !v.is_finite()) - { - return None; - } - let d1 = values.chunks_exact(comps).map(|c| if comps == 3 { [c[0], c[1], c[2]] } else { [c[0]; 3] }).collect(); - Some(Lut { n1: len, d1, n3: 0, d3: vec![], dmin: [from[0]; 3], dmax: [from[1]; 3] }) -} - -/// Parse a Sony Imageworks `.spi3d` lattice: `SPILUT 1.0`, `3 3`, the sizes, then one -/// `r g b R G B` line per lattice point (indices, then the colour). -pub fn parse_spi3d(text: &str) -> Option { - let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); - if !lines.next()?.starts_with("SPILUT") { - return None; - } - lines.next()?; - let size: Vec = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::>()?; - let n = *size.first()?; - if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) { - return None; - } - let mut d3 = vec![[0.0f32; 3]; n * n * n]; - let mut seen = 0usize; - for line in lines { - let v: Vec<&str> = line.split_whitespace().collect(); - let [r, g, b, cr, cg, cb] = v.as_slice() else { return None }; - let (r, g, b): (usize, usize, usize) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?); - if r >= n || g >= n || b >= n { - return None; - } - // Our lattice is red fastest. - *d3.get_mut(r + g * n + b * n * n)? = [cr.parse().ok()?, cg.parse().ok()?, cb.parse().ok()?]; - seen += 1; - } - (seen == n * n * n).then_some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }) -} - -/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line). -pub fn parse_3dl(text: &str) -> Option { - let mut mesh: Option> = None; - let mut rows: Vec<[f64; 3]> = vec![]; - let mut out_bits: Option = 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 " (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 = 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> { 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 { - let ext = ext.to_ascii_lowercase(); - if ext == "cc" || ext == "ccc" || ext == "cdl" || text.contains(">>>; - -/// Load (and cache) a transform from a file path or inline content. -pub fn load_file(src: &str) -> Option> { - let src_t = src.trim(); - if src_t.is_empty() { - return None; - } - static CACHE: OnceLock = 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(std::ffi::OsStr::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::Matrix { m, offset } => crate::ocio_config::matrix_apply(m, offset, c.map(|v| v as f64), inverse).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, 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 effects that take Configuration ▸ Custom (a `.ocio` file). -pub(crate) const CUSTOM_CONFIG_FX: [&str; 2] = ["ec.color.ociocolorspace", "ec.color.ociodisplay"]; - -/// Effect `id`'s transform under Configuration ▸ Custom: the config's conversion from the source -/// space to the destination space (Color Space Transform) or through a display's view (Display -/// Transform), see [`crate::ocio_config`]. `Ok(None)` with the built-in configuration; an error -/// (the pixels pass through) when the config can't be read or has no such colour space. -fn custom_xf(id: &str, pr: &crate::Params) -> Result, String> { - use crate::ocio_config::Xf; - if !CUSTOM_CONFIG_FX.contains(&id) || pr.e("config") != 1 { - return Ok(None); - } - let cfg = crate::ocio_config::load_config(pr.s("configFile")).map_err(|e| format!("Can't read the OCIO config: {e}"))?; - // A space by its typed name, else by the built-in popup's name. - let space = |name: &str, popup: u32| { - let name = if name.trim().is_empty() { space_at(popup).name } else { name.trim() }; - cfg.space(name).ok_or_else(|| format!("The OCIO config has no color space `{name}`")) - }; - let a = space(pr.s("sourceName"), pr.e("source"))?; - let inverse = pr.e("direction") == 1; - if id == "ec.color.ociodisplay" { - let (dn, vn) = display_view_names(pr); - let xf = cfg.display_path(a, dn, vn); - return Ok(Some(if inverse { Xf::Inverse(Box::new(xf)) } else { xf })); - } - let z = space(pr.s("destinationName"), pr.e("destination"))?; - // Inverse: the conversion the other way (OCIO's inverse ColorSpaceTransform). - Ok(Some(if inverse { cfg.path(z, a) } else { cfg.path(a, z) })) -} - -/// The display and view a Display Transform names: the typed names, else the popups'. -fn display_view_names(pr: &crate::Params) -> (&str, &str) { - 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 typed = |k: &str, popup| if pr.s(k).trim().is_empty() { popup } else { pr.s(k).trim() }; - (typed("displayName", display), typed("viewName", view)) -} - -/// Effect Controls' warning for effect `id`: a custom OCIO configuration that can't be read, has -/// no such colour space, display or view, or uses transforms EffectCraft can't apply (colours -/// pass through those steps, #410). `None` when the effect renders as asked. -pub fn warning(id: &str, pr: &crate::Params) -> Option { - let xf = match custom_xf(id, pr) { - Ok(x) => x?, - Err(e) => return Some(format!("{e}: the colors pass through.")), - }; - let mut notes = vec![]; - if id == "ec.color.ociodisplay" - && let Ok(cfg) = crate::ocio_config::load_config(pr.s("configFile")) - { - let (dn, vn) = display_view_names(pr); - if let Some((d, v)) = cfg.view(dn, vn) - && (!d.eq_ignore_ascii_case(dn) || !v.name.eq_ignore_ascii_case(vn)) - { - notes.push(format!("The OCIO config has no display `{dn}` with a view `{vn}`: showing {d} / {}.", v.name)); - } - } - let missing = xf.unsupported_list(); - if !missing.is_empty() { - notes.push(format!("Not supported, the colors pass through: {}.", missing.join(", "))); - } - (!notes.is_empty()).then(|| notes.join(" ")) -} - -/// Configuration ▸ Custom: render through the config's transform. `false` with the built-in -/// configuration (the caller renders it). -fn custom_render(id: &str, ctx: &EffectCtx, b: &mut Buf) -> bool { - match custom_xf(id, ctx.params) { - Ok(None) => false, - Ok(Some(xf)) => { - map_px(b, |c| xf.apply(c.map(|v| v as f64), false).map(|v| v as f32)); - true - } - // A config that can't be read, or an unknown space: the pixels pass through. - Err(_) => true, - } -} - -fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf { - if custom_render("ec.color.ociocolorspace", ctx, &mut b) { - 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 { - if custom_render("ec.color.ociodisplay", ctx, &mut b) { - 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 = &ACES_CCT; - 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 => ACES_CG, - ColorSpace::Aces2065 => ACES2065_1, - // 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 => (ACES_CG, true), - _ => (ACES2065_1, 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, shaper: Option; 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 }, - /// ExponentWithLinearTransform, forward (decode) or inverse (as - /// [`crate::ocio_config::moncurve`]). - MonCurve { gamma: [f32; 3], offset: [f32; 3], inverse: bool, mirror: bool }, - /// Clamp each channel to `lo..=hi` (a clamping RangeTransform; ±`f32::MAX` = unbounded). - Clamp { lo: [f32; 3], hi: [f32; 3] }, -} - -/// The ops of a matrix-and-offset transform (MatrixTransform, `.spimtx`), forward or inverse -/// (as [`crate::ocio_config::matrix_apply`]). -fn matrix_ops(m: &[f64; 16], offset: &[f64; 4], inverse: bool, ops: &mut Vec) { - 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] }); - } -} - -/// The ops of a LUT / CDL / matrix file transform (as [`FileXform::apply`]). -fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec) { - 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 }); - } - FileXform::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops), - } -} - -/// 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) { - 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 } => matrix_ops(m, offset, inverse, ops), - Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops), - Xf::ExponentLinear { gamma, offset, mirror } => ops.push(ColorOp::MonCurve { gamma: f3(gamma), offset: f3(offset), inverse, mirror: *mirror }), - // A BuiltinTransform's curve: forward decodes. - Xf::Curve(tf) => ops.push(if inverse { ColorOp::Encode(*tf) } else { ColorOp::Decode(*tf) }), - Xf::Named(_) => {} - 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(r) => { - let (k, off, lo, hi) = r.parts(inverse); - 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: [0.0; 3], post: [off as f32; 3] }); - if lo.is_finite() || hi.is_finite() { - let bound = |v: f64| (v as f32).clamp(-f32::MAX, f32::MAX); - ops.push(ColorOp::Clamp { lo: [bound(lo); 3], hi: [bound(hi); 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)); - } - } - } -} - -/// 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) { - 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, Straight)> { - let pr = ctx.params; - let mut ops = vec![]; - // Configuration ▸ Custom compiles the config's transforms (a config that can't be read, or - // names unknown spaces, passes the pixels through). - match custom_xf(id, pr) { - Ok(None) => {} - Ok(Some(xf)) => { - config_xf_ops(&xf, false, &mut ops); - return Some((ops, Straight::Unpremul)); - } - Err(_) => return Some((ops, Straight::Unpremul)), - } - 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" => { - 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" => { - 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 = &ACES_CCT; - 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 pts.last().is_some_and(|l| x > l.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 v.last().is_some_and(|l| x > l.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 { - 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()), crate::file_ui(&["ocio"])), - 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()), crate::file_ui(&["ocio"])), - 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::>())), - 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()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])), - 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, - ), - ] -} +pub use effects::parse_looks; +pub use effects::{color_stabilizer_maps, warning}; +#[cfg(test)] +use effects::{tonemap, tonemap_inv}; +pub use lut::{FileXform, load_file}; +#[cfg(test)] +pub use lut::{parse_file, parse_spi1d, parse_spi3d, parse_spimtx}; +pub use program::{ColorOp, Straight, color_program}; +pub use registry::specs; +#[cfg(test)] +use space::M3; +pub(crate) use space::{ACES_CG, ACES2065_1, D65, P3, REC709, REC2020, sp}; +#[cfg(test)] +pub use space::{SPACES, Xform, bradford, space_by_name}; +pub use space::{Space, Tf, matrix}; #[cfg(test)] mod tests; diff --git a/crates/effects/src/ocio/cdl.rs b/crates/effects/src/ocio/cdl.rs new file mode 100644 index 00000000..1feab04a --- /dev/null +++ b/crates/effects/src/ocio/cdl.rs @@ -0,0 +1,115 @@ +use super::*; + +// ---------------------------------------------------------------- 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!(""))? + gt; + Some(&xml[gt + 1..close]) +} + +fn nums3(s: &str) -> Option<[f64; 3]> { + let v: Vec = 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 `` 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("').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("").map(|e| e + "".len()).unwrap_or(after.len()); + out.push((id, parse_cc(&after[..end]))); + rest = &after[end..]; + } + out +} + +pub(super) fn sl(lo: f64, hi: f64, smin: f64, smax: f64) -> ParamUi { + slider(lo, hi, smin, smax, 3) +} diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs new file mode 100644 index 00000000..7d07011a --- /dev/null +++ b/crates/effects/src/ocio/effects.rs @@ -0,0 +1,467 @@ +use super::cdl::Cdl; +use super::lut::load_file; +use super::space::{ACES_CCT, ACES_CG, ACES2065_1, D65, IDENTITY, P3, REC709, REC2020, SPACES, Space, Tf, Xform, matrix, sp, space_names}; +use super::*; + +// ---------------------------------------------------------------- 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. +pub(super) fn tonemap(v: f64) -> f64 { + let w2 = 256.0; + if v <= 0.0 { v } else { v * (1.0 + v / w2) / (1.0 + v) } +} + +pub(super) 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 + +pub(super) fn spec(id: &'static str, name: &'static str, cat: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: cat, params, render, gpu: false, float: true } +} + +pub(super) fn space_param(id: &'static str, name: &'static str, default: u32) -> crate::ParamSpec { + p(id, name, Value::Enum(default), popup(&space_names())) +} + +pub(super) 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]; + }); +} + +pub(super) fn space_at(i: u32) -> &'static Space { + &SPACES[(i as usize).min(SPACES.len() - 1)] +} + +/// The effects that take Configuration ▸ Custom (a `.ocio` file). +pub(crate) const CUSTOM_CONFIG_FX: [&str; 2] = ["ec.color.ociocolorspace", "ec.color.ociodisplay"]; + +/// Effect `id`'s transform under Configuration ▸ Custom: the config's conversion from the source +/// space to the destination space (Color Space Transform) or through a display's view (Display +/// Transform), see [`crate::ocio_config`]. `Ok(None)` with the built-in configuration; an error +/// (the pixels pass through) when the config can't be read or has no such colour space. +pub(super) fn custom_xf(id: &str, pr: &crate::Params) -> Result, String> { + use crate::ocio_config::Xf; + if !CUSTOM_CONFIG_FX.contains(&id) || pr.e("config") != 1 { + return Ok(None); + } + let cfg = crate::ocio_config::load_config(pr.s("configFile")).map_err(|e| format!("Can't read the OCIO config: {e}"))?; + // A space by its typed name, else by the built-in popup's name. + let space = |name: &str, popup: u32| { + let name = if name.trim().is_empty() { space_at(popup).name } else { name.trim() }; + cfg.space(name).ok_or_else(|| format!("The OCIO config has no color space `{name}`")) + }; + let a = space(pr.s("sourceName"), pr.e("source"))?; + let inverse = pr.e("direction") == 1; + if id == "ec.color.ociodisplay" { + let (dn, vn) = display_view_names(pr); + let xf = cfg.display_path(a, dn, vn); + return Ok(Some(if inverse { Xf::Inverse(Box::new(xf)) } else { xf })); + } + let z = space(pr.s("destinationName"), pr.e("destination"))?; + // Inverse: the conversion the other way (OCIO's inverse ColorSpaceTransform). + Ok(Some(if inverse { cfg.path(z, a) } else { cfg.path(a, z) })) +} + +/// The display and view a Display Transform names: the typed names, else the popups'. +fn display_view_names(pr: &crate::Params) -> (&str, &str) { + 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 typed = |k: &str, popup| if pr.s(k).trim().is_empty() { popup } else { pr.s(k).trim() }; + (typed("displayName", display), typed("viewName", view)) +} + +/// Effect Controls' warning for effect `id`: a custom OCIO configuration that can't be read, has +/// no such colour space, display or view, or uses transforms EffectCraft can't apply (colours +/// pass through those steps, #410). `None` when the effect renders as asked. +pub fn warning(id: &str, pr: &crate::Params) -> Option { + let xf = match custom_xf(id, pr) { + Ok(x) => x?, + Err(e) => return Some(format!("{e}: the colors pass through.")), + }; + let mut notes = vec![]; + if id == "ec.color.ociodisplay" + && let Ok(cfg) = crate::ocio_config::load_config(pr.s("configFile")) + { + let (dn, vn) = display_view_names(pr); + if let Some((d, v)) = cfg.view(dn, vn) + && (!d.eq_ignore_ascii_case(dn) || !v.name.eq_ignore_ascii_case(vn)) + { + notes.push(format!("The OCIO config has no display `{dn}` with a view `{vn}`: showing {d} / {}.", v.name)); + } + } + let missing = xf.unsupported_list(); + if !missing.is_empty() { + notes.push(format!("Not supported, the colors pass through: {}.", missing.join(", "))); + } + (!notes.is_empty()).then(|| notes.join(" ")) +} + +/// Configuration ▸ Custom: render through the config's transform. `false` with the built-in +/// configuration (the caller renders it). +fn custom_render(id: &str, ctx: &EffectCtx, b: &mut Buf) -> bool { + match custom_xf(id, ctx.params) { + Ok(None) => false, + Ok(Some(xf)) => { + map_px(b, |c| xf.apply(c.map(|v| v as f64), false).map(|v| v as f32)); + true + } + // A config that can't be read, or an unknown space: the pixels pass through. + Err(_) => true, + } +} + +pub(super) fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf { + if custom_render("ec.color.ociocolorspace", ctx, &mut b) { + 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 +} + +pub(super) 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), + } +} + +pub(super) 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 +} + +pub(super) fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf { + if custom_render("ec.color.ociodisplay", ctx, &mut b) { + 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 +} + +pub(super) 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() +} + +pub(super) 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 = &ACES_CCT; + 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"]; + +pub(super) 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 => ACES_CG, + ColorSpace::Aces2065 => ACES2065_1, + // 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 => (ACES_CG, true), + _ => (ACES2065_1, 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) +} + +pub(super) 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 +} + +// ---- 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 pts.last().is_some_and(|l| x > l.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 v.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 { + v.push((x, y)); + } + } + v.push((1.0, 1.0)); + v + } + } + })) +} + +pub(super) 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 +} diff --git a/crates/effects/src/ocio/lut.rs b/crates/effects/src/ocio/lut.rs new file mode 100644 index 00000000..ff4cbd93 --- /dev/null +++ b/crates/effects/src/ocio/lut.rs @@ -0,0 +1,331 @@ +use super::cdl::{Cdl, parse_cdl_xml}; +use super::*; + +// ---------------------------------------------------------------- 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)>), + /// A matrix with offset (Sony Imageworks `.spimtx`), as a MatrixTransform's. + Matrix { m: [f64; 16], offset: [f64; 4] }, +} + +/// Parse a Sony Imageworks `.spimtx` matrix: three rows of four numbers, three matrix columns +/// and an offset in 16-bit code values (÷ 65535). +pub fn parse_spimtx(text: &str) -> Option { + let v: Vec = text.split_whitespace().map(|t| t.parse().ok()).collect::>()?; + if v.len() != 12 || v.iter().any(|x| !x.is_finite()) { + return None; + } + let mut m = [0.0; 16]; + let mut offset = [0.0; 4]; + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { + m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); + offset[r] = row[3] / 65535.0; + } + m[15] = 1.0; + Some(FileXform::Matrix { m, offset }) +} + +/// Parse a Sony Imageworks `.spi1d` 1D LUT: `From `, `Length `, `Components +/// <1 | 3>`, then `{` the values `}`. +pub fn parse_spi1d(text: &str) -> Option { + let (mut from, mut len, mut comps) = ([0.0f32, 1.0], 0usize, 1usize); + let mut values: Vec = vec![]; + let mut body = false; + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + if body { + if line.starts_with('}') { + break; + } + for t in line.split_whitespace() { + values.push(t.parse().ok()?); + } + continue; + } + let mut t = line.split_whitespace(); + match t.next()? { + "{" => body = true, + "From" => from = [t.next()?.parse().ok()?, t.next()?.parse().ok()?], + "Length" => len = t.next()?.parse().ok()?, + "Components" => comps = t.next()?.parse().ok()?, + _ => {} + } + } + // A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries). + if !(2..=1 << 24).contains(&len) + || !matches!(comps, 1 | 3) + || values.len() != len.checked_mul(comps)? + || from[1] <= from[0] + || from.iter().any(|v| !v.is_finite()) + { + return None; + } + let d1 = values.chunks_exact(comps).map(|c| if comps == 3 { [c[0], c[1], c[2]] } else { [c[0]; 3] }).collect(); + Some(Lut { n1: len, d1, n3: 0, d3: vec![], dmin: [from[0]; 3], dmax: [from[1]; 3] }) +} + +/// Parse a Sony Imageworks `.spi3d` lattice: `SPILUT 1.0`, `3 3`, the sizes, then one +/// `r g b R G B` line per lattice point (indices, then the colour). +pub fn parse_spi3d(text: &str) -> Option { + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + if !lines.next()?.starts_with("SPILUT") { + return None; + } + lines.next()?; + let size: Vec = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::>()?; + let n = *size.first()?; + if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) { + return None; + } + let mut d3 = vec![[0.0f32; 3]; n * n * n]; + let mut seen = 0usize; + for line in lines { + let v: Vec<&str> = line.split_whitespace().collect(); + let [r, g, b, cr, cg, cb] = v.as_slice() else { return None }; + let (r, g, b): (usize, usize, usize) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?); + if r >= n || g >= n || b >= n { + return None; + } + // Our lattice is red fastest. + *d3.get_mut(r + g * n + b * n * n)? = [cr.parse().ok()?, cg.parse().ok()?, cb.parse().ok()?]; + seen += 1; + } + (seen == n * n * n).then_some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }) +} + +/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line). +pub fn parse_3dl(text: &str) -> Option { + let mut mesh: Option> = None; + let mut rows: Vec<[f64; 3]> = vec![]; + let mut out_bits: Option = 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 " (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 = 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> { 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 { + let ext = ext.to_ascii_lowercase(); + if ext == "cc" || ext == "ccc" || ext == "cdl" || text.contains(">>>; + +/// Load (and cache) a transform from a file path or inline content. +pub fn load_file(src: &str) -> Option> { + let src_t = src.trim(); + if src_t.is_empty() { + return None; + } + static CACHE: OnceLock = 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(std::ffi::OsStr::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::Matrix { m, offset } => crate::ocio_config::matrix_apply(m, offset, c.map(|v| v as f64), inverse).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 + } + } + } +} diff --git a/crates/effects/src/ocio/program.rs b/crates/effects/src/ocio/program.rs new file mode 100644 index 00000000..ab9c4ea7 --- /dev/null +++ b/crates/effects/src/ocio/program.rs @@ -0,0 +1,252 @@ +use super::cdl::Cdl; +use super::effects::{DISPLAYS, cdl_from_params, custom_xf, looks, parse_looks, profile, space_at}; +use super::lut::{FileXform, load_file}; +use super::space::{ACES_CCT, D65, IDENTITY, Space, Tf, Xform, matrix}; +use super::*; + +// ---- 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, shaper: Option; 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 }, + /// ExponentWithLinearTransform, forward (decode) or inverse (as + /// [`crate::ocio_config::moncurve`]). + MonCurve { gamma: [f32; 3], offset: [f32; 3], inverse: bool, mirror: bool }, + /// Clamp each channel to `lo..=hi` (a clamping RangeTransform; ±`f32::MAX` = unbounded). + Clamp { lo: [f32; 3], hi: [f32; 3] }, +} + +/// The ops of a matrix-and-offset transform (MatrixTransform, `.spimtx`), forward or inverse +/// (as [`crate::ocio_config::matrix_apply`]). +fn matrix_ops(m: &[f64; 16], offset: &[f64; 4], inverse: bool, ops: &mut Vec) { + 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] }); + } +} + +/// The ops of a LUT / CDL / matrix file transform (as [`FileXform::apply`]). +fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec) { + 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 }); + } + FileXform::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops), + } +} + +/// 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) { + 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 } => matrix_ops(m, offset, inverse, ops), + Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops), + Xf::ExponentLinear { gamma, offset, mirror } => ops.push(ColorOp::MonCurve { gamma: f3(gamma), offset: f3(offset), inverse, mirror: *mirror }), + // A BuiltinTransform's curve: forward decodes. + Xf::Curve(tf) => ops.push(if inverse { ColorOp::Encode(*tf) } else { ColorOp::Decode(*tf) }), + Xf::Named(_) => {} + 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(r) => { + let (k, off, lo, hi) = r.parts(inverse); + 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: [0.0; 3], post: [off as f32; 3] }); + if lo.is_finite() || hi.is_finite() { + let bound = |v: f64| (v as f32).clamp(-f32::MAX, f32::MAX); + ops.push(ColorOp::Clamp { lo: [bound(lo); 3], hi: [bound(hi); 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)); + } + } + } +} + +/// 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) { + 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, Straight)> { + let pr = ctx.params; + let mut ops = vec![]; + // Configuration ▸ Custom compiles the config's transforms (a config that can't be read, or + // names unknown spaces, passes the pixels through). + match custom_xf(id, pr) { + Ok(None) => {} + Ok(Some(xf)) => { + config_xf_ops(&xf, false, &mut ops); + return Some((ops, Straight::Unpremul)); + } + Err(_) => return Some((ops, Straight::Unpremul)), + } + 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" => { + 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" => { + 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 = &ACES_CCT; + 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)) +} diff --git a/crates/effects/src/ocio/registry.rs b/crates/effects/src/ocio/registry.rs new file mode 100644 index 00000000..b020b053 --- /dev/null +++ b/crates/effects/src/ocio/registry.rs @@ -0,0 +1,129 @@ +use super::cdl::sl; +use super::effects::{ + CONFIGS, DISPLAYS, PROFILES, VIEWS, color_stabilizer, direction, looks, ocio_cdl, ocio_cst, ocio_display, ocio_file, ocio_look, profile_converter, + space_param, spec, +}; +use super::space::SPACES; +use super::*; + +pub fn specs() -> Vec { + 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()), crate::file_ui(&["ocio"])), + 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()), crate::file_ui(&["ocio"])), + 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::>())), + 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()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])), + 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, + ), + ] +} diff --git a/crates/effects/src/ocio/space.rs b/crates/effects/src/ocio/space.rs new file mode 100644 index 00000000..44fcf84d --- /dev/null +++ b/crates/effects/src/ocio/space.rs @@ -0,0 +1,214 @@ +use super::*; + +pub(super) type M3 = [[f64; 3]; 3]; + +pub(crate) 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]]; + +pub(crate) const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; + +pub(crate) const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; + +pub(crate) const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; + +pub(super) 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, + /// ACEScc (Academy S-2014-003), the pure-log grading encoding. + AcesCc, + /// SMPTE ST 2084 with 1.0 = 100 cd/m². + Pq, +} + +/// ACEScc: the lowest code value (linear 0 and below) and the end of its toe (linear 2⁻¹⁵). +const ACESCC_MIN: f64 = (-16.0 + 9.72) / 17.52; + +const ACESCC_TOE: f64 = (-15.0 + 9.72) / 17.52; + +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::AcesCc => { + if v < ACESCC_TOE { + (2f64.powf(v * 17.52 - 9.72) - 2f64.powi(-16)) * 2.0 + } 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::AcesCc => { + if v <= 0.0 { + ACESCC_MIN + } else if v < 2f64.powi(-15) { + ((2f64.powi(-16) + v * 0.5).log2() + 9.72) / 17.52 + } 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, +} + +pub(crate) 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(crate) const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear); + +pub(crate) const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear); + +pub(super) const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct); + +pub const SPACES: &[Space] = &[ + ACES2065_1, + ACES_CG, + ACES_CCT, + 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() +} + +#[cfg(test)] +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 { + pub(super) dec: Tf, + pub(super) m: [[f32; 3]; 3], + pub(super) ident: bool, + pub(super) 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) + } +}