quercus-core 0.7.0

Rust value types mirroring quercuscore (Rational, TimeRange, format enums) (internal; do not depend directly)
// Quercus Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Quercus Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

//! CPU-built 3D LUTs for GPU color management (M2).
//!
//! The display pipeline's per-pixel math (working space → project output
//! spec → display device via ICC) is evaluated once per settings change
//! with the exact CPU reference implementation
//! ([`crate::colormath`] + [`crate::color::ColorProcessor`]) and baked
//! into a 3D LUT. The GPU present pass then samples it with manual
//! trilinear interpolation, so the pixels that reach the swapchain are
//! color-managed on the GPU without a CPU readback — the display
//! transform runs at full precision against the same code the CPU path
//! has always used.
//!
//! The LUT is stored as tightly packed f32 RGB, red varying fastest, and
//! covers the cube `[lo, hi]³`; samples outside the domain clamp to the
//! boundary. Alpha is never part of the transform.

/// A 3D RGB lookup table over `[lo, hi]³`, `edge³` samples, red fastest.
#[derive(Clone, Debug, PartialEq)]
pub struct Lut3d {
	/// Samples per axis (≥ 2).
	pub edge: u32,
	/// Domain low corner.
	pub lo: [f32; 3],
	/// Domain high corner.
	pub hi: [f32; 3],
	/// `edge³ * 3` tightly packed RGB values.
	pub data: Vec<f32>,
}

impl Lut3d {
	/// The standard display-transform edge: 65³ samples keep trilinear
	/// error far below the 10-bit display quantization step for the
	/// analytic output-node/ICC chains.
	pub const DISPLAY_EDGE: u32 = 65;

	/// The display LUT's input domain: working-space scene-linear values
	/// can exceed 1 (HDR) and dip negative from gamut matrices. The CPU
	/// output node clamps after its gamut matrix, so values outside this
	/// range are already clipped by the baked transform.
	pub const DISPLAY_LO: [f32; 3] = [-0.25, -0.25, -0.25];
	/// See [`Lut3d::DISPLAY_LO`].
	pub const DISPLAY_HI: [f32; 3] = [4.0, 4.0, 4.0];

	/// Build a LUT by evaluating `f` on the `edge³` grid.
	pub fn build<F: FnMut([f32; 3]) -> [f32; 3]>(
		edge: u32,
		lo: [f32; 3],
		hi: [f32; 3],
		mut f: F,
	) -> Self {
		let edge = edge.max(2);
		let n = edge as usize;
		let mut data = Vec::with_capacity(n * n * n * 3);
		let step = |i: usize, axis: usize| -> f32 {
			let t = i as f32 / (edge - 1) as f32;
			lo[axis] + (hi[axis] - lo[axis]) * t
		};
		for b in 0..n {
			for g in 0..n {
				for r in 0..n {
					let out = f([step(r, 0), step(g, 1), step(b, 2)]);
					data.extend_from_slice(&out);
				}
			}
		}
		Self { edge, lo, hi, data }
	}

	/// The identity LUT (useful for pass-through/legacy display paths).
	pub fn identity(edge: u32) -> Self {
		Self::build(edge, [0.0; 3], [1.0; 3], |c| c)
	}

	/// Index of the sample `(r, g, b)` in [`Lut3d::data`].
	///
	/// Lattice indices with red varying fastest:
	/// `((b * edge + g) * edge + r) * 3`. Not range-checked (callers pass
	/// valid indices); [`Lut3d::sample`] reads three values from the returned
	/// offset.
	fn index(&self, r: u32, g: u32, b: u32) -> usize {
		(((b * self.edge + g) * self.edge + r) * 3) as usize
	}

	/// One sample (panics if the LUT data is malformed).
	///
	/// `r`/`g`/`b` are lattice indices, each in `[0, edge)`, with red varying
	/// fastest; returns the packed RGB triple at that corner. Indices are not
	/// range-checked — use [`Lut3d::eval`] for domain-space lookups. Panics
	/// when `data` is shorter than the index implies (a LUT not produced by
	/// [`Lut3d::build`]/[`Lut3d::identity`]).
	pub fn sample(&self, r: u32, g: u32, b: u32) -> [f32; 3] {
		let i = self.index(r, g, b);
		[self.data[i], self.data[i + 1], self.data[i + 2]]
	}

