Effects: OCIO custom configs read Blender's v2 transforms and warn (#410)

Cause: the config reader didn't know ColorSpaceTransform or
ExponentWithLinearTransform and passed colours through them silently.
Blender's Linear Rec.709 -> sRGB therefore stayed in its E-white XYZ
reference space (the blue cast) without the sRGB curve. Display
colorspaces, view transforms, v2 views, looks, AllocationTransform,
BuiltinTransform and .spi1d / .spimtx files were missing too, and
nothing told the user.

Fix (from the OCIO v2 documentation and the CLF "moncurve", not OCIO's
source): ExponentWithLinear, ColorSpace, Look, DisplayView, Allocation
and Builtin transforms (identity, ACES AP0/AP1 matrices, ACEScct /
ACEScc, the sRGB / P3 / Rec.1886 / gamma / PQ displays). RangeTransform
now clamps and takes one-sided ranges. Display colorspaces meet the
scene reference through the default view transform. Added v2 views,
view looks, the looks section and .spi1d / .spi3d / .spimtx files. A
conversion is built once as a flat list of steps (Config::path /
display_path) for both the CPU effect and the GPU colour program (new
moncurve and clamp ops and the ACEScc curve; a program over the
kernel's 255 ops renders on the CPU). Named-transform resolution is
limited to 16 levels and 4096 steps, and YAML nesting to 64 levels, so
hostile or looping configs stop. Transforms that are still unsupported
(Grading Primary / Tone, ACES output, HLG…), unreadable configs and
unknown spaces are now reported: a warning under the effect in Effect
Controls, and the new effect.warning query.

Tests: ocio_config::blender_like_config_converts_linear_rec709_to_srgb
(CST, display colorspaces, v1 / v2 views, allocation, looks, builtin,
range, loops), moncurve_matches_srgb_and_inverts, builtin_styles,
yaml_nesting_is_bounded, ocio::spi_files_parse,
tests_effects::ocio_effect_warns_about_unsupported_config_transforms,
gpu tests_fx_lut::ocio_custom_config_v2 (GPU = CPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 23:20:03 +07:00
parent d6de68b20d
commit 492fa6de0a
11 changed files with 1483 additions and 233 deletions

View File

@ -238,6 +238,18 @@ pub fn flatten_params(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -
Params { values }
}
/// The warning shown with effect instance `g` (Effect Controls, `effect.warning`), its
/// parameters evaluated by `eval`: settings it can't render as asked, such as an OCIO config it
/// can't read or transforms it passes through. `None` when it renders as asked.
pub fn warning(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -> Option<String> {
let GroupKind::Effect { effect } = &g.kind else { return None };
// Only the effects that can warn evaluate their parameters.
if !ocio::CUSTOM_CONFIG_FX.contains(&effect.as_str()) {
return None;
}
ocio::warning(effect, &flatten_params(g, eval))
}
/// A layer image in flight: point `p` sits at pixel `p * scale + offset`. Points are in layer
/// space, or in effect space while the layer's effects run (see [`Buf::rebase`]).
#[derive(Clone, Debug, Default)]

View File

@ -17,9 +17,12 @@
//! * CDL maths follows the ASC CDL v1.2 specification (slope, offset, power, then saturation with
//! Rec. 709 luma weights); the "No Clamp" style mirrors power around zero;
//! * File Transform reads `.cube` (Adobe/Resolve), `.3dl` (Autodesk Lustre/Flame integer
//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper) and ASC `.cc` / `.ccc` /
//! `.cdl` XML, with nearest, trilinear or tetrahedral interpolation and an inverse direction
//! (exact for 1D/CDL, iterative for 3D lattices).
//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper), Sony Imageworks
//! `.spi1d` / `.spi3d` / `.spimtx` and ASC `.cc` / `.ccc` / `.cdl` XML, with nearest, trilinear
//! or tetrahedral interpolation and an inverse direction (exact for 1D/CDL/matrices, iterative
//! for 3D lattices);
//! * Configuration ▸ Custom reads a `.ocio` file instead ([`crate::ocio_config`]); what it can't
//! apply is reported by [`warning`].
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
@ -36,13 +39,13 @@ use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
type M3 = [[f64; 3]; 3];
const D65: [f64; 2] = [0.3127, 0.3290];
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]];
const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
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).
@ -52,10 +55,16 @@ pub enum Tf {
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 {
@ -75,6 +84,15 @@ impl Tf {
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);
@ -99,6 +117,15 @@ impl Tf {
(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);
@ -123,13 +150,13 @@ pub struct Space {
pub raw: bool,
}
const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space {
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.
const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear);
const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear);
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] = &[
@ -332,6 +359,92 @@ pub enum FileXform {
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.chunks_exact(4).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).
@ -470,6 +583,15 @@ pub fn parse_file(text: &str, ext: &str) -> Option<FileXform> {
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 });
}
@ -514,6 +636,7 @@ impl FileXform {
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 {
@ -656,33 +779,86 @@ fn space_at(i: u32) -> &'static Space {
&SPACES[(i as usize).min(SPACES.len() - 1)]
}
/// The custom config chosen by an effect (Configuration ▸ Custom with a readable file).
fn custom_config(ctx: &EffectCtx) -> Option<std::sync::Arc<crate::ocio_config::Config>> {
if ctx.params.e("config") != 1 {
return None;
/// 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<Option<crate::ocio_config::Xf>, String> {
use crate::ocio_config::Xf;
if !CUSTOM_CONFIG_FX.contains(&id) || pr.e("config") != 1 {
return Ok(None);
}
crate::ocio_config::load_config(ctx.params.s("configFile"))
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) }))
}
/// A space of a custom config by its typed name, else by the built-in popup's name.
fn custom_space<'c>(cfg: &'c crate::ocio_config::Config, name: &str, popup: u32) -> Option<&'c crate::ocio_config::ConfigSpace> {
cfg.space(name).or_else(|| cfg.space(space_at(popup).name))
/// 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,
}
}
fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
let (Some(a), Some(z)) = (
custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")),
custom_space(&cfg, ctx.params.s("destinationName"), ctx.params.e("destination")),
) else {
return b;
};
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
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")));
@ -720,19 +896,7 @@ fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
// The display's view names a colour space of the config.
let display = DISPLAYS.get(ctx.params.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(ctx.params.e("view") as usize).copied().unwrap_or("");
let dn = if ctx.params.s("displayName").is_empty() { display } else { ctx.params.s("displayName") };
let vn = if ctx.params.s("viewName").is_empty() { view } else { ctx.params.s("viewName") };
let (Some(a), Some(z)) = (custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")), cfg.view_space(dn, vn)) else { return b };
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
if custom_render("ec.color.ociodisplay", ctx, &mut b) {
return b;
}
let input = space_at(ctx.params.e("source"));
@ -899,9 +1063,26 @@ pub enum ColorOp {
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 LUT / CDL file transform (as [`FileXform::apply`]).
/// 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) => {
@ -911,6 +1092,7 @@ fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut V
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),
}
}
@ -920,16 +1102,12 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
let f3 = |v: &[f64; 3]| v.map(|x| x as f32);
match x {
Xf::Inverse(x) => config_xf_ops(x, !inverse, ops),
Xf::Matrix { m, offset } => {
if !inverse {
let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]);
ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] });
} else if let Some(inv) = crate::ocio_config::mat3_inverse(m) {
let m = inv.map(|r| r.map(|v| v as f32));
ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] });
}
}
Xf::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 } => {
@ -944,11 +1122,14 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
inverse,
}),
Xf::Cdl(cdl) => ops.push(ColorOp::Cdl { cdl: *cdl, clamp: true, inverse }),
Xf::Range { min_in, max_in, min_out, max_out } => {
let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) };
let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 };
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: [-a0 as f32; 3], post: [b0 as f32; 3] });
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 {
@ -960,23 +1141,6 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
}
}
/// The ops of a custom config's conversion from space `a` to space `z` (as `Config::convert`).
fn config_ops(a: &crate::ocio_config::ConfigSpace, z: &crate::ocio_config::ConfigSpace, ops: &mut Vec<ColorOp>) {
if a.is_data || z.is_data || a.name == z.name {
return;
}
match (&a.to_ref, &a.from_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
match (&z.from_ref, &z.to_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
}
/// How a colour program meets premultiplied pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Straight {
@ -1004,6 +1168,16 @@ fn xform_ops(x: &Xform, ops: &mut Vec<ColorOp>) {
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")) {
@ -1012,18 +1186,6 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
return Some((ops, Straight::Divide));
}
"ec.color.ociocolorspace" => {
if pr.e("config") == 1 {
// A custom config (one that can't be read, or names unknown spaces: the pixels
// pass through).
if let Some(cfg) = custom_config(ctx)
&& let (Some(a), Some(z)) =
(custom_space(&cfg, pr.s("sourceName"), pr.e("source")), custom_space(&cfg, pr.s("destinationName"), pr.e("destination")))
{
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
return Some((ops, Straight::Unpremul));
}
let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination")));
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
if a.name != z.name {
@ -1037,19 +1199,6 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
}
}
"ec.color.ociodisplay" => {
if pr.e("config") == 1 {
if let Some(cfg) = custom_config(ctx) {
let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or("");
let dn = if pr.s("displayName").is_empty() { display } else { pr.s("displayName") };
let vn = if pr.s("viewName").is_empty() { view } else { pr.s("viewName") };
if let (Some(a), Some(z)) = (custom_space(&cfg, pr.s("sourceName"), pr.e("source")), cfg.view_space(dn, vn)) {
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
}
return Some((ops, Straight::Unpremul));
}
let input = space_at(pr.e("source"));
let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1);
let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)];
@ -1474,6 +1623,44 @@ mod tests {
v
}
/// Sony Imageworks `.spi1d`, `.spi3d` and `.spimtx` (Blender's configs use them, #410), and
/// malformed ones rejected without panicking.
#[test]
fn spi_files_parse() {
let spi1d = "Version 1\nFrom 0.0 2.0\nLength 3\nComponents 1\n{\n0.0\n0.25\n1.0\n}\n";
let x = parse_file(spi1d, "spi1d").unwrap();
let o = x.apply([1.0, 0.5, 2.0], 1, false, "");
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.125).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}");
let back = x.apply(o, 1, true, "");
assert!((back[0] - 1.0).abs() < 1e-5 && (back[2] - 2.0).abs() < 1e-5, "{back:?}");
let three = parse_spi1d("Version 1\nFrom 0 1\nLength 2\nComponents 3\n{\n0 0 0\n1 0.5 2\n}\n").unwrap();
assert_eq!(three.d1, vec![[0.0; 3], [1.0, 0.5, 2.0]]);
let mut spi3d = String::from("SPILUT 1.0\n3 3\n2 2 2\n");
for (r, g, b) in (0..8).map(|i| (i & 1, (i >> 1) & 1, i >> 2)) {
spi3d += &format!("{r} {g} {b} {} {} {}\n", r as f32 * 0.5, g, b);
}
let x = parse_file(&spi3d, "spi3d").unwrap();
let o = x.apply([1.0, 0.5, 0.25], 1, false, "");
assert!((o[0] - 0.5).abs() < 1e-6 && (o[1] - 0.5).abs() < 1e-6 && (o[2] - 0.25).abs() < 1e-6, "{o:?}");
let m = parse_file("2 0 0 6553.5\n0 1 0 0\n0 0 1 0\n", "spimtx").unwrap();
let o = m.apply([0.25, 0.5, 1.0], 1, false, "");
assert!((o[0] - 0.6).abs() < 1e-6 && o[1] == 0.5 && o[2] == 1.0, "{o:?}");
assert!((m.apply(o, 1, true, "")[0] - 0.25).abs() < 1e-6);
for bad in [
"Version 1\nFrom 0 1\nLength 99999999999\nComponents 1\n{\n0\n}\n",
"Version 1\nFrom 1 0\nLength 2\nComponents 1\n{\n0\n1\n}\n",
"Version 1\nFrom 0 1\nLength 3\nComponents 1\n{\n0\n1\n}\n",
"Version 1\nFrom 0 1\nLength 2\nComponents 2\n{\n0 0\n1 1\n}\n",
] {
assert!(parse_spi1d(bad).is_none(), "{bad}");
}
assert!(parse_spi3d("SPILUT 1.0\n3 3\n4000 4000 4000\n").is_none());
assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n9 0 0 1 1 1\n").is_none());
assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n0 0 0 1 1 1\n").is_none(), "incomplete lattice");
assert!(parse_spimtx("1 0 0\n0 1 0\n").is_none());
assert!(parse_spimtx("NaN 0 0 0 0 1 0 0 0 0 1 0").is_none());
}
#[test]
fn lut_files_parse_and_interpolate() {
// .cube with a non-linear lattice: trilinear between lattice points.

File diff suppressed because it is too large Load Diff

View File

@ -126,6 +126,19 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
Ok((cid, lid, g.uid))
}
/// `effect.warning`: what Effect Controls warns about an effect (a custom OCIO config it can't
/// read, or transforms it passes through), `null` when it renders as asked.
fn warning(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "effect.warning";
let (cid, lid, uid) = find_fx(s, p, cmd)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
}
/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input
/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a
/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it.
@ -457,6 +470,12 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste),
cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset),
crate::query!("effect.list", "List Effects", "{filter?}", list),
crate::query!(
"effect.warning",
"Effect Warning",
"{layer?, effect?: index|uid|name (default: the selected effect), time?} → {effect, warning: string|null} — what Effect Controls warns about (an OCIO config it can't read, transforms it passes through)",
warning
),
crate::query!(
"effect.plugins.list",
"List Effect Plug-ins",

View File

@ -317,3 +317,32 @@ fn opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space(
let _ = std::fs::remove_dir_all(dir);
check(&s3);
}
/// #410: `effect.warning` reports what an OCIO effect can't render as asked (Effect Controls
/// shows it under the effect's name): a transform of its custom config that passes colours
/// through, a colour space the config lacks or a config it can't read; nothing for the
/// built-in config.
#[test]
fn ocio_effect_warns_about_unsupported_config_transforms() {
let (mut s, a, _) = comp_with_two_solids();
let fx = apply(&mut s, a, "OCIO Color Space Transform");
let warning = |s: &mut Session| s.execute("effect.warning", json!({"layer": a, "effect": fx})).unwrap()["warning"].clone();
assert!(warning(&mut s).is_null(), "built-in config");
let set = |s: &mut Session, param: &str, value: serde_json::Value| {
let uid = layer(s, a).effects().unwrap().groups().next().unwrap().get(param).unwrap().uid;
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": value})).unwrap();
};
let config = "ocio_profile_version: 2\ncolorspaces:\n - !<ColorSpace>\n name: lin\n - !<ColorSpace>\n name: graded\n from_scene_reference: !<GradingToneTransform> {}\n";
set(&mut s, "config", json!(1));
set(&mut s, "configFile", json!(config));
set(&mut s, "sourceName", json!("lin"));
set(&mut s, "destinationName", json!("graded"));
let w = warning(&mut s);
assert!(w.as_str().is_some_and(|w| w.contains("GradingToneTransform")), "{w}");
set(&mut s, "destinationName", json!("lin"));
assert!(warning(&mut s).is_null());
set(&mut s, "destinationName", json!("nowhere"));
assert!(warning(&mut s).as_str().is_some_and(|w| w.contains("no color space `nowhere`")));
set(&mut s, "configFile", json!("/no/such/config.ocio"));
assert!(warning(&mut s).as_str().is_some_and(|w| w.starts_with("Can't read the OCIO config")));
}

View File

@ -10,8 +10,10 @@
//! iterative 3D inverse): hardware 3D-texture filtering would round the interpolation weights
//! and cannot do tetrahedral interpolation. The CPU evaluates transfer functions, CDLs and
//! Color Profile Converter's matrix in f64; the kernel in f32 (within the 1e-3 tolerance).
//! Custom `.ocio` configurations compile their colour spaces' transforms (matrix and offset,
//! exponent, log / log-affine, range, CDL, file LUTs, groups, inverses) to the same program.
//! Custom `.ocio` configurations compile their conversions (matrix and offset, exponent,
//! exponent with linear segment, log / log-affine, allocation, range with its clamp, CDL, file
//! LUTs and matrices, built-in curves, with colour space / look / display-view transforms
//! resolved) to the same program; one longer than the kernel's 255 ops renders on the CPU.
use effectcraft_effects::{ColorOp, EffectCtx, Lut, Straight, Tf};
@ -57,9 +59,13 @@ fn tf_code(t: Tf) -> [f32; 2] {
Tf::Gamma(g) => [2.0, g as f32],
Tf::AcesCct => [3.0, 0.0],
Tf::Pq => [4.0, 0.0],
Tf::AcesCc => [5.0, 0.0],
}
}
/// Most ops `fxl_run` interprets (its loop guard); longer programs render on the CPU.
const MAX_OPS: usize = 255;
/// A LUT in the layout `fxl_lut_*` read at the returned offset: n1, n3, domain min (3), domain
/// max (3), the 1D table (n1 × RGB), then the lattice (n3³ × RGB, red fastest).
pub(crate) fn push_lut(d: &mut Vec<f32>, lut: &Lut) -> f32 {
@ -75,6 +81,9 @@ pub(crate) fn push_lut(d: &mut Vec<f32>, lut: &Lut) -> f32 {
/// Program layout: a stream of (opcode, arguments…) ending in 0, followed by LUT data.
fn encode(ops: &[ColorOp]) -> Option<Vec<f32>> {
if ops.len() > MAX_OPS {
return None;
}
let mut prog: Vec<f32> = vec![];
let mut tables: Vec<Vec<f32>> = vec![];
// LUT offsets are patched once the program's length is known.
@ -123,6 +132,15 @@ fn encode(ops: &[ColorOp]) -> Option<Vec<f32>> {
prog.extend(log_slope.iter().chain(log_offset).chain(lin_slope).chain(lin_offset));
prog.extend([*inverse as u32 as f32, f64::MIN_POSITIVE.ln() as f32]);
}
ColorOp::MonCurve { gamma, offset, inverse, mirror } => {
prog.push(12.0);
prog.extend(gamma.iter().chain(offset));
prog.extend([*inverse as u32 as f32, *mirror as u32 as f32]);
}
ColorOp::Clamp { lo, hi } => {
prog.push(13.0);
prog.extend(lo.iter().chain(hi));
}
ColorOp::Lut { lut, shaper, interp, inverse } => {
prog.extend([8.0, 0.0, *interp as f32, *inverse as u32 as f32, -1.0]);
let mut t = vec![];

View File

@ -266,6 +266,16 @@ fn fxl_decode(tf: u32, g: f32, v: f32) -> f32 {
let l = powz(max(e - FXL_PQ_C1, 0.0) / (FXL_PQ_C2 - FXL_PQ_C3 * e), 1.0 / FXL_PQ_M1);
return l * 100.0;
}
case 5u: {
// ACEScc.
if (v < (-15.0 + 9.72) / 17.52) {
return (exp2(v * 17.52 - 9.72) - exp2(-16.0)) * 2.0;
}
if (v < (log2(65504.0) + 9.72) / 17.52) {
return exp2(v * 17.52 - 9.72);
}
return 65504.0;
}
default: {
return v;
}
@ -297,6 +307,16 @@ fn fxl_encode(tf: u32, g: f32, v: f32) -> f32 {
let y = powz(max(v / 100.0, 0.0), FXL_PQ_M1);
return powz((FXL_PQ_C1 + FXL_PQ_C2 * y) / (1.0 + FXL_PQ_C3 * y), FXL_PQ_M2);
}
case 5u: {
// ACEScc.
if (v <= 0.0) {
return (-16.0 + 9.72) / 17.52;
}
if (v < exp2(-15.0)) {
return (log2(exp2(-16.0) + v * 0.5) + 9.72) / 17.52;
}
return (log2(v) + 9.72) / 17.52;
}
default: {
return v;
}
@ -383,6 +403,37 @@ fn fxl_tonemap(v: f32, inverse: bool) -> f32 {
return (-b + sqrt(b * b - 4.0 * a * c)) / (2.0 * a);
}
// ExponentWithLinearTransform (ocio_config::moncurve): forward decodes; a gamma of at most 1 or
// a non-positive offset is a plain power curve.
fn fxl_moncurve(v0: f32, g: f32, o: f32, inv: bool, mirror: bool) -> f32 {
var v = v0;
var sign = 1.0;
if (mirror && v < 0.0) {
v = -v;
sign = -1.0;
}
if (g <= 1.0 || o <= 0.0) {
var p = g;
if (inv) {
p = 1.0 / max(g, 1e-9);
}
return sign * powz(max(v, 0.0), p);
}
let xb = o / (g - 1.0);
let yb = powz((xb + o) / (1.0 + o), g);
let slope = yb / xb;
if (!inv) {
if (v >= xb) {
return sign * powz((v + o) / (1.0 + o), g);
}
return sign * v * slope;
}
if (v >= yb) {
return sign * ((1.0 + o) * powz(v, 1.0 / g) - o);
}
return sign * v / slope;
}
// Run the colour program at data[0..] on a straight colour.
fn fxl_run(c0: vec3<f32>) -> vec3<f32> {
var c = c0;
@ -485,6 +536,22 @@ fn fxl_run(c0: vec3<f32>) -> vec3<f32> {
}
pc += 16u;
}
case 12u: {
// ExponentWithLinearTransform: gamma (3), offset (3), inverse, mirror.
let g = fxl_d3(pc + 1u);
let o = fxl_d3(pc + 4u);
let inv = data[pc + 7u] != 0.0;
let mirror = data[pc + 8u] != 0.0;
for (var i = 0u; i < 3u; i++) {
c[i] = fxl_moncurve(c[i], g[i], o[i], inv, mirror);
}
pc += 9u;
}
case 13u: {
// Clamp (a clamping RangeTransform): low (3), high (3).
c = min(max(c, fxl_d3(pc + 1u)), fxl_d3(pc + 4u));
pc += 7u;
}
default: {
return c;
}

View File

@ -241,3 +241,105 @@ fn ocio_custom_config() {
}
effect_case("ec.color.ociocolorspace", &[("config", e(1)), ("configFile", s("/missing.ocio"))]);
}
/// A config with the OCIO v2 transforms of Blender's (#410): an E-white XYZ reference,
/// ColorSpaceTransform, ExponentWithLinearTransform, display-referred spaces through the
/// default view transform, AllocationTransform, BuiltinTransform (a matrix, the PQ and ACEScc
/// curves), a minimum-only RangeTransform and a look in a view.
const CONFIG_V2: &str = r#"ocio_profile_version: 2
roles:
reference: XYZ E
scene_linear: Linear Rec.709
default_view_transform: Standard
view_transforms:
- !<ViewTransform>
name: Standard
from_scene_reference: !<MatrixTransform> {matrix: [0.9531, -0.0266, 0.0239, 0, -0.0382, 1.0288, 0.0094, 0, 0.0026, -0.0030, 1.0893, 0, 0, 0, 0, 1]}
displays:
sRGB:
- !<View> {name: Standard, view_transform: Standard, display_colorspace: sRGB Display}
- !<View> {name: Log, colorspace: Log, looks: +Lift}
- !<View> {name: PQ, view_transform: Standard, display_colorspace: PQ Display}
display_colorspaces:
- !<ColorSpace>
name: XYZ D65
- !<ColorSpace>
name: sRGB Display
from_display_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ D65, dst: Linear Rec.709}
- !<ExponentWithLinearTransform> {gamma: 2.4, offset: 0.055, direction: inverse}
- !<ColorSpace>
name: PQ Display
from_display_reference: !<BuiltinTransform> {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ}
colorspaces:
- !<ColorSpace>
name: XYZ E
- !<ColorSpace>
name: Linear Rec.709
from_scene_reference: !<GroupTransform>
children:
- !<MatrixTransform> {matrix: [0.9531, -0.0266, 0.0239, 0, -0.0382, 1.0288, 0.0094, 0, 0.0026, -0.0030, 1.0893, 0, 0, 0, 0, 1]}
- !<MatrixTransform> {matrix: [3.2410, -1.5374, -0.4986, 0, -0.9692, 1.8760, 0.0416, 0, 0.0556, -0.2040, 1.0570, 0, 0, 0, 0, 1]}
- !<ColorSpace>
name: sRGB
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: Linear Rec.709}
- !<ExponentWithLinearTransform> {gamma: 2.4, offset: 0.055, direction: inverse}
- !<ColorSpace>
name: Log
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: Linear Rec.709}
- !<AllocationTransform> {allocation: lg2, vars: [-10, 6.5]}
- !<ColorSpace>
name: ACEScg
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: XYZ D65}
- !<BuiltinTransform> {style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}
- !<ColorSpace>
name: ACEScc
to_scene_reference: !<GroupTransform>
children:
- !<BuiltinTransform> {style: ACEScc_to_ACES2065-1}
- !<MatrixTransform> {matrix: [0.9525, 0, 0.0001, 0, 0.3439, 0.7281, -0.0721, 0, 0, 0, 1.0088, 0, 0, 0, 0, 1]}
- !<ColorSpace>
name: Floor
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: sRGB}
- !<RangeTransform> {min_in_value: 0.1, min_out_value: 0.1}
- !<ColorSpace>
name: Uniform
from_scene_reference: !<AllocationTransform> {allocation: uniform, vars: [-0.5, 2]}
looks:
- !<Look>
name: Lift
process_space: Log
transform: !<CDLTransform> {offset: [0.05, 0.02, 0], sat: 0.8}
"#;
#[test]
fn ocio_custom_config_v2() {
let cfg = [("config", e(1)), ("configFile", s(CONFIG_V2))];
for (a, z) in [
("Linear Rec.709", "sRGB"),
("Linear Rec.709", "sRGB Display"),
("sRGB", "Log"),
("ACEScg", "sRGB"),
("ACEScc", "Linear Rec.709"),
("sRGB", "Floor"),
("Linear Rec.709", "Uniform"),
] {
for dir in 0..2 {
let vals = [cfg.as_slice(), &[("sourceName", s(a)), ("destinationName", s(z)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociocolorspace", &vals);
}
}
for (view, dir) in [("Standard", 0), ("Standard", 1), ("Log", 0), ("Log", 1), ("PQ", 0)] {
let vals = [cfg.as_slice(), &[("sourceName", s("Linear Rec.709")), ("displayName", s("sRGB")), ("viewName", s(view)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociodisplay", &vals);
}
}

View File

@ -1056,6 +1056,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
item(ui, "Move Up", fi > 0, "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi.max(1)}));
item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2}));
});
// What the effect can't render as asked (an OCIO config it can't read, transforms it
// passes through), under its name.
if let Some(w) = effectcraft_engine::effects::warning(g, &mut |pr| ectx.value(&layer, pr)) {
let galley = bp.layout(format!("⚠ {w}"), Tokens::ui(11.0), t.warning, (body.width() - 20.0).max(60.0));
let wr = Rect::from_min_size(pos2(body.min.x + 10.0, y + 3.0), galley.size());
bp.galley(wr.min, galley, t.warning);
app.auto.add(&format!("effectControls.effect.{}.warning", g.uid), wr, &w);
y += wr.height() + 6.0;
}
if open {
let eh = editor_height(effect, g, body.width());
if eh > 0.0 {

View File

@ -222,6 +222,9 @@ build real projects through these interfaces; they are worked examples of everyt
* **Colour of footage**: `file.interpretFootage {"items":[id], "preserveRgb":true}` is Interpret
Footage ▸ Preserve RGB: the file's values unconverted (float EXRs stay linear), for a
per-layer OCIO conversion or data passes.
* **OCIO effects**: with Configuration ▸ Custom, `configFile` is a `.ocio` path or the config's
text; `effect.warning {layer, effect}` says what Effect Controls warns about (a config it
can't read, a colour space it lacks, transforms that pass colours through) or `null`.
* **Multi-layer OpenEXR**: the footage shows the beauty pass (an unnamed RGBA layer, else
Combined / Beauty / Image / RGBA…, never Cryptomatte or depth). `layer.channels {layer}`
lists a footage layer's EXR layers and

View File

@ -192,7 +192,7 @@ Mocha and Cineware are third-party and not counted.
|---|---|---|
| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header; EXtractoR picks a layer and channels from popups of the footage's layers and channels, `layer.channels`, #295; the footage itself shows the beauty pass, never Cryptomatte or depth, #412) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. |
| Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere to Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. |
| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are read (Configuration ▸ Custom: colorspaces, roles, displays/views; Matrix, File, Exponent, Log, LogAffine, CDL, Range and Group transforms). |
| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` / `.spi1d` / `.spi3d` / `.spimtx` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are read from the OCIO v2 documentation (Configuration ▸ Custom: colorspaces and display colorspaces with the scene / display reference spaces joined by the default view transform, roles, displays with v1 and v2 views, view transforms, looks; Matrix, File, Exponent, ExponentWithLinear, Log, LogAffine, CDL, Range, Allocation, ColorSpace, Look, DisplayView, Builtin (matrices and display curves) and Group transforms), so Blender 4.x / 5.x configs give the Standard, AgX, Filmic and False Color views (#410). Grading Primary / Tone transforms, the ACES output and gamut-compression builtins and HLG pass colours through, and the effect shows a warning naming them (`effect.warning`). |
| Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. |
| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. |
| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. |
@ -753,7 +753,8 @@ composited at 8 and 32 bpc:
cineSpace shapers are read from a storage buffer with the CPU's nearest / trilinear /
tetrahedral interpolation and inverses (not a hardware 3D texture: its filtering rounds the
weights and cannot do tetrahedral). Lumetri's Input LUT and Look no longer force the CPU.
Custom `.ocio` configurations render on the CPU. Exception: nearest-neighbour lattice lookups
Custom `.ocio` configurations compile to the same programs (one of more than 255 steps
renders on the CPU). Exception: nearest-neighbour lattice lookups
may pick the other lattice point for 8 bpc inputs exactly on a rounding boundary (up to 1 %
of pixels allowed).
- **Simulation render passes** (`gpu::fx_sim`): CC Rainfall, CC Snowfall, CC Star Burst, CC