mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -06:00
render: the debug-only quantisation check uses debug_assert! instead of panic!. gpu: the test-only panic on a wgpu validation error is cfg(test) with a narrow allow, and release builds only log. Test-only unreachable!() calls become panic!(). Denies the panic lints in both crates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
10 KiB
Rust
270 lines
10 KiB
Rust
//! 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 sRGB curve is used.
|
||
|
||
use effectcraft_color::{ColorSpace, Conversion};
|
||
use effectcraft_project::{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,
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
|
||
fn curve(&self) -> ColorSpace {
|
||
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)
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
/// 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 = (p[3].max(0.0).min(1.0) * levels + 0.5) as u32 as f32 * inv;
|
||
let hi = [a, a, a, a];
|
||
for c in 0..3 {
|
||
p[c] = (p[c].max(0.0).min(hi[c]) * levels + 0.5) as u32 as f32 * inv;
|
||
}
|
||
p[3] = a;
|
||
}
|
||
|
||
/// [`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];
|
||
});
|
||
}
|