Files
effectcraft/crates/render/src/color.rs
T
vangel76andClaude Opus 5.5 cf8b9aebc9 #750: float footage follows Working Gamma and is encoded once
Problem: with no working space and Working Gamma 2.4, a linear EXR still
converted with the sRGB curve (0.18 -> 0.4627, After Effects 0.4902):
float footage was sRGB-encoded at decode, before the render knew the
project's curve. Interpret As Linear Light on float footage then encoded
a second time (0.18 -> 0.7255).

Fix: FootageSource::frame_with asks for float footage with a FloatCurve
(sRGB, a pure 2.4 power curve, or as stored). The renderer asks for the
Working Gamma's curve when unmanaged, sRGB when managed, and the stored
values when Interpret As Linear Light encodes them afterwards.
MediaPool decodes float values that way (Preserve RGB still reads them
as stored) and caches each curve apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:03:10 +02:00

326 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Project colour settings in the compositor: bit depth, working space, linear blending.
//!
//! * **Bit depth.** Pixels are `f32` throughout, but 8 and 16 bpc projects behave like integer
//! pipelines: each layer's pixels are clamped to 0..1 and quantised to the depth after its
//! source and masks and after every effect, and the comp is clamped and quantised after every
//! layer is blended. Over-range values (Add/Screen of bright layers, Exposure…) therefore only
//! survive in 32 bpc. 16 bpc uses After Effects' 0..32768 range.
//! * **Working space** (colour management). Footage is converted from its colour profile (its
//! metadata, else sRGB) into the working space; the top-level comp output is converted from
//! the working space to the sRGB display. Without a working space nothing is converted.
//! * **Linearize Working Space**: the working space is linear light, so sources (footage and
//! the colours of solids, text and shapes, which are authored in the working space's encoding)
//! are linearised before masks and effects, and everything runs in linear.
//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels
//! are linearised just before they are transformed and blended, the comp accumulates in linear
//! and is encoded back to the working space when the comp is done (so precomps hand encoded
//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage
//! and for float footage (OpenEXR, float TIFF), whose linear values are encoded once
//! ([`Pipe::float_curve`]).
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
use effectcraft_raster::Image;
use rayon::prelude::*;
/// The colour pipeline of a project (see the module docs).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pipe {
/// Quantisation levels (255, 32768) or `None` for 32 bpc float.
pub levels: Option<f32>,
/// Working space (`None` = unmanaged).
pub space: Option<ColorSpace>,
/// The working space is linear.
pub linear: bool,
/// Blend in linear light (and the working space is not already linear).
pub linear_blend: bool,
/// The top-level output's space (sRGB unless Project Settings choose another).
pub out: ColorSpace,
/// HDR handling for standard-dynamic-range output.
pub hdr: HdrMode,
/// Unmanaged with Working Gamma 2.4: the pure 2.4 power curve instead of sRGB's.
pub gamma24: bool,
}
impl Pipe {
pub fn of(s: &ProjectSettings) -> Pipe {
// ACES working spaces are linear light by definition.
let linear = s.working_space.is_some_and(|w| s.linearize || w.is_linear());
Pipe {
levels: s.bit_depth.levels(),
space: s.working_space,
linear,
linear_blend: s.blend_linear && !linear,
out: s.output_space.unwrap_or(ColorSpace::Srgb),
hdr: s.hdr,
gamma24: s.working_space.is_none() && s.working_gamma_24(),
}
}
/// The space whose curve encodes working-space pixels (unmanaged: the Working Gamma's, sRGB
/// at 2.2 and Rec. 709's pure 2.4 power curve at 2.4).
fn curve(&self) -> ColorSpace {
self.space.unwrap_or(if self.gamma24 { ColorSpace::Rec709 } else { ColorSpace::Srgb })
}
/// The space Interpret As Linear Light encodes footage `f` in: its colour profile, else sRGB
/// (managed; the working space converts from there) or the Working Gamma's curve (unmanaged).
pub fn linear_footage_space(&self, f: &Footage) -> ColorSpace {
f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() })
}
/// How float footage `f` (OpenEXR, float TIFF; linear light) is decoded: as stored when
/// Interpret As Linear Light encodes it afterwards (once, with
/// [`Pipe::linear_footage_space`]), else with the Working Gamma's curve when unmanaged and
/// the sRGB curve otherwise (the working space converts from there).
pub fn float_curve(&self, f: &Footage) -> crate::FloatCurve {
if f.linear_light_applies() { crate::FloatCurve::Linear } else { self.float_encoding() }
}
/// The curve float footage is encoded with when nothing interprets it afterwards: the
/// Working Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4), else sRGB.
pub fn float_encoding(&self) -> crate::FloatCurve {
if self.space.is_none() && self.gamma24 { crate::FloatCurve::Gamma24 } else { crate::FloatCurve::Srgb }
}
/// 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
/// space.
pub fn authored_in(&self) -> Option<Conversion> {
self.linear.then(|| Conversion::linearize(self.curve()))
}
/// Working space → blending space (linear when blending with 1.0 gamma).
pub fn to_blend(&self) -> Option<Conversion> {
self.linear_blend.then(|| Conversion::linearize(self.curve()))
}
/// Blending space → working space.
pub fn from_blend(&self) -> Option<Conversion> {
self.linear_blend.then(|| Conversion::delinearize(self.curve()))
}
/// Working space → the output space (top-level comp output; sRGB by default). `None` when
/// unmanaged or when [`Pipe::output_hdr`] applies.
pub fn output(&self) -> Option<Conversion> {
let ws = self.space?;
if self.output_hdr().is_some() {
return None;
}
Conversion::new(ws, self.linear, self.out, false)
}
/// Output with HDR compand / tone mapping (standard-dynamic-range outputs only): working
/// space → linear output primaries, the HDR curve, then the output's encoding.
pub fn output_hdr(&self) -> Option<(Option<Conversion>, HdrMode, Conversion)> {
let ws = self.space?;
(self.hdr != HdrMode::Clip && !self.out.is_hdr())
.then(|| (Conversion::new(ws, self.linear, self.out, true), self.hdr, Conversion::delinearize(self.out)))
}
/// One pixel clamped and quantised to the bit depth.
pub fn quantize_px(&self, mut p: [f32; 4]) -> [f32; 4] {
if let Some(l) = self.levels {
quantize_px(&mut p, l, 1.0 / l);
}
p
}
/// Clamp and quantise to the bit depth (no-op in 32 bpc).
pub fn quantize(&self, img: &mut Image) {
self.quantize_region(img, Region::Full);
}
/// Clamp and quantise the pixels of `region` (no-op in 32 bpc). Pixels outside it must
/// already be quantised (checked in debug builds).
pub fn quantize_region(&self, img: &mut Image, region: Region) {
let Some(l) = self.levels else { return };
let r = match region {
Region::Empty => None,
Region::Full => Some(PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }),
Region::Rect(r) => Some(r.clamp_to(img.width, img.height)),
};
if let Some(r) = r {
quantize_rect(img, r, l);
}
#[cfg(debug_assertions)]
if img.data.len() <= 1 << 20 {
let inv = 1.0 / l;
let bad = img.data.iter().position(|p| {
let q = |v: f32, hi: f32| (v.clamp(0.0, hi) * l).round() * inv;
let a = q(p[3], 1.0);
(p[3] - a).abs() > 1e-5 || (0..3).any(|c| (p[c] - q(p[c], a)).abs() > 1e-5)
});
if let Some(i) = bad {
debug_assert!(
false,
"pixel ({}, {}) outside the quantised region {region:?} is not quantised: {:?}",
i as u32 % img.width,
i as u32 / img.width,
img.data[i]
);
}
}
}
/// Hash of everything that changes pixels (for cache keys).
pub fn key(&self) -> u64 {
let mut k = self.levels.map_or(0, |l| l as u64);
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64);
k = k.wrapping_mul(31).wrapping_add(self.gamma24 as u64);
k
}
}
/// A pixel rectangle `[x0, x1) × [y0, y1)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PxRect {
pub x0: u32,
pub y0: u32,
pub x1: u32,
pub y1: u32,
}
impl PxRect {
/// The pixels covering the real-valued box `[x0, x1] × [y0, y1]` grown by `margin`.
pub fn covering(x0: f64, y0: f64, x1: f64, y1: f64, margin: f64) -> Option<PxRect> {
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
return None;
}
let c = |v: f64| v.clamp(0.0, u32::MAX as f64) as u32;
Some(PxRect { x0: c((x0 - margin).floor()), y0: c((y0 - margin).floor()), x1: c((x1 + margin).ceil()), y1: c((y1 + margin).ceil()) })
}
fn clamp_to(self, w: u32, h: u32) -> PxRect {
let (x1, y1) = (self.x1.min(w), self.y1.min(h));
PxRect { x0: self.x0.min(x1), y0: self.y0.min(y1), x1, y1 }
}
}
/// The part of a canvas that a drawing step may have changed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Region {
Empty,
Rect(PxRect),
Full,
}
/// Clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round to `levels` steps.
pub fn quantize(img: &mut Image, levels: f32) {
quantize_rect(img, PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }, levels);
}
/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up; values are non-negative after the
/// clamp, so the integer conversion rounds like `round()` and vectorises.
#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN
#[inline(always)]
fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) {
let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv;
let hi = [a, a, a, a];
for c in 0..3 {
p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv;
}
p[3] = a;
}
/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's
/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm
/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there).
#[inline(always)]
fn round_half_up(v: f32) -> f32 {
if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 }
}
/// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel.
fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
if r.x0 >= r.x1 || r.y0 >= r.y1 {
return;
}
let inv = 1.0 / levels;
let w = img.width as usize;
let (x0, x1) = (r.x0 as usize, r.x1 as usize);
img.data[r.y0 as usize * w..r.y1 as usize * w].par_chunks_mut(w).for_each(|row| {
for p in &mut row[x0..x1] {
quantize_px(p, levels, inv);
}
});
}
/// Convert premultiplied pixels (on straight colour).
pub fn convert(img: &mut Image, c: &Conversion) {
img.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a <= 0.0 {
return;
}
let o = c.apply([p[0] / a, p[1] / a, p[2] / a]);
*p = [o[0] * a, o[1] * a, o[2] * a, a];
});
}
/// Compand: an exponential knee from 80%, approaching 1.0 (linear light, per channel).
pub fn compand(v: f32) -> f32 {
const K: f32 = 0.8;
if v <= K { v } else { K + (1.0 - K) * (1.0 - (-(v - K) / (1.0 - K)).exp()) }
}
/// Extended Reinhard tone mapping of luminance with white at `W` (linear light).
pub fn tone_map(c: [f32; 3]) -> [f32; 3] {
const W: f32 = 4.0;
let l = effectcraft_color::luminance(c[0], c[1], c[2]);
if l <= 0.0 {
return c;
}
let lm = l * (1.0 + l / (W * W)) / (1.0 + l);
let k = lm / l;
c.map(|v| v * k)
}
/// The HDR step of [`Pipe::output_hdr`] on premultiplied pixels.
pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode, encode: Conversion) {
img.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a <= 0.0 {
return;
}
let mut c = [p[0] / a, p[1] / a, p[2] / a];
if let Some(t) = &to_linear {
c = t.apply(c);
}
c = match mode {
HdrMode::Clip => c,
HdrMode::Compand => c.map(compand),
HdrMode::ToneMap => tone_map(c),
};
let o = encode.apply(c);
*p = [o[0] * a, o[1] * a, o[2] * a, a];
});
}
#[cfg(test)]
mod tests {
/// Both forms `round_half_up` picks between agree on everything quantising feeds it:
/// 0..=levels, exact halves included.
#[test]
fn rounding_forms_agree() {
for levels in [255.0f32, 32_768.0] {
for i in 0..=(levels as u32 * 16) {
let v = i as f32 / 16.0;
assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}");
}
for i in 0..=100_000 {
let v = (i as f32 / 100_000.0) * levels;
assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}");
}
}
}
}