Files
craft-libs/crates/cms/src/gpu_matrix_shaper.rs
T
Dylan M. Taylor 628a235ae5 craft-cms: the changes PhotoCraft made to its copy since extraction (#858)
* craft-cms: the changes PhotoCraft made to its copy since extraction

PhotoCraft kept editing crates/cms after craft-cms was extracted from it at
storytold/photocraft@b3222ee8. This ports its 41 commits up to
storytold/photocraft@0143b148 with a three-way merge per file, keeping the
changes craft-cms made on its own (Result-returning API, white preservation,
FOGRA39, named colours, profile ID).

Ported: the .3dl shaper, Mesh header, UTF-16 and .look fixes in lutfile; the
checked ICC offsets and the per-profile curve sample budget; same_bytes and the
byte-confirmed transform cache; the blocked matrix/TRC path and the 8-bit
first-stage table; GpuMatrixShaper; cicp_rgb; the ACEScg and Dot Gain 20 %
built-ins (the latter now the default gray); bisection for a table's flat ends;
PhotoCraft spelled with a capital C in the built-in descriptions, with the
shipped coated CMYK profile's bytes as PhotoCraft regenerated them.

Not taken, because craft-cms already does it another way: Curve::inverse_table
(inverse64_many), the effective-dimension loop in multilinear interpolation,
the curve-count and mluc checks, and the .cube DOMAIN writer.

The 8-bit first-stage table checks device white as the exact path does.

Refs #779

* craft-cms: Lut3d::from_fn

PhotoCraft's proof and HDR display tables are built from a chain of several
transforms, which it wrote into Lut3d's fields. Those are private here, so
from_fn samples any RGB function, with the same size bounds as from_transform.

Refs #779

* craft-cms: README names Lut3d::from_fn
2026-11-11 18:37:23 -05:00

132 lines
5.8 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.
//! Separable RGB display transforms without a coarse 3D interpolation near black.
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
/// transform. Values are floats independent of the document's pixel depth.
///
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
/// [`Self::matrix_rows`] to that result, then sample the output table at
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
/// separately between its two adjacent table entries. These nonuniform output samples
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
///
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
/// [`crate::Lut3d`] fallback.
#[derive(Clone, Debug, PartialEq)]
pub struct GpuMatrixShaper {
matrix: [[f32; 4]; 3],
input: Vec<[f32; 4]>,
output: Vec<[f32; 4]>,
}
impl GpuMatrixShaper {
/// The input table has one more entry than this number of equal intervals.
pub const INPUT_INTERVALS: usize = 4096;
/// Output entries are spaced uniformly in the fourth root of the linear value.
pub const OUTPUT_ENTRIES: usize = 4096;
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
self.matrix
}
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
pub fn input_table(&self) -> &[[f32; 4]] {
&self.input
}
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
pub fn output_table(&self) -> &[[f32; 4]] {
&self.output
}
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
if !matrix.iter().flatten().all(|v| v.is_finite()) {
return None;
}
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
for row in matrix {
let magnitude = row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>()
+ f64::from(row[3]).abs();
if magnitude > f64::from(f32::MAX) {
return None;
}
}
Some(Self { matrix, input, output })
}
}
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
if let Some(tables) = tables
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
{
return None;
}
let mut packed: Vec<[f32; 4]> = Vec::new();
// Both lengths are fixed constants, but a failed allocation still means the caller
// should retain its existing fallback rather than abort while opening a document.
packed.try_reserve_exact(entries).ok()?;
for i in 0..entries {
let rgb = match tables {
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
None => [identity(i as f32 / (entries - 1) as f32); 3],
};
if !rgb.iter().all(|v| v.is_finite()) {
return None;
}
if let Some(previous) = packed.last()
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
{
return None;
}
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
}
Some(packed)
}
#[cfg(test)]
mod tests {
use super::*;
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
#[test]
fn rejects_invalid_tables_and_nonfinite_values() {
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
let mut invalid = inputs.clone();
invalid[0].pop();
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut invalid = inputs.clone();
invalid[1][123] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
let mut invalid = outputs.clone();
invalid[2][321] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
let mut invalid = IDENTITY;
invalid[1][3] = v;
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
}
}
#[test]
fn rejects_finite_data_that_overflows_shader_arithmetic() {
let mut matrix = IDENTITY;
matrix[0] = [f32::MAX; 4];
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
inputs[0][100] = -f32::MAX;
inputs[0][101] = f32::MAX;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
}
}