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 animation header (per ISO 18181-1, Section 7.1.3).
11///
12/// Defines the timing base for all frames in an animated JPEG-XL image.
13/// Frame durations are expressed as `duration_ticks * tps_denominator / tps_numerator`
14/// seconds.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct JxlAnimation {
17    /// Tick numerator for frame timing (tps_numerator).
18    /// Together with `tps_denominator`, defines the time unit for frame durations.
19    /// For example, `tps_numerator = 1000` and `tps_denominator = 1` gives
20    /// millisecond-resolution timing.
21    pub tps_numerator: u32,
22    /// Tick denominator for frame timing (tps_denominator).
23    pub tps_denominator: u32,
24    /// Number of loops (0 = infinite loop).
25    pub num_loops: u32,
26    /// Whether timecodes are present in frame headers.
27    pub have_timecodes: bool,
28}
29
30impl JxlAnimation {
31    /// Create an animation header with the given tick rate.
32    ///
33    /// # Errors
34    ///
35    /// Returns error if `tps_numerator` or `tps_denominator` is zero.
36    pub fn new(tps_numerator: u32, tps_denominator: u32) -> CodecResult<Self> {
37        if tps_numerator == 0 {
38            return Err(CodecError::InvalidParameter(
39                "tps_numerator must be non-zero".into(),
40            ));
41        }
42        if tps_denominator == 0 {
43            return Err(CodecError::InvalidParameter(
44                "tps_denominator must be non-zero".into(),
45            ));
46        }
47        Ok(Self {
48            tps_numerator,
49            tps_denominator,
50            num_loops: 0,
51            have_timecodes: false,
52        })
53    }
54
55    /// Create an animation header for millisecond-resolution timing
56    /// (tps_numerator = 1000, tps_denominator = 1).
57    pub fn millisecond() -> Self {
58        Self {
59            tps_numerator: 1000,
60            tps_denominator: 1,
61            num_loops: 0,
62            have_timecodes: false,
63        }
64    }
65
66    /// Set the number of loops (0 = infinite).
67    pub fn with_num_loops(mut self, num_loops: u32) -> Self {
68        self.num_loops = num_loops;
69        self
70    }
71
72    /// Enable timecodes in frame headers.
73    pub fn with_timecodes(mut self, have_timecodes: bool) -> Self {
74        self.have_timecodes = have_timecodes;
75        self
76    }
77
78    /// Calculate the frame duration in seconds for a given number of ticks.
79    pub fn duration_seconds(&self, ticks: u32) -> f64 {
80        if self.tps_numerator == 0 {
81            return 0.0;
82        }
83        (ticks as f64 * self.tps_denominator as f64) / self.tps_numerator as f64
84    }
85}
86
87/// Per-frame animation info embedded in each frame header.
88///
89/// Each frame in an animated JPEG-XL codestream carries timing metadata
90/// that controls playback.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct JxlFrameAnimation {
93    /// Frame duration in ticks (relative to the animation tick rate).
94    pub duration: u32,
95    /// Timecode (if `have_timecodes` is true in the animation header).
96    pub timecode: u32,
97    /// Whether this is the last frame in the animation.
98    pub is_last: bool,
99}
100
101impl JxlFrameAnimation {
102    /// Create a new frame animation info.
103    pub fn new(duration: u32, is_last: bool) -> Self {
104        Self {
105            duration,
106            timecode: 0,
107            is_last,
108        }
109    }
110}
111
112/// JPEG-XL container (ISOBMFF) signature.
113pub const JXL_CONTAINER_SIGNATURE: [u8; 12] = [
114    0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
115];
116
117/// JPEG-XL image header.
118///
119/// Contains all metadata needed to interpret a decoded image.
120#[derive(Clone, Debug)]
121pub struct JxlHeader {
122    /// Image width in pixels.
123    pub width: u32,
124    /// Image height in pixels.
125    pub height: u32,
126    /// Bits per sample (8, 16, or 32).
127    pub bits_per_sample: u8,
128    /// Number of color channels (1 for gray, 3 for RGB, 4 for RGBA).
129    pub num_channels: u8,
130    /// Whether samples are floating point.
131    pub is_float: bool,
132    /// Whether the image has an alpha channel.
133    pub has_alpha: bool,
134    /// Color space of the image data.
135    pub color_space: JxlColorSpace,
136    /// EXIF orientation (1-8, 1 = normal).
137    pub orientation: u8,
138    /// Animation header (None for still images).
139    pub animation: Option<JxlAnimation>,
140}
141
142impl JxlHeader {
143    /// Create a header for an 8-bit sRGB image.
144    pub fn srgb(width: u32, height: u32, channels: u8) -> CodecResult<Self> {
145        if channels == 0 || channels > 4 {
146            return Err(CodecError::InvalidParameter(format!(
147                "Invalid channel count: {channels}, must be 1-4"
148            )));
149        }
150        if width == 0 || height == 0 {
151            return Err(CodecError::InvalidParameter(
152                "Width and height must be non-zero".into(),
153            ));
154        }
155        let has_alpha = channels == 2 || channels == 4;
156        let color_space = if channels <= 2 {
157            JxlColorSpace::Gray
158        } else {
159            JxlColorSpace::Srgb
160        };
161        Ok(Self {
162            width,
163            height,
164            bits_per_sample: 8,
165            num_channels: channels,
166            is_float: false,
167            has_alpha,
168            color_space,
169            orientation: 1,
170            animation: None,
171        })
172    }
173
174    /// Total number of channels including alpha.
175    pub fn total_channels(&self) -> u8 {
176        self.num_channels
177    }
178
179    /// Number of color channels (excluding alpha).
180    pub fn color_channels(&self) -> u8 {
181        if self.has_alpha {
182            self.num_channels.saturating_sub(1)
183        } else {
184            self.num_channels
185        }
186    }
187
188    /// Bytes per sample for this bit depth.
189    pub fn bytes_per_sample(&self) -> usize {
190        match self.bits_per_sample {
191            1..=8 => 1,
192            9..=16 => 2,
193            _ => 4,
194        }
195    }
196
197    /// Total expected data size in bytes for interleaved pixel data.
198    pub fn data_size(&self) -> usize {
199        self.width as usize
200            * self.height as usize
201            * self.num_channels as usize
202            * self.bytes_per_sample()
203    }
204
205    /// Validate that the header is internally consistent.
206    pub fn validate(&self) -> CodecResult<()> {
207        if self.width == 0 || self.height == 0 {
208            return Err(CodecError::InvalidParameter(
209                "Width and height must be non-zero".into(),
210            ));
211        }
212        if self.width > 1_073_741_823 || self.height > 1_073_741_823 {
213            return Err(CodecError::InvalidParameter(
214                "Dimensions exceed JPEG-XL maximum (2^30 - 1)".into(),
215            ));
216        }
217        if self.num_channels == 0 || self.num_channels > 4 {
218            return Err(CodecError::InvalidParameter(format!(
219                "Invalid channel count: {}",
220                self.num_channels
221            )));
222        }
223        match self.bits_per_sample {
224            8 | 16 | 32 => {}
225            other => {
226                return Err(CodecError::InvalidParameter(format!(
227                    "Unsupported bit depth: {other}, must be 8, 16, or 32"
228                )));
229            }
230        }
231        Ok(())
232    }
233}
234
235impl Default for JxlHeader {
236    fn default() -> Self {
237        Self {
238            width: 0,
239            height: 0,
240            bits_per_sample: 8,
241            num_channels: 3,
242            is_float: false,
243            has_alpha: false,
244            color_space: JxlColorSpace::Srgb,
245            orientation: 1,
246            animation: None,
247        }
248    }
249}
250
251/// JPEG-XL color space.
252///
253/// JPEG-XL natively uses the XYB perceptual color space for lossy encoding,
254/// but lossless mode typically operates in the original color space with RCT.
255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub enum JxlColorSpace {
257    /// Standard sRGB (IEC 61966-2-1).
258    Srgb,
259    /// Linear sRGB (no transfer function).
260    LinearSrgb,
261    /// Grayscale (single luminance channel).
262    Gray,
263    /// XYB perceptual color space (JPEG-XL native, used for lossy).
264    Xyb,
265}
266
267impl Default for JxlColorSpace {
268    fn default() -> Self {
269        Self::Srgb
270    }
271}
272
273/// Frame encoding mode.
274///
275/// JPEG-XL supports two fundamentally different encoding modes.
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
277pub enum JxlFrameEncoding {
278    /// VarDCT mode for lossy compression (DCT-based, similar to JPEG but improved).
279    VarDct,
280    /// Modular mode for lossless (and progressive) compression.
281    Modular,
282}
283
284/// Encoder configuration.
285///
286/// Controls the encoding behavior including quality, effort, and mode.
287#[derive(Clone, Debug)]
288pub struct JxlConfig {
289    /// Quality factor: 0.0 = lossless, 100.0 = worst quality.
290    /// Values below ~1.0 are effectively lossless.
291    pub quality: f32,
292    /// Encoding effort (1-9). Higher values produce smaller files but take longer.
293    /// - 1: Fastest, largest files
294    /// - 7: Default balance
295    /// - 9: Slowest, smallest files
296    pub effort: u8,
297    /// Force lossless encoding regardless of quality setting.
298    pub lossless: bool,
299    /// Use container format (ISOBMFF box structure) instead of bare codestream.
300    pub use_container: bool,
301    /// Animation configuration (None for still images).
302    pub animation: Option<JxlAnimation>,
303}
304
305impl JxlConfig {
306    /// Create a lossless configuration.
307    pub fn new_lossless() -> Self {
308        Self {
309            quality: 0.0,
310            effort: 7,
311            lossless: true,
312            use_container: false,
313            animation: None,
314        }
315    }
316
317    /// Create a lossy configuration with given quality.
318    pub fn new_lossy(quality: f32) -> Self {
319        Self {
320            quality: quality.clamp(0.0, 100.0),
321            effort: 7,
322            lossless: false,
323            use_container: false,
324            animation: None,
325        }
326    }
327
328    /// Create an animated lossless configuration with the given animation header.
329    pub fn new_animated(animation: JxlAnimation) -> Self {
330        Self {
331            quality: 0.0,
332            effort: 7,
333            lossless: true,
334            use_container: false,
335            animation: Some(animation),
336        }
337    }
338
339    /// Set animation configuration.
340    pub fn with_animation(mut self, animation: JxlAnimation) -> Self {
341        self.animation = Some(animation);
342        self
343    }
344
345    /// Set effort level.
346    pub fn with_effort(mut self, effort: u8) -> Self {
347        self.effort = effort.clamp(1, 9);
348        self
349    }
350
351    /// Determine the frame encoding mode from configuration.
352    pub fn frame_encoding(&self) -> JxlFrameEncoding {
353        if self.lossless {
354            JxlFrameEncoding::Modular
355        } else {
356            JxlFrameEncoding::VarDct
357        }
358    }
359
360    /// Validate configuration values.
361    pub fn validate(&self) -> CodecResult<()> {
362        if self.effort < 1 || self.effort > 9 {
363            return Err(CodecError::InvalidParameter(format!(
364                "Effort must be 1-9, got {}",
365                self.effort
366            )));
367        }
368        if self.quality < 0.0 || self.quality > 100.0 {
369            return Err(CodecError::InvalidParameter(format!(
370                "Quality must be 0.0-100.0, got {}",
371                self.quality
372            )));
373        }
374        Ok(())
375    }
376}
377
378impl Default for JxlConfig {
379    fn default() -> Self {
380        Self::new_lossless()
381    }
382}
383
384/// A decoded frame from an animated JPEG-XL codestream.
385///
386/// Contains the pixel data and animation timing for one frame.
387#[derive(Clone, Debug)]
388pub struct JxlFrame {
389    /// Interleaved pixel data for this frame.
390    pub data: Vec<u8>,
391    /// Frame width in pixels.
392    pub width: u32,
393    /// Frame height in pixels.
394    pub height: u32,
395    /// Number of channels.
396    pub channels: u8,
397    /// Bits per sample.
398    pub bit_depth: u8,
399    /// Frame duration in ticks (relative to animation tick rate).
400    pub duration_ticks: u32,
401    /// Whether this is the last frame.
402    pub is_last: bool,
403    /// Color space of the frame data.
404    pub color_space: JxlColorSpace,
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    #[ignore]
413    fn test_header_srgb() {
414        let header = JxlHeader::srgb(1920, 1080, 3).expect("valid header");
415        assert_eq!(header.width, 1920);
416        assert_eq!(header.height, 1080);
417        assert_eq!(header.num_channels, 3);
418        assert!(!header.has_alpha);
419        assert_eq!(header.color_space, JxlColorSpace::Srgb);
420    }
421
422    #[test]
423    #[ignore]
424    fn test_header_srgb_rgba() {
425        let header = JxlHeader::srgb(100, 100, 4).expect("valid header");
426        assert!(header.has_alpha);
427        assert_eq!(header.color_channels(), 3);
428        assert_eq!(header.total_channels(), 4);
429    }
430
431    #[test]
432    #[ignore]
433    fn test_header_gray() {
434        let header = JxlHeader::srgb(64, 64, 1).expect("valid header");
435        assert_eq!(header.color_space, JxlColorSpace::Gray);
436        assert!(!header.has_alpha);
437    }
438
439    #[test]
440    #[ignore]
441    fn test_header_invalid_channels() {
442        assert!(JxlHeader::srgb(100, 100, 0).is_err());
443        assert!(JxlHeader::srgb(100, 100, 5).is_err());
444    }
445
446    #[test]
447    #[ignore]
448    fn test_header_zero_dimensions() {
449        assert!(JxlHeader::srgb(0, 100, 3).is_err());
450        assert!(JxlHeader::srgb(100, 0, 3).is_err());
451    }
452
453    #[test]
454    #[ignore]
455    fn test_header_data_size() {
456        let header = JxlHeader::srgb(10, 10, 3).expect("valid");
457        assert_eq!(header.data_size(), 10 * 10 * 3);
458    }
459
460    #[test]
461    #[ignore]
462    fn test_config_lossless() {
463        let config = JxlConfig::new_lossless();
464        assert!(config.lossless);
465        assert_eq!(config.frame_encoding(), JxlFrameEncoding::Modular);
466    }
467
468    #[test]
469    #[ignore]
470    fn test_config_lossy() {
471        let config = JxlConfig::new_lossy(50.0);
472        assert!(!config.lossless);
473        assert_eq!(config.frame_encoding(), JxlFrameEncoding::VarDct);
474    }
475
476    #[test]
477    #[ignore]
478    fn test_config_effort() {
479        let config = JxlConfig::new_lossless().with_effort(3);
480        assert_eq!(config.effort, 3);
481    }
482
483    #[test]
484    #[ignore]
485    fn test_config_validate() {
486        assert!(JxlConfig::new_lossless().validate().is_ok());
487        let mut bad = JxlConfig::new_lossless();
488        bad.effort = 0;
489        assert!(bad.validate().is_err());
490    }
491
492    #[test]
493    #[ignore]
494    fn test_codestream_signature() {
495        assert_eq!(JXL_CODESTREAM_SIGNATURE, [0xFF, 0x0A]);
496    }
497
498    #[test]
499    #[ignore]
500    fn test_container_signature() {
501        assert_eq!(JXL_CONTAINER_SIGNATURE.len(), 12);
502        // First 4 bytes are box size (12), next 4 are "JXL " type
503        assert_eq!(&JXL_CONTAINER_SIGNATURE[4..8], b"JXL ");
504    }
505
506    #[test]
507    fn test_animation_header_new() {
508        let anim = JxlAnimation::new(1000, 1).expect("valid");
509        assert_eq!(anim.tps_numerator, 1000);
510        assert_eq!(anim.tps_denominator, 1);
511        assert_eq!(anim.num_loops, 0);
512        assert!(!anim.have_timecodes);
513    }
514
515    #[test]
516    fn test_animation_header_zero_numerator() {
517        assert!(JxlAnimation::new(0, 1).is_err());
518    }
519
520    #[test]
521    fn test_animation_header_zero_denominator() {
522        assert!(JxlAnimation::new(1000, 0).is_err());
523    }
524
525    #[test]
526    fn test_animation_header_millisecond() {
527        let anim = JxlAnimation::millisecond();
528        assert_eq!(anim.tps_numerator, 1000);
529        assert_eq!(anim.tps_denominator, 1);
530    }
531
532    #[test]
533    fn test_animation_header_with_loops() {
534        let anim = JxlAnimation::millisecond().with_num_loops(3);
535        assert_eq!(anim.num_loops, 3);
536    }
537
538    #[test]
539    fn test_animation_header_with_timecodes() {
540        let anim = JxlAnimation::millisecond().with_timecodes(true);
541        assert!(anim.have_timecodes);
542    }
543
544    #[test]
545    fn test_animation_duration_seconds() {
546        let anim = JxlAnimation::millisecond();
547        // 100 ticks at 1000 tps = 0.1 seconds
548        let dur = anim.duration_seconds(100);
549        assert!((dur - 0.1).abs() < 1e-9);
550    }
551
552    #[test]
553    fn test_animation_duration_custom_rate() {
554        let anim = JxlAnimation::new(24, 1).expect("valid");
555        // 1 tick at 24 tps = 1/24 seconds
556        let dur = anim.duration_seconds(1);
557        assert!((dur - 1.0 / 24.0).abs() < 1e-9);
558    }
559
560    #[test]
561    fn test_frame_animation_new() {
562        let fa = JxlFrameAnimation::new(100, false);
563        assert_eq!(fa.duration, 100);
564        assert_eq!(fa.timecode, 0);
565        assert!(!fa.is_last);
566    }
567
568    #[test]
569    fn test_frame_animation_last() {
570        let fa = JxlFrameAnimation::new(50, true);
571        assert!(fa.is_last);
572    }
573
574    #[test]
575    fn test_header_animation_default_none() {
576        let header = JxlHeader::default();
577        assert!(header.animation.is_none());
578    }
579
580    #[test]
581    fn test_config_animation_default_none() {
582        let config = JxlConfig::new_lossless();
583        assert!(config.animation.is_none());
584    }
585
586    #[test]
587    fn test_config_new_animated() {
588        let anim = JxlAnimation::millisecond();
589        let config = JxlConfig::new_animated(anim.clone());
590        assert_eq!(config.animation.as_ref(), Some(&anim));
591        assert!(config.lossless);
592    }
593
594    #[test]
595    fn test_config_with_animation() {
596        let anim = JxlAnimation::millisecond();
597        let config = JxlConfig::new_lossless().with_animation(anim.clone());
598        assert_eq!(config.animation.as_ref(), Some(&anim));
599    }
600
601    #[test]
602    fn test_jxl_frame_struct() {
603        let frame = JxlFrame {
604            data: vec![0u8; 12],
605            width: 2,
606            height: 2,
607            channels: 3,
608            bit_depth: 8,
609            duration_ticks: 100,
610            is_last: false,
611            color_space: JxlColorSpace::Srgb,
612        };
613        assert_eq!(frame.width, 2);
614        assert_eq!(frame.duration_ticks, 100);
615        assert!(!frame.is_last);
616    }
617}