1use crate::error::{CodecError, CodecResult};
6
7pub const JXL_CODESTREAM_SIGNATURE: [u8; 2] = [0xFF, 0x0A];
9
10#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct JxlAnimation {
17 pub tps_numerator: u32,
22 pub tps_denominator: u32,
24 pub num_loops: u32,
26 pub have_timecodes: bool,
28}
29
30impl JxlAnimation {
31 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 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 pub fn with_num_loops(mut self, num_loops: u32) -> Self {
68 self.num_loops = num_loops;
69 self
70 }
71
72 pub fn with_timecodes(mut self, have_timecodes: bool) -> Self {
74 self.have_timecodes = have_timecodes;
75 self
76 }
77
78 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#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct JxlFrameAnimation {
93 pub duration: u32,
95 pub timecode: u32,
97 pub is_last: bool,
99}
100
101impl JxlFrameAnimation {
102 pub fn new(duration: u32, is_last: bool) -> Self {
104 Self {
105 duration,
106 timecode: 0,
107 is_last,
108 }
109 }
110}
111
112pub const JXL_CONTAINER_SIGNATURE: [u8; 12] = [
114 0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
115];
116
117#[derive(Clone, Debug)]
121pub struct JxlHeader {
122 pub width: u32,
124 pub height: u32,
126 pub bits_per_sample: u8,
128 pub num_channels: u8,
130 pub is_float: bool,
132 pub has_alpha: bool,
134 pub color_space: JxlColorSpace,
136 pub orientation: u8,
138 pub animation: Option<JxlAnimation>,
140}
141
142impl JxlHeader {
143 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 pub fn total_channels(&self) -> u8 {
176 self.num_channels
177 }
178
179 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 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub enum JxlColorSpace {
257 Srgb,
259 LinearSrgb,
261 Gray,
263 Xyb,
265}
266
267impl Default for JxlColorSpace {
268 fn default() -> Self {
269 Self::Srgb
270 }
271}
272
273#[derive(Clone, Copy, Debug, PartialEq, Eq)]
277pub enum JxlFrameEncoding {
278 VarDct,
280 Modular,
282}
283
284#[derive(Clone, Debug)]
288pub struct JxlConfig {
289 pub quality: f32,
292 pub effort: u8,
297 pub lossless: bool,
299 pub use_container: bool,
301 pub animation: Option<JxlAnimation>,
303}
304
305impl JxlConfig {
306 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 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 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 pub fn with_animation(mut self, animation: JxlAnimation) -> Self {
341 self.animation = Some(animation);
342 self
343 }
344
345 pub fn with_effort(mut self, effort: u8) -> Self {
347 self.effort = effort.clamp(1, 9);
348 self
349 }
350
351 pub fn frame_encoding(&self) -> JxlFrameEncoding {
353 if self.lossless {
354 JxlFrameEncoding::Modular
355 } else {
356 JxlFrameEncoding::VarDct
357 }
358 }
359
360 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#[derive(Clone, Debug)]
388pub struct JxlFrame {
389 pub data: Vec<u8>,
391 pub width: u32,
393 pub height: u32,
395 pub channels: u8,
397 pub bit_depth: u8,
399 pub duration_ticks: u32,
401 pub is_last: bool,
403 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 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 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 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}