From 628a235ae535a462adc462d479c150afc5f3bfc0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:37:23 -0500 Subject: [PATCH] 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 --- crates/cms/README.md | 21 +- .../cms/profiles/photocraft-coated-cmyk.icc | Bin 236844 -> 236844 bytes crates/cms/src/builtin.rs | 117 ++- crates/cms/src/clut.rs | 14 + crates/cms/src/curve.rs | 26 +- crates/cms/src/gpu_matrix_shaper.rs | 131 ++++ crates/cms/src/lib.rs | 3 + crates/cms/src/lut3d.rs | 32 + crates/cms/src/lutfile.rs | 738 +++++++++++++++++- crates/cms/src/pipeline.rs | 10 + crates/cms/src/profile.rs | 179 ++++- crates/cms/src/synth.rs | 2 +- crates/cms/src/transform.rs | 148 +++- crates/cms/tests/cms.rs | 90 +++ crates/cms/tests/gpu_matrix_shaper.rs | 201 +++++ crates/cms/tests/profile_id.rs | 2 +- 16 files changed, 1620 insertions(+), 94 deletions(-) create mode 100644 crates/cms/src/gpu_matrix_shaper.rs create mode 100644 crates/cms/tests/gpu_matrix_shaper.rs diff --git a/crates/cms/README.md b/crates/cms/README.md index 7953539ba..55bbc6b82 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -30,8 +30,14 @@ Buffers are checked once per call, before any pixel is written. * `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain. * `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning). -* `Lut3d::from_transform(&t, 33)?.to_rgba16f_bytes()`: display transform as a 3D texture. +* `Lut3d::from_transform(&t, 33)?.to_rgba16f_bytes()`: display transform as a 3D texture; + `Lut3d::from_fn(size, f)` samples any RGB function. +* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for + display shaders. Float tables retain near-black detail; unsupported chains return `None` + for the 3D LUT fallback. No GPU dependency is required by this crate. * `transform::cached(src, dst, opts)`: process-wide transform cache. +* `builtin::cicp_rgb(primaries, transfer, linear)`: the RGB profile for an ITU-T H.273 colour + description (a HEIF `nclx` box, a video stream). * `lutfile::parse(name, bytes)` / `lutfile::write_cube(&lut)`: `.cube`, `.3dl` and `.look` LUT files; `lutfile::builtin(id)` returns generated looks (`lutfile::BUILTIN`). * `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding @@ -59,6 +65,10 @@ Buffers are checked once per call, before any pixel is written. tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon (native only). +* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus + offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated + separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB + 33³ table cannot resolve steep inverse gamma curves near black, even with float texels. * Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero, negative or non-finite white is refused with `CmsError::Invalid`); BPC uses @@ -73,7 +83,8 @@ Buffers are checked once per call, before any pixel is written. ## Built-in profiles (all CC0-1.0, generated by this crate) The profile descriptions and copyright text keep the PhotoCraft names they were first -published with, so profiles embedded in existing files still match byte for byte. +published with (spelled "PhotoCraft" since PhotoCraft's #5614), so profiles embedded in +existing files still match byte for byte. | id | description | |---|---| @@ -82,10 +93,12 @@ published with, so profiles embedded in existing files still match byte for byte | `adobe-rgb-compat` | Adobe RGB (1998)-compatible (published primaries, gamma 563/256) | | `prophoto-compat` | ProPhoto-compatible (ROMM primaries, D50, gamma 1.8) | | `linear-srgb` | sRGB primaries, linear | +| `aces-cg` | ACEScg: AP1 primaries, ACES white (~D60), linear | | `rec2020` | Rec. 2020 primaries, Rec. 709 OETF | -| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) | +| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve | +| `dot-gain-20-compat` | gray, gamma 1.732, fitted to Photoshop's Dot Gain 20 % (default gray working space) | | `lab-d50` | Lab identity (v4 encoding) | -| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)**: default CMYK | +| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)**: default CMYK | | `coated-fogra39` | Coated FOGRA39 (fitted, craft-cms): fitted to Fogra's FOGRA39 data, 330% TAC, medium GCR | ### The CMYK profiles diff --git a/crates/cms/profiles/photocraft-coated-cmyk.icc b/crates/cms/profiles/photocraft-coated-cmyk.icc index 0d2d1da16da0eb612c735a6f8d589a1907e7a16c..b553fe9cecae7da389930d865073f6e3d40c2331 100644 GIT binary patch delta 48 zcmV-00MGxdx(=+m4zL#i0Yifr0k;?d0RdwHthX9u0pVN$s<-^W0r3n0xQFh|0k`hW G0zS{DO%s0r delta 48 zcmZ3pi*L;?z767xjLFRsjN2s`fegmA+oe(%AI31Q-2UeP<3}FGo$c?=GH!o&mdWls E0IVPrpa1{> diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 361f228c1..ca7034286 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -34,12 +34,18 @@ pub enum Builtin { ProPhotoCompat, /// sRGB primaries with a linear transfer function. LinearSrgb, + /// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders. + AcesCg, /// Rec. 2020 primaries, D65, Rec. 709 transfer function. Rec2020, /// Gray, gamma 2.2. GrayGamma22, /// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB. SGray, + /// Gray matching Photoshop's default working gray (Dot Gain 20 %) with our own curve: a pure + /// gamma of 1.732, fitted on Photoshop 27.11's RGB → Grayscale (sRGB 64 / 128 / 192 and pure red + /// become 46 / 105 / 177 / 107, all within 1 level). Not Adobe's profile. + DotGain20Compat, /// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding). LabD50, /// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`]. @@ -51,15 +57,17 @@ pub enum Builtin { } impl Builtin { - pub const ALL: [Builtin; 11] = [ + pub const ALL: [Builtin; 13] = [ Builtin::Srgb, Builtin::DisplayP3, Builtin::AdobeRgbCompat, Builtin::ProPhotoCompat, Builtin::LinearSrgb, + Builtin::AcesCg, Builtin::Rec2020, Builtin::GrayGamma22, Builtin::SGray, + Builtin::DotGain20Compat, Builtin::LabD50, Builtin::CoatedCmyk, Builtin::CoatedFogra39, @@ -73,9 +81,11 @@ impl Builtin { Builtin::AdobeRgbCompat => "adobe-rgb-compat", Builtin::ProPhotoCompat => "prophoto-compat", Builtin::LinearSrgb => "linear-srgb", + Builtin::AcesCg => "aces-cg", Builtin::Rec2020 => "rec2020", Builtin::GrayGamma22 => "gray-gamma-2.2", Builtin::SGray => "sgray", + Builtin::DotGain20Compat => "dot-gain-20-compat", Builtin::LabD50 => "lab-d50", Builtin::CoatedCmyk => "coated-cmyk", Builtin::CoatedFogra39 => "coated-fogra39", @@ -87,13 +97,15 @@ impl Builtin { match self { Builtin::Srgb => "sRGB IEC61966-2.1", Builtin::DisplayP3 => "Display P3", - Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)", - Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)", - Builtin::LinearSrgb => "Linear sRGB (Photocraft)", - Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)", - Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)", - Builtin::SGray => "sGray (sRGB tone curve, Photocraft)", - Builtin::LabD50 => "Lab D50 identity (Photocraft)", + Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)", + Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)", + Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)", + Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)", + Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)", + Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)", + Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)", + Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)", + Builtin::LabD50 => "Lab D50 identity (PhotoCraft)", Builtin::CoatedCmyk => crate::synth::DESCRIPTION, Builtin::CoatedFogra39 => COATED_FOGRA39_DESCRIPTION, } @@ -111,6 +123,8 @@ impl Builtin { "rec2020" | "bt2020" => Some(Builtin::Rec2020), "gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22), "sgray" | "graysrgb" => Some(Builtin::SGray), + "dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat), + "aces-cg" | "acescg" => Some(Builtin::AcesCg), "lab" | "labd50" | "cielab" => Some(Builtin::LabD50), "cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk), "fogra39" | "coatedfogra39" => Some(Builtin::CoatedFogra39), @@ -151,7 +165,7 @@ impl Builtin { /// The profile (built once per process). pub fn profile(self) -> &'static Profile { - static CELLS: [OnceLock; 11] = [const { OnceLock::new() }; 11]; + static CELLS: [OnceLock; 13] = [const { OnceLock::new() }; 13]; // `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`), // so the discriminant is the cell index. CELLS[self as usize].get_or_init(|| build(self)) @@ -163,11 +177,12 @@ fn profile_name_key(s: &str) -> String { s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect() } -/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50. +/// The default profile for a colour space: sRGB, Dot Gain 20 %-compatible gray (Photoshop's default +/// working gray), the coated CMYK profile, Lab D50. pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> { Some(match cs { ColorSpace::Rgb => Builtin::Srgb.profile(), - ColorSpace::Gray => Builtin::SGray.profile(), + ColorSpace::Gray => Builtin::DotGain20Compat.profile(), ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(), ColorSpace::Lab => Builtin::LabD50.profile(), _ => return None, @@ -182,7 +197,7 @@ pub fn srgb() -> &'static Profile { /// only assigned to such files, never offered as a working space. pub fn linear_gray() -> &'static Profile { static PROFILE: OnceLock = OnceLock::new(); - PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0))) + PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0))) } /// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290). @@ -211,6 +226,8 @@ const P3_SPACE: RgbSpace = rgb_space!([[0.680, 0.320], [0.265, 0.690], [0.150, 0 const ADOBE_SPACE: RgbSpace = rgb_space!([[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]], D65); const ROMM_SPACE: RgbSpace = rgb_space!([[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]], math::D50); const BT2020_SPACE: RgbSpace = rgb_space!([[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], D65); +/// ACEScg: the AP1 primaries with the ACES white point (~D60, xy 0.32168/0.33767, the ACES 2065-1 neutral). +const AP1_SPACE: RgbSpace = rgb_space!([[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]], math::xy_to_xyz([0.32168, 0.33767])); /// Colorant matrix adapted to D50 and quantized to s15Fixed16, with each row nudged so that /// RGB (1, 1, 1) maps exactly to the stored D50 white (keeps neutrals neutral). @@ -321,6 +338,46 @@ pub const COATED_FOGRA39_ICC: &[u8] = include_bytes!("../profiles/craft-coated-f /// with `CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release regen`). pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc"); +/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. +fn rec709_trc() -> Curve { + let a = 1.099; + Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] } +} + +/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video +/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB, +/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the +/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are +/// held, since no ICC curve represents those transfers. `None` for anything else. +pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option { + let (prim, name) = match primaries { + 1 => (SRGB_SPACE, "BT.709"), + 9 => (BT2020_SPACE, "Rec. ITU-R BT.2020"), + 12 => (P3_SPACE, "Display P3"), + _ => return None, + }; + let transfer = if linear { 8 } else { transfer }; + let builtin = match (primaries, transfer) { + (1, 13) => Some(Builtin::Srgb), + (1, 8) => Some(Builtin::LinearSrgb), + (12, 13) => Some(Builtin::DisplayP3), + (9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020), + _ => None, + }; + if let Some(b) = builtin { + return Some(b.profile().clone()); + } + let (trc, curve) = match transfer { + 8 => (Curve::Gamma(1.0), "linear"), + 13 => (srgb_trc(), "sRGB curve"), + 1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"), + 4 => (Curve::Gamma(2.2), "gamma 2.2"), + 5 => (Curve::Gamma(2.8), "gamma 2.8"), + _ => return None, + }; + Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, trc)) +} + fn build(b: Builtin) -> Profile { match b { Builtin::Srgb => rgb(b.description(), SRGB_SPACE, srgb_trc()), @@ -328,14 +385,11 @@ fn build(b: Builtin) -> Profile { Builtin::AdobeRgbCompat => rgb(b.description(), ADOBE_SPACE, Curve::Gamma(563.0 / 256.0)), Builtin::ProPhotoCompat => rgb(b.description(), ROMM_SPACE, Curve::Gamma(1.8)), Builtin::LinearSrgb => rgb(b.description(), SRGB_SPACE, Curve::Gamma(1.0)), - Builtin::Rec2020 => { - // Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below. - let a = 1.099; - let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] }; - rgb(b.description(), BT2020_SPACE, trc) - } + Builtin::AcesCg => rgb(b.description(), AP1_SPACE, Curve::Gamma(1.0)), + Builtin::Rec2020 => rgb(b.description(), BT2020_SPACE, rec709_trc()), Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)), Builtin::SGray => gray(b.description(), srgb_trc()), + Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)), Builtin::LabD50 => lab(), // The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test). Builtin::CoatedCmyk => shipped_cmyk(COATED_CMYK_ICC, b), @@ -369,3 +423,30 @@ fn shipped_cmyk(bytes: &[u8], b: Builtin) -> Profile { hash: Default::default(), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cicp_maps_onto_the_builtins_and_linear_bt2020() { + let desc = |p: Option| p.map(|p| p.description); + assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string())); + assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string())); + assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string())); + assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string())); + + // An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve. + let hlg = cicp_rgb(9, 18, true).unwrap(); + assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)])); + assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix); + assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description); + assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile"); + + // HLG and PQ have no ICC curve; unknown primaries have no profile. + assert!(cicp_rgb(9, 18, false).is_none()); + assert!(cicp_rgb(9, 16, false).is_none()); + assert!(cicp_rgb(2, 13, false).is_none()); + assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none()); + } +} diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs index dd0315c20..766fa837e 100644 --- a/crates/cms/src/clut.rs +++ b/crates/cms/src/clut.rs @@ -350,4 +350,18 @@ mod tests { narrow.eval_multilinear(&[0.3, 0.7], &mut b).unwrap(); assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "{a:?} {b:?}"); } + + #[test] + fn degenerate_dimensions_do_not_change_the_result() { + // Axes 1 and 3 have a single grid point (degenerate); axes 0, 2, 4 vary. Interpolating + // over the effective dimensions only gives the same answer as the function sampled. + let f = |p: &[f32], o: &mut [f32]| o[0] = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4]; + let c = Clut::sample(vec![3, 1, 4, 1, 5], 1, f).unwrap(); + let mut got = [0.0]; + for p in [[0.2f32, 0.0, 0.7, 0.0, 0.4], [1.0, 0.0, 0.0, 0.0, 1.0], [0.5, 0.0, 0.5, 0.0, 0.5]] { + c.eval_multilinear(&p, &mut got).unwrap(); + let want = 0.25 * p[0] + 0.5 * p[2] + 0.125 * p[4]; + assert!((got[0] - want).abs() < 1e-5, "got {} want {want}", got[0]); + } + } } diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index a26ec428b..5da1be801 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -305,15 +305,22 @@ fn table_inverse_with(t: &[f32], y: f32, edge: impl Fn(bool) -> f32) -> f32 { /// For a flat run at the table start (`start = true`) the last index of the run, or for a flat /// run at the end the first index of it, as a normalised input. +/// +/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long +/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's +/// length times the image's. fn edge_index(t: &[f32], start: bool) -> f32 { let n = t.len(); if start { let v = t[0]; - let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1); + // The first value that differs, given they do so from there on. + let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7); + let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(1) }; i as f32 / (n - 1) as f32 } else { let v = t[n - 1]; - let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1); + // The start of the trailing run equal to the last value. + let i = t.partition_point(|x| (*x - v).abs() > 1e-7); i as f32 / (n - 1) as f32 } } @@ -390,4 +397,19 @@ mod tests { let g = Curve::Gamma(2.2); assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12); } + + #[test] + fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() { + // Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls. + let mut t = vec![0.0f32; 4_000_000]; + t.push(1.0); + let c = Curve::Table(t); + let started = std::time::Instant::now(); + let mut last = 0.0; + for _ in 0..200_000 { + last = c.eval_inverse(0.0); + } + assert!(last > 0.99, "the flat start ends at the last zero: {last}"); + assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); + } } diff --git a/crates/cms/src/gpu_matrix_shaper.rs b/crates/cms/src/gpu_matrix_shaper.rs new file mode 100644 index 000000000..0d5c0f078 --- /dev/null +++ b/crates/cms/src/gpu_matrix_shaper.rs @@ -0,0 +1,131 @@ +//! 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], output: Option<&[Vec]>) -> Option { + 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::from(row[3]).abs(); + if magnitude > f64::from(f32::MAX) { + return None; + } + } + Some(Self { matrix, input, output }) + } +} + +fn pack_table(tables: Option<&[Vec]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option> { + 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()); + } +} diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs index e484f8fd8..8d4f99f49 100644 --- a/crates/cms/src/lib.rs +++ b/crates/cms/src/lib.rs @@ -13,6 +13,7 @@ //! * [`NamedColors`] reads a named-colour profile's spot colours and converts them into a //! destination profile. //! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas. +//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms. //! //! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, //! CIE 15, and Adobe's published black point compensation paper. @@ -22,6 +23,7 @@ pub mod builtin; pub mod clut; pub mod curve; pub mod gamut; +mod gpu_matrix_shaper; pub mod lut3d; pub mod lutfile; pub mod math; @@ -36,6 +38,7 @@ pub use builtin::Builtin; pub use clut::Clut; pub use curve::Curve; pub use gamut::GamutCheck; +pub use gpu_matrix_shaper::GpuMatrixShaper; pub use lut3d::Lut3d; pub use named::{NamedColor, NamedColors}; pub use profile::{ColorSpace, Pcs, Profile, ProfileClass}; diff --git a/crates/cms/src/lut3d.rs b/crates/cms/src/lut3d.rs index 9103b2388..b1a010e74 100644 --- a/crates/cms/src/lut3d.rs +++ b/crates/cms/src/lut3d.rs @@ -37,6 +37,25 @@ impl Lut3d { Ok(Lut3d { size, data }) } + /// Samples `f` (RGB in, RGB out) on a `size`³ grid, `size` from 2 to [`MAX_SIZE`]: a display + /// chain that is more than one [`Transform`]. + pub fn from_fn(size: usize, mut f: impl FnMut([f32; 3]) -> [f32; 3]) -> Result { + if !(2..=MAX_SIZE).contains(&size) { + return Err(invalid_arg(format!("3D LUT size {size} (2 to {MAX_SIZE})"))); + } + let s = (size - 1) as f32; + let mut data = Vec::with_capacity(size * size * size); + for b in 0..size { + for g in 0..size { + for r in 0..size { + let v = f([r as f32 / s, g as f32 / s, b as f32 / s]); + data.push([v[0], v[1], v[2], 1.0]); + } + } + } + Ok(Lut3d { size, data }) + } + /// Edge length. pub fn size(&self) -> usize { self.size @@ -156,4 +175,17 @@ mod tests { assert_eq!(f32_to_f16(1.0), 0x3C00); assert_eq!(f32_to_f16(1e6), 0x7C00); } + + #[test] + fn from_fn_samples_red_fastest_and_bounds_the_size() { + let lut = Lut3d::from_fn(3, |[r, g, b]| [r, g * 0.5, 1.0 - b]).unwrap(); + assert_eq!(lut.size(), 3); + assert_eq!(lut.data().len(), 27); + assert_eq!(lut.data()[1], [0.5, 0.0, 1.0, 1.0]); + assert_eq!(lut.data()[3], [0.0, 0.25, 1.0, 1.0]); + assert_eq!(lut.data()[26], [1.0, 0.5, 0.0, 1.0]); + for size in [0, 1, MAX_SIZE + 1, usize::MAX] { + assert!(matches!(Lut3d::from_fn(size, |v| v), Err(CmsError::InvalidArgument(_))), "{size}"); + } + } } diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 1bd0e6e30..c2261325d 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -88,16 +88,63 @@ impl LutFile { } } +/// A LUT file format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Cube, + ThreeDl, + Look, +} + +impl Format { + /// The format [`parse`] reads `bytes` as: from `file_name`'s extension, else sniffed + /// (XML is `.look`, anything else `.cube`). + pub fn detect(file_name: &str, bytes: &[u8]) -> Self { + let lower = file_name.to_ascii_lowercase(); + if lower.ends_with(".3dl") { + Format::ThreeDl + } else if lower.ends_with(".look") || decode_text(bytes).trim_start().starts_with('<') { + Format::Look + } else { + Format::Cube + } + } + + /// The file extension, without the dot. + pub fn extension(self) -> &'static str { + match self { + Format::Cube => "cube", + Format::ThreeDl => "3dl", + Format::Look => "look", + } + } +} + /// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing). pub fn parse(file_name: &str, bytes: &[u8]) -> Result { - let lower = file_name.to_ascii_lowercase(); - let text = String::from_utf8_lossy(bytes); - if lower.ends_with(".3dl") { - parse_3dl(&text) - } else if lower.ends_with(".look") || text.trim_start().starts_with('<') { - parse_look(&text) - } else { - parse_cube(&text) + parse_as(Format::detect(file_name, bytes), bytes) +} + +/// Parses a LUT in a known format. +pub fn parse_as(format: Format, bytes: &[u8]) -> Result { + let text = decode_text(bytes); + match format { + Format::Cube => parse_cube(&text), + Format::ThreeDl => parse_3dl(&text), + Format::Look => parse_look(&text), + } +} + +/// UTF-16 with a byte-order mark (Photoshop loads such `.look` files, #9118), else UTF-8. +fn decode_text(bytes: &[u8]) -> std::borrow::Cow<'_, str> { + let utf16 = |rest: &[u8], from: fn([u8; 2]) -> u16| { + let units: Vec = rest.as_chunks::<2>().0.iter().map(|&c| from(c)).collect(); + String::from_utf16_lossy(&units).into() + }; + match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), + [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), + _ => String::from_utf8_lossy(bytes), } } @@ -108,7 +155,14 @@ pub fn parse_cube(text: &str) -> Result { let (mut dmin, mut dmax) = ([0.0f32; 3], [1.0f32; 3]); let mut rows: Vec = Vec::new(); for line in text.lines() { - let line = line.split('#').next().unwrap_or("").trim(); + // A '#' inside a quoted TITLE is part of the title, not a comment. + let raw = line.trim_start(); + let skip = if raw.starts_with("TITLE") { raw.find('"').and_then(|q| raw[q + 1..].find('"').map(|e| q + e + 2)) } else { None }.unwrap_or(0); + let line = match raw[skip..].find('#') { + Some(i) => &raw[..skip + i], + None => raw, + } + .trim(); if line.is_empty() { continue; } @@ -172,41 +226,226 @@ pub fn parse_cube(text: &str) -> Result { err("no LUT_3D_SIZE in .cube file") } -/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue** -/// varying fastest; the output bit depth is inferred from the largest value. +/// Lustre `.3dl`: an optional `3DMESH` magic and `Mesh ` line, an optional +/// shaper line of input values, then integer RGB rows with **blue** varying fastest. The +/// `Mesh` line declares the cube edge (2^inBits + 1) and the output code range (up to +/// 2^outBits − 1), and Photoshop honours both (#9073, #9078); without it the edge is the cube +/// root of the row count and the range is inferred from the largest value (#9121). A shaper that +/// isn't the even ramp is applied before the table, as Photoshop does (#8982): see [`apply_shaper`]. pub fn parse_3dl(text: &str) -> Result { + let mut mesh: Option<(u32, u32)> = None; let mut rows: Vec<[f64; 3]> = Vec::new(); + // How many numbers the first numeric line carried: a shaper of exactly `n` values (n ≤ 3) + // is otherwise indistinguishable from a node row. + let mut first_len: Option = None; + // Any text line before the rows is a header: a headerless table peaking at 255 or 1023 is + // on the 12-bit scale, as Photoshop 27.11 reads it (#9121). + let mut header = false; + let mut shaper: Option> = None; for line in text.lines() { let line = line.split('#').next().unwrap_or("").trim(); - if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) { + // A header is `3DMESH` or starts with a word (`Mesh 4 12`): a letter, and not a number. + // `NaN` and `inf` are numbers, and `0oops` starts like one: those rows are read strictly. + let Some(first) = line.split_whitespace().next() else { continue }; + if first.eq_ignore_ascii_case("3DMESH") || first.starts_with(char::is_alphabetic) && first.parse::().is_err() { + header = true; + // The only header keyword that carries data. A malformed or out-of-range `Mesh` + // line is ignored, as every text line always was. + let mut it = line.split_whitespace(); + if it.next().is_some_and(|k| k.eq_ignore_ascii_case("Mesh")) { + // A declaration is exactly two integer fields after the keyword. Anything else + // (a non-numeric field, too few or too many) is not a declaration: `mesh` stays + // unset so headerless inference applies, rather than recovering numbers from + // the wrong positions (#9078). + let fields: Vec<&str> = it.collect(); + if let [a, b] = fields.as_slice() + && let (Ok(n), Ok(bits)) = (a.parse::(), b.parse::()) + && (1..=7).contains(&n) + && (1..=16).contains(&bits) + { + mesh = Some((n, bits)); + } + } continue; } - let v: Vec = line.split_whitespace().filter_map(|s| s.parse().ok()).collect(); + // Every token is a number: a malformed one is refused rather than dropped, which could + // turn a shaper into an even ramp and lose it silently. + let v: Vec = line.split_whitespace().map(str::parse).collect::>().map_err(|_| LutError(format!("bad line `{line}`")))?; + first_len.get_or_insert(v.len()); if v.len() == 3 { rows.push([v[0], v[1], v[2]]); + } else if v.len() >= 2 { + // Two values are a 2-point shaper (a 2³ table's), not something to drop. + // The input shaper: one row of values ahead of the table, and only one. + if shaper.is_some() || !rows.is_empty() { + return err(format!("a second shaper row `{line}`")); + } + shaper = Some(v); } - // Lines with more than three numbers are the input shaper; it is assumed linear. } - let n = (rows.len() as f64).cbrt().round() as usize; - if n < 2 || n * n * n != rows.len() { - return err(format!("{} rows is not a cube", rows.len())); - } - let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); - let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) }; + let (n, nodes, scale) = match mesh { + Some((in_bits, out_bits)) => { + let n = 2usize.pow(in_bits) + 1; + let expected = n.checked_pow(3).ok_or_else(|| LutError(format!("unsupported 3dl cube size {n}")))?; + // One row more than the declared cube, and the first row has exactly `n` values: + // that row is the shaper, not a 28th node of a 3³ cube (#9078). + let nodes = if shaper.is_none() && rows.len() == expected + 1 && first_len == Some(n) { + shaper = rows.first().map(|row| row.to_vec()); + &rows[1..] + } else { + &rows[..] + }; + if nodes.len() != expected { + return err(format!("expected {expected} rows for the declared {n}³ cube, found {}", nodes.len())); + } + // Under a valid declaration the node codes are integers in the declared range, so + // the declaration wins for every in-range value — including a small max, where 1 is + // 1/declared of full output, not full output itself (#9073). Only a node beyond the + // declared range is a wrong declaration, and the old inference keeps such a file + // loading as before. + let max = nodes.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); + let declared = 2f64.powi(i32::try_from(out_bits).unwrap_or(16)) - 1.0; + (n, nodes, if max > declared { inferred_3dl_scale(max, header) } else { declared }) + } + None => { + let n = (rows.len() as f64).cbrt().round() as usize; + if n < 2 || n * n * n != rows.len() { + return err(format!("{} rows is not a cube", rows.len())); + } + let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); + (n, &rows[..], inferred_3dl_scale(max, header)) + } + }; let mut data = vec![0.0f32; n * n * n * 3]; - for (i, row) in rows.iter().enumerate() { + for (i, row) in nodes.iter().enumerate() { let (r, g, b) = (i / (n * n), (i / n) % n, i % n); let at = ((b * n + g) * n + r) * 3; for k in 0..3 { data[at + k] = (row[k] / scale) as f32; } } - LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check() + let lut = LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()?; + Ok(match shaper { + Some(shaper) => apply_shaper(lut, &shaper)?, + None => lut, + }) +} + +/// Bakes a `.3dl` shaper into the table. Photoshop applies the shaper row as a 1-D curve on each +/// input channel ahead of the 3-D table (input `x` reads the shaper, sampled evenly over 0..1, +/// at `x`, interpolated linearly), so each node of the table becomes the table's own trilinear +/// value at the shaped input. An even ramp (the usual shaper) changes nothing and is skipped. +/// The table is baked on a grid holding both its own nodes and the shaper's knots: exact for an +/// identity table, an approximation at that resolution otherwise. A shaper that would need more +/// than [`MAX_SIZE`] nodes for that, or that holds a value that isn't finite, is refused. +fn apply_shaper(lut: LutFile, shaper: &[f64]) -> Result { + let m = shaper.len(); + if m < 2 { + return Ok(lut); + } + // Apply or refuse: a shaper that can't be applied never leaves the table to render alone. + if shaper.iter().any(|v| !v.is_finite()) { + return err("the .3dl shaper holds a value that isn't finite"); + } + // The shaper's own depth, from 10 bits up: a 0..1023 ramp is the even ramp with or without a header. + let scale = inferred_3dl_scale(shaper.iter().fold(0.0f64, |a, &b| a.max(b)), true); + let even = |k: usize| scale * k as f64 / (m - 1) as f64; + // Shapers round their ramp: within one code is still the even ramp. An integer shaper's code + // is 1; a float shaper (0..1) is held to one 10-bit step. + let code = if scale > 1.0 { 1.0 } else { 1.0 / 1023.0 }; + if shaper.iter().enumerate().all(|(k, &v)| (v - even(k)).abs() <= code) { + return Ok(lut); + } + // Scaled to 0..1 first, so interpolating between two extreme values can't overflow. + let shaped: Vec = shaper.iter().map(|v| v / scale).collect(); + let curve = |x: f64| -> f32 { + let p = x.clamp(0.0, 1.0) * (m - 1) as f64; + let i = (p.floor() as usize).min(m - 2); + let f = p - i as f64; + let (a, b) = (shaped.get(i).copied().unwrap_or(0.0), shaped.get(i + 1).copied().unwrap_or(0.0)); + (a + (b - a) * f).clamp(0.0, 1.0) as f32 + }; + // The bake grid holds both the table's nodes and the shaper's knots (lcm of their intervals), + // so the curve keeps its breakpoints whatever the table's size: exact on an identity table. A + // grid that would pass MAX_SIZE is refused: sampled more coarsely, a bent shaper could read as + // even and be lost without a word. + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + let (a, k) = (lut.size - 1, m - 1); + let aligned = (a / gcd(a, k)).checked_mul(k).map(|l| l + 1).filter(|&l| l <= MAX_SIZE); + let Some(n) = aligned else { + return err(format!("the .3dl shaper's {m} points can't be kept on a table of at most {MAX_SIZE} nodes")); + }; + let step = (n - 1) as f64; + let mut out = LutFile { size: n, data: vec![0.0; n * n * n * 3], ..lut.clone() }; + for b in 0..n { + for g in 0..n { + for r in 0..n { + let at = ((b * n + g) * n + r) * 3; + let v = sample(&lut, [curve(r as f64 / step), curve(g as f64 / step), curve(b as f64 / step)]); + if let Some(dst) = out.data.get_mut(at..at + 3) { + dst.copy_from_slice(&v); + } + } + } + } + out.check() +} + +/// Trilinear lookup of `rgb` (0..1) in a [`LutFile`] (red fastest). +fn sample(l: &LutFile, rgb: [f32; 3]) -> [f32; 3] { + let n = l.size; + let loc = |v: f32| { + let p = v.clamp(0.0, 1.0) * (n - 1) as f32; + let i = (p as usize).min(n.saturating_sub(2)); + (i, p - i as f32) + }; + let (r0, fr) = loc(rgb[0]); + let (g0, fg) = loc(rgb[1]); + let (b0, fb) = loc(rgb[2]); + let at = |r: usize, g: usize, b: usize, k: usize| l.data.get(((b * n + g) * n + r) * 3 + k).copied().unwrap_or(0.0); + let mut o = [0.0f32; 3]; + for (k, ov) in o.iter_mut().enumerate() { + let c = |r, g, b| at(r, g, b, k); + let x00 = c(r0, g0, b0) + (c(r0 + 1, g0, b0) - c(r0, g0, b0)) * fr; + let x10 = c(r0, g0 + 1, b0) + (c(r0 + 1, g0 + 1, b0) - c(r0, g0 + 1, b0)) * fr; + let x01 = c(r0, g0, b0 + 1) + (c(r0 + 1, g0, b0 + 1) - c(r0, g0, b0 + 1)) * fr; + let x11 = c(r0, g0 + 1, b0 + 1) + (c(r0 + 1, g0 + 1, b0 + 1) - c(r0, g0 + 1, b0 + 1)) * fr; + let y0 = x00 + (x10 - x00) * fg; + let y1 = x01 + (x11 - x01) * fg; + *ov = y0 + (y1 - y0) * fb; + } + o +} + +/// The code range a `.3dl` node table without a declared output depth was written with: the +/// smallest common integer depth that covers the largest value; a table of 0..1 floats passes +/// through. A headerless table has no 10-bit reading — 255 and 1023 are on the 12-bit scale, as +/// Photoshop 27.11 reads them (#9121). +fn inferred_3dl_scale(max: f64, header: bool) -> f64 { + if max <= 1.0 { + return 1.0; + } + let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] }; + depths.iter().copied().find(|&s| max <= s).unwrap_or(max) } /// SpeedGrade `.look`: `` and a hex `` string of little-endian float32 RGB triplets /// (red fastest). pub fn parse_look(text: &str) -> Result { + // Comments may hold tag-like text, so drop them before looking for tags (#9109). + let mut clean = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find("").map_or("", |e| &rest[i + 4 + e + 3..]); + } + clean.push_str(rest); + let text = clean.as_str(); let tag = |name: &str| -> Option { let open = format!("<{name}>"); let start = text.find(&open)? + open.len(); @@ -215,7 +454,14 @@ pub fn parse_look(text: &str) -> Result { }; let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no in .look".into()))?; let (entries, _) = checked_data_len(size)?; - let hex: Vec = tag("data").ok_or_else(|| LutError("no in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect(); + let hex: Vec = tag("data").ok_or_else(|| LutError("no in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if hex.iter().any(|b| !b.is_ascii_hexdigit()) { + return err("invalid hexadecimal in .look"); + } + // Each float needs eight hex digits; as_chunks would silently discard a partial value. + if !hex.len().is_multiple_of(8) { + return err("incomplete float in .look "); + } let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8; let bytes: Vec = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect(); let floats: Vec = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); @@ -308,6 +554,226 @@ pub fn builtin(id: &str) -> Option { mod tests { use super::*; + /// An identity `.3dl` of edge `n` (12-bit nodes, blue fastest) under `shaper`. + fn identity_3dl(shaper: &[u32], n: usize) -> String { + let mut t = shaper.iter().map(u32::to_string).collect::>().join(" ") + "\n"; + let node = |i: usize| (i * 4095 / (n - 1)).to_string(); + for r in 0..n { + for g in 0..n { + for b in 0..n { + t += &format!("{} {} {}\n", node(r), node(g), node(b)); + } + } + } + t + } + + #[test] + fn three_dl_shaper_applies_before_the_table() { + // #8982: Photoshop applies a .3dl's shaper row as a curve ahead of the 3-D table. On grey + // 128 an identity table with the shaper 0 114 455 1023 gives 71 there, and a 17-point + // squared shaper 64 (reported measurements); even ramps leave grey 128 as it is. + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let bent = parse_3dl(&identity_3dl(&[0, 114, 455, 1023], 4)).unwrap(); + assert_eq!(grey(&bent), 71.0); + let squared: Vec = (0..17).map(|i| (1023.0 * (i as f64 / 16.0).powi(2)).round() as u32).collect(); + assert_eq!(grey(&parse_3dl(&identity_3dl(&squared, 17)).unwrap()), 64.0); + let even4 = parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4)).unwrap(); + assert_eq!(grey(&even4), 128.0); + assert_eq!(even4, parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).lines().skip(1).collect::>().join("\n")).unwrap()); + let even17: Vec = (0..17).map(|i| (1023.0 * i as f64 / 16.0).round() as u32).collect(); + assert_eq!(grey(&parse_3dl(&identity_3dl(&even17, 17)).unwrap()), 128.0); + // The ends stay put, and the shaper bends each channel on its own. + assert_eq!(sample(&bent, [0.0, 1.0, 0.0]), [0.0, 1.0, 0.0]); + let c = sample(&bent, [1.0 / 3.0, 0.0, 2.0 / 3.0]); + assert!((c[0] - 114.0 / 1023.0).abs() < 1e-4 && (c[2] - 455.0 / 1023.0).abs() < 1e-4, "{c:?}"); + // A shaper that isn't finite is refused rather than dropped (see + // `three_dl_non_finite_shaper_is_refused`). + assert!(parse_3dl(&identity_3dl(&[0, 341, 682, 1023], 4).replacen("341", "NaN", 1)).is_err()); + } + + /// #9170 review: a float shaper (values in 0..1) is held to a float ramp, not to one integer + /// code, so a bent float shaper is applied. + #[test] + fn three_dl_float_shaper_is_applied() { + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let with = |shaper: &str| { + let table = identity_3dl(&[0, 1, 2, 3], 4); + parse_3dl(&format!("{shaper}\n{}", table.split_once('\n').unwrap().1)).unwrap() + }; + // 0.502 lands between 0.1 and 0.3 at 0.506 of the way: 0.2012, so grey 51. + assert_eq!(grey(&with("0 0.1 0.3 1.0")), 51.0); + // An even float ramp, written to three decimals, still changes nothing. + assert_eq!(grey(&with("0 0.333 0.667 1.0")), 128.0); + } + + /// #9170 review: a shaper with more points than the table keeps its curve: the table is + /// resampled at the shaper's resolution rather than reading the shaper at its own two nodes. + #[test] + fn three_dl_shaper_finer_than_the_table_keeps_its_curve() { + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let small = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], 2)).unwrap(); + // 0.502 lands between 0 and 512 at 0.506 of the way: 259 / 1023, so grey 65. + assert_eq!(grey(&small), 65.0); + assert_eq!(sample(&small, [1.0, 0.0, 1.0]), [1.0, 0.0, 1.0], "the ends stay put"); + } + + /// #9170 review: a finite but extreme shaper can't overflow into NaN when it is baked. + #[test] + fn three_dl_extreme_shaper_bakes_only_finite_values() { + let table = identity_3dl(&[0, 1, 2, 3], 2); + let text = format!("0 1e308 -1e308 0\n{}", table.split_once('\n').unwrap().1); + // Refused or baked, but never a table holding NaN. + if let Ok(l) = parse_3dl(&text) { + assert!(l.data.iter().all(|v| v.is_finite()), "baked a non-finite value"); + } + } + + /// #9170 review (bommerts): the shaper's breakpoints survive a table whose grid doesn't line + /// up with them. The bake grid holds both the table's nodes and the shaper's knots, so on an + /// identity table the curve is exact: `0 0 512 1023` is 0 at ⅓ over 5³ and 17³ tables. + #[test] + fn three_dl_shaper_knots_survive_a_nonaligned_table() { + for n in [5, 17] { + let l = parse_3dl(&identity_3dl(&[0, 0, 512, 1023], n)).unwrap(); + let at = |x: f32| sample(&l, [x, 0.0, 0.0])[0]; + assert!(at(1.0 / 3.0).abs() < 1e-6, "{n}³: {}", at(1.0 / 3.0)); + // The helper's 12-bit nodes are truncated, so the table itself is within a 12-bit step. + assert!((at(2.0 / 3.0) - 512.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: {}", at(2.0 / 3.0)); + assert!((at(0.5) - 256.0 / 1023.0).abs() < 1.0 / 4095.0, "{n}³: halfway between the knots"); + } + } + + /// A two-value shaper row (a 2³ table's) is applied like a longer one, not dropped; the even + /// two-point ramp changes nothing. + #[test] + fn three_dl_two_point_shaper_is_applied() { + let bent = parse_3dl(&identity_3dl(&[0, 512], 2)).unwrap(); + let top = sample(&bent, [1.0, 1.0, 1.0]); + assert!(top.iter().all(|v| (v - 512.0 / 1023.0).abs() < 1e-4), "{top:?}"); + assert!((sample(&bent, [0.5, 0.0, 0.0])[0] - 256.0 / 1023.0).abs() < 1e-4); + let even = parse_3dl(&identity_3dl(&[0, 1023], 2)).unwrap(); + assert_eq!( + even, + parse_3dl(&identity_3dl(&[0, 1023], 2).lines().skip(1).collect::>().join( + " +" + )) + .unwrap() + ); + // A second two-value row is a second shaper, refused like a longer one. + let body = identity_3dl(&[0, 512], 2); + assert!( + parse_3dl(&format!( + "{body}0 512 +" + )) + .is_err() + ); + } + + /// #9170 review (bommerts): a shaper row with a token that isn't a number, or a second + /// shaper row, is refused rather than silently dropped. + #[test] + fn three_dl_malformed_shaper_rows_are_refused() { + let table = identity_3dl(&[0, 1, 2, 3], 4); + let body = table.split_once('\n').unwrap().1; + assert!(parse_3dl(&format!("0 341 junk 682 1023\n{body}")).is_err(), "a junk token"); + assert!(parse_3dl(&format!("0 114 455 1023\n0 114 455 1023\n{body}")).is_err(), "two shaper rows"); + assert!(parse_3dl(&format!("0 114 455 1023\n{body}")).is_ok()); + } + + /// #9170 review (bommerts): the standard `3DMESH` / `Title lut 12` preamble still reads; only + /// the rows after it are parsed strictly. + #[test] + fn three_dl_mesh_preamble_is_accepted() { + let grey = |l: &LutFile| (sample(l, [128.0 / 255.0; 3])[0] * 255.0).round(); + let plain = identity_3dl(&[0, 114, 455, 1023], 4); + let headed = parse_3dl(&format!("3DMESH\nTitle lut 12\n{plain}")).unwrap(); + assert_eq!(headed, parse_3dl(&plain).unwrap(), "the preamble changes nothing"); + assert_eq!(grey(&headed), 71.0, "the bent shaper still applies"); + let even = identity_3dl(&[0, 341, 682, 1023], 4); + assert_eq!(grey(&parse_3dl(&format!("3dmesh\nTitle lut 12\n{even}")).unwrap()), 128.0); + assert!( + parse_3dl(&format!("3DMESH\nTitle lut 12\n0 341 junk 682 1023\n{}", even.split_once('\n').unwrap().1)).is_err(), + "still strict after it" + ); + } + + /// #9170 review: a shaper that isn't finite is refused, not dropped, so a Color Lookup never + /// renders the table alone for a file whose shaper it can't apply. + #[test] + fn three_dl_non_finite_shaper_is_refused() { + let body = identity_3dl(&[0, 1, 2, 3], 4); + let body = body.split_once('\n').unwrap().1; + for shaper in ["0 NaN 682 1023", "0 341 inf 1023", "-inf 341 682 1023"] { + assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}"); + } + } + + /// #9170 review: a shaper row that starts with `NaN` or `inf` is a shaper, not a header, so + /// it reaches the non-finite refusal instead of being skipped. + #[test] + fn three_dl_shaper_starting_non_finite_is_refused() { + let body = identity_3dl(&[0, 1, 2, 3], 4); + let body = body.split_once('\n').unwrap().1; + for shaper in ["NaN 341 682 1023", "inf 341 682 1023", "-inf 341 682 1023"] { + assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}"); + } + } + + /// #9170 review: only a line starting with a word is a header. One whose first token looks + /// like a number but isn't (`0oops`) is read strictly and refused, not skipped with its shaper. + #[test] + fn three_dl_malformed_first_token_is_refused() { + let body = identity_3dl(&[0, 1, 2, 3], 4); + let body = body.split_once('\n').unwrap().1; + for shaper in ["0oops 114 455 1023", "-x 114 455 1023", ".5x 114 455 1023", "+ 114 455 1023"] { + assert!(parse_3dl(&format!("{shaper}\n{body}")).is_err(), "{shaper}"); + } + let (first, rest) = body.split_once('\n').unwrap(); + assert!(parse_3dl(&format!("0 114 455 1023\n{first}\n1x 0 0\n{rest}")).is_err(), "a table row too"); + assert!(parse_3dl(&format!("3DMESH\nTitle lut 12\n0 114 455 1023\n{body}")).is_ok(), "headers still skip"); + } + + /// #9170 review: a bent shaper whose knots can't all be kept within MAX_SIZE is refused rather + /// than sampled where it may look even (here a 257-point shaper bent only at odd knots, whose + /// 129-node fallback would read the identity). + #[test] + fn three_dl_shaper_too_fine_to_keep_is_refused() { + let shaper: Vec = (0..257u32).map(|i| (i * 1023 / 256 + if i % 2 == 1 { 40 } else { 0 }).min(1023)).collect(); + assert!(parse_3dl(&identity_3dl(&shaper, 2)).is_err()); + // As many knots as nodes stays within reach. + let same: Vec = (0..17u32).map(|i| (1023.0 * (f64::from(i) / 16.0).powi(2)).round() as u32).collect(); + assert!(parse_3dl(&identity_3dl(&same, 17)).is_ok()); + } + + #[test] + fn cube_title_with_hash_round_trips() { + let l = LutFile::from_fn("Look #3", 2, |c| c).unwrap(); + assert_eq!(parse_cube(&write_cube(&l)).unwrap().title, "Look #3"); + let t = "TITLE \"A #b\" # note\nLUT_3D_SIZE 2 # c\n# comment\n"; + let rows = "0 0 0 # x\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + let p = parse_cube(&format!("{t}{rows}")).unwrap(); + assert_eq!(p.title, "A #b"); + } + + #[test] + fn write_cube_keeps_non_default_domain() { + let mut l = LutFile::identity(2).unwrap(); + l.domain_min = [0.1; 3]; + l.domain_max = [0.9; 3]; + let back = parse_cube(&write_cube(&l)).unwrap(); + assert_eq!(back.domain_min, [0.1; 3]); + assert_eq!(back.domain_max, [0.9; 3]); + l.domain_min = [0.0, 0.0, 0.5]; + l.domain_max = [1.0; 3]; + let back = parse_cube(&write_cube(&l)).unwrap(); + assert_eq!(back.domain_min, [0.0, 0.0, 0.5]); + assert_eq!(back.domain_max, [1.0; 3]); + assert!(!write_cube(&LutFile::identity(2).unwrap()).contains("DOMAIN")); + } + #[test] fn cube_domain_is_kept_and_validated() { let base = "TITLE \"t\" @@ -403,6 +869,26 @@ DOMAIN_MAX 0.5 1 1 assert!(parse_cube(&text).is_err()); } + #[test] + fn three_dl_without_a_header_reads_low_nodes_as_12_bit() { + let table = |peak: u32| -> String { + let mut text = String::from("0 1023\n"); + for i in 0..8 { + let on = |bit: u32| if i & bit != 0 { peak } else { 0 }; + text.push_str(&format!("{} {} {}\n", on(4), on(2), on(1))); + } + text + }; + for peak in [255, 1023, 4095] { + let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec(); + assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}"); + } + // A header keeps the inferred 10-bit depth. The bare `3DMESH` magic is a header without a + // cube declaration; `Mesh 1 10` would declare a 3³ cube and refuse this 2³ table (#9078). + let white = parse_3dl(&format!("3DMESH\n{}", table(1023))).unwrap().data[21..24].to_vec(); + assert_eq!(white, [1.0; 3]); + } + #[test] fn three_dl_blue_fastest_and_bit_depth() { let mut text = String::from("0 1023\n"); @@ -418,6 +904,143 @@ DOMAIN_MAX 0.5 1 1 assert_eq!(l.data, LutFile::identity(2).unwrap().data); } + /// A `size`³ inverse-identity `.3dl` (blue fastest): node (r,g,b) outputs `max - max·v/(size-1)` + /// per channel, with a uniform shaper of `size` codes up to 1023. + fn mesh_3dl(n_bits: u32, out_bits: u32, size: usize, max: f64, shaper: bool) -> String { + let mut text = format!("3DMESH\nMesh {n_bits} {out_bits}\n"); + if shaper { + for i in 0..size { + if i > 0 { + text.push(' '); + } + text.push_str(&format!("{}", (1023.0 * i as f64 / (size - 1) as f64).round())); + } + text.push('\n'); + } + for r in 0..size { + for g in 0..size { + for b in 0..size { + let v = [r, g, b].map(|c| (max - max * c as f64 / (size - 1) as f64).round()); + text.push_str(&format!("{} {} {}\n", v[0], v[1], v[2])); + } + } + } + text + } + + #[test] + fn three_dl_mesh_three_point_shaper_is_not_a_node() { + // #9078: Photoshop loads a 3³ cube whose shaper line has three values; counting it as a + // 28th node rejected the whole file. + let l = parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, true)).unwrap(); + assert_eq!(l.size, 3); + assert_eq!(l.data.len(), 3 * 3 * 3 * 3); + // The inverse identity: node (0,0,0) is full, node (2,2,2) black. + assert!((l.data[0] - 1.0).abs() < 1e-6 && (l.data[1] - 1.0).abs() < 1e-6 && (l.data[2] - 1.0).abs() < 1e-6); + let last = &l.data[l.data.len() - 3..]; + assert_eq!(last, [0.0, 0.0, 0.0]); + // The same 27 nodes without the shaper row still load (the node-only control). + assert_eq!(parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, false)).unwrap().data, l.data); + } + + #[test] + fn three_dl_mesh_declares_the_output_depth() { + // #9073: identical node codes divide by the declared range, not by the largest value. + let mut text_10 = String::new(); + let mut text_12 = String::new(); + for (n_bits, out_bits, dst) in [(2, 10, &mut text_10), (2, 12, &mut text_12)] { + // A 5³ identity scaled to 10-bit codes (max 1023). + *dst = format!("3DMESH\nMesh {n_bits} {out_bits}\n0 256 512 768 1023\n"); + for r in 0..5 { + for g in 0..5 { + for b in 0..5 { + dst.push_str(&format!("{} {} {}\n", r * 255, g * 255, b * 255)); + } + } + } + } + let white_10 = parse_3dl(&text_10).unwrap(); + let white_12 = parse_3dl(&text_12).unwrap(); + assert_eq!(white_10.size, 5); + let last_10 = &white_10.data[white_10.data.len() - 3..]; + let last_12 = &white_12.data[white_12.data.len() - 3..]; + assert!((last_10[0] - 1020.0 / 1023.0).abs() < 1e-6, "{last_10:?}"); + // 1020/4095 ≈ 0.249: white renders grey, as Photoshop measures it. + assert!((last_12[0] - 1020.0 / 4095.0).abs() < 1e-6, "{last_12:?}"); + } + + #[test] + fn three_dl_mesh_header_mismatch_is_rejected() { + // A Mesh header that disagrees with the row count is refused, not silently re-inferred. + assert!(parse_3dl("3DMESH\nMesh 1 12\n0 0 0\n0 0 1\n").is_err()); + // A node beyond the declared range is a wrong declaration: the old inference from the + // largest value keeps the file loading as it did before the header was honoured. + let mut text = String::from("3DMESH\nMesh 1 10\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + text.push_str(&format!("{} {} {}\n", r * 2047, g * 2047, b * 2047)); + } + } + } + let l = parse_3dl(&text).unwrap(); + let last = &l.data[l.data.len() - 3..]; + assert!((last[0] - 4094.0 / 4095.0).abs() < 1e-6, "{last:?}"); + } + + #[test] + fn three_dl_mesh_extra_node_cannot_replace_a_captured_shaper() { + for (bits, size) in [(2, 5), (3, 9)] { + let mut text = mesh_3dl(bits, 12, size, 4095.0, true); + let valid = parse_3dl(&text).unwrap(); + assert_eq!(valid.size, size); + assert_eq!(valid.data, parse_3dl(&mesh_3dl(bits, 12, size, 4095.0, false)).unwrap().data); + text.push_str("4095 4095 4095\n"); + let error = parse_3dl(&text).unwrap_err().to_string(); + assert!(error.contains(&format!("expected {} rows", size * size * size)), "{error}"); + } + } + + #[test] + fn three_dl_malformed_mesh_header_falls_back_to_inference() { + // A `Mesh` line that is not exactly two integer fields is not a declaration: it is + // ignored and the table is inferred as if headerless, rather than recovering numbers + // from the wrong positions (#9078). A 2³ cube would otherwise be read as a 3³ cube. + for header in ["Mesh invalid 1 12", "Mesh 1 12 13", "Mesh 1 x", "Mesh 1"] { + let mut text = format!("3DMESH\n{header}\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + text.push_str(&format!("{} {} {}\n", r * 1023, g * 1023, b * 1023)); + } + } + } + let l = parse_3dl(&text).unwrap(); + assert_eq!(l.size, 2, "{header}"); + assert_eq!(l.data, LutFile::identity(2).unwrap().data, "{header}"); + } + } + + #[test] + fn three_dl_mesh_uses_declared_range_for_small_integer_codes() { + // #9073: under a valid `Mesh` declaration the node codes are integers in the declared + // range, so a max value of 1 is 1/4095 of full output — not full output itself. + let mut text = String::from("3DMESH\nMesh 1 12\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + let v = u8::from(r == 0 && g == 0 && b == 0); + text.push_str(&format!("{v} {v} {v}\n")); + } + } + } + let l = parse_3dl(&text).unwrap(); + assert_eq!(l.size, 3); + // blue fastest: node (0,0,0) is the first entry, code 1 → 1/4095. + assert!((l.data[0] - 1.0 / 4095.0).abs() < 1e-6, "{:?}", &l.data[0..3]); + assert!(l.data[3..].iter().all(|&v| v == 0.0), "{:?}", l.data); + } + #[test] fn look_hex_floats() { let id = LutFile::identity(2).unwrap(); @@ -426,12 +1049,81 @@ DOMAIN_MAX 0.5 1 1 assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data); } + #[test] + fn look_reads_utf16_with_either_byte_order() { + let id = LutFile::identity(2).unwrap(); + let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let xml = format!("\"2\"\"{hex}\""); + let le: Vec = [0xFF, 0xFE].into_iter().chain(xml.encode_utf16().flat_map(u16::to_le_bytes)).collect(); + let be: Vec = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect(); + assert_eq!(parse("a.look", &le).unwrap().data, id.data); + assert_eq!(parse("a.look", &be).unwrap().data, id.data); + // Sniffed as XML through the byte-order mark too, so the recorded format is the one parsed. + assert_eq!(Format::detect("a.lut", &le), Format::Look); + assert_eq!(parse_as(Format::Look, &be).unwrap().data, id.data); + } + + #[test] + fn look_ignores_tags_inside_xml_comments() { + let hex = |l: &LutFile| -> String { l.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect() }; + let inverse = LutFile::from_fn("", 2, |[r, g, b]| [1.0 - r, 1.0 - g, 1.0 - b]).unwrap(); + let xml = format!( + "\"2\"\"{}\"