Skip to main content

motion_core/
grading.rs

1//! Immutable color grading data, without filesystem or renderer dependencies.
2
3use crate::ValidationError;
4use std::sync::Arc;
5
6/// ASC slope/offset/power and Rec.709 saturation in straight linear RGB.
7#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct Cdl {
9    /// Per-channel gain.
10    pub slope: [f32; 3],
11    /// Per-channel offset.
12    pub offset: [f32; 3],
13    /// Per-channel nonnegative power.
14    pub power: [f32; 3],
15    /// Nonnegative chroma multiplier.
16    pub saturation: f32,
17}
18
19impl Default for Cdl {
20    fn default() -> Self {
21        Self {
22            slope: [1.0; 3],
23            offset: [0.0; 3],
24            power: [1.0; 3],
25            saturation: 1.0,
26        }
27    }
28}
29
30impl Cdl {
31    /// Composes simple linear RGB temperature/tint gains in -1..=1.
32    pub fn temperature_tint(
33        mut self,
34        temperature: f32,
35        tint: f32,
36    ) -> Result<Self, ValidationError> {
37        if !(-1.0..=1.0).contains(&temperature) || !(-1.0..=1.0).contains(&tint) {
38            return Err(ValidationError::invalid(
39                "cdl",
40                "white_balance",
41                "Temperature and tint must be finite in -1..=1",
42            ));
43        }
44        let gains = [
45            libm::exp2f(temperature * 0.5 + tint * 0.25),
46            libm::exp2f(-tint * 0.5),
47            libm::exp2f(-temperature * 0.5 + tint * 0.25),
48        ];
49        self.slope = std::array::from_fn(|channel| self.slope[channel] * gains[channel]);
50        self.validate()?;
51        Ok(self)
52    }
53
54    /// Validates finite, nonnegative gain, power and saturation.
55    pub fn validate(&self) -> Result<(), ValidationError> {
56        if self
57            .slope
58            .iter()
59            .chain(&self.offset)
60            .chain(&self.power)
61            .any(|value| !value.is_finite())
62            || self
63                .slope
64                .iter()
65                .chain(&self.power)
66                .any(|value| *value < 0.0)
67            || !self.saturation.is_finite()
68            || self.saturation < 0.0
69        {
70            return Err(ValidationError::invalid(
71                "cdl",
72                "parameters",
73                "Require finite CDL parameters and nonnegative gain, power and saturation",
74            ));
75        }
76        Ok(())
77    }
78
79    /// Applies the no-clamp CDL convention, clipping negative power bases only.
80    pub fn apply(self, rgb: [f32; 3]) -> [f32; 3] {
81        if self == Self::default() {
82            return rgb;
83        }
84        let rgb = std::array::from_fn(|channel| {
85            libm::powf(
86                (rgb[channel] * self.slope[channel] + self.offset[channel]).max(0.0),
87                self.power[channel],
88            )
89        });
90        if self.saturation == 1.0 {
91            return rgb;
92        }
93        let luma = rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;
94        rgb.map(|value| luma + (value - luma) * self.saturation)
95    }
96}
97
98/// Encoding expected on both sides of a look LUT.
99#[derive(Clone, Copy, Debug, Default, PartialEq)]
100pub enum LutInput {
101    /// Scene-linear values.
102    #[default]
103    Linear,
104    /// Encoded sRGB values.
105    SrgbEncoded,
106    /// Normalized log2 stops between two strictly ordered limits.
107    Log {
108        /// Minimum stop.
109        minimum: f32,
110        /// Maximum stop.
111        maximum: f32,
112    },
113}
114
115impl LutInput {
116    /// Validates the optional log domain.
117    pub fn validate(self) -> Result<(), ValidationError> {
118        if let Self::Log { minimum, maximum } = self
119            && (!minimum.is_finite()
120                || !maximum.is_finite()
121                || minimum >= maximum
122                || minimum < -64.0
123                || maximum > 64.0)
124        {
125            return Err(ValidationError::invalid(
126                "lut",
127                "encoding",
128                "Require ordered log limits within -64..=64 stops",
129            ));
130        }
131        Ok(())
132    }
133}
134
135/// Immutable per-channel linear shaper with 2..=65536 samples.
136///
137/// ```
138/// use motion_core::{Cdl, ColorWheels, Lut1d};
139/// let shaper = Lut1d::new([0.0; 3], [16.0; 3], vec![[0.0; 3], [0.8; 3], [1.0; 3]])?;
140/// let look = ColorWheels::default().bake(2, 1.0, Cdl::default())?.with_shaper(shaper);
141/// assert!((look.sample([8.0; 3])[0] - 0.8).abs() < 1e-6);
142/// # Ok::<(), motion_core::ValidationError>(())
143/// ```
144#[derive(Clone, Debug, PartialEq)]
145pub struct Lut1d {
146    domain_min: [f32; 3],
147    domain_max: [f32; 3],
148    data: Arc<[[f32; 3]]>,
149}
150
151impl Lut1d {
152    /// Freezes finite samples and strictly increasing per-channel input domains.
153    pub fn new(
154        domain_min: [f32; 3],
155        domain_max: [f32; 3],
156        data: Vec<[f32; 3]>,
157    ) -> Result<Self, ValidationError> {
158        if !(2..=65536).contains(&data.len())
159            || (0..3).any(|channel| {
160                !domain_min[channel].is_finite()
161                    || !domain_max[channel].is_finite()
162                    || domain_min[channel] >= domain_max[channel]
163                    || !(domain_max[channel] - domain_min[channel]).is_finite()
164            })
165            || data.iter().flatten().any(|value| !value.is_finite())
166        {
167            return Err(ValidationError::invalid(
168                "lut",
169                "shaper",
170                "Require 2..=65536 finite samples and ordered finite domains",
171            ));
172        }
173        Ok(Self {
174            domain_min,
175            domain_max,
176            data: data.into(),
177        })
178    }
179
180    /// Minimum input domain.
181    pub fn domain_min(&self) -> [f32; 3] {
182        self.domain_min
183    }
184
185    /// Maximum input domain.
186    pub fn domain_max(&self) -> [f32; 3] {
187        self.domain_max
188    }
189
190    /// Immutable RGB rows, one sample per channel in each row.
191    pub fn data(&self) -> &[[f32; 3]] {
192        &self.data
193    }
194
195    /// Parses a standalone 1D `.cube`; combined files belong to `Lut3d::from_cube`.
196    pub fn from_cube(text: &str) -> Result<Self, ValidationError> {
197        let parsed = ParsedCube::parse(text)?;
198        if parsed.size.is_some() {
199            return Err(cube_error());
200        }
201        parsed.shaper.ok_or_else(cube_error)
202    }
203
204    /// Samples channels independently, clamping input but not output.
205    pub fn sample(&self, rgb: [f32; 3]) -> [f32; 3] {
206        std::array::from_fn(|channel| {
207            let value = if rgb[channel].is_nan() {
208                self.domain_min[channel]
209            } else {
210                rgb[channel]
211            };
212            let position = ((value - self.domain_min[channel])
213                / (self.domain_max[channel] - self.domain_min[channel]))
214                .clamp(0.0, 1.0)
215                * (self.data.len() - 1) as f32;
216            let index = (position as usize).min(self.data.len() - 2);
217            let fraction = position - index as f32;
218            self.data[index][channel] * (1.0 - fraction) + self.data[index + 1][channel] * fraction
219        })
220    }
221}
222
223/// Validated three-dimensional cube, with red varying fastest.
224#[derive(Clone, Debug)]
225pub struct Lut3d {
226    size: u16,
227    domain_min: [f32; 3],
228    domain_max: [f32; 3],
229    data: Arc<[[f32; 3]]>,
230    shaper: Option<Lut1d>,
231
232    fingerprint: u64,
233}
234
235impl PartialEq for Lut3d {
236    fn eq(&self, other: &Self) -> bool {
237        self.fingerprint == other.fingerprint
238            && self.size == other.size
239            && self.domain_min == other.domain_min
240            && self.domain_max == other.domain_max
241            && self.data == other.data
242            && self.shaper == other.shaper
243    }
244}
245
246/// Smooth three-way linear-light grading around the 0.18 middle-gray pivot.
247#[derive(Clone, Copy, Debug, Default, PartialEq)]
248pub struct ColorWheels {
249    /// Shadow RGB offsets, weighted down to zero at luminance 0.5.
250    pub shadows: [f32; 3],
251    /// Midtone RGB offsets, peaking at luminance 0.18.
252    pub midtones: [f32; 3],
253    /// Highlight RGB offsets, smoothly entering above luminance 0.5.
254    pub highlights: [f32; 3],
255}
256
257impl ColorWheels {
258    /// Evaluates finite, unclamped scene-linear RGB offsets.
259    pub fn apply(self, rgb: [f32; 3]) -> [f32; 3] {
260        let luma = (rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722).max(0.0);
261        let smooth = |value: f32| {
262            let value = value.clamp(0.0, 1.0);
263            value * value * (3.0 - 2.0 * value)
264        };
265        let shadow = 1.0 - smooth(luma / 0.5);
266        let highlight = smooth((luma - 0.5) / 0.5);
267        let midtone = if luma <= 0.18 {
268            smooth(luma / 0.18)
269        } else {
270            1.0 - smooth((luma - 0.18) / 0.82)
271        };
272        std::array::from_fn(|channel| {
273            rgb[channel]
274                + self.shadows[channel] * shadow
275                + self.midtones[channel] * midtone
276                + self.highlights[channel] * highlight
277        })
278    }
279
280    /// Bakes an original look into an immutable CPU/GPU cube in an explicit domain.
281    pub fn bake(self, size: u16, maximum: f32, cdl: Cdl) -> Result<Lut3d, ValidationError> {
282        cdl.validate()?;
283        if !(2..=64).contains(&size)
284            || !maximum.is_finite()
285            || maximum <= 0.0
286            || self
287                .shadows
288                .iter()
289                .chain(&self.midtones)
290                .chain(&self.highlights)
291                .any(|value| !value.is_finite())
292        {
293            return Err(ValidationError::invalid(
294                "color_wheels",
295                "parameters",
296                "Require finite wheels, positive domain and size 2..=64",
297            ));
298        }
299        let mut data = Vec::with_capacity(usize::from(size).pow(3));
300        for blue in 0..size {
301            for green in 0..size {
302                for red in 0..size {
303                    data.push(
304                        self.apply(
305                            cdl.apply(
306                                [red, green, blue]
307                                    .map(|value| f32::from(value) / f32::from(size - 1) * maximum),
308                            ),
309                        ),
310                    );
311                }
312            }
313        }
314        Lut3d::new(size, [0.0; 3], [maximum; 3], data)
315    }
316}
317
318impl Lut3d {
319    /// Freezes a finite 2..=64 cube with a strictly increasing per-channel domain.
320    pub fn new(
321        size: u16,
322        domain_min: [f32; 3],
323        domain_max: [f32; 3],
324        data: Vec<[f32; 3]>,
325    ) -> Result<Self, ValidationError> {
326        if !(2..=64).contains(&size)
327            || data.len() != usize::from(size).pow(3)
328            || (0..3).any(|channel| {
329                !domain_min[channel].is_finite()
330                    || !domain_max[channel].is_finite()
331                    || domain_min[channel] >= domain_max[channel]
332                    || !(domain_max[channel] - domain_min[channel]).is_finite()
333            })
334            || data.iter().flatten().any(|value| !value.is_finite())
335        {
336            return Err(ValidationError::invalid(
337                "lut",
338                "cube",
339                "Require a finite 2..=64 cube and ordered finite domain",
340            ));
341        }
342        let mut fingerprint = 0xcbf29ce484222325u64;
343        for value in domain_min
344            .iter()
345            .chain(&domain_max)
346            .chain(data.iter().flatten())
347        {
348            for byte in value.to_bits().to_le_bytes() {
349                fingerprint = (fingerprint ^ u64::from(byte)).wrapping_mul(0x100000001b3);
350            }
351        }
352        Ok(Self {
353            size,
354            domain_min,
355            domain_max,
356            data: data.into(),
357            shaper: None,
358            fingerprint,
359        })
360    }
361
362    /// Parses a bounded 3D `.cube`, optionally preceded by a 1D shaper.
363    ///
364    /// In combined files DOMAIN_MIN/MAX describe the shaper input. The cube
365    /// defaults to 0..1 unless LUT_3D_INPUT_RANGE specifies another range.
366    pub fn from_cube(text: &str) -> Result<Self, ValidationError> {
367        let parsed = ParsedCube::parse(text)?;
368        let mut lut = Self::new(
369            parsed.size.ok_or_else(cube_error)?,
370            parsed.min,
371            parsed.max,
372            parsed.data,
373        )?;
374        lut.shaper = parsed.shaper;
375        Ok(lut)
376    }
377
378    /// Cube edge length.
379    pub fn size(&self) -> u16 {
380        self.size
381    }
382
383    /// Minimum input domain.
384    pub fn domain_min(&self) -> [f32; 3] {
385        self.domain_min
386    }
387
388    /// Maximum input domain.
389    pub fn domain_max(&self) -> [f32; 3] {
390        self.domain_max
391    }
392
393    /// Red-fastest immutable samples.
394    pub fn data(&self) -> &[[f32; 3]] {
395        &self.data
396    }
397
398    /// Attaches an input shaper; its output is interpreted in the cube domain.
399    pub fn with_shaper(mut self, shaper: Lut1d) -> Self {
400        self.shaper = Some(shaper);
401        self
402    }
403
404    /// Optional per-channel input shaper, evaluated before tetrahedral lookup.
405    pub fn shaper(&self) -> Option<&Lut1d> {
406        self.shaper.as_ref()
407    }
408
409    /// Encodes linear input, samples a look and decodes its output back to linear.
410    pub fn apply(&self, rgb: [f32; 3], input: LutInput) -> [f32; 3] {
411        let encoded = rgb.map(|value| match input {
412            LutInput::Linear => value,
413            LutInput::SrgbEncoded => crate::linear_to_srgb(value.max(0.0)),
414            LutInput::Log { minimum, maximum } => {
415                (libm::log2f(value.max(libm::exp2f(minimum))) - minimum) / (maximum - minimum)
416            }
417        });
418        self.sample(encoded).map(|value| match input {
419            LutInput::Linear => value,
420            LutInput::SrgbEncoded => crate::srgb_to_linear(value.max(0.0)),
421            LutInput::Log { minimum, maximum } => {
422                libm::exp2f(value * (maximum - minimum) + minimum)
423            }
424        })
425    }
426
427    /// Tetrahedral interpolation; out-of-domain input clamps to the boundary.
428    pub fn sample(&self, rgb: [f32; 3]) -> [f32; 3] {
429        let rgb = self
430            .shaper
431            .as_ref()
432            .map_or(rgb, |shaper| shaper.sample(rgb));
433        let rgb: [f32; 3] = std::array::from_fn(|channel| {
434            if rgb[channel].is_nan() {
435                self.domain_min[channel]
436            } else {
437                rgb[channel]
438            }
439        });
440        let edge = usize::from(self.size);
441        let position: [f32; 3] = std::array::from_fn(|channel| {
442            ((rgb[channel] - self.domain_min[channel])
443                / (self.domain_max[channel] - self.domain_min[channel]))
444                .clamp(0.0, 1.0)
445                * (edge - 1) as f32
446        });
447        let mut cell = position.map(|value| (value as usize).min(edge - 2));
448        let fraction: [f32; 3] =
449            std::array::from_fn(|channel| position[channel] - cell[channel] as f32);
450        let mut order = [0, 1, 2];
451        order.sort_by(|first, second| fraction[*second].total_cmp(&fraction[*first]));
452        let at = |cell: [usize; 3]| self.data[cell[0] + edge * (cell[1] + edge * cell[2])];
453        let start = at(cell);
454        cell[order[0]] += 1;
455        let first = at(cell);
456        cell[order[1]] += 1;
457        let second = at(cell);
458        cell[order[2]] += 1;
459        let end = at(cell);
460        std::array::from_fn(|channel| {
461            start[channel]
462                + fraction[order[0]] * (first[channel] - start[channel])
463                + fraction[order[1]] * (second[channel] - first[channel])
464                + fraction[order[2]] * (end[channel] - second[channel])
465        })
466    }
467}
468
469fn cube_error() -> ValidationError {
470    ValidationError::invalid(
471        "lut",
472        "cube",
473        "Malformed, ambiguous or unsupported .cube data",
474    )
475}
476
477struct ParsedCube {
478    size: Option<u16>,
479    min: [f32; 3],
480    max: [f32; 3],
481    data: Vec<[f32; 3]>,
482    shaper: Option<Lut1d>,
483}
484
485impl ParsedCube {
486    fn parse(text: &str) -> Result<Self, ValidationError> {
487        if text.len() > 8 * 1024 * 1024 {
488            return Err(cube_error());
489        }
490        let mut size = None;
491        let mut shaper_size = None;
492        let mut domain_min = None;
493        let mut domain_max = None;
494        let mut input1 = None;
495        let mut input3 = None;
496        let mut title = false;
497        let mut data = Vec::new();
498        for line in text.lines() {
499            let line = line.split('#').next().unwrap_or_default().trim();
500            if line.is_empty() {
501                continue;
502            }
503            let words: Vec<_> = line.split_whitespace().collect();
504            let number = |index: usize| -> Result<f32, ValidationError> {
505                let value: f32 = words
506                    .get(index)
507                    .ok_or_else(cube_error)?
508                    .parse()
509                    .map_err(|_| cube_error())?;
510                if !value.is_finite() {
511                    return Err(cube_error());
512                }
513                Ok(value)
514            };
515            match words[0] {
516                "TITLE"
517                    if !title
518                        && data.is_empty()
519                        && line[5..].trim().starts_with('"')
520                        && line.ends_with('"') =>
521                {
522                    title = true;
523                }
524                "LUT_3D_SIZE" if size.is_none() && data.is_empty() && words.len() == 2 => {
525                    let count: u16 = words[1].parse().map_err(|_| cube_error())?;
526                    if !(2..=64).contains(&count) {
527                        return Err(cube_error());
528                    }
529                    size = Some(count);
530                }
531                "LUT_1D_SIZE" if shaper_size.is_none() && data.is_empty() && words.len() == 2 => {
532                    let count: usize = words[1].parse().map_err(|_| cube_error())?;
533                    if !(2..=65536).contains(&count) {
534                        return Err(cube_error());
535                    }
536                    shaper_size = Some(count);
537                }
538                "DOMAIN_MIN" if domain_min.is_none() && data.is_empty() && words.len() == 4 => {
539                    domain_min = Some([number(1)?, number(2)?, number(3)?]);
540                }
541                "DOMAIN_MAX" if domain_max.is_none() && data.is_empty() && words.len() == 4 => {
542                    domain_max = Some([number(1)?, number(2)?, number(3)?]);
543                }
544                "LUT_1D_INPUT_RANGE" if input1.is_none() && data.is_empty() && words.len() == 3 => {
545                    input1 = Some((number(1)?, number(2)?));
546                }
547                "LUT_3D_INPUT_RANGE" if input3.is_none() && data.is_empty() && words.len() == 3 => {
548                    input3 = Some((number(1)?, number(2)?));
549                }
550                _ => {
551                    let count = shaper_size.unwrap_or(0)
552                        + size.map_or(0, |value| usize::from(value).pow(3));
553                    if words.len() != 3 || data.len() >= count {
554                        return Err(cube_error());
555                    }
556                    if data.is_empty() {
557                        data.try_reserve_exact(count).map_err(|_| cube_error())?;
558                    }
559                    data.push([number(0)?, number(1)?, number(2)?]);
560                }
561            }
562        }
563        let has_domain = domain_min.is_some() || domain_max.is_some();
564        if (input1.is_some() && (shaper_size.is_none() || has_domain))
565            || (input3.is_some() && (size.is_none() || (shaper_size.is_none() && has_domain)))
566            || (size.is_none() && shaper_size.is_none())
567            || data.len()
568                != shaper_size.unwrap_or(0) + size.map_or(0, |value| usize::from(value).pow(3))
569        {
570            return Err(cube_error());
571        }
572        let min = domain_min.unwrap_or([0.0; 3]);
573        let max = domain_max.unwrap_or([1.0; 3]);
574        let shaper = if let Some(count) = shaper_size {
575            let (min, max) = input1.map_or((min, max), |(min, max)| ([min; 3], [max; 3]));
576            Some(Lut1d::new(min, max, data.drain(..count).collect())?)
577        } else {
578            None
579        };
580        let (min, max) = input3.map_or(
581            if shaper.is_some() {
582                ([0.0; 3], [1.0; 3])
583            } else {
584                (min, max)
585            },
586            |(min, max)| ([min; 3], [max; 3]),
587        );
588        Ok(Self {
589            size,
590            min,
591            max,
592            data,
593            shaper,
594        })
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn shaper_precedes_cube_and_preserves_unbounded_output() {
604        let shaper = Lut1d::new(
605            [0.0; 3],
606            [4.0; 3],
607            vec![[0.0; 3], [0.2, 0.4, 0.6], [1.0; 3]],
608        )
609        .unwrap();
610        assert_eq!(shaper.sample([2.0; 3]), [0.2, 0.4, 0.6]);
611        assert_eq!(
612            shaper.sample([f32::NAN, f32::INFINITY, -1.0]),
613            [0.0, 1.0, 0.0]
614        );
615        let cube = ColorWheels::default().bake(2, 1.0, Cdl::default()).unwrap();
616        let shaped = cube.clone().with_shaper(shaper);
617        assert_ne!(cube, shaped);
618        for (actual, expected) in shaped.sample([2.0; 3]).into_iter().zip([0.2, 0.4, 0.6]) {
619            assert!((actual - expected).abs() < 1e-6);
620        }
621        let hdr = Lut1d::new([0.0; 3], [1.0; 3], vec![[-2.0; 3], [8.0; 3]]).unwrap();
622        assert_eq!(hdr.sample([0.5; 3]), [3.0; 3]);
623        assert!(Lut1d::new([0.0; 3], [0.0; 3], vec![[0.0; 3]; 2]).is_err());
624        assert!(Lut1d::new([0.0; 3], [1.0; 3], vec![[0.0; 3]]).is_err());
625    }
626
627    #[test]
628    fn standalone_and_combined_cube_shapers_parse() {
629        let header = "LUT_1D_SIZE 3\nLUT_1D_INPUT_RANGE 0 4\n";
630        let rows = "0 0 0\n0.2 0.4 0.6\n1 1 1\n";
631        let standalone = Lut1d::from_cube(&format!("{header}{rows}")).unwrap();
632        assert_eq!(standalone.sample([2.0; 3]), [0.2, 0.4, 0.6]);
633        let cube = "0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
634        let combined = format!("{header}LUT_3D_SIZE 2\nLUT_3D_INPUT_RANGE 0 1\n{rows}{cube}");
635        let lut = Lut3d::from_cube(&combined).unwrap();
636        assert_eq!(lut.shaper(), Some(&standalone));
637        for (actual, expected) in lut.sample([2.0; 3]).into_iter().zip([0.2, 0.4, 0.6]) {
638            assert!((actual - expected).abs() < 1e-6);
639        }
640        assert!(Lut1d::from_cube(&combined).is_err());
641        for text in [
642            "LUT_1D_SIZE 65537",
643            "LUT_1D_SIZE 2\n0 0 0",
644            "LUT_1D_SIZE 2\nLUT_1D_SIZE 2",
645            "LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 1 0\n0 0 0\n1 1 1",
646            "LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 0 1\nDOMAIN_MIN 0 0 0\n0 0 0\n1 1 1",
647            "LUT_1D_SIZE 2\n0 0 0\nNaN 1 1",
648            "LUT_1D_SIZE 2\n0 0 0\n1 1 1\n2 2 2",
649        ] {
650            assert!(Lut1d::from_cube(text).is_err(), "{text}");
651        }
652    }
653
654    #[test]
655    fn shaper_runs_between_input_encoding_and_output_decoding() {
656        let shaper = Lut1d::new(
657            [0.0; 3],
658            [1.0; 3],
659            vec![[0.0; 3], [0.7, 0.2, 0.4], [1.0; 3]],
660        )
661        .unwrap();
662        let lut = ColorWheels::default()
663            .bake(2, 1.0, Cdl::default())
664            .unwrap()
665            .with_shaper(shaper.clone());
666        let rgb = [0.01, 0.18, 0.8];
667        for input in [
668            LutInput::Linear,
669            LutInput::SrgbEncoded,
670            LutInput::Log {
671                minimum: -8.0,
672                maximum: 4.0,
673            },
674        ] {
675            let encoded = rgb.map(|value| match input {
676                LutInput::Linear => value,
677                LutInput::SrgbEncoded => crate::linear_to_srgb(value),
678                LutInput::Log { minimum, maximum } => {
679                    (libm::log2f(value) - minimum) / (maximum - minimum)
680                }
681            });
682            let expected = shaper.sample(encoded).map(|value| match input {
683                LutInput::Linear => value,
684                LutInput::SrgbEncoded => crate::srgb_to_linear(value),
685                LutInput::Log { minimum, maximum } => {
686                    libm::exp2f(value * (maximum - minimum) + minimum)
687                }
688            });
689            for (actual, expected) in lut.apply(rgb, input).into_iter().zip(expected) {
690                assert!((actual - expected).abs() <= 1e-5 * expected.abs().max(1.0));
691            }
692        }
693    }
694
695    #[test]
696    fn identity_cube_and_tetrahedral_boundaries() {
697        let lut = ColorWheels::default().bake(4, 1.0, Cdl::default()).unwrap();
698        for rgb in [
699            [0.0; 3],
700            [1.0; 3],
701            [0.13, 0.83, 0.42],
702            [0.8, 0.2, 0.2],
703            [0.5; 3],
704        ] {
705            let actual = lut.sample(rgb);
706            for channel in 0..3 {
707                assert!((actual[channel] - rgb[channel]).abs() < 1e-5);
708            }
709        }
710        assert_eq!(lut.sample([-1.0, 2.0, 0.0]), [0.0, 1.0, 0.0]);
711        assert_eq!(
712            lut,
713            ColorWheels::default().bake(4, 1.0, Cdl::default()).unwrap()
714        );
715    }
716
717    #[test]
718    fn cube_parser_rejects_missing_cube_duplicates_nan_and_truncation() {
719        for text in [
720            "LUT_1D_SIZE 2",
721            "LUT_3D_SIZE 1",
722            "LUT_3D_SIZE 2\n0 0 0",
723            "LUT_3D_SIZE 2\nLUT_3D_SIZE 2",
724            "LUT_3D_SIZE 2\nNaN 0 0",
725        ] {
726            assert!(Lut3d::from_cube(text).is_err());
727        }
728        let data = "TITLE \"identity\"\nLUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
729        assert_eq!(
730            Lut3d::from_cube(data).unwrap().sample([0.2, 0.7, 0.4]),
731            [0.2, 0.7, 0.4]
732        );
733    }
734
735    #[test]
736    fn cdl_white_balance_and_wheels_validate() {
737        assert_eq!(Cdl::default().apply([-0.1, 0.18, 4.0]), [-0.1, 0.18, 4.0]);
738        assert_eq!(
739            Cdl::default().temperature_tint(0.0, 0.0).unwrap(),
740            Cdl::default()
741        );
742        assert!(Cdl::default().temperature_tint(f32::NAN, 0.0).is_err());
743        assert!(
744            ColorWheels {
745                shadows: [f32::NAN; 3],
746                ..ColorWheels::default()
747            }
748            .bake(2, 1.0, Cdl::default())
749            .is_err()
750        );
751    }
752}