Skip to main content

ffcine_core/
lut.rs

1//! 3D LUTs (`.cube`), parsed on the CPU and uploaded by the backend.
2
3use std::fmt;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6
7use crate::color::named_enum;
8use crate::{Error, Result};
9
10named_enum!(
11    /// What colour space a LUT's input/output live in (libplacebo's `lut_type`).
12    LutType {
13        /// Applied to the source's native (encoded) values, before any conversion.
14        Native = "native",
15        /// Applied to normalized, decoded RGB (the prototype's baked grade).
16        Normalized = "normalized",
17        /// A full colour space conversion; replaces libplacebo's own.
18        Conversion = "conversion",
19    }
20);
21
22/// A 3D LUT: `size`^3 RGB entries, red varying fastest (`.cube` order).
23#[derive(Clone, PartialEq)]
24pub struct Lut {
25    pub title: Option<String>,
26    pub size: usize,
27    pub domain_min: [f32; 3],
28    pub domain_max: [f32; 3],
29    /// `size`^3 entries, index `r + g * size + b * size * size`.
30    pub data: Vec<[f32; 3]>,
31    /// Where it was loaded from, if anywhere.
32    pub path: Option<PathBuf>,
33}
34
35impl fmt::Debug for Lut {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.debug_struct("Lut")
38            .field("title", &self.title)
39            .field("size", &self.size)
40            .field("domain_min", &self.domain_min)
41            .field("domain_max", &self.domain_max)
42            .field("path", &self.path)
43            .finish_non_exhaustive()
44    }
45}
46
47impl Lut {
48    /// Reads an Adobe/Resolve `.cube` 3D LUT.
49    pub fn from_cube_file(path: impl AsRef<Path>) -> Result<Self> {
50        let path = path.as_ref();
51        let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
52            path: path.to_owned(),
53            source,
54        })?;
55        let mut lut = Self::parse_cube(&text).map_err(|(line, msg)| Error::CubeParse {
56            path: path.to_owned(),
57            line,
58            msg,
59        })?;
60        lut.path = Some(path.to_owned());
61        Ok(lut)
62    }
63
64    /// This LUT mixed with identity by `strength` (0..1, `lut_strength`): each lattice entry
65    /// moved from its identity value (in the LUT's domain) towards its own by `strength`.
66    #[must_use]
67    pub fn mixed(mut self, strength: f32) -> Self {
68        let s = strength.clamp(0.0, 1.0);
69        if s >= 1.0 {
70            return self;
71        }
72        let id = Self::identity(self.size);
73        let span: Vec<f32> = (0..3)
74            .map(|c| self.domain_max[c] - self.domain_min[c])
75            .collect();
76        for (v, i) in self.data.iter_mut().zip(&id.data) {
77            for c in 0..3 {
78                let ident = self.domain_min[c] + i[c] * span[c];
79                v[c] = ident + s * (v[c] - ident);
80            }
81        }
82        self
83    }
84
85    /// The identity LUT of the given size.
86    pub fn identity(size: usize) -> Self {
87        #[allow(clippy::cast_precision_loss)]
88        let v = |i: usize| i as f32 / (size - 1) as f32;
89        let mut data = Vec::with_capacity(size.pow(3));
90        for b in 0..size {
91            for g in 0..size {
92                for r in 0..size {
93                    data.push([v(r), v(g), v(b)]);
94                }
95            }
96        }
97        Self {
98            title: None,
99            size,
100            domain_min: [0.0; 3],
101            domain_max: [1.0; 3],
102            data,
103            path: None,
104        }
105    }
106
107    /// Nearest-entry lookup at lattice coordinates (for tests and debugging; the GPU interpolates).
108    pub fn at(&self, r: usize, g: usize, b: usize) -> [f32; 3] {
109        self.data[r + g * self.size + b * self.size * self.size]
110    }
111
112    /// Trilinear lookup of `rgb` (in the LUT's domain, clamped to it): how libplacebo samples a
113    /// `pl_custom_lut` (a linearly filtered 3D texture), so baked LUTs agree with the GPU.
114    #[allow(
115        clippy::cast_precision_loss,
116        clippy::cast_possible_truncation,
117        clippy::cast_sign_loss,
118        clippy::many_single_char_names
119    )]
120    pub fn sample(&self, rgb: [f32; 3]) -> [f32; 3] {
121        let n = self.size - 1;
122        let (mut i0, mut t) = ([0usize; 3], [0f32; 3]);
123        for c in 0..3 {
124            let span = self.domain_max[c] - self.domain_min[c];
125            let u = ((rgb[c] - self.domain_min[c]) / span).clamp(0.0, 1.0) * n as f32;
126            let i = (u.floor() as usize).min(n.saturating_sub(1));
127            i0[c] = i;
128            t[c] = u - i as f32;
129        }
130        let mut out = [0f32; 3];
131        for corner in 0..8 {
132            let d = [corner & 1, (corner >> 1) & 1, (corner >> 2) & 1];
133            let w: f32 = (0..3)
134                .map(|c| if d[c] == 1 { t[c] } else { 1.0 - t[c] })
135                .product();
136            if w == 0.0 {
137                continue;
138            }
139            let v = self.at(i0[0] + d[0], i0[1] + d[1], i0[2] + d[2]);
140            for c in 0..3 {
141                out[c] += w * v[c];
142            }
143        }
144        out
145    }
146
147    /// This LUT after a per-channel `gain` on its input, clamped to 0..1 (ffmpeg's
148    /// `colorchannelmixer rr:gg:bb` ahead of `lut3d`), resampled on the same lattice.
149    #[must_use]
150    pub fn with_input_gain(&self, gain: [f32; 3]) -> Self {
151        let id = Self::identity(self.size);
152        let data = id
153            .data
154            .iter()
155            .map(|p| {
156                let mut x = [0f32; 3];
157                for c in 0..3 {
158                    let v = self.domain_min[c] + p[c] * (self.domain_max[c] - self.domain_min[c]);
159                    x[c] = (v * gain[c]).clamp(0.0, 1.0);
160                }
161                self.sample(x)
162            })
163            .collect();
164        Self {
165            data,
166            path: None,
167            ..self.clone()
168        }
169    }
170
171    /// The LUT as `.cube` text (`TITLE`, `LUT_3D_SIZE`, the domain if not 0..1, then entries).
172    #[allow(clippy::float_cmp)] // exact defaults
173    pub fn to_cube(&self) -> String {
174        use std::fmt::Write as _;
175        let mut s = String::new();
176        if let Some(t) = &self.title {
177            let _ = writeln!(s, "TITLE \"{t}\"");
178        }
179        let _ = writeln!(s, "LUT_3D_SIZE {}", self.size);
180        if self.domain_min != [0.0; 3] || self.domain_max != [1.0; 3] {
181            let [a, b, c] = self.domain_min;
182            let _ = writeln!(s, "DOMAIN_MIN {a} {b} {c}");
183            let [a, b, c] = self.domain_max;
184            let _ = writeln!(s, "DOMAIN_MAX {a} {b} {c}");
185        }
186        for [r, g, b] in &self.data {
187            let _ = writeln!(s, "{r:.6} {g:.6} {b:.6}");
188        }
189        s
190    }
191
192    /// Writes the LUT as a `.cube` file.
193    pub fn write_cube(&self, path: &Path) -> Result<()> {
194        std::fs::write(path, self.to_cube()).map_err(|source| Error::Io {
195            path: path.to_owned(),
196            source,
197        })
198    }
199
200    fn parse_cube(text: &str) -> Result<Self, (usize, String)> {
201        let mut title = None;
202        let mut size = None;
203        let mut domain_min = [0.0; 3];
204        let mut domain_max = [1.0; 3];
205        let mut data = Vec::new();
206        let mut last_line = 0;
207
208        for (i, raw) in text.lines().enumerate() {
209            let line_no = i + 1;
210            last_line = line_no;
211            let line = raw.split('#').next().unwrap_or_default().trim();
212            if line.is_empty() {
213                continue;
214            }
215            let (key, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
216            let rest = rest.trim();
217            match key {
218                "TITLE" => title = Some(rest.trim_matches('"').to_owned()),
219                "LUT_3D_SIZE" => {
220                    let n: usize = rest
221                        .parse()
222                        .map_err(|_| (line_no, format!("bad LUT_3D_SIZE {rest:?}")))?;
223                    if !(2..=256).contains(&n) {
224                        return Err((line_no, format!("LUT_3D_SIZE {n} out of range")));
225                    }
226                    size = Some(n);
227                    data.reserve(n.pow(3));
228                }
229                "LUT_1D_SIZE" => return Err((line_no, "1D LUTs are not supported".into())),
230                "DOMAIN_MIN" => domain_min = triple(rest).map_err(|m| (line_no, m))?,
231                "DOMAIN_MAX" => domain_max = triple(rest).map_err(|m| (line_no, m))?,
232                // Resolve/other vendor keywords we don't use.
233                k if k.chars().next().is_some_and(|c| c.is_ascii_uppercase()) => {}
234                _ => data.push(triple(line).map_err(|m| (line_no, m))?),
235            }
236        }
237
238        let size = size.ok_or((last_line, "missing LUT_3D_SIZE".to_owned()))?;
239        if data.len() != size.pow(3) {
240            return Err((
241                last_line,
242                format!("expected {} entries, found {}", size.pow(3), data.len()),
243            ));
244        }
245        Ok(Self {
246            title,
247            size,
248            domain_min,
249            domain_max,
250            data,
251            path: None,
252        })
253    }
254}
255
256fn triple(s: &str) -> Result<[f32; 3], String> {
257    let mut it = s.split_whitespace().map(f32::from_str);
258    match (it.next(), it.next(), it.next(), it.next()) {
259        (Some(Ok(a)), Some(Ok(b)), Some(Ok(c)), None) => Ok([a, b, c]),
260        _ => Err(format!("expected three numbers, got {s:?}")),
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    const TINY: &str = "# comment\nTITLE \"tiny\"\nLUT_3D_SIZE 2\nDOMAIN_MIN 0.0 0.0 0.0\n\
269        DOMAIN_MAX 1.0 1.0 1.0\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";
270
271    #[test]
272    fn parses_cube() {
273        let lut = Lut::parse_cube(TINY).unwrap();
274        assert_eq!(lut.title.as_deref(), Some("tiny"));
275        assert_eq!(lut.size, 2);
276        assert_eq!(
277            lut,
278            Lut {
279                title: Some("tiny".into()),
280                ..Lut::identity(2)
281            }
282        );
283        assert_eq!(lut.at(1, 0, 1), [1.0, 0.0, 1.0]);
284    }
285
286    #[test]
287    fn input_gain() {
288        // On the identity a gain is the gain, clamped at 1 (exact at lattice points).
289        let g = Lut::identity(17).with_input_gain([2.0, 1.0, 0.5]);
290        assert_eq!(g.at(4, 4, 4), [0.5, 0.25, 0.125]);
291        assert_eq!(g.at(12, 0, 0), [1.0, 0.0, 0.0]);
292        assert_eq!(g.at(16, 16, 16), [1.0, 1.0, 0.5]);
293        // Through a LUT: the LUT read at the gained input.
294        let mut l = Lut::identity(9);
295        for v in &mut l.data {
296            *v = [v[0] * v[0], v[1], 1.0 - v[2]];
297        }
298        let lg = l.with_input_gain([0.5, 1.0, 1.0]);
299        let s = lg.at(8, 2, 8);
300        assert!((s[0] - l.sample([0.5, 0.0, 0.0])[0]).abs() < 1e-6, "{s:?}");
301        assert!((s[0] - 0.25).abs() < 1e-6);
302        assert!((s[1] - 0.25).abs() < 1e-6);
303        assert!(s[2].abs() < 1e-6);
304        // Unit gain is the LUT itself.
305        assert_eq!(l.with_input_gain([1.0; 3]).data, l.data);
306    }
307
308    #[test]
309    fn samples_trilinear() {
310        let id = Lut::identity(5);
311        for v in [[0.0, 0.5, 1.0], [0.13, 0.77, 0.4], [1.2, -0.1, 0.99]] {
312            let s = id.sample(v);
313            for c in 0..3 {
314                assert!((s[c] - v[c].clamp(0.0, 1.0)).abs() < 1e-6, "{v:?} -> {s:?}");
315            }
316        }
317        // Halfway between two entries is their mean.
318        let mut l = Lut::identity(2);
319        l.data[1] = [0.5, 0.0, 0.0];
320        assert!((l.sample([0.5, 0.0, 0.0])[0] - 0.25).abs() < 1e-6);
321    }
322
323    #[test]
324    fn cube_round_trip() {
325        let mut l = Lut::identity(3);
326        l.title = Some("t".into());
327        l.data[4] = [0.25, 0.5, 0.125];
328        assert_eq!(Lut::parse_cube(&l.to_cube()).unwrap(), l);
329    }
330
331    #[test]
332    fn rejects_short_cube() {
333        let err = Lut::parse_cube("LUT_3D_SIZE 2\n0 0 0\n").unwrap_err();
334        assert!(err.1.contains("expected 8 entries"), "{err:?}");
335    }
336
337    #[test]
338    fn rejects_garbage_row() {
339        let err = Lut::parse_cube("LUT_3D_SIZE 2\n0 0\n").unwrap_err();
340        assert_eq!(err.0, 2);
341    }
342}