	/// CPU trilinear evaluation (the reference for tests; the GPU pass
	/// implements the same interpolation in WGSL).
	///
	/// `rgb` is a working-space color: each axis is normalized to
	/// `(rgb[a] - lo[a]) / (hi[a] - lo[a])` and clamped to `[0, 1]`, so
	/// out-of-domain colors return the nearest boundary sample. The result is
	/// the trilinear blend of the surrounding lattice samples, per component;
	/// a zero-width domain axis maps to its low sample. Panics only if `data`
	/// is shorter than `edge³ * 3` (see [`Lut3d::sample`]) — [`Lut3d::build`]
	/// and [`Lut3d::identity`] always produce well-formed data.
	pub fn eval(&self, rgb: [f32; 3]) -> [f32; 3] {
		let last = self.edge - 1;
		let mut p = [0.0f32; 3];
		for a in 0..3 {
			let span = self.hi[a] - self.lo[a];
			let t = if span.abs() > f32::EPSILON {
				(rgb[a] - self.lo[a]) / span
			} else {
				0.0
			}
			.clamp(0.0, 1.0);
			p[a] = t * last as f32;
		}
		let i0 = [
			p[0].floor() as u32,
			p[1].floor() as u32,
			p[2].floor() as u32,
		];
		let f = [
			p[0] - i0[0] as f32,
			p[1] - i0[1] as f32,
			p[2] - i0[2] as f32,
		];
		let i1 = [
			(i0[0] + 1).min(last),
			(i0[1] + 1).min(last),
			(i0[2] + 1).min(last),
		];
		std::array::from_fn(|a| {
			let c00 = lerp(
				self.sample(i0[0], i0[1], i0[2])[a],
				self.sample(i1[0], i0[1], i0[2])[a],
				f[0],
			);
			let c10 = lerp(
				self.sample(i0[0], i1[1], i0[2])[a],
				self.sample(i1[0], i1[1], i0[2])[a],
				f[0],
			);
			let c01 = lerp(
				self.sample(i0[0], i0[1], i1[2])[a],
				self.sample(i1[0], i0[1], i1[2])[a],
				f[0],
			);
			let c11 = lerp(
				self.sample(i0[0], i1[1], i1[2])[a],
				self.sample(i1[0], i1[1], i1[2])[a],
				f[0],
			);
			let c0 = lerp(c00, c10, f[1]);
			let c1 = lerp(c01, c11, f[1]);
			lerp(c0, c1, f[2])
		})
	}

	/// The LUT's input domain as `(lo, hi)`.
	///
	/// Both corners are per-axis arrays; values outside the domain are clamped
	/// to the nearest boundary by [`Lut3d::eval`], whose samples were baked
	/// over exactly this cube. The returned arrays are copies.
	pub fn domain(&self) -> ([f32; 3], [f32; 3]) {
		(self.lo, self.hi)
	}
}

/// Linear interpolation between `a` and `b` at parameter `t`.
///
/// Returns `a + (b - a) * t`; `t` is not clamped here, so callers pass a
/// fraction in `[0, 1]` for an in-range blend. Used by [`Lut3d::eval`].
fn lerp(a: f32, b: f32, t: f32) -> f32 {
	a + (b - a) * t
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn identity_lut_interpolates_linearly() {
		let lut = Lut3d::identity(3);
		assert_eq!(lut.edge, 3);
		let c = lut.eval([0.25, 0.5, 0.75]);
		assert!((c[0] - 0.25).abs() < 1e-6);
		assert!((c[1] - 0.5).abs() < 1e-6);
		assert!((c[2] - 0.75).abs() < 1e-6);
	}

	#[test]
	fn build_covers_the_domain_corners() {
		let lut = Lut3d::build(2, [-1.0; 3], [2.0; 3], |c| [c[0] * 2.0, c[1], c[2]]);
		assert_eq!(lut.sample(0, 0, 0), [-2.0, -1.0, -1.0]);
		assert_eq!(lut.sample(1, 1, 1), [4.0, 2.0, 2.0]);
		// Out-of-domain samples clamp to the nearest corner.
		assert_eq!(lut.eval([-100.0, -100.0, -100.0]), [-2.0, -1.0, -1.0]);
		assert_eq!(lut.eval([100.0, 100.0, 100.0]), [4.0, 2.0, 2.0]);
	}

	#[test]
	fn trilinear_matches_manual_blend() {
		// A linear LUT is reproduced exactly by trilinear interpolation.
		let lut = Lut3d::build(5, [0.0; 3], [1.0; 3], |c| {
			[c[0] * 0.5 + c[1] * 0.25 + c[2] * 0.25, c[1], c[2]]
		});
		for probe in [[0.1, 0.2, 0.3], [0.9, 0.4, 0.7], [0.0, 1.0, 0.5]] {
			let out = lut.eval(probe);
			let expect = [
				probe[0] * 0.5 + probe[1] * 0.25 + probe[2] * 0.25,
				probe[1],
				probe[2],
			];
			for a in 0..3 {
				assert!(
					(out[a] - expect[a]).abs() < 1e-5,
					"axis {a}: {} vs {}",
					out[a],
					expect[a]
				);
			}
		}
	}
}