Skip to main content

oximedia_codec/jpegxl/
types.rs

1//! JPEG-XL type definitions.
2//!
3//! Core types for JPEG-XL image headers, configuration, and color spaces.
4
5use crate::error::{CodecError, CodecResult};
6
7/// JPEG-XL codestream signature: 0xFF 0x0A.
8pub const JXL_CODESTREAM_SIGNATURE: [u8; 2] = [0xFF, 0x0A];
9
10/// JPEG-XL container (ISOBMFF) signature.
11pub const JXL_CONTAINER_SIGNATURE: [u8; 12] = [
12    0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
13];
14
15/// JPEG-XL image header.
16///
17/// Contains all metadata needed to interpret a decoded image.
18#[derive(Clone, Debug)]
19pub struct JxlHeader {
20    /// Image width in pixels.
21    pub width: u32,
22    /// Image height in pixels.
23    pub height: u32,
24    /// Bits per sample (8, 16, or 32).
25    pub bits_per_sample: u8,
26    /// Number of color channels (1 for gray, 3 for RGB, 4 for RGBA).
27    pub num_channels: u8,
28    /// Whether samples are floating point.
29    pub is_float: bool,
30    /// Whether the image has an alpha channel.
31    pub has_alpha: bool,
32    /// Color space of the image data.
33    pub color_space: JxlColorSpace,
34    /// EXIF orientation (1-8, 1 = normal).
35    pub orientation: u8,
36}
37
38impl JxlHeader {
39    /// Create a header for an 8-bit sRGB image.
40    pub fn srgb(width: u32, height: u32, channels: u8) -> CodecResult<Self> {
41        if channels == 0 || channels > 4 {
42            return Err(CodecError::InvalidParameter(format!(
43                "Invalid channel count: {channels}, must be 1-4"
44            )));
45        }
46        if width == 0 || height == 0 {
47            return Err(CodecError::InvalidParameter(
48                "Width and height must be non-zero".into(),
49            ));
50        }
51        let has_alpha = channels == 2 || channels == 4;
52        let color_space = if channels <= 2 {
53            JxlColorSpace::Gray
54        } else {
55            JxlColorSpace::Srgb
56        };
57        Ok(Self {
58            width,
59            height,
60            bits_per_sample: 8,
61            num_channels: channels,
62            is_float: false,
63            has_alpha,
64            color_space,
65            orientation: 1,
66        })
67    }
68
69    /// Total number of channels including alpha.
70    pub fn total_channels(&self) -> u8 {
71        self.num_channels
72    }
73
74    /// Number of color channels (excluding alpha).
75    pub fn color_channels(&self) -> u8 {
76        if self.has_alpha {
77            self.num_channels.saturating_sub(1)
78        } else {
79            self.num_channels
80        }
81    }
82
83    /// Bytes per sample for this bit depth.
84    pub fn bytes_per_sample(&self) -> usize {
85        match self.bits_per_sample {
86            1..=8 => 1,
87            9..=16 => 2,
88            _ => 4,
89        }
90    }
91
92    /// Total expected data size in bytes for interleaved pixel data.
93    pub fn data_size(&self) -> usize {
94        self.width as usize
95            * self.height as usize
96            * self.num_channels as usize
97            * self.bytes_per_sample()
98    }
99
100    /// Validate that the header is internally consistent.
101    pub fn validate(&self) -> CodecResult<()> {
102        if self.width == 0 || self.height == 0 {
103            return Err(CodecError::InvalidParameter(
104                "Width and height must be non-zero".into(),
105            ));
106        }
107        if self.width > 1_073_741_823 || self.height > 1_073_741_823 {
108            return Err(CodecError::InvalidParameter(
109                "Dimensions exceed JPEG-XL maximum (2^30 - 1)".into(),
110            ));
111        }
112        if self.num_channels == 0 || self.num_channels > 4 {
113            return Err(CodecError::InvalidParameter(format!(
114                "Invalid channel count: {}",
115                self.num_channels
116            )));
117        }
118        match self.bits_per_sample {
119            8 | 16 | 32 => {}
120            other => {
121                return Err(CodecError::InvalidParameter(format!(
122                    "Unsupported bit depth: {other}, must be 8, 16, or 32"
123                )));
124            }
125        }
126        Ok(())
127    }
128}
129
130impl Default for JxlHeader {
131    fn default() -> Self {
132        Self {
133            width: 0,
134            height: 0,
135            bits_per_sample: 8,
136            num_channels: 3,
137            is_float: false,
138            has_alpha: false,
139            color_space: JxlColorSpace::Srgb,
140            orientation: 1,
141        }
142    }
143}
144
145/// JPEG-XL color space.
146///
147/// JPEG-XL natively uses the XYB perceptual color space for lossy encoding,
148/// but lossless mode typically operates in the original color space with RCT.
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum JxlColorSpace {
151    /// Standard sRGB (IEC 61966-2-1).
152    Srgb,
153    /// Linear sRGB (no transfer function).
154    LinearSrgb,
155    /// Grayscale (single luminance channel).
156    Gray,
157    /// XYB perceptual color space (JPEG-XL native, used for lossy).
158    Xyb,
159}
160
161impl Default for JxlColorSpace {
162    fn default() -> Self {
163        Self::Srgb
164    }
165}
166
167/// Frame encoding mode.
168///
169/// JPEG-XL supports two fundamentally different encoding modes.
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum JxlFrameEncoding {
172    /// VarDCT mode for lossy compression (DCT-based, similar to JPEG but improved).
173    VarDct,
174    /// Modular mode for lossless (and progressive) compression.
175    Modular,
176}
177
178/// Encoder configuration.
179///
180/// Controls the encoding behavior including quality, effort, and mode.
181#[derive(Clone, Debug)]
182pub struct JxlConfig {
183    /// Quality factor: 0.0 = lossless, 100.0 = worst quality.
184    /// Values below ~1.0 are effectively lossless.
185    pub quality: f32,
186    /// Encoding effort (1-9). Higher values produce smaller files but take longer.
187    /// - 1: Fastest, largest files
188    /// - 7: Default balance
189    /// - 9: Slowest, smallest files
190    pub effort: u8,
191    /// Force lossless encoding regardless of quality setting.
192    pub lossless: bool,
193    /// Use container format (ISOBMFF box structure) instead of bare codestream.
194    pub use_container: bool,
195}
196
197impl JxlConfig {
198    /// Create a lossless configuration.
199    pub fn new_lossless() -> Self {
200        Self {
201            quality: 0.0,
202            effort: 7,
203            lossless: true,
204            use_container: false,
205        }
206    }
207
208    /// Create a lossy configuration with given quality.
209    pub fn new_lossy(quality: f32) -> Self {
210        Self {
211            quality: quality.clamp(0.0, 100.0),
212            effort: 7,
213            lossless: false,
214            use_container: false,
215        }
216    }
217
218    /// Set effort level.
219    pub fn with_effort(mut self, effort: u8) -> Self {
220        self.effort = effort.clamp(1, 9);
221        self
222    }
223
224    /// Determine the frame encoding mode from configuration.
225    pub fn frame_encoding(&self) -> JxlFrameEncoding {
226        if self.lossless {
227            JxlFrameEncoding::Modular
228        } else {
229            JxlFrameEncoding::VarDct
230        }
231    }
232
233    /// Validate configuration values.
234    pub fn validate(&self) -> CodecResult<()> {
235        if self.effort < 1 || self.effort > 9 {
236            return Err(CodecError::InvalidParameter(format!(
237                "Effort must be 1-9, got {}",
238                self.effort
239            )));
240        }
241        if self.quality < 0.0 || self.quality > 100.0 {
242            return Err(CodecError::InvalidParameter(format!(
243                "Quality must be 0.0-100.0, got {}",
244                self.quality
245            )));
246        }
247        Ok(())
248    }
249}
250
251impl Default for JxlConfig {
252    fn default() -> Self {
253        Self::new_lossless()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    #[ignore]
263    fn test_header_srgb() {
264        let header = JxlHeader::srgb(1920, 1080, 3).expect("valid header");
265        assert_eq!(header.width, 1920);
266        assert_eq!(header.height, 1080);
267        assert_eq!(header.num_channels, 3);
268        assert!(!header.has_alpha);
269        assert_eq!(header.color_space, JxlColorSpace::Srgb);
270    }
271
272    #[test]
273    #[ignore]
274    fn test_header_srgb_rgba() {
275        let header = JxlHeader::srgb(100, 100, 4).expect("valid header");
276        assert!(header.has_alpha);
277        assert_eq!(header.color_channels(), 3);
278        assert_eq!(header.total_channels(), 4);
279    }
280
281    #[test]
282    #[ignore]
283    fn test_header_gray() {
284        let header = JxlHeader::srgb(64, 64, 1).expect("valid header");
285        assert_eq!(header.color_space, JxlColorSpace::Gray);
286        assert!(!header.has_alpha);
287    }
288
289    #[test]
290    #[ignore]
291    fn test_header_invalid_channels() {
292        assert!(JxlHeader::srgb(100, 100, 0).is_err());
293        assert!(JxlHeader::srgb(100, 100, 5).is_err());
294    }
295
296    #[test]
297    #[ignore]
298    fn test_header_zero_dimensions() {
299        assert!(JxlHeader::srgb(0, 100, 3).is_err());
300        assert!(JxlHeader::srgb(100, 0, 3).is_err());
301    }
302
303    #[test]
304    #[ignore]
305    fn test_header_data_size() {
306        let header = JxlHeader::srgb(10, 10, 3).expect("valid");
307        assert_eq!(header.data_size(), 10 * 10 * 3);
308    }
309
310    #[test]
311    #[ignore]
312    fn test_config_lossless() {
313        let config = JxlConfig::new_lossless();
314        assert!(config.lossless);
315        assert_eq!(config.frame_encoding(), JxlFrameEncoding::Modular);
316    }
317
318    #[test]
319    #[ignore]
320    fn test_config_lossy() {
321        let config = JxlConfig::new_lossy(50.0);
322        assert!(!config.lossless);
323        assert_eq!(config.frame_encoding(), JxlFrameEncoding::VarDct);
324    }
325
326    #[test]
327    #[ignore]
328    fn test_config_effort() {
329        let config = JxlConfig::new_lossless().with_effort(3);
330        assert_eq!(config.effort, 3);
331    }
332
333    #[test]
334    #[ignore]
335    fn test_config_validate() {
336        assert!(JxlConfig::new_lossless().validate().is_ok());
337        let mut bad = JxlConfig::new_lossless();
338        bad.effort = 0;
339        assert!(bad.validate().is_err());
340    }
341
342    #[test]
343    #[ignore]
344    fn test_codestream_signature() {
345        assert_eq!(JXL_CODESTREAM_SIGNATURE, [0xFF, 0x0A]);
346    }
347
348    #[test]
349    #[ignore]
350    fn test_container_signature() {
351        assert_eq!(JXL_CONTAINER_SIGNATURE.len(), 12);
352        // First 4 bytes are box size (12), next 4 are "JXL " type
353        assert_eq!(&JXL_CONTAINER_SIGNATURE[4..8], b"JXL ");
354    }
355}