Merge pull request #449 from storytold/fix/410-411-412-exr-ocio

EXR beauty layer, Interpret Footage Preserve RGB, OCIO v2 transforms for Blender configs (#412, #411, #410)
This commit is contained in:
BFlat
2026-10-10 00:27:39 +07:00
committed by GitHub
23 changed files with 1714 additions and 275 deletions
+12
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)]
+287 -100
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
+19
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",
+9 -2
View File
@@ -553,6 +553,8 @@ pub(crate) struct Interpretation {
fields: Option<effectcraft_project::FieldOrder>,
profile: Option<Option<effectcraft_project::ColorSpace>>,
linear: Option<bool>,
/// Color ▸ Preserve RGB.
preserve_rgb: Option<bool>,
/// Start Timecode.
start: Option<StartTimecode>,
}
@@ -631,6 +633,7 @@ impl Interpretation {
None => None,
};
it.linear = super::b_p(p, "linearLight");
it.preserve_rgb = super::b_p(p, "preserveRgb");
// Start Timecode: Use Source File Timecode (`overrideStart: false`), or Override Start
// (timecode or a frame number).
let start_err = || bad(cmd, "startTimecode: timecode (0:00:41:20) or a frame number, 0 to 1e9");
@@ -705,6 +708,9 @@ impl Interpretation {
if let Some(v) = self.linear {
f.linear_light = v;
}
if let Some(v) = self.preserve_rgb {
f.preserve_rgb = v;
}
// After the frame rate: timecode is read at the footage's rate.
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
f.start_timecode = start;
@@ -785,7 +791,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
Some(ItemKind::Footage(f)) => Some(json!({
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
"linearLight": f.linear_light, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
"linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
"frames": f.sequence_frames(),
})),
_ => None,
@@ -815,6 +821,7 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
f.fields = src.fields;
f.invert_alpha = src.invert_alpha;
f.linear_light = src.linear_light;
f.preserve_rgb = src.preserve_rgb;
f.color_profile = src.color_profile;
if src.native_rate.is_some() {
if f.native_rate.is_none() {
@@ -1025,7 +1032,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
has_footage_selection,
interpret
),
+1
View File
@@ -318,6 +318,7 @@ pub fn footage_metadata(f: &Footage) -> Value {
"alpha": format!("{:?}", f.alpha),
"colorProfile": f.color_profile.map(|c| format!("{c:?}")).unwrap_or_else(|| "sRGB (default)".into()),
"linearLight": f.linear_light,
"preserveRgb": f.preserve_rgb,
"fields": format!("{:?}", f.fields),
"sequenceFiles": f.sequence.len(),
"dataRows": f.data.as_ref().map(|d| d.lines().count().saturating_sub(1)),
+29
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")));
}
+8
View File
@@ -272,4 +272,12 @@ fn guess_alpha_invert_and_linear_light() {
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).linear_light);
// Color ▸ Preserve RGB (#411) is set, rendered and carried by Apply Interpretation.
let out = s.execute("file.interpretFootage", json!({"item": h.0, "preserveRgb": true})).unwrap();
assert_eq!(out["items"][0]["preserveRgb"], true);
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
assert!((p[0] - 0.25).abs() < 0.01, "white at 25 %, unconverted: {p:?}");
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).preserve_rgb);
}
+20 -2
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![];
+67
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;
}
+102
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);
}
}
+19 -6
View File
@@ -6,7 +6,7 @@
//! Values stay gamma-encoded: like After Effects, footage pixels enter the working space as they
//! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation.
use effectcraft_project::AlphaMode;
use effectcraft_project::{AlphaMode, Footage};
use effectcraft_raster::{Image, Px};
use filmcraft_color::{Matrix, Range};
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
@@ -17,11 +17,24 @@ use rayon::prelude::*;
pub(crate) struct AlphaOp {
mode: AlphaMode,
matte: [f32; 3],
/// Interpret Footage ▸ Preserve RGB: float values stay as the file stores them (not
/// sRGB-encoded).
preserve_rgb: bool,
}
impl AlphaOp {
pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self {
Self { mode, matte }
Self { mode, matte, preserve_rgb: false }
}
/// The interpretation of footage `f`.
pub(crate) fn of(f: &Footage) -> Self {
Self { preserve_rgb: f.preserve_rgb, ..Self::new(f.alpha, f.premul_color) }
}
/// A linear float value as the compositor takes it: sRGB-encoded, or the file's own value
/// under Preserve RGB.
#[inline(always)]
fn float(self, v: f32) -> f32 {
if self.preserve_rgb { v } else { encode(v) }
}
/// `c` is the file's colour (straight or premultiplied as the mode says), `a` its alpha.
#[inline(always)]
@@ -72,7 +85,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -
for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) {
let a = s[3];
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
let c = [encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a];
let c = [op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a];
*o = if op.mode == AlphaMode::Ignore { [c[0], c[1], c[2], 1.0] } else { [c[0], c[1], c[2], a] };
}
});
@@ -231,7 +244,7 @@ fn convert_yuv<T: Sample>(img: &mut Image, w: usize, h: usize, chroma: Chroma, p
}
/// Convert a still decoded by the `image` crate. Float images (OpenEXR, float TIFF) hold linear
/// light and are sRGB-encoded here (values above 1 are kept).
/// light and are sRGB-encoded here (values above 1 are kept), unless the footage preserves RGB.
pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image {
use image::DynamicImage as D;
let (w, h) = (img.width(), img.height());
@@ -251,9 +264,9 @@ pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image
let a = s[3];
let c = if premul {
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
[encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a]
[op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a]
} else {
[encode(s[0]), encode(s[1]), encode(s[2])]
[op.float(s[0]), op.float(s[1]), op.float(s[2])]
};
*o = op.apply(c, a.clamp(0.0, 1.0));
}
+88 -13
View File
@@ -11,7 +11,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_raster::AuxChannels;
use effectcraft_raster::channels3d::BACKGROUND_DEPTH;
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel};
fn is_depth(name: &str) -> bool {
let last = name.rsplit('.').next().unwrap_or(name);
@@ -94,13 +94,23 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
Some(aux)
}
/// The picture of a multi-layer OpenEXR file without an unnamed RGB layer (Blender's
/// `ViewLayer.Combined.R`, Nuke's `beauty.R`, …), which the image decoder rejects: its colour
/// layer (preferring one named combined, beauty or rgba), else its first channel as grey.
/// Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR.
pub fn layered_image(bytes: &[u8]) -> Option<image::DynamicImage> {
let aux = read_exr_channels(bytes)?;
let layers = aux.layers();
/// Names of the beauty pass, most preferred first, matched against the last part of a layer's
/// name (Blender's `ViewLayer.Combined` is `Combined`): Blender's Combined pass and its
/// compositor's File Output default `Image`, the beauty / rgba / rgb / color of Nuke, Arnold and
/// V-Ray.
const BEAUTY: [&str; 7] = ["combined", "beauty", "image", "rgba", "rgb", "color", "colour"];
/// A data pass, never the picture: Cryptomatte (`CryptoMaterial00`), depth, mist, normals,
/// motion vectors, positions, UVs and object / material indices.
fn is_data_layer(layer: &str) -> bool {
let last = layer.rsplit('.').next().unwrap_or(layer).to_ascii_lowercase();
last.starts_with("crypto")
|| ["z", "uv", "id", "mist", "indexob", "indexma"].contains(&last.as_str())
|| ["depth", "normal", "vector", "position", "velocity", "motion", "objectid", "materialid"].iter().any(|k| last.contains(k))
}
/// The layer whose colour a multi-layer file shows (see [`layered_image`]).
fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> {
// Colour layers: three different channels (not one channel, such as depth, shown in all).
let rgb: Vec<&str> = layers
.iter()
@@ -110,13 +120,33 @@ pub fn layered_image(bytes: &[u8]) -> Option<image::DynamicImage> {
!r.is_empty() && !g.is_empty() && !b.is_empty() && r != g
})
.collect();
let colour = rgb
.iter()
.find(|l| ["combined", "beauty", "rgba"].iter().any(|k| l.to_ascii_lowercase().contains(k)))
let last = |l: &str| l.rsplit('.').next().unwrap_or(l).to_ascii_lowercase();
let pictures = || rgb.iter().filter(|l| !is_data_layer(l));
// The unnamed layer (plain R, G, B), then the beauty pass by name.
rgb.iter()
.find(|l| l.is_empty())
.or_else(|| BEAUTY.iter().find_map(|k| rgb.iter().find(|l| last(l) == *k)))
.or_else(|| pictures().find(|l| ["combined", "beauty"].iter().any(|k| last(l).contains(k))))
.or_else(|| pictures().next())
.or_else(|| layers.iter().find(|l| !is_data_layer(l)))
.or_else(|| rgb.first())
.or_else(|| layers.first())?;
.or_else(|| layers.first())
.copied()
}
/// The picture of a multi-layer OpenEXR file without an unnamed RGB layer (Blender's
/// `ViewLayer.Combined.R`, the compositor's `Image.R`, Nuke's `beauty.R`, …), which the image
/// decoder rejects. After Effects shows a file's main RGBA channels and leaves the other layers
/// to EXtractoR; here that is the beauty pass by name ([`BEAUTY`]), else the first colour layer
/// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel
/// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR.
pub fn layered_image(bytes: &[u8]) -> Option<image::DynamicImage> {
let aux = read_exr_channels(bytes)?;
let layers = aux.layers();
let colour = picture_layer(&aux, &layers)?;
let names = aux.layer_rgba(colour);
let first = aux.channels.first().map(|(n, _)| n.as_str()).unwrap_or_default();
// A layer without R, G and B channels shows its first channel as grey.
let first = aux.names().into_iter().find(|n| split_channel(n).0 == colour).unwrap_or_default();
let plane = |k: usize| -> Option<&[f32]> { aux.get(names.get(k).map(String::as_str).filter(|n| !n.is_empty()).unwrap_or(first)) };
let (r, g, b) = (plane(0)?, plane(1)?, plane(2)?);
let a = names.get(3).filter(|n| !n.is_empty()).and_then(|n| aux.get(n));
@@ -213,4 +243,49 @@ mod tests {
assert_eq!(aux.layers(), ["depth", "diffuse"]);
assert_eq!(aux.layer_rgba("diffuse"), ["diffuse.R", "diffuse.G", "diffuse.B", ""].map(String::from));
}
/// Straight RGBA of [`layered_image`] at pixel 0 for a file of single-value channels.
fn picture<S: AsRef<str>>(channels: &[(S, f32)]) -> Vec<f32> {
use exr::prelude::*;
let (w, h) = (2usize, 2usize);
let list: Vec<AnyChannel<FlatSamples>> = channels.iter().map(|(n, v)| AnyChannel::new(n.as_ref(), FlatSamples::F32(vec![*v; w * h]))).collect();
let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, AnyChannels::sort(list.into())));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().to_buffered(&mut bytes).unwrap();
let img = layered_image(bytes.get_ref()).unwrap().to_rgba32f();
img.get_pixel(0, 0).0.to_vec()
}
/// #412: a Blender compositor File Output (multilayer) names its beauty pass `Image`, and its
/// Cryptomatte layers (lowercase `r, g, b, a`) sort first. The picture was CryptoMaterial00;
/// it is the Image layer, and data passes are never the picture.
#[test]
fn layered_exr_shows_the_beauty_pass_not_cryptomatte() {
let mut blender = vec![];
for layer in ["CryptoMaterial00", "CryptoMaterial01", "CryptoMaterial02"] {
blender.extend(["r", "g", "b", "a"].map(|c| (format!("{layer}.{c}"), 0.9)));
}
for (layer, v) in [("GlossCol", 0.3), ("GlossDir", 0.4), ("Image", 0.5), ("VolDir", 0.6)] {
blender.extend(["R", "G", "B", "A"].map(|c| (format!("{layer}.{c}"), v)));
}
blender.push(("Mist.Z".into(), 0.7));
assert_eq!(picture(&blender), [0.5; 4], "the Image layer");
// Blender's render-layer names and Nuke's beauty.
assert_eq!(
picture(&[
("ViewLayer.CryptoObject00.r", 0.9),
("ViewLayer.CryptoObject00.g", 0.9),
("ViewLayer.CryptoObject00.b", 0.9),
("ViewLayer.Combined.R", 0.2),
("ViewLayer.Combined.G", 0.2),
("ViewLayer.Combined.B", 0.2)
]),
[0.2, 0.2, 0.2, 1.0]
);
// No beauty name: the first colour layer that isn't a data pass.
let no_beauty: Vec<(String, f32)> = blender.iter().filter(|(n, _)| !n.starts_with("Image")).cloned().collect();
assert_eq!(picture(&no_beauty), [0.3; 4], "GlossCol, not Cryptomatte");
// Only data and a grey pass: the grey pass, not depth.
assert_eq!(picture(&[("Depth.Z", 9.0), ("AO.Y", 0.25)]), [0.25, 0.25, 0.25, 1.0]);
}
}
+30 -4
View File
@@ -37,7 +37,9 @@ fn colour_bars(w: u32, h: u32) -> Image {
struct Key {
path: Arc<str>,
frame: i64,
alpha: u8,
/// The alpha interpretation (bits 0–1) and Preserve RGB (bit 2): they change the decoded
/// pixels.
interp: u8,
matte: [u32; 3],
}
@@ -516,8 +518,9 @@ impl Inner {
AlphaMode::Premultiplied => 1,
AlphaMode::Ignore => 2,
};
let interp = alpha | (u8::from(footage.preserve_rgb) << 2);
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
let key = |path: &str, frame| Key { path: path.into(), frame, alpha, matte };
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte };
match footage.kind {
FootageKind::Sequence if !footage.sequence.is_empty() => {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
@@ -607,7 +610,7 @@ impl Inner {
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick) {}
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
let op = AlphaOp::new(footage.alpha, footage.premul_color);
let op = AlphaOp::of(footage);
let path = &loc.key.path;
match loc.media_t {
None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
@@ -759,7 +762,7 @@ mod tests {
fn lru_respects_budget_and_recycles() {
let mut c = Lru::default();
let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead
let k = |i| Key { path: "a".into(), frame: i, alpha: 0, matte: [0; 3] };
let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3] };
let budget = 3 * (4096 + 64);
for i in 0..10 {
c.insert(k(i), img(), budget);
@@ -774,4 +777,27 @@ mod tests {
assert_eq!(c.take_spare(256).len(), 256);
assert!(c.take_spare(17).is_empty());
}
/// #411: Interpret Footage ▸ Preserve RGB reads a float OpenEXR's values as the file stores
/// them (no automatic linear → sRGB encode), so an OCIO effect is the only conversion; the
/// default interpretation still encodes. Both interpretations of one file decode separately.
#[test]
fn preserve_rgb_reads_float_exr_values_unconverted() {
use exr::prelude::*;
let (w, h) = (2usize, 2usize);
let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h]));
let channels = AnyChannels::sort(vec![ch("R", 0.18), ch("G", 0.5), ch("B", 2.0), ch("A", 1.0)].into());
let image = exr::image::Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().to_buffered(&mut bytes).unwrap();
let b: Arc<[u8]> = bytes.into_inner().into();
let pool = MediaPool::new();
pool.add_bytes("/render.exr", b.clone());
let f = crate::probe_bytes("/render.exr", b).unwrap();
let encoded = pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0);
assert!((encoded[0] - 0.4613).abs() < 1e-3 && (encoded[1] - 0.7354).abs() < 1e-3 && encoded[2] > 1.3, "sRGB-encoded: {encoded:?}");
let raw = pool.frame_at(&Footage { preserve_rgb: true, ..f.clone() }, Tick::ZERO).unwrap().get(0, 0);
assert_eq!(raw, [0.18, 0.5, 2.0, 1.0]);
assert_eq!(pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0), encoded, "the default interpretation is cached apart");
}
}
+11
View File
@@ -963,6 +963,11 @@ pub struct Footage {
/// Interpret Footage ▸ Color ▸ Interpret As Linear Light: the file's values are linear.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub linear_light: bool,
/// Interpret Footage ▸ Color ▸ Preserve RGB: the file's values enter the composition
/// unconverted. Float footage (OpenEXR, float TIFF) is not sRGB-encoded on decode, and the
/// colour profile, Interpret As Linear Light and the working space don't apply.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preserve_rgb: bool,
/// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the
/// project so expressions read it everywhere (and on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1009,6 +1014,7 @@ impl Default for Footage {
fields: FieldOrder::Off,
invert_alpha: false,
linear_light: false,
preserve_rgb: false,
data: None,
layer: None,
page: 0,
@@ -1019,6 +1025,11 @@ impl Default for Footage {
}
impl Footage {
/// Interpret As Linear Light applies (Preserve RGB passes the file's values through).
pub fn linear_light_applies(&self) -> bool {
self.linear_light && !self.preserve_rgb
}
/// Source time as the footage's timecode: its frame `frame` from Start Timecode
/// (`0:00:41:20`), as the Footage panel shows it.
pub fn timecode(&self, frame: i64) -> String {
+6 -5
View File
@@ -17,7 +17,7 @@
//! pixels to their parent). Without a working space the sRGB curve is used.
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::{HdrMode, ProjectSettings};
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
use effectcraft_raster::Image;
use rayon::prelude::*;
@@ -57,10 +57,11 @@ impl Pipe {
self.space.unwrap_or(ColorSpace::Srgb)
}
/// Footage with colour profile `profile` (`None` = sRGB) → working space.
pub fn media_in(&self, profile: Option<ColorSpace>) -> Option<Conversion> {
let ws = self.space?;
Conversion::new(profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear)
/// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for
/// Preserve RGB, whose values enter the working space as the file stores them.
pub fn media_in(&self, f: &Footage) -> Option<Conversion> {
let ws = self.space.filter(|_| !f.preserve_rgb)?;
Conversion::new(f.color_profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear)
}
/// Authored colours (solids, text, shapes) → working space: linearised in a linear working
+6 -5
View File
@@ -1089,7 +1089,7 @@ impl<'a> Renderer<'a> {
let k = self.raster_scale(ctx, layer);
if let Some(img) = self.footage.vector_frame(*item, f, k) {
let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k };
if let Some(c) = self.pipe.media_in(f.color_profile) {
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
}
return Some(buf);
@@ -1127,7 +1127,7 @@ impl<'a> Renderer<'a> {
} else {
Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }
};
if let Some(c) = self.pipe.media_in(f.color_profile) {
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
}
buf
@@ -1156,7 +1156,7 @@ impl<'a> Renderer<'a> {
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f)));
}
let img = self.footage_frame_raw(ctx, layer, item, f, t)?;
if f.invert_alpha || f.linear_light { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
@@ -2024,7 +2024,8 @@ pub fn field_frame(img: &Image, parity: usize) -> Image {
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
/// pixels.
pub fn interpret_pixels(img: &Image, f: &Footage) -> Image {
if !f.invert_alpha && !f.linear_light {
let linear = f.linear_light_applies();
if !f.invert_alpha && !linear {
return img.clone();
}
let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb);
@@ -2032,7 +2033,7 @@ pub fn interpret_pixels(img: &Image, f: &Footage) -> Image {
for p in out.data.iter_mut() {
let a = p[3];
let mut c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] };
if f.linear_light {
if linear {
// The file's values are linear light: encode them like the rest of the footage.
c = c.map(|v| space.encode(v));
}
+28
View File
@@ -198,6 +198,34 @@ fn footage_profile_is_converted_into_the_working_space() {
assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}");
}
/// #411: Interpret Footage ▸ Preserve RGB: the footage's values enter the composition as the file
/// stores them, whatever its colour profile, Interpret As Linear Light or the working space.
#[test]
fn preserve_rgb_footage_is_not_converted() {
let render = |linearize: bool, f: Footage| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Rec2020);
p.settings.linearize = linearize;
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = Flat([0.18, 0.5, 0.75]);
let out = Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5);
// Back from the output conversion (working space → sRGB) to the working space's values.
let ws = effectcraft_color::Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, linearize).unwrap();
let c = ws.apply([out[0], out[1], out[2]]);
[c[0], c[1], c[2], out[3]]
};
let preserve = Footage { preserve_rgb: true, linear_light: true, ..footage(Some(ColorSpace::DisplayP3)) };
for linearize in [false, true] {
let o = render(linearize, preserve.clone());
assert!((o[0] - 0.18).abs() < 1e-3 && (o[1] - 0.5).abs() < 1e-3 && (o[2] - 0.75).abs() < 1e-3, "linearize {linearize}: {o:?}");
// Without Preserve RGB the profile and linear light convert the values.
let o = render(linearize, Footage { preserve_rgb: false, ..preserve.clone() });
assert!((o[0] - 0.18).abs() > 0.05, "linearize {linearize}: {o:?}");
}
}
#[test]
fn layer_cache_is_keyed_by_colour_settings() {
let (mut p, cid, _) = setup(BitDepth::Bpc8);
@@ -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 {
+1
View File
@@ -458,6 +458,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
],
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
];
// After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
+9 -1
View File
@@ -219,7 +219,15 @@ build real projects through these interfaces; they are worked examples of everyt
colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12,
"startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new
length). See [footage.md](footage.md).
* **Multi-layer OpenEXR**: `layer.channels {layer}` lists a footage layer's EXR layers and
* **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
channels (`{"layers":[{"name":"diffuse","rgba":["diffuse.R","diffuse.G","diffuse.B",""]}]}`);
EXtractoR shows them when its `red` / `green` / `blue` / `alpha` are set to those names
(`prop.set {"path":"effects/#1/red","value":"depth.Z"}`). Effect Controls offers them as popups.
+16 -4
View File
@@ -62,17 +62,29 @@ used, and layers that ran to the end of the footage keep doing so.
| **Alpha** | *Straight - Unmatted*, *Premultiplied - Matted With Color* (with the matte colour), *Ignore* (opaque), or *Guess* (EffectCraft looks at the first frame). *Invert Alpha* flips it. |
| **Assume this frame rate** | The rate the frames play at. A 250-frame sequence at 25 fps lasts 10 seconds; at 24 fps, 10.4 seconds. |
| **Start Timecode** | *Use Source File Timecode* (0:00:00:00), or *Override Start*: the timecode the footage's first frame shows in the Footage panel. It relabels source time only; the frames stay the same. For a 1001–1250 render at 24 fps, Override Start 0:00:41:17 (frame 1001) makes source time count from the render's own frame numbers. |
| Fields, pixel aspect, loop, colour profile, linear light | As in After Effects. |
| Fields, pixel aspect, loop | As in After Effects. |
| **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. |
| **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. |
Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers
when a sequence imports; Interpret Footage lists them too.
**File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha,
fields, pixel aspect, loop and colour settings to other footage.
fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage.
Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied",
"startTimecode": 1001}` (timecode or a frame number; `"overrideStart": false` goes back to Use
Source File Timecode).
"startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart":
false` goes back to Use Source File Timecode).
## Multi-layer OpenEXR
Float OpenEXR renders hold linear light; EffectCraft sRGB-encodes them as they are read, like
After Effects with Interpret As Linear Light for 32 bpc, unless the item preserves RGB (above).
A multi-layer file shows its main RGBA channels, as in After Effects. Renders that keep every
pass in named layers (Blender's `ViewLayer.Combined`, its compositor's File Output `Image`,
Nuke's `beauty`) show the beauty pass: the layer named Combined, Beauty, Image, RGBA, RGB or
Color, else the first colour layer that isn't a data pass. Cryptomatte, depth, mist, normals,
vectors, positions and IDs are never the picture. EXtractoR reads every other layer and channel.
## Replacing and reloading
+5 -4
View File
@@ -172,7 +172,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; right-clicking a property's name in the Timeline opens its menu (Reset, Edit Value…, Separate Dimensions, Add / Remove Expression, Add Property to Essential Graphics), #407; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) |
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297; Interpret Footage ▸ Preserve RGB: #411) |
| Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 96% | 0.3 | face tracking's trained model (MediaPipe Face Landmarker, M13.36) is an optional download; without it the classical (skin model + feature components + shape model) fitter is weak on profile views and occluded faces; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |
@@ -190,9 +190,9 @@ Mocha and Cineware are third-party and not counted.
| Category | Effects | Notes |
|---|---|---|
| 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) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. |
| 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