use crate::error::{CodecError, CodecResult};
pub const JXL_CODESTREAM_SIGNATURE: [u8; 2] = [0xFF, 0x0A];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JxlAnimation {
pub tps_numerator: u32,
pub tps_denominator: u32,
pub num_loops: u32,
pub have_timecodes: bool,
}
impl JxlAnimation {
pub fn new(tps_numerator: u32, tps_denominator: u32) -> CodecResult<Self> {
if tps_numerator == 0 {
return Err(CodecError::InvalidParameter(
"tps_numerator must be non-zero".into(),
));
}
if tps_denominator == 0 {
return Err(CodecError::InvalidParameter(
"tps_denominator must be non-zero".into(),
));
}
Ok(Self {
tps_numerator,
tps_denominator,
num_loops: 0,
have_timecodes: false,
})
}
pub fn millisecond() -> Self {
Self {
tps_numerator: 1000,
tps_denominator: 1,
num_loops: 0,
have_timecodes: false,
}
}
pub fn with_num_loops(mut self, num_loops: u32) -> Self {
self.num_loops = num_loops;
self
}
pub fn with_timecodes(mut self, have_timecodes: bool) -> Self {
self.have_timecodes = have_timecodes;
self
}
pub fn duration_seconds(&self, ticks: u32) -> f64 {
if self.tps_numerator == 0 {
return 0.0;
}
(ticks as f64 * self.tps_denominator as f64) / self.tps_numerator as f64
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JxlFrameAnimation {
pub duration: u32,
pub timecode: u32,
pub is_last: bool,
}
impl JxlFrameAnimation {
pub fn new(duration: u32, is_last: bool) -> Self {
Self {
duration,
timecode: 0,
is_last,
}
}
}
pub const JXL_CONTAINER_SIGNATURE: [u8; 12] = [
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
];
#[derive(Clone, Debug)]
pub struct JxlHeader {
pub width: u32,
pub height: u32,
pub bits_per_sample: u8,
pub num_channels: u8,
pub is_float: bool,
pub has_alpha: bool,
pub color_space: JxlColorSpace,
pub orientation: u8,
pub animation: Option<JxlAnimation>,
}
impl JxlHeader {
pub fn srgb(width: u32, height: u32, channels: u8) -> CodecResult<Self> {
if channels == 0 || channels > 4 {
return Err(CodecError::InvalidParameter(format!(
"Invalid channel count: {channels}, must be 1-4"
)));
}
if width == 0 || height == 0 {
return Err(CodecError::InvalidParameter(
"Width and height must be non-zero".into(),
));
}
let has_alpha = channels == 2 || channels == 4;
let color_space = if channels <= 2 {
JxlColorSpace::Gray
} else {
JxlColorSpace::Srgb
};
Ok(Self {
width,
height,
bits_per_sample: 8,
num_channels: channels,
is_float: false,
has_alpha,
color_space,
orientation: 1,
animation: None,
})
}
pub fn total_channels(&self) -> u8 {
self.num_channels
}
pub fn color_channels(&self) -> u8 {
if self.has_alpha {
self.num_channels.saturating_sub(1)
} else {
self.num_channels
}
}
pub fn bytes_per_sample(&self) -> usize {
match self.bits_per_sample {
1..=8 => 1,
9..=16 => 2,
_ => 4,
}
}
pub fn data_size(&self) -> usize {
self.width as usize
* self.height as usize
* self.num_channels as usize
* self.bytes_per_sample()
}
pub fn validate(&self) -> CodecResult<()> {
if self.width == 0 || self.height == 0 {
return Err(CodecError::InvalidParameter(
"Width and height must be non-zero".into(),
));
}
if self.width > 1_073_741_823 || self.height > 1_073_741_823 {
return Err(CodecError::InvalidParameter(
"Dimensions exceed JPEG-XL maximum (2^30 - 1)".into(),
));
}
if self.num_channels == 0 || self.num_channels > 4 {
return Err(CodecError::InvalidParameter(format!(
"Invalid channel count: {}",
self.num_channels
)));
}
match self.bits_per_sample {
8 | 16 | 32 => {}
other => {
return Err(CodecError::InvalidParameter(format!(
"Unsupported bit depth: {other}, must be 8, 16, or 32"
)));
}
}
Ok(())
}
}
impl Default for JxlHeader {
fn default() -> Self {
Self {
width: 0,
height: 0,
bits_per_sample: 8,
num_channels: 3,
is_float: false,
has_alpha: false,
color_space: JxlColorSpace::Srgb,
orientation: 1,
animation: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JxlColorSpace {
Srgb,
LinearSrgb,
Gray,
Xyb,
}
impl Default for JxlColorSpace {
fn default() -> Self {
Self::Srgb
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JxlFrameEncoding {
VarDct,
Modular,
}
#[derive(Clone, Debug)]
pub struct JxlConfig {
pub quality: f32,
pub effort: u8,
pub lossless: bool,
pub use_container: bool,
pub animation: Option<JxlAnimation>,
}
impl JxlConfig {
pub fn new_lossless() -> Self {
Self {
quality: 0.0,
effort: 7,
lossless: true,
use_container: false,
animation: None,
}
}
pub fn new_lossy(quality: f32) -> Self {
Self {
quality: quality.clamp(0.0, 100.0),
effort: 7,
lossless: false,
use_container: false,
animation: None,
}
}
pub fn new_animated(animation: JxlAnimation) -> Self {
Self {
quality: 0.0,
effort: 7,
lossless: true,
use_container: false,
animation: Some(animation),
}
}
pub fn with_animation(mut self, animation: JxlAnimation) -> Self {
self.animation = Some(animation);
self
}
pub fn with_effort(mut self, effort: u8) -> Self {
self.effort = effort.clamp(1, 9);
self
}
pub fn frame_encoding(&self) -> JxlFrameEncoding {
if self.lossless {
JxlFrameEncoding::Modular
} else {
JxlFrameEncoding::VarDct
}
}
pub fn validate(&self) -> CodecResult<()> {
if self.effort < 1 || self.effort > 9 {
return Err(CodecError::InvalidParameter(format!(
"Effort must be 1-9, got {}",
self.effort
)));
}
if self.quality < 0.0 || self.quality > 100.0 {
return Err(CodecError::InvalidParameter(format!(
"Quality must be 0.0-100.0, got {}",
self.quality
)));
}
Ok(())
}
}
impl Default for JxlConfig {
fn default() -> Self {
Self::new_lossless()
}
}
#[derive(Clone, Debug)]
pub struct JxlFrame {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub channels: u8,
pub bit_depth: u8,
pub duration_ticks: u32,
pub is_last: bool,
pub color_space: JxlColorSpace,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore]
fn test_header_srgb() {
let header = JxlHeader::srgb(1920, 1080, 3).expect("valid header");
assert_eq!(header.width, 1920);
assert_eq!(header.height, 1080);
assert_eq!(header.num_channels, 3);
assert!(!header.has_alpha);
assert_eq!(header.color_space, JxlColorSpace::Srgb);
}
#[test]
#[ignore]
fn test_header_srgb_rgba() {
let header = JxlHeader::srgb(100, 100, 4).expect("valid header");
assert!(header.has_alpha);
assert_eq!(header.color_channels(), 3);
assert_eq!(header.total_channels(), 4);
}
#[test]
#[ignore]
fn test_header_gray() {
let header = JxlHeader::srgb(64, 64, 1).expect("valid header");
assert_eq!(header.color_space, JxlColorSpace::Gray);
assert!(!header.has_alpha);
}
#[test]
#[ignore]
fn test_header_invalid_channels() {
assert!(JxlHeader::srgb(100, 100, 0).is_err());
assert!(JxlHeader::srgb(100, 100, 5).is_err());
}
#[test]
#[ignore]
fn test_header_zero_dimensions() {
assert!(JxlHeader::srgb(0, 100, 3).is_err());
assert!(JxlHeader::srgb(100, 0, 3).is_err());
}
#[test]
#[ignore]
fn test_header_data_size() {
let header = JxlHeader::srgb(10, 10, 3).expect("valid");
assert_eq!(header.data_size(), 10 * 10 * 3);
}
#[test]
#[ignore]
fn test_config_lossless() {
let config = JxlConfig::new_lossless();
assert!(config.lossless);
assert_eq!(config.frame_encoding(), JxlFrameEncoding::Modular);
}
#[test]
#[ignore]
fn test_config_lossy() {
let config = JxlConfig::new_lossy(50.0);
assert!(!config.lossless);
assert_eq!(config.frame_encoding(), JxlFrameEncoding::VarDct);
}
#[test]
#[ignore]
fn test_config_effort() {
let config = JxlConfig::new_lossless().with_effort(3);
assert_eq!(config.effort, 3);
}
#[test]
#[ignore]
fn test_config_validate() {
assert!(JxlConfig::new_lossless().validate().is_ok());
let mut bad = JxlConfig::new_lossless();
bad.effort = 0;
assert!(bad.validate().is_err());
}
#[test]
#[ignore]
fn test_codestream_signature() {
assert_eq!(JXL_CODESTREAM_SIGNATURE, [0xFF, 0x0A]);
}
#[test]
#[ignore]
fn test_container_signature() {
assert_eq!(JXL_CONTAINER_SIGNATURE.len(), 12);
assert_eq!(&JXL_CONTAINER_SIGNATURE[4..8], b"JXL ");
}
#[test]
fn test_animation_header_new() {
let anim = JxlAnimation::new(1000, 1).expect("valid");
assert_eq!(anim.tps_numerator, 1000);
assert_eq!(anim.tps_denominator, 1);
assert_eq!(anim.num_loops, 0);
assert!(!anim.have_timecodes);
}
#[test]
fn test_animation_header_zero_numerator() {
assert!(JxlAnimation::new(0, 1).is_err());
}
#[test]
fn test_animation_header_zero_denominator() {
assert!(JxlAnimation::new(1000, 0).is_err());
}
#[test]
fn test_animation_header_millisecond() {
let anim = JxlAnimation::millisecond();
assert_eq!(anim.tps_numerator, 1000);
assert_eq!(anim.tps_denominator, 1);
}
#[test]
fn test_animation_header_with_loops() {
let anim = JxlAnimation::millisecond().with_num_loops(3);
assert_eq!(anim.num_loops, 3);
}
#[test]
fn test_animation_header_with_timecodes() {
let anim = JxlAnimation::millisecond().with_timecodes(true);
assert!(anim.have_timecodes);
}
#[test]
fn test_animation_duration_seconds() {
let anim = JxlAnimation::millisecond();
let dur = anim.duration_seconds(100);
assert!((dur - 0.1).abs() < 1e-9);
}
#[test]
fn test_animation_duration_custom_rate() {
let anim = JxlAnimation::new(24, 1).expect("valid");
let dur = anim.duration_seconds(1);
assert!((dur - 1.0 / 24.0).abs() < 1e-9);
}
#[test]
fn test_frame_animation_new() {
let fa = JxlFrameAnimation::new(100, false);
assert_eq!(fa.duration, 100);
assert_eq!(fa.timecode, 0);
assert!(!fa.is_last);
}
#[test]
fn test_frame_animation_last() {
let fa = JxlFrameAnimation::new(50, true);
assert!(fa.is_last);
}
#[test]
fn test_header_animation_default_none() {
let header = JxlHeader::default();
assert!(header.animation.is_none());
}
#[test]
fn test_config_animation_default_none() {
let config = JxlConfig::new_lossless();
assert!(config.animation.is_none());
}
#[test]
fn test_config_new_animated() {
let anim = JxlAnimation::millisecond();
let config = JxlConfig::new_animated(anim.clone());
assert_eq!(config.animation.as_ref(), Some(&anim));
assert!(config.lossless);
}
#[test]
fn test_config_with_animation() {
let anim = JxlAnimation::millisecond();
let config = JxlConfig::new_lossless().with_animation(anim.clone());
assert_eq!(config.animation.as_ref(), Some(&anim));
}
#[test]
fn test_jxl_frame_struct() {
let frame = JxlFrame {
data: vec![0u8; 12],
width: 2,
height: 2,
channels: 3,
bit_depth: 8,
duration_ticks: 100,
is_last: false,
color_space: JxlColorSpace::Srgb,
};
assert_eq!(frame.width, 2);
assert_eq!(frame.duration_ticks, 100);
assert!(!frame.is_last);
}
}