Files
effectcraft/crates/effects/src/ocio_config.rs
T
Brandon ThomasandClaude Opus 5.5 1c855c3e07 M13.29: GPU effects part D (particles and remaining fallbacks)
GPU render passes for CC Particle World / Systems II, Particle Playground,
CC Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise (fx_particles,
chunked multi-pass raster); Shatter wireframe views, Foam extras, Timewarp
with a Matte Layer, strong Warp Fisheye/Twist (CPU f64 inverse map) and
custom .ocio configs no longer fall back to the CPU. CPU refactors pinned by
particle_golden / timewarp_golden / sim_golden hashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:35:56 -04:00

929 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Custom OpenColorIO configuration files (`.ocio`) for the OCIO effects, read from the
//! published configuration format (a YAML document), not from OpenColorIO's source.
//!
//! Supported subset:
//!
//! * `search_path` (a string with `:`-separated entries or a list), resolved against the
//! config's folder, for `FileTransform` sources;
//! * `roles` (any role name can stand for its colour space);
//! * `colorspaces`: `name`, `aliases`, `isdata`, and the transforms to / from the reference
//! space — `to_reference` / `from_reference` (OCIO v1) or `to_scene_reference` /
//! `from_scene_reference` (v2); a space with only one direction is inverted for the other;
//! * `displays`: display → views (`!<View> {name, colorspace}`);
//! * transforms: `MatrixTransform` (`matrix` 4×4 row-major, `offset`), `FileTransform` (the LUT
//! and CDL files [`super::ocio::parse_file`] reads; `interpolation`, `ccc_id`),
//! `ExponentTransform` (`value`), `LogTransform` (`base`), `LogAffineTransform` (`base`,
//! `logSideSlope`, `logSideOffset`, `linSideSlope`, `linSideOffset`), `CDLTransform` (`slope`,
//! `offset`, `power`, `sat`), `RangeTransform` (`min_in_value`, `max_in_value`,
//! `min_out_value`, `max_out_value`) and `GroupTransform` (`children`), each with
//! `direction: inverse`. Other transforms are reported by [`Config::unsupported`] and pass
//! colours through.
//!
//! The YAML reader covers what configs use: block mappings and sequences, flow `{}` / `[]`
//! collections (also spanning lines), `!<Tag>` tags, quoted scalars, `|` / `>` block scalars
//! and comments.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use super::ocio::{Cdl, FileXform, load_file};
// ---------------------------------------------------------------- YAML subset
/// A parsed YAML node.
#[derive(Clone, Debug, PartialEq)]
pub enum Yaml {
Str(String),
Seq(Vec<Yaml>),
Map(Vec<(String, Yaml)>),
/// A `!<Tag>` node.
Tagged(String, Box<Yaml>),
Null,
}
impl Yaml {
pub fn get(&self, k: &str) -> Option<&Yaml> {
match self {
Yaml::Map(m) => m.iter().find(|(key, _)| key == k).map(|(_, v)| v),
Yaml::Tagged(_, v) => v.get(k),
_ => None,
}
}
pub fn str(&self) -> Option<&str> {
match self {
Yaml::Str(s) => Some(s),
Yaml::Tagged(_, v) => v.str(),
_ => None,
}
}
pub fn seq(&self) -> &[Yaml] {
match self {
Yaml::Seq(v) => v,
Yaml::Tagged(_, v) => v.seq(),
_ => &[],
}
}
pub fn tag(&self) -> Option<&str> {
match self {
Yaml::Tagged(t, _) => Some(t),
_ => None,
}
}
fn f(&self) -> Option<f64> {
self.str()?.trim().parse().ok()
}
fn nums(&self) -> Vec<f64> {
match self {
Yaml::Seq(v) => v.iter().filter_map(Yaml::f).collect(),
Yaml::Str(_) => self.f().into_iter().collect(),
Yaml::Tagged(_, v) => v.nums(),
_ => vec![],
}
}
}
/// Strip a trailing comment (a `#` outside quotes, at the start or after whitespace).
fn strip_comment(line: &str) -> &str {
let (mut sq, mut dq) = (false, false);
let b = line.as_bytes();
for (i, &c) in b.iter().enumerate() {
match c {
b'\'' if !dq => sq = !sq,
b'"' if !sq => dq = !dq,
b'#' if !sq && !dq && (i == 0 || b[i - 1].is_ascii_whitespace()) => return &line[..i],
_ => {}
}
}
line
}
fn unquote(s: &str) -> String {
let t = s.trim();
if t.len() >= 2 && ((t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\''))) {
return t[1..t.len() - 1].replace("\\\"", "\"").replace("''", "'");
}
t.to_string()
}
/// Split `key: value` at the first `:` followed by a space or the end (outside quotes/brackets).
fn split_key(s: &str) -> Option<(String, &str)> {
let b = s.as_bytes();
let (mut sq, mut dq, mut depth) = (false, false, 0i32);
for (i, &c) in b.iter().enumerate() {
match c {
b'\'' if !dq => sq = !sq,
b'"' if !sq => dq = !dq,
b'{' | b'[' if !sq && !dq => depth += 1,
b'}' | b']' if !sq && !dq => depth -= 1,
b':' if !sq && !dq && depth == 0 && (i + 1 == b.len() || b[i + 1] == b' ' || b[i + 1] == b'\t') => {
let k = s[..i].trim();
if k.is_empty() || k.starts_with('!') || k.starts_with('{') || k.starts_with('[') {
return None;
}
return Some((unquote(k), s[i + 1..].trim()));
}
_ => {}
}
}
None
}
/// Bracket balance of a flow fragment (outside quotes).
fn balance(s: &str) -> i32 {
let (mut sq, mut dq, mut d) = (false, false, 0);
for c in s.chars() {
match c {
'\'' if !dq => sq = !sq,
'"' if !sq => dq = !dq,
'{' | '[' if !sq && !dq => d += 1,
'}' | ']' if !sq && !dq => d -= 1,
_ => {}
}
}
d
}
struct Flow<'a> {
s: &'a [u8],
i: usize,
}
impl Flow<'_> {
fn ws(&mut self) {
while self.i < self.s.len() && (self.s[self.i] as char).is_whitespace() {
self.i += 1;
}
}
fn peek(&self) -> Option<u8> {
self.s.get(self.i).copied()
}
fn value(&mut self) -> Yaml {
self.ws();
match self.peek() {
Some(b'{') => {
self.i += 1;
let mut m = vec![];
loop {
self.ws();
match self.peek() {
None => break,
Some(b'}') => {
self.i += 1;
break;
}
Some(b',') => {
self.i += 1;
continue;
}
_ => {}
}
let k = self.scalar(true);
self.ws();
if self.peek() == Some(b':') {
self.i += 1;
}
let v = self.value();
m.push((unquote(&k), v));
}
Yaml::Map(m)
}
Some(b'[') => {
self.i += 1;
let mut v = vec![];
loop {
self.ws();
match self.peek() {
None => break,
Some(b']') => {
self.i += 1;
break;
}
Some(b',') => {
self.i += 1;
continue;
}
_ => v.push(self.value()),
}
}
Yaml::Seq(v)
}
Some(b'!') => {
let start = self.i;
while self.i < self.s.len() && !(self.s[self.i] as char).is_whitespace() && self.s[self.i] != b'{' && self.s[self.i] != b'[' {
self.i += 1;
}
let tag = tag_name(std::str::from_utf8(&self.s[start..self.i]).unwrap_or(""));
self.ws();
let inner = match self.peek() {
Some(b'{') | Some(b'[') => self.value(),
_ => Yaml::Null,
};
Yaml::Tagged(tag, Box::new(inner))
}
_ => {
let s = self.scalar(false);
if s.is_empty() { Yaml::Null } else { Yaml::Str(unquote(&s)) }
}
}
}
/// A plain or quoted scalar, ending at `,` `}` `]` (and `:` + space for keys).
fn scalar(&mut self, key: bool) -> String {
self.ws();
let start = self.i;
if let Some(q @ (b'"' | b'\'')) = self.peek() {
self.i += 1;
while self.i < self.s.len() && self.s[self.i] != q {
self.i += 1;
}
self.i = (self.i + 1).min(self.s.len());
return String::from_utf8_lossy(&self.s[start..self.i]).into_owned();
}
while self.i < self.s.len() {
let c = self.s[self.i];
if c == b',' || c == b'}' || c == b']' {
break;
}
if key && c == b':' {
break;
}
self.i += 1;
}
String::from_utf8_lossy(&self.s[start..self.i]).trim().to_string()
}
}
fn tag_name(t: &str) -> String {
t.trim().trim_start_matches('!').trim_start_matches('<').trim_end_matches('>').to_string()
}
fn flow(s: &str) -> Yaml {
Flow { s: s.as_bytes(), i: 0 }.value()
}
struct Lines {
/// (indent, content without comment), blank lines removed.
v: Vec<(usize, String)>,
}
impl Lines {
fn new(text: &str) -> Lines {
let v = text
.lines()
.filter(|l| !l.trim_start().starts_with('%') && l.trim() != "---" && l.trim() != "...")
.map(|l| {
let c = strip_comment(l).trim_end();
(c.len() - c.trim_start().len(), c.trim_start().to_string())
})
.filter(|(_, c)| !c.is_empty())
.collect();
Lines { v }
}
/// The value of a `key:` / `- ` remainder `rest` whose children are indented more than
/// `indent` (from line `*i`).
fn value(&self, rest: &str, indent: usize, i: &mut usize) -> Yaml {
let rest = rest.trim();
if rest.is_empty() {
return match self.v.get(*i) {
// A sequence may sit at the key's own indent.
Some((ind, c)) if *ind > indent || (*ind == indent && (c == "-" || c.starts_with("- "))) => self.block(*ind, i),
_ => Yaml::Null,
};
}
if rest == "|" || rest == ">" || rest.starts_with("|-") || rest.starts_with(">-") || rest.starts_with("|+") || rest.starts_with(">+") {
let mut out = vec![];
while let Some((ind, c)) = self.v.get(*i) {
if *ind <= indent {
break;
}
out.push(c.clone());
*i += 1;
}
return Yaml::Str(out.join(if rest.starts_with('>') { " " } else { "\n" }));
}
if rest.starts_with('!') && !rest.contains('{') && !rest.contains('[') {
let tag = tag_name(rest);
let inner = match self.v.get(*i) {
Some((ind, _)) if *ind > indent => self.block(*ind, i),
_ => Yaml::Null,
};
return Yaml::Tagged(tag, Box::new(inner));
}
if rest.starts_with('{') || rest.starts_with('[') || (rest.starts_with('!') && (rest.contains('{') || rest.contains('['))) {
// Flow collections may continue on the next lines.
let mut text = rest.to_string();
while balance(&text) > 0 && *i < self.v.len() {
text.push(' ');
text.push_str(&self.v[*i].1);
*i += 1;
}
return flow(&text);
}
Yaml::Str(unquote(rest))
}
/// A block node whose lines start at `indent`.
fn block(&self, indent: usize, i: &mut usize) -> Yaml {
let Some((_, first)) = self.v.get(*i) else { return Yaml::Null };
if first == "-" || first.starts_with("- ") {
let mut items = vec![];
while let Some((ind, c)) = self.v.get(*i) {
if *ind != indent || !(c == "-" || c.starts_with("- ")) {
break;
}
*i += 1;
let rest = c[1..].trim_start().to_string();
// `- key: value` opens a mapping whose keys are indented to the key.
if split_key(&rest).is_some() && !rest.starts_with('{') && !rest.starts_with('!') {
let inner = indent + 1 + (c.len() - 1 - c[1..].trim_start().len());
items.push(self.map_from(Some(rest), inner.max(indent + 2), i));
} else {
items.push(self.value(&rest, indent, i));
}
}
return Yaml::Seq(items);
}
if split_key(first).is_some() {
return self.map_from(None, indent, i);
}
*i += 1;
Yaml::Str(unquote(first))
}
/// A block mapping at `indent`; `first` is an entry already taken from a `- ` line.
fn map_from(&self, first: Option<String>, indent: usize, i: &mut usize) -> Yaml {
let mut m = vec![];
if let Some(f) = first
&& let Some((k, rest)) = split_key(&f)
{
let v = self.value(rest, indent, i);
m.push((k, v));
}
while let Some((ind, c)) = self.v.get(*i) {
if *ind != indent {
if *ind < indent {
break;
}
// Over-indented stray line: skip.
*i += 1;
continue;
}
let Some((k, rest)) = split_key(c) else { break };
*i += 1;
let v = self.value(rest, indent, i);
m.push((k, v));
}
Yaml::Map(m)
}
}
/// Parse a YAML document (the subset described in the module docs).
pub fn parse_yaml(text: &str) -> Yaml {
let l = Lines::new(text);
let mut i = 0;
let indent = l.v.first().map_or(0, |(ind, _)| *ind);
l.block(indent, &mut i)
}
// ---------------------------------------------------------------- transforms
/// A colour transform of a config.
#[derive(Clone, Debug)]
pub enum Xf {
/// 4×4 row-major matrix (RGB of the first three rows/columns) plus offset.
Matrix {
m: [f64; 16],
offset: [f64; 4],
},
File {
src: String,
xf: Option<Arc<FileXform>>,
interp: u32,
ccc: String,
},
Exponent([f64; 3]),
Log {
base: f64,
},
LogAffine {
base: f64,
log_slope: [f64; 3],
log_offset: [f64; 3],
lin_slope: [f64; 3],
lin_offset: [f64; 3],
},
Cdl(Cdl),
Range {
min_in: f64,
max_in: f64,
min_out: f64,
max_out: f64,
},
Group(Vec<Xf>),
Inverse(Box<Xf>),
/// An unsupported transform (by tag): identity.
Unsupported(String),
}
fn vec3_of(y: Option<&Yaml>, d: f64) -> [f64; 3] {
let v = y.map(Yaml::nums).unwrap_or_default();
match v.len() {
0 => [d; 3],
1 | 2 => [v[0]; 3],
_ => [v[0], v[1], v[2]],
}
}
fn mat_apply(m: &[f64; 16], off: &[f64; 4], c: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|r| m[r * 4] * c[0] + m[r * 4 + 1] * c[1] + m[r * 4 + 2] * c[2] + off[r])
}
pub(crate) fn mat3_inverse(m: &[f64; 16]) -> Option<[[f64; 3]; 3]> {
let a = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
(det.abs() > 1e-12).then(|| effectcraft_color::space::invert(&a))
}
fn log_base(v: f64, base: f64) -> f64 {
v.max(f64::MIN_POSITIVE).ln() / base.ln()
}
impl Xf {
fn from_yaml(y: &Yaml, dir: &Path, search: &[String]) -> Xf {
let tag = y.tag().unwrap_or("");
let body = match y {
Yaml::Tagged(_, b) => b.as_ref(),
other => other,
};
let inverse = body.get("direction").and_then(Yaml::str).is_some_and(|d| d.eq_ignore_ascii_case("inverse"));
let xf = match tag {
"MatrixTransform" => {
let v = body.get("matrix").map(Yaml::nums).unwrap_or_default();
let mut m = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
if v.len() == 16 {
m.copy_from_slice(&v);
} else if v.len() == 9 {
for r in 0..3 {
for c in 0..3 {
m[r * 4 + c] = v[r * 3 + c];
}
}
}
let o = body.get("offset").map(Yaml::nums).unwrap_or_default();
let mut offset = [0.0; 4];
for (k, v) in o.iter().take(4).enumerate() {
offset[k] = *v;
}
Xf::Matrix { m, offset }
}
"FileTransform" => {
let src = body.get("src").and_then(Yaml::str).unwrap_or("").to_string();
let interp = match body.get("interpolation").and_then(Yaml::str).unwrap_or("linear").to_ascii_lowercase().as_str() {
"nearest" => 0,
"tetrahedral" | "best" => 2,
_ => 1,
};
let ccc = body.get("cccid").or_else(|| body.get("ccc_id")).and_then(Yaml::str).unwrap_or("").to_string();
let xf = resolve(&src, dir, search).and_then(|p| load_file(&p.to_string_lossy()));
Xf::File { src, xf, interp, ccc }
}
"ExponentTransform" => Xf::Exponent(vec3_of(body.get("value"), 1.0)),
"LogTransform" => Xf::Log { base: body.get("base").and_then(Yaml::f).unwrap_or(2.0) },
"LogAffineTransform" => Xf::LogAffine {
base: body.get("base").and_then(Yaml::f).unwrap_or(2.0),
log_slope: vec3_of(body.get("logSideSlope"), 1.0),
log_offset: vec3_of(body.get("logSideOffset"), 0.0),
lin_slope: vec3_of(body.get("linSideSlope"), 1.0),
lin_offset: vec3_of(body.get("linSideOffset"), 0.0),
},
"CDLTransform" => Xf::Cdl(Cdl {
slope: vec3_of(body.get("slope"), 1.0),
offset: vec3_of(body.get("offset"), 0.0),
power: vec3_of(body.get("power"), 1.0),
sat: body.get("sat").and_then(Yaml::f).unwrap_or(1.0),
}),
"RangeTransform" => Xf::Range {
min_in: body.get("min_in_value").and_then(Yaml::f).unwrap_or(0.0),
max_in: body.get("max_in_value").and_then(Yaml::f).unwrap_or(1.0),
min_out: body.get("min_out_value").and_then(Yaml::f).unwrap_or(0.0),
max_out: body.get("max_out_value").and_then(Yaml::f).unwrap_or(1.0),
},
"GroupTransform" => Xf::Group(body.get("children").map(|c| c.seq().iter().map(|t| Xf::from_yaml(t, dir, search)).collect()).unwrap_or_default()),
other => Xf::Unsupported(other.to_string()),
};
if inverse { Xf::Inverse(Box::new(xf)) } else { xf }
}
/// Apply forward (or inverse) to linear-or-encoded RGB.
pub fn apply(&self, c: [f64; 3], inverse: bool) -> [f64; 3] {
match self {
Xf::Inverse(x) => x.apply(c, !inverse),
Xf::Matrix { m, offset } => {
if !inverse {
return mat_apply(m, offset, c);
}
match mat3_inverse(m) {
Some(inv) => {
let d = [c[0] - offset[0], c[1] - offset[1], c[2] - offset[2]];
effectcraft_color::space::mul_vec(&inv, d)
}
None => c,
}
}
Xf::File { xf, interp, ccc, .. } => match xf {
Some(x) => x.apply(c.map(|v| v as f32), *interp, inverse, ccc).map(|v| v as f64),
None => c,
},
Xf::Exponent(e) => [0, 1, 2].map(|k| {
let p = if inverse { 1.0 / e[k].max(1e-9) } else { e[k] };
c[k].max(0.0).powf(p)
}),
Xf::Log { base } => {
if inverse {
c.map(|v| base.powf(v))
} else {
c.map(|v| log_base(v, *base))
}
}
Xf::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset } => [0, 1, 2].map(|k| {
if inverse {
(base.powf((c[k] - log_offset[k]) / log_slope[k]) - lin_offset[k]) / lin_slope[k]
} else {
log_slope[k] * log_base(lin_slope[k] * c[k] + lin_offset[k], *base) + log_offset[k]
}
}),
Xf::Cdl(cdl) => {
if inverse {
cdl.invert(c, true)
} else {
cdl.apply(c, true)
}
}
Xf::Range { min_in, max_in, min_out, max_out } => {
let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) };
let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 };
c.map(|v| b0 + (v - a0) * k)
}
Xf::Group(list) => {
let mut v = c;
if inverse {
for x in list.iter().rev() {
v = x.apply(v, true);
}
} else {
for x in list {
v = x.apply(v, false);
}
}
v
}
Xf::Unsupported(_) => c,
}
}
fn unsupported(&self, out: &mut Vec<String>) {
match self {
Xf::Unsupported(t) => out.push(t.clone()),
Xf::Inverse(x) => x.unsupported(out),
Xf::Group(v) => v.iter().for_each(|x| x.unsupported(out)),
Xf::File { src, xf: None, .. } => out.push(format!("FileTransform (missing `{src}`)")),
_ => {}
}
}
}
/// Find a FileTransform source on the search path (relative to the config's folder).
fn resolve(src: &str, dir: &Path, search: &[String]) -> Option<PathBuf> {
let p = Path::new(src);
if p.is_absolute() {
return p.exists().then(|| p.to_path_buf());
}
let mut dirs: Vec<PathBuf> = search.iter().map(|s| dir.join(s)).collect();
dirs.push(dir.to_path_buf());
dirs.into_iter().map(|d| d.join(src)).find(|c| c.exists())
}
// ---------------------------------------------------------------- the config
/// A colour space of a config.
#[derive(Clone, Debug)]
pub struct ConfigSpace {
pub name: String,
pub aliases: Vec<String>,
pub family: String,
pub is_data: bool,
pub to_ref: Option<Xf>,
pub from_ref: Option<Xf>,
}
impl ConfigSpace {
fn to_reference(&self, c: [f64; 3]) -> [f64; 3] {
match (&self.to_ref, &self.from_ref) {
(Some(x), _) => x.apply(c, false),
(None, Some(x)) => x.apply(c, true),
_ => c,
}
}
fn reference_to(&self, c: [f64; 3]) -> [f64; 3] {
match (&self.from_ref, &self.to_ref) {
(Some(x), _) => x.apply(c, false),
(None, Some(x)) => x.apply(c, true),
_ => c,
}
}
}
/// A parsed `.ocio` configuration.
#[derive(Clone, Debug, Default)]
pub struct Config {
pub name: String,
pub search_path: Vec<String>,
pub roles: Vec<(String, String)>,
pub spaces: Vec<ConfigSpace>,
/// Display → [(view, colour space)].
pub displays: Vec<(String, Vec<(String, String)>)>,
}
impl Config {
/// Parse config text; FileTransform sources resolve against `dir`.
pub fn parse(text: &str, dir: &Path) -> Result<Config, String> {
let y = parse_yaml(text);
if !matches!(y, Yaml::Map(_)) {
return Err("not an OCIO config (expected a YAML mapping)".into());
}
let search_path: Vec<String> = match y.get("search_path") {
Some(Yaml::Seq(v)) => v.iter().filter_map(|s| s.str().map(str::to_string)).collect(),
Some(s) => s.str().map(|s| s.split(':').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()).unwrap_or_default(),
None => vec![],
};
let roles = match y.get("roles") {
Some(Yaml::Map(m)) => m.iter().filter_map(|(k, v)| v.str().map(|s| (k.clone(), s.to_string()))).collect(),
_ => vec![],
};
let mut spaces = vec![];
for list in ["colorspaces", "display_colorspaces"] {
for cs in y.get(list).map(Yaml::seq).unwrap_or(&[]) {
let Some(name) = cs.get("name").and_then(Yaml::str) else { continue };
let xf = |keys: &[&str]| keys.iter().find_map(|k| cs.get(k)).filter(|v| !matches!(v, Yaml::Null)).map(|v| Xf::from_yaml(v, dir, &search_path));
spaces.push(ConfigSpace {
name: name.to_string(),
aliases: cs.get("aliases").map(|a| a.seq().iter().filter_map(|s| s.str().map(str::to_string)).collect()).unwrap_or_default(),
family: cs.get("family").and_then(Yaml::str).unwrap_or("").to_string(),
is_data: cs.get("isdata").and_then(Yaml::str).is_some_and(|v| v == "true"),
to_ref: xf(&["to_reference", "to_scene_reference", "to_display_reference"]),
from_ref: xf(&["from_reference", "from_scene_reference", "from_display_reference"]),
});
}
}
if spaces.is_empty() {
return Err("the config has no colorspaces".into());
}
let displays = match y.get("displays") {
Some(Yaml::Map(m)) => m
.iter()
.map(|(d, views)| {
let v = views.seq().iter().filter_map(|v| Some((v.get("name")?.str()?.to_string(), v.get("colorspace")?.str()?.to_string()))).collect();
(d.clone(), v)
})
.collect(),
_ => vec![],
};
Ok(Config { name: y.get("name").and_then(Yaml::str).unwrap_or("").to_string(), search_path, roles, spaces, displays })
}
pub fn space_names(&self) -> Vec<&str> {
self.spaces.iter().map(|s| s.name.as_str()).collect()
}
/// A colour space by name, alias or role (case-insensitive).
pub fn space(&self, name: &str) -> Option<&ConfigSpace> {
let n = name.trim();
let by = |n: &str| self.spaces.iter().find(|s| s.name.eq_ignore_ascii_case(n) || s.aliases.iter().any(|a| a.eq_ignore_ascii_case(n)));
by(n).or_else(|| self.roles.iter().find(|(r, _)| r.eq_ignore_ascii_case(n)).and_then(|(_, s)| by(s)))
}
/// The colour space of a display's view.
pub fn view_space(&self, display: &str, view: &str) -> Option<&ConfigSpace> {
let (_, views) = self.displays.iter().find(|(d, _)| d.eq_ignore_ascii_case(display.trim())).or(self.displays.first())?;
let (_, cs) = views.iter().find(|(v, _)| v.eq_ignore_ascii_case(view.trim())).or(views.first())?;
self.space(cs)
}
/// Convert one colour from space `a` to space `b` (through the reference space; data
/// spaces pass through).
pub fn convert(&self, a: &ConfigSpace, b: &ConfigSpace, c: [f64; 3]) -> [f64; 3] {
if a.is_data || b.is_data || a.name == b.name {
return c;
}
b.reference_to(a.to_reference(c))
}
/// Transforms this module can't apply (by tag), for warnings.
pub fn unsupported(&self) -> Vec<String> {
let mut out = vec![];
for s in &self.spaces {
for x in [&s.to_ref, &s.from_ref].into_iter().flatten() {
x.unsupported(&mut out);
}
}
out.sort();
out.dedup();
out
}
}
type ConfigCache = Mutex<HashMap<String, Option<Arc<Config>>>>;
/// Load (and cache) a config from a `.ocio` path or the config text itself.
pub fn load_config(src: &str) -> Option<Arc<Config>> {
let t = src.trim();
if t.is_empty() {
return None;
}
static CACHE: OnceLock<ConfigCache> = 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 parsed = if src.contains('\n') {
Config::parse(src, Path::new("."))
} else {
let p = Path::new(t);
std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|text| Config::parse(&text, p.parent().unwrap_or(Path::new("."))))
}
.ok()
.map(Arc::new);
if let Ok(mut m) = cache.lock() {
m.insert(src.to_string(), parsed.clone());
}
parsed
}
#[cfg(test)]
mod tests {
use super::*;
const CONFIG: &str = r#"
ocio_profile_version: 2
name: Test Config # a comment
search_path: "luts:other"
roles:
scene_linear: lin
color_timing: "log2"
displays:
sRGB:
- !<View> {name: Standard, colorspace: gamma22}
- !<View> {name: Raw, colorspace: raw}
colorspaces:
- !<ColorSpace>
name: lin
family: ""
description: |
The reference.
Linear.
isdata: false
- !<ColorSpace>
name: gamma22
aliases: [g22, "gamma 2.2"]
to_scene_reference: !<ExponentTransform> {value: [2.2, 2.2, 2.2, 1]}
- !<ColorSpace>
name: half
from_reference: !<MatrixTransform> {matrix: [0.5, 0, 0, 0,
0, 0.5, 0, 0,
0, 0, 0.5, 0,
0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]}
- !<ColorSpace>
name: log2
from_reference: !<GroupTransform>
children:
- !<MatrixTransform> {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]}
- !<LogTransform> {base: 2}
- !<ColorSpace>
name: affine
to_reference: !<LogAffineTransform> {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse}
- !<ColorSpace>
name: lut
to_reference: !<FileTransform> {src: double.cube, interpolation: linear}
- !<ColorSpace>
name: raw
isdata: true
- !<ColorSpace>
name: weird
to_reference: !<ExposureContrastTransform> {exposure: 1}
"#;
fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() < tol)
}
#[test]
fn yaml_subset() {
let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: !<T> {v: 3}\nd:\n- 4\n- 5\n");
assert_eq!(y.get("a").and_then(Yaml::str), Some("1"));
assert_eq!(y.get("b").unwrap().seq().len(), 2);
assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]);
assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r"));
assert_eq!(y.get("c").unwrap().tag(), Some("T"));
assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3"));
assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent");
}
#[test]
fn parses_spaces_roles_displays_and_converts() {
let dir = std::env::temp_dir().join(format!("ec-ocio-{}", std::process::id()));
std::fs::create_dir_all(dir.join("luts")).unwrap();
// A 1D LUT that doubles (0..1 → 0..2).
std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap();
let c = Config::parse(CONFIG, &dir).unwrap();
assert_eq!(c.name, "Test Config");
assert_eq!(c.search_path, vec!["luts", "other"]);
assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]);
// Roles and aliases resolve.
assert_eq!(c.space("scene_linear").unwrap().name, "lin");
assert_eq!(c.space("G22").unwrap().name, "gamma22");
assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22");
assert_eq!(c.view_space("sRGB", "Standard").unwrap().name, "gamma22");
let sp = |n: &str| c.space(n).unwrap();
// gamma22 → lin: decode with the exponent.
let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]);
assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}");
// lin → gamma22: the inverse (only to_reference given).
let back = c.convert(sp("lin"), sp("gamma22"), g);
assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}");
// lin → half: matrix and offset; and back through the inverse matrix.
let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]);
assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}");
assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9));
// lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2.
let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]);
assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}");
assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9));
// LogAffine (inverse direction as to_reference) round-trips.
let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]);
let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2);
assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}");
assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9));
// FileTransform found on the search path.
let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]);
assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}");
// Data spaces pass through; unsupported transforms are reported.
assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]);
assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]);
// Loading from a file path (cached), and errors.
std::fs::write(dir.join("config.ocio"), CONFIG).unwrap();
let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap();
assert_eq!(loaded.spaces.len(), 8);
assert!(load_config("/no/such/config.ocio").is_none());
assert!(Config::parse("just: text\n", &dir).is_err());
}
/// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom.
#[test]
fn effects_use_a_custom_config() {
use effectcraft_keyframe::Value;
let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - !<View> {name: Video, colorspace: g2}\ncolorspaces:\n - !<ColorSpace>\n name: lin\n - !<ColorSpace>\n name: g2\n to_reference: !<ExponentTransform> {value: [2, 2, 2, 1]}\n";
let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]);
let fx = |id: &str, extra: &[(&str, Value)]| {
let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))];
v.extend(extra.iter().cloned());
crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0]
};
let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]);
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}");
// Inverse direction.
let o = fx(
"ec.color.ociocolorspace",
&[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))],
);
assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}");
// Display ▸ view → its colour space.
let o = fx(
"ec.color.ociodisplay",
&[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))],
);
assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}");
// An unreadable config leaves pixels alone.
let o = crate::run_fx(
"ec.color.ociocolorspace",
&[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))],
img.clone(),
0.0,
crate::EffectEnv::default(),
);
assert_eq!(o.img.data, img.data);
}
}