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