Skip to main content

alpha2_render/
effect_resource.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use half::f16;
5use wgpu::util::DeviceExt;
6
7use crate::{EffectResourceKind, RenderError, Renderer};
8
9/// Canonical RGB samples for one cubic 3D lookup table.
10#[derive(Clone, Debug, PartialEq)]
11pub struct Lut3dData {
12    /// Equal width, height, and depth of the cube.
13    pub edge_length: u32,
14    /// Per-channel input-domain lower bound declared by the file.
15    pub domain_min: [f32; 3],
16    /// Per-channel input-domain upper bound declared by the file.
17    pub domain_max: [f32; 3],
18    /// RGB samples in blue-major, green-middle, red-fastest order.
19    pub values: Vec<[f32; 3]>,
20}
21
22impl Lut3dData {
23    /// Parses the portable 3D subset of the Adobe/Resolve `.cube` text format.
24    ///
25    /// `TITLE`, comments, per-channel domains, and `LUT_3D_INPUT_RANGE` are
26    /// accepted, as are CRLF line ends and a leading UTF-8 byte order mark,
27    /// which a browser's text decoding removes and `std::fs::read_to_string`
28    /// keeps: every host parses the same file alike. One-dimensional tables
29    /// are rejected instead of being silently interpreted as a different
30    /// color transform.
31    ///
32    /// # Errors
33    /// Returns a line-oriented diagnostic for malformed or unsupported input.
34    pub fn from_cube(text: &str) -> Result<Self, LutParseError> {
35        let mut edge = None;
36        let mut domain_min = [0.0; 3];
37        let mut domain_max = [1.0; 3];
38        let mut values = Vec::new();
39        let text = text.strip_prefix('\u{feff}').unwrap_or(text);
40        for (index, raw) in text.lines().enumerate() {
41            let line_number = index + 1;
42            let line = raw.split('#').next().unwrap_or_default().trim();
43            if line.is_empty() || line.starts_with("TITLE") {
44                continue;
45            }
46            let fields = line.split_whitespace().collect::<Vec<_>>();
47            match fields[0] {
48                "LUT_3D_SIZE" if fields.len() == 2 => {
49                    let parsed = fields[1]
50                        .parse::<u32>()
51                        .map_err(|_| LutParseError::new(line_number, "invalid LUT_3D_SIZE"))?;
52                    if parsed < 2 {
53                        return Err(LutParseError::new(
54                            line_number,
55                            "LUT_3D_SIZE must be at least two",
56                        ));
57                    }
58                    edge = Some(parsed);
59                }
60                "LUT_1D_SIZE" => {
61                    return Err(LutParseError::new(
62                        line_number,
63                        "one-dimensional .cube tables are not supported",
64                    ));
65                }
66                "DOMAIN_MIN" if fields.len() == 4 => {
67                    domain_min = parse_triplet(&fields[1..], line_number)?;
68                }
69                "DOMAIN_MAX" if fields.len() == 4 => {
70                    domain_max = parse_triplet(&fields[1..], line_number)?;
71                }
72                "LUT_3D_INPUT_RANGE" if fields.len() == 3 => {
73                    let min = parse_component(fields[1], line_number)?;
74                    let max = parse_component(fields[2], line_number)?;
75                    domain_min = [min; 3];
76                    domain_max = [max; 3];
77                }
78                directive if directive.chars().next().is_some_and(char::is_alphabetic) => {
79                    return Err(LutParseError::new(
80                        line_number,
81                        format!("unsupported .cube directive {directive}"),
82                    ));
83                }
84                _ if fields.len() == 3 => values.push(parse_triplet(&fields, line_number)?),
85                _ => {
86                    return Err(LutParseError::new(
87                        line_number,
88                        "expected an RGB sample or supported .cube directive",
89                    ));
90                }
91            }
92        }
93        let edge = edge.ok_or_else(|| LutParseError::new(0, "missing LUT_3D_SIZE"))?;
94        let expected = usize::try_from(edge)
95            .ok()
96            .and_then(|value| value.checked_pow(3))
97            .ok_or_else(|| LutParseError::new(0, "LUT size is too large"))?;
98        if values.len() != expected {
99            return Err(LutParseError::new(
100                0,
101                format!("expected {expected} RGB samples, found {}", values.len()),
102            ));
103        }
104        if domain_min
105            .iter()
106            .zip(domain_max)
107            .any(|(min, max)| *min >= max)
108        {
109            return Err(LutParseError::new(
110                0,
111                ".cube input domain minimum must be below its maximum",
112            ));
113        }
114        Ok(Self {
115            edge_length: edge,
116            domain_min,
117            domain_max,
118            values,
119        })
120    }
121
122    fn is_valid(&self, maximum: u32) -> bool {
123        self.edge_length >= 2
124            && self.edge_length <= maximum
125            && usize::try_from(self.edge_length)
126                .ok()
127                .and_then(|value| value.checked_pow(3))
128                .is_some_and(|expected| expected == self.values.len())
129            && self.values.iter().flatten().all(|value| value.is_finite())
130            && self
131                .domain_min
132                .iter()
133                .zip(self.domain_max)
134                .all(|(min, max)| min.is_finite() && max.is_finite() && *min < max)
135    }
136}
137
138fn parse_triplet(fields: &[&str], line: usize) -> Result<[f32; 3], LutParseError> {
139    let mut output = [0.0; 3];
140    for (target, field) in output.iter_mut().zip(fields) {
141        *target = parse_component(field, line)?;
142    }
143    Ok(output)
144}
145
146fn parse_component(field: &str, line: usize) -> Result<f32, LutParseError> {
147    field
148        .parse::<f32>()
149        .ok()
150        .filter(|value| value.is_finite())
151        .ok_or_else(|| LutParseError::new(line, "invalid finite RGB component"))
152}
153
154/// Diagnostic returned while parsing `.cube` text.
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct LutParseError {
157    line: usize,
158    message: String,
159}
160
161impl LutParseError {
162    fn new(line: usize, message: impl Into<String>) -> Self {
163        Self {
164            line,
165            message: message.into(),
166        }
167    }
168}
169
170impl fmt::Display for LutParseError {
171    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172        if self.line == 0 {
173            formatter.write_str(&self.message)
174        } else {
175            write!(formatter, ".cube line {}: {}", self.line, self.message)
176        }
177    }
178}
179
180impl std::error::Error for LutParseError {}
181
182#[derive(Debug)]
183pub(crate) struct RegisteredEffectResource {
184    pub(crate) kind: EffectResourceKind,
185    pub(crate) _texture: wgpu::Texture,
186    pub(crate) view: wgpu::TextureView,
187    pub(crate) metadata: wgpu::Buffer,
188}
189
190pub(crate) type EffectResourceRegistry = HashMap<String, RegisteredEffectResource>;
191
192impl Renderer {
193    /// Uploads or replaces one filterable 3D lookup-table resource.
194    ///
195    /// # Errors
196    /// Returns [`RenderError::InvalidEffectResource`] for an invalid identity,
197    /// cube shape, sample, or device-incompatible edge length, and
198    /// [`RenderError::Gpu`] when the device cannot make the texture (natively;
199    /// a browser reports that as an uncaptured error). A resource that fails
200    /// leaves the registry as it was.
201    pub fn register_lut3d(
202        &mut self,
203        id: impl Into<String>,
204        data: &Lut3dData,
205    ) -> Result<(), RenderError> {
206        let id = id.into();
207        if id.is_empty() || !data.is_valid(self.limits.max_texture_dimension_3d) {
208            return Err(RenderError::InvalidEffectResource);
209        }
210        let mut bytes = Vec::with_capacity(data.values.len() * 8);
211        for rgb in &data.values {
212            for value in [rgb[0], rgb[1], rgb[2], 1.0] {
213                bytes.extend_from_slice(&f16::from_f32(value).to_bits().to_le_bytes());
214            }
215        }
216        let (texture, error) = crate::gpu_errors::capture_creation(self.gpu.device(), || {
217            self.gpu.device().create_texture_with_data(
218                self.gpu.queue(),
219                &wgpu::TextureDescriptor {
220                    label: Some(&format!("alpha2 LUT resource {id}")),
221                    size: wgpu::Extent3d {
222                        width: data.edge_length,
223                        height: data.edge_length,
224                        depth_or_array_layers: data.edge_length,
225                    },
226                    mip_level_count: 1,
227                    sample_count: 1,
228                    dimension: wgpu::TextureDimension::D3,
229                    format: wgpu::TextureFormat::Rgba16Float,
230                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
231                    view_formats: &[],
232                },
233                wgpu::util::TextureDataOrder::LayerMajor,
234                &bytes,
235            )
236        });
237        if let Some(kind) = error {
238            return Err(RenderError::Gpu { kind });
239        }
240        let view = crate::pool::default_view(&texture);
241        let mut metadata_bytes = Vec::with_capacity(32);
242        for value in [
243            data.domain_min[0],
244            data.domain_min[1],
245            data.domain_min[2],
246            0.0,
247            data.domain_max[0],
248            data.domain_max[1],
249            data.domain_max[2],
250            0.0,
251        ] {
252            metadata_bytes.extend_from_slice(&value.to_le_bytes());
253        }
254        let metadata = self
255            .gpu
256            .device()
257            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
258                label: Some(&format!("alpha2 LUT metadata {id}")),
259                contents: &metadata_bytes,
260                usage: wgpu::BufferUsages::UNIFORM,
261            });
262        self.effect_resources.insert(
263            id,
264            RegisteredEffectResource {
265                kind: EffectResourceKind::Lut3d,
266                _texture: texture,
267                view,
268                metadata,
269            },
270        );
271        // Results name a resource by id; its pixels may have just changed.
272        self.clear_result_cache_for(crate::CacheInvalidation::EffectResources);
273        Ok(())
274    }
275
276    /// Removes one uploaded effect resource and returns whether it existed.
277    ///
278    /// Removing one clears the cached results, since they may have used it.
279    pub fn unregister_effect_resource(&mut self, id: &str) -> bool {
280        let removed = self.effect_resources.remove(id).is_some();
281        if removed {
282            self.clear_result_cache_for(crate::CacheInvalidation::EffectResources);
283        }
284        removed
285    }
286
287    /// Removes all uploaded effect resources and clears the cached results.
288    pub fn clear_effect_resources(&mut self) {
289        self.effect_resources.clear();
290        self.clear_result_cache_for(crate::CacheInvalidation::EffectResources);
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn parses_a_three_dimensional_cube() {
300        let data = Lut3dData::from_cube(
301            "TITLE \"identity\"\nLUT_3D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n\
302             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",
303        )
304        .expect("valid cube");
305        assert_eq!(data.edge_length, 2);
306        assert_triplet_eq(data.domain_min, [0.0; 3]);
307        assert_eq!(data.values.len(), 8);
308        assert_triplet_eq(data.values[7], [1.0, 1.0, 1.0]);
309    }
310
311    #[test]
312    fn accepts_non_unit_domains_and_rejects_invalid_cubes() {
313        let ranged = Lut3dData::from_cube(
314            "LUT_3D_SIZE 2\nDOMAIN_MIN -1 -0.5 0\nDOMAIN_MAX 1 1.5 2\n\
315             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",
316        )
317        .expect("per-channel domains are supported");
318        assert_triplet_eq(ranged.domain_min, [-1.0, -0.5, 0.0]);
319        assert_triplet_eq(ranged.domain_max, [1.0, 1.5, 2.0]);
320        assert!(
321            Lut3dData::from_cube("LUT_3D_SIZE 2\nDOMAIN_MIN 2 2 2\nDOMAIN_MAX 1 1 1\n").is_err()
322        );
323        assert!(Lut3dData::from_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
324    }
325
326    #[test]
327    fn a_cube_written_with_a_byte_order_mark_or_crlf_parses_as_without() {
328        // A browser's UTF-8 decode drops a leading BOM and `fs::read_to_string` keeps it, so
329        // the parser drops it too, or a native host refuses the file the browser previews.
330        let body = "LUT_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";
331        let plain = Lut3dData::from_cube(body).expect("valid cube");
332        for text in [
333            format!("\u{feff}{body}"),
334            format!("\u{feff}TITLE \"bom\"\n{body}"),
335            format!("\u{feff}# written by a Windows tool\n{body}"),
336            body.replace('\n', "\r\n"),
337            format!("\u{feff}{}", body.replace('\n', "\r\n")),
338        ] {
339            assert_eq!(Lut3dData::from_cube(&text), Ok(plain.clone()), "{text:?}");
340        }
341        // Only a leading mark: one anywhere else is not whitespace.
342        let inner = Lut3dData::from_cube(&body.replace("0 0 1\n", "\u{feff}0 0 1\n"))
343            .expect_err("a mark inside the file is not text the parser knows");
344        assert_eq!(inner.line, 6);
345    }
346
347    #[test]
348    fn a_cube_refuses_one_dimensional_tables_and_unknown_directives_by_line() {
349        let one_dimensional = Lut3dData::from_cube("TITLE \"1d\"\nLUT_1D_SIZE 2\n0 0 0\n1 1 1\n")
350            .expect_err("a 1D table is a different transform");
351        assert_eq!(one_dimensional.line, 2);
352        assert!(one_dimensional.to_string().contains("one-dimensional"));
353        let unknown = Lut3dData::from_cube("LUT_3D_SIZE 2\nLUT_3D_SHAPER 4\n")
354            .expect_err("an unknown directive is refused, not skipped");
355        assert_eq!(unknown.line, 2);
356        assert!(unknown.to_string().contains("LUT_3D_SHAPER"));
357    }
358
359    fn assert_triplet_eq(actual: [f32; 3], expected: [f32; 3]) {
360        assert!(
361            actual
362                .iter()
363                .zip(expected)
364                .all(|(actual, expected)| (*actual - expected).abs() <= f32::EPSILON)
365        );
366    }
367}