refactor(effects): split ocio.rs into ocio/*.rs

This commit is contained in:
aiongg
2026-10-26 21:49:12 +00:00
parent 13fe6e5c03
commit 5cb742591c
7 changed files with 1531 additions and 1475 deletions
+23 -1475
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -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!("</{name}>"))? + gt;
Some(&xml[gt + 1..close])
}
fn nums3(s: &str) -> Option<[f64; 3]> {
let v: Vec<f64> = s.split_whitespace().filter_map(|t| t.parse().ok()).collect();
(v.len() >= 3).then(|| [v[0], v[1], v[2]])
}
fn parse_cc(block: &str) -> Cdl {
let mut c = Cdl::default();
if let Some(s) = tag(block, "Slope").and_then(nums3) {
c.slope = s;
}
if let Some(s) = tag(block, "Offset").and_then(nums3) {
c.offset = s;
}
if let Some(s) = tag(block, "Power").and_then(nums3) {
c.power = s;
}
if let Some(s) = tag(block, "Saturation").and_then(|t| t.trim().parse().ok()) {
c.sat = s;
}
c
}
/// Parse ASC CDL XML (`.cc`, `.ccc`, `.cdl`): every `<ColorCorrection>` with its `id`.
pub fn parse_cdl_xml(xml: &str) -> Vec<(String, Cdl)> {
let mut out = vec![];
let mut rest = xml;
while let Some(i) = rest.find("<ColorCorrection") {
let after = &rest[i..];
let head_end = after.find('>').unwrap_or(after.len());
let head = &after[..head_end];
let id = head
.find("id=")
.and_then(|k| {
let q = head[k + 3..].chars().next()?;
let s = &head[k + 4..];
s.find(q).map(|e| s[..e].to_string())
})
.unwrap_or_default();
let end = after.find("</ColorCorrection>").map(|e| e + "</ColorCorrection>".len()).unwrap_or(after.len());
out.push((id, parse_cc(&after[..end])));
rest = &after[end..];
}
out
}
pub(super) fn sl(lo: f64, hi: f64, smin: f64, smax: f64) -> ParamUi {
slider(lo, hi, smin, smax, 3)
}
+467
View File
@@ -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<crate::ParamSpec>, 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<Option<crate::ocio_config::Xf>, 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<String> {
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
}
+331
View File
@@ -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<FileXform> {
let v: Vec<f64> = text.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
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 <min> <max>`, `Length <n>`, `Components
/// <1 | 3>`, then `{` the values `}`.
pub fn parse_spi1d(text: &str) -> Option<Lut> {
let (mut from, mut len, mut comps) = ([0.0f32, 1.0], 0usize, 1usize);
let mut values: Vec<f32> = 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<Lut> {
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<usize> = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
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<Lut> {
let mut mesh: Option<Vec<f64>> = None;
let mut rows: Vec<[f64; 3]> = vec![];
let mut out_bits: Option<u32> = None;
for line in text.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') || l.starts_with('<') {
continue;
}
if l.starts_with(|c: char| c.is_ascii_alphabetic()) {
// "Mesh <in bits> <out bits>" (Lustre header); other keywords are ignored.
let t: Vec<&str> = l.split_whitespace().collect();
if t[0].eq_ignore_ascii_case("mesh") && t.len() >= 3 {
out_bits = t[2].parse().ok();
}
continue;
}
let v: Vec<f64> = l.split_whitespace().filter_map(|t| t.parse().ok()).collect();
if v.len() > 3 && mesh.is_none() && rows.is_empty() {
mesh = Some(v);
} else if v.len() == 3 {
rows.push([v[0], v[1], v[2]]);
} else {
return None;
}
}
let is_cube = |k: usize| {
let n = (k as f64).cbrt().round() as usize;
n * n * n == k
};
// A 3-entry input mesh (3³ lattice) looks like a data row: tell them apart by the count.
if mesh.is_none() && !is_cube(rows.len()) && rows.len() == 28 {
mesh = Some(rows.remove(0).to_vec());
}
let n = match &mesh {
Some(m) => m.len(),
None => (rows.len() as f64).cbrt().round() as usize,
};
if n < 2 || rows.len() != n * n * n {
return None;
}
let maxv = rows.iter().flat_map(|r| r.iter()).fold(0.0f64, |m, &v| m.max(v));
let out_max = match out_bits {
Some(b) if b > 0 && b < 32 => (1u64 << b) as f64 - 1.0,
_ => [255.0, 1023.0, 4095.0, 65535.0].into_iter().find(|&m| maxv <= m).unwrap_or(maxv.max(1.0)),
};
// .3dl rows: red slowest, blue fastest. Our lattice is red fastest.
let mut d3 = vec![[0.0f32; 3]; n * n * n];
for (k, r) in rows.iter().enumerate() {
let (ri, gi, bi) = (k / (n * n), (k / n) % n, k % n);
d3[ri + gi * n + bi * n * n] = r.map(|v| (v / out_max) as f32);
}
Some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] })
}
/// Parse a cineSpace `.csp` LUT (1D or 3D, with its three pre-LUT shapers).
pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
if !lines.next()?.starts_with("CSPLUTV100") {
return None;
}
let kind = lines.next()?;
let mut lines = lines.peekable();
if lines.peek().is_some_and(|l| l.starts_with("BEGIN METADATA")) {
for l in lines.by_ref() {
if l.starts_with("END METADATA") {
break;
}
}
}
let nums = |l: &str| -> Option<Vec<f32>> { l.split_whitespace().map(|t| t.parse().ok()).collect() };
let mut shaper: [Vec<(f32, f32)>; 3] = Default::default();
for ch in shaper.iter_mut() {
let n: usize = lines.next()?.parse().ok()?;
let ins = nums(lines.next()?)?;
let outs = nums(lines.next()?)?;
if ins.len() != n || outs.len() != n || n < 2 {
return None;
}
*ch = ins.into_iter().zip(outs).collect();
}
if kind.starts_with("3D") {
let sz = nums(lines.next()?)?;
if sz.len() != 3 || sz[0] != sz[1] || sz[1] != sz[2] {
return None;
}
let n = sz[0] as usize;
let mut d3 = Vec::with_capacity(n * n * n);
for _ in 0..n * n * n {
let v = nums(lines.next()?)?;
if v.len() != 3 {
return None;
}
d3.push([v[0], v[1], v[2]]);
}
(n >= 2).then_some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
} else {
let n: usize = lines.next()?.parse().ok()?;
let mut d1 = Vec::with_capacity(n);
for _ in 0..n {
let v = nums(lines.next()?)?;
if v.len() != 3 {
return None;
}
d1.push([v[0], v[1], v[2]]);
}
(n >= 2).then_some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
}
}
fn shape(curve: &[(f32, f32)], x: f32) -> f32 {
if curve.len() < 2 {
return x;
}
let i = curve.partition_point(|p| p.0 < x).clamp(1, curve.len() - 1);
let (a, b) = (curve[i - 1], curve[i]);
let t = ((x - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0);
a.1 + (b.1 - a.1) * t
}
fn shape_inv(curve: &[(f32, f32)], y: f32) -> f32 {
let flipped: Vec<(f32, f32)> = curve.iter().map(|&(a, b)| (b, a)).collect();
shape(&flipped, y)
}
/// Parse transform text by its format (detected from `ext` or the content).
pub fn parse_file(text: &str, ext: &str) -> Option<FileXform> {
let ext = ext.to_ascii_lowercase();
if ext == "cc" || ext == "ccc" || ext == "cdl" || text.contains("<ColorCorrection") {
let v = parse_cdl_xml(text);
return (!v.is_empty()).then_some(FileXform::Cdl(v));
}
if ext == "csp" || text.trim_start().starts_with("CSPLUTV100") {
return parse_csp(text).map(|(lut, s)| FileXform::Lut { lut, shaper: Some(s) });
}
if ext == "spimtx" {
return parse_spimtx(text);
}
if ext == "spi1d" || text.trim_start().starts_with("Version") {
return parse_spi1d(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
if ext == "spi3d" || text.trim_start().starts_with("SPILUT") {
return parse_spi3d(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
if ext == "cube" || text.contains("LUT_3D_SIZE") || text.contains("LUT_1D_SIZE") {
return parse_cube(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
parse_3dl(text).map(|lut| FileXform::Lut { lut, shaper: None })
}
type FileCache = Mutex<HashMap<String, Option<Arc<FileXform>>>>;
/// Load (and cache) a transform from a file path or inline content.
pub fn load_file(src: &str) -> Option<Arc<FileXform>> {
let src_t = src.trim();
if src_t.is_empty() {
return None;
}
static CACHE: OnceLock<FileCache> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(m) = cache.lock()
&& let Some(v) = m.get(src)
{
return v.clone();
}
let inline = src.contains('\n');
let parsed = if inline {
parse_file(src, "")
} else {
let ext = std::path::Path::new(src_t).extension().and_then(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
}
}
}
}
+252
View File
@@ -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<Lut>, shaper: Option<Arc<[Vec<(f32, f32)>; 3]>>, interp: u32, inverse: bool },
/// `m × (c + pre) + post` (a custom config's MatrixTransform and RangeTransform).
Affine { m: [[f32; 3]; 3], pre: [f32; 3], post: [f32; 3] },
/// `max(c, 0) ^ p` per channel (ExponentTransform).
Pow([f32; 3]),
/// LogTransform / LogAffineTransform, forward or inverse (as `ocio_config::Xf::apply`).
LogAffine { base: f32, log_slope: [f32; 3], log_offset: [f32; 3], lin_slope: [f32; 3], lin_offset: [f32; 3], inverse: bool },
/// 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<ColorOp>) {
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<ColorOp>) {
match x {
FileXform::Cdl(list) => {
let cdl = list.iter().find(|(i, _)| !ccc_id.is_empty() && i == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default();
ops.push(ColorOp::Cdl { cdl, clamp: true, inverse });
}
FileXform::Lut { lut, shaper } => {
ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse });
}
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<ColorOp>) {
use crate::ocio_config::Xf;
let f3 = |v: &[f64; 3]| v.map(|x| x as f32);
match x {
Xf::Inverse(x) => config_xf_ops(x, !inverse, ops),
Xf::Matrix { m, offset } => 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<ColorOp>) {
if x.dec != Tf::Linear {
ops.push(ColorOp::Decode(x.dec));
}
if !x.ident {
ops.push(ColorOp::Matrix(x.m));
}
if x.enc != Tf::Linear {
ops.push(ColorOp::Encode(x.enc));
}
}
/// Effect `id`'s per-pixel transform as a list of [`ColorOp`]s that reproduces the CPU effect
/// (empty = the layer passes through; custom OCIO configs compile their transforms too). `None`
/// for another effect.
pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straight)> {
let pr = ctx.params;
let mut ops = vec![];
// 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))
}
+129
View File
@@ -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<EffectSpec> {
let cc = "Color Correction";
let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap_or(0) as u32;
let lin709 = SPACES.iter().position(|s| s.name == "Linear Rec.709 (sRGB)").unwrap_or(0) as u32;
let acescg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap_or(0) as u32;
let mut look_opts = vec!["None"];
look_opts.extend(looks().iter().map(|(n, _)| *n));
vec![
spec(
"ec.color.ociocdl",
"OCIO CDL Transform",
cc,
vec![
p("slopeRed", "Slope Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
p("slopeGreen", "Slope Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
p("slopeBlue", "Slope Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
p("offsetRed", "Offset Red", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)),
p("offsetGreen", "Offset Green", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)),
p("offsetBlue", "Offset Blue", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)),
p("powerRed", "Power Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
p("powerGreen", "Power Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
p("powerBlue", "Power Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
p("saturation", "Saturation", num(1.0), sl(0.0, 100.0, 0.0, 4.0)),
p("style", "Style", Value::Enum(0), popup(&["ASC CDL v1.2", "No Clamp"])),
direction(),
],
ocio_cdl,
),
spec(
"ec.color.ociocolorspace",
"OCIO Color Space Transform",
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), 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::<Vec<_>>())),
p("displayName", "Display (config name)", Value::Str(String::new()), ParamUi::Text),
p("view", "View Transform", Value::Enum(0), popup(VIEWS)),
p("viewName", "View (config name)", Value::Str(String::new()), ParamUi::Text),
direction(),
],
ocio_display,
),
// `file`: a path to a .cube/.3dl/.csp/.cc/.ccc/.cdl file, or the file's text itself.
spec(
"ec.color.ociofile",
"OCIO File Transform",
cc,
vec![
p("file", "File", Value::Str(String::new()), 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,
),
]
}
+214
View File
@@ -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)
}
}