diff --git a/CHANGELOG.md b/CHANGELOG.md index d03e0a18..fd8f8cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `jxl-oxide`: Accept `u8` and `u16` output buffers in `ImageStream::write_to_buffer` (#366). +- `jxl-oxide`: Add `image` integration under a feature flag (#368). ### Changed - `jxl-color`: Use better PQ to HLG method (#348). diff --git a/Cargo.lock b/Cargo.lock index 9fb9daff..9f71a4a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,9 +148,15 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.16.3" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "102087e286b4677862ea56cf8fc58bb2cdfa8725c40ffb80fe3a008eb7f2fc83" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" @@ -686,6 +692,18 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc144d44a31d753b02ce64093d532f55ff8dc4ebf2ffb8a63c0dda691385acae" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.4.0" @@ -828,6 +846,8 @@ dependencies = [ name = "jxl-oxide" version = "0.10.0" dependencies = [ + "bytemuck", + "image", "jxl-bitstream", "jxl-color", "jxl-frame", @@ -877,6 +897,7 @@ name = "jxl-oxide-tests" version = "0.0.0" dependencies = [ "criterion", + "image", "jxl-oxide", "mimalloc", "rand", diff --git a/Cargo.toml b/Cargo.toml index 36d3e64d..c8ba43ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ members = ["crates/*"] resolver = "2" +[workspace.dependencies.bytemuck] +version = "1.19.0" + [workspace.dependencies.tracing] version = "0.1.40" default-features = false diff --git a/crates/jxl-oxide-tests/Cargo.toml b/crates/jxl-oxide-tests/Cargo.toml index 72d0b252..d371b87f 100644 --- a/crates/jxl-oxide-tests/Cargo.toml +++ b/crates/jxl-oxide-tests/Cargo.toml @@ -15,6 +15,11 @@ tracing.workspace = true version = "0.5.1" optional = true +[dependencies.image] +version = "0.25.4" +default-features = false +optional = true + [dependencies.jxl-oxide] version = "0.10.0" path = "../jxl-oxide" @@ -42,10 +47,11 @@ version = "0.13.0" optional = true [features] -default = ["net", "mimalloc", "rayon", "conformance", "crop", "decode", "bench"] +default = ["net", "mimalloc", "rayon", "image", "conformance", "crop", "decode", "bench"] net = ["dep:reqwest"] mimalloc = ["dep:mimalloc"] rayon = ["jxl-oxide/rayon"] +image = ["dep:image", "jxl-oxide/image"] conformance = [] crop = ["dep:rand"] decode = ["dep:zstd"] diff --git a/crates/jxl-oxide-tests/tests/image/grayscale.icc b/crates/jxl-oxide-tests/tests/image/grayscale.icc new file mode 100644 index 00000000..a6a00360 Binary files /dev/null and b/crates/jxl-oxide-tests/tests/image/grayscale.icc differ diff --git a/crates/jxl-oxide-tests/tests/image/mod.rs b/crates/jxl-oxide-tests/tests/image/mod.rs new file mode 100644 index 00000000..c54aa7cb --- /dev/null +++ b/crates/jxl-oxide-tests/tests/image/mod.rs @@ -0,0 +1,88 @@ +use std::fs::File; + +use image::DynamicImage; +use jxl_oxide::integration::JxlDecoder; +use jxl_oxide_tests as util; + +#[test] +fn decode_u8() { + let path = util::conformance_path("lz77_flower"); + let file = File::open(path).unwrap(); + let decoder = JxlDecoder::new(file).unwrap(); + + let image = DynamicImage::from_decoder(decoder).unwrap(); + assert_eq!(image.color(), image::ColorType::Rgb8); + assert_eq!(image.width(), 834); + assert_eq!(image.height(), 244); +} + +#[test] +fn decode_u16() { + let path = util::conformance_path("sunset_logo"); + let file = File::open(path).unwrap(); + let decoder = JxlDecoder::new(file).unwrap(); + + let image = DynamicImage::from_decoder(decoder).unwrap(); + assert_eq!(image.color(), image::ColorType::Rgba16); + assert_eq!(image.width(), 924); + assert_eq!(image.height(), 1386); +} + +#[test] +fn decode_f32() { + let path = util::conformance_path("lossless_pfm"); + let file = File::open(path).unwrap(); + let decoder = JxlDecoder::new(file).unwrap(); + + let image = DynamicImage::from_decoder(decoder).unwrap(); + assert_eq!(image.color(), image::ColorType::Rgb32F); + assert_eq!(image.width(), 500); + assert_eq!(image.height(), 500); +} + +#[test] +fn decode_gray_xyb() { + let path = util::conformance_path("grayscale"); + let file = File::open(path).unwrap(); + let decoder = JxlDecoder::new(file).unwrap(); + + let image = DynamicImage::from_decoder(decoder).unwrap(); + assert_eq!(image.color(), image::ColorType::L8); + assert_eq!(image.width(), 200); + assert_eq!(image.height(), 200); +} + +#[test] +fn decode_gray_modular() { + let path = util::conformance_path("grayscale_public_university"); + let file = File::open(path).unwrap(); + let decoder = JxlDecoder::new(file).unwrap(); + + let image = DynamicImage::from_decoder(decoder).unwrap(); + assert_eq!(image.color(), image::ColorType::L8); + assert_eq!(image.width(), 2880); + assert_eq!(image.height(), 1620); +} + +#[test] +fn decode_cmyk() { + let path = util::conformance_path("cmyk_layers"); + let file = File::open(path).unwrap(); + let decoder = JxlDecoder::new(file).unwrap(); + + let image = DynamicImage::from_decoder(decoder).unwrap(); + assert_eq!(image.color(), image::ColorType::Rgba8); + assert_eq!(image.width(), 512); + assert_eq!(image.height(), 512); +} + +#[test] +fn icc_profile() { + let path = util::conformance_path("grayscale"); + let file = File::open(path).unwrap(); + let mut decoder = JxlDecoder::new(file).unwrap(); + let icc = image::ImageDecoder::icc_profile(&mut decoder) + .unwrap() + .unwrap(); + assert_eq!(&icc, include_bytes!("./grayscale.icc")); +} diff --git a/crates/jxl-oxide-tests/tests/test.rs b/crates/jxl-oxide-tests/tests/test.rs index 413fb79f..1e151f5b 100644 --- a/crates/jxl-oxide-tests/tests/test.rs +++ b/crates/jxl-oxide-tests/tests/test.rs @@ -7,4 +7,7 @@ mod crop; #[cfg(feature = "decode")] mod decode; +#[cfg(feature = "image")] +mod image; + mod fuzz_findings; diff --git a/crates/jxl-oxide/Cargo.toml b/crates/jxl-oxide/Cargo.toml index 81d934ae..784f9e18 100644 --- a/crates/jxl-oxide/Cargo.toml +++ b/crates/jxl-oxide/Cargo.toml @@ -14,6 +14,15 @@ edition = "2021" [dependencies] tracing.workspace = true +[dependencies.bytemuck] +workspace = true +optional = true + +[dependencies.image] +version = "0.25.4" +default-features = false +optional = true + [dependencies.jxl-bitstream] version = "0.5.0-alpha.0" path = "../jxl-bitstream" @@ -52,5 +61,11 @@ optional = true [features] default = ["rayon"] -rayon = ["jxl-threadpool/rayon"] +image = ["dep:bytemuck", "dep:image"] lcms2 = ["dep:lcms2"] +rayon = ["jxl-threadpool/rayon"] +__examples = ["image?/png"] + +[[example]] +name = "image-integration" +required-features = ["image", "__examples"] diff --git a/crates/jxl-oxide/examples/image-integration.rs b/crates/jxl-oxide/examples/image-integration.rs new file mode 100644 index 00000000..6f16b30b --- /dev/null +++ b/crates/jxl-oxide/examples/image-integration.rs @@ -0,0 +1,32 @@ +use image::{DynamicImage, ImageDecoder}; +use jxl_oxide::integration::JxlDecoder; + +fn main() { + let mut args = std::env::args_os().skip(1); + let path = args + .next() + .expect("expected input filename as a command line argument"); + let output_path = args + .next() + .expect("expected output filename as a command line argument"); + assert!(args.next().is_none(), "extra command line argument found"); + + let file = std::fs::File::open(path).expect("cannot open file"); + let mut decoder = JxlDecoder::new(file).expect("cannot decode image"); + + #[allow(unused)] + let icc = decoder.icc_profile().unwrap(); + let image = DynamicImage::from_decoder(decoder).expect("cannot decode image"); + + let output_file = std::fs::File::create(output_path).expect("cannot open output file"); + let encoder = image::codecs::png::PngEncoder::new(output_file); + // FIXME: PNG encoder of `image` doesn't support setting ICC profile for some reason + // use image::ImageEncoder; + // let mut encoder = encoder; + // if let Some(icc) = icc { + // encoder.set_icc_profile(icc).unwrap(); + // } + image + .write_with_encoder(encoder) + .expect("cannot encode image"); +} diff --git a/crates/jxl-oxide/src/fb.rs b/crates/jxl-oxide/src/fb.rs index b8fe5067..55bac9d3 100644 --- a/crates/jxl-oxide/src/fb.rs +++ b/crates/jxl-oxide/src/fb.rs @@ -420,11 +420,18 @@ mod private { use jxl_image::BitDepth; use jxl_render::ImageBuffer; + #[cfg(not(feature = "image"))] pub trait Sealed: Sized + Default { fn copy_from_grid(&mut self, grid: &ImageBuffer, x: usize, y: usize, bit_depth: BitDepth); fn copy_from_f32(&mut self, val: f32); } + #[cfg(feature = "image")] + pub trait Sealed: Sized + Default + bytemuck::NoUninit + bytemuck::AnyBitPattern { + fn copy_from_grid(&mut self, grid: &ImageBuffer, x: usize, y: usize, bit_depth: BitDepth); + fn copy_from_f32(&mut self, val: f32); + } + impl Sealed for f32 { #[inline] fn copy_from_grid(&mut self, grid: &ImageBuffer, x: usize, y: usize, bit_depth: BitDepth) { diff --git a/crates/jxl-oxide/src/integration.rs b/crates/jxl-oxide/src/integration.rs new file mode 100644 index 00000000..20029e42 --- /dev/null +++ b/crates/jxl-oxide/src/integration.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "image")] +mod image; + +pub use image::*; diff --git a/crates/jxl-oxide/src/integration/image.rs b/crates/jxl-oxide/src/integration/image.rs new file mode 100644 index 00000000..4944fe1f --- /dev/null +++ b/crates/jxl-oxide/src/integration/image.rs @@ -0,0 +1,366 @@ +use std::io::prelude::*; + +use image::error::{DecodingError, ImageFormatHint}; +use image::{ColorType, ImageError, ImageResult}; +use jxl_grid::AllocTracker; + +use crate::{CropInfo, InitializeResult, JxlImage}; + +/// JPEG XL decoder which implements `image::ImageDecoder`. +pub struct JxlDecoder { + reader: R, + image: JxlImage, + current_crop: CropInfo, + current_memory_limit: usize, + buf: Vec, + buf_valid: usize, +} + +impl JxlDecoder { + /// Initializes a decoder which reads from given image stream. + /// + /// Decoder will be initialized with default thread pool. + pub fn new(reader: R) -> ImageResult { + let builder = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(usize::MAX)); + + Self::init(builder, reader) + } + + /// Initializes a decoder which reads from given image stream, with custom thread pool. + pub fn with_thread_pool(reader: R, pool: crate::JxlThreadPool) -> ImageResult { + let builder = JxlImage::builder() + .pool(pool) + .alloc_tracker(AllocTracker::with_limit(usize::MAX)); + + Self::init(builder, reader) + } + + fn init(builder: crate::JxlImageBuilder, mut reader: R) -> ImageResult { + let mut buf = vec![0u8; 4096]; + let mut buf_valid = 0usize; + let image = Self::init_image(builder, &mut reader, &mut buf, &mut buf_valid) + .map_err(|e| ImageError::Decoding(DecodingError::new(ImageFormatHint::Unknown, e)))?; + + let crop = CropInfo { + width: image.width(), + height: image.height(), + left: 0, + top: 0, + }; + + let mut decoder = Self { + reader, + image, + current_memory_limit: usize::MAX, + current_crop: crop, + buf, + buf_valid, + }; + + // Convert CMYK to sRGB + if decoder.image.pixel_format().has_black() { + decoder + .image + .request_color_encoding(jxl_color::EnumColourEncoding::srgb( + jxl_color::RenderingIntent::Relative, + )); + } + + Ok(decoder) + } + + fn init_image( + builder: crate::JxlImageBuilder, + reader: &mut R, + buf: &mut [u8], + buf_valid: &mut usize, + ) -> crate::Result { + let mut uninit = builder.build_uninit(); + + let image = loop { + let count = reader.read(&mut buf[*buf_valid..])?; + if count == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "reader ended before parsing image header", + ) + .into()); + } + *buf_valid += count; + let consumed = uninit.feed_bytes(&buf[..*buf_valid])?; + buf.copy_within(consumed..*buf_valid, 0); + *buf_valid -= consumed; + + match uninit.try_init()? { + InitializeResult::NeedMoreData(x) => { + uninit = x; + } + InitializeResult::Initialized(x) => { + break x; + } + } + }; + + Ok(image) + } + + fn load_until_first_keyframe(&mut self) -> crate::Result<()> { + while self.image.ctx.loaded_keyframes() == 0 { + let count = self.reader.read(&mut self.buf[self.buf_valid..])?; + if count == 0 { + break; + } + self.buf_valid += count; + let consumed = self.image.feed_bytes(&self.buf[..self.buf_valid])?; + self.buf.copy_within(consumed..self.buf_valid, 0); + self.buf_valid -= consumed; + } + + if self.image.frame_by_keyframe(0).is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "reader ended before parsing first frame", + ) + .into()); + } + + Ok(()) + } + + #[inline] + fn is_float(&self) -> bool { + use crate::BitDepth; + + let metadata = &self.image.image_header().metadata; + matches!( + metadata.bit_depth, + BitDepth::FloatSample { .. } + | BitDepth::IntegerSample { + bits_per_sample: 17.. + } + ) + } + + #[inline] + fn need_16bit(&self) -> bool { + let metadata = &self.image.image_header().metadata; + metadata.bit_depth.bits_per_sample() > 8 + } + + fn read_image_inner( + &mut self, + crop: CropInfo, + buf: &mut [u8], + buf_stride: Option, + ) -> crate::Result<()> { + if self.current_crop != crop { + self.image.set_image_region(crop); + self.current_crop = crop; + } + + self.load_until_first_keyframe()?; + + let render = if self.image.num_loaded_keyframes() > 0 { + self.image.render_frame(0) + } else { + self.image.render_loading_frame() + }; + let render = render.map_err(|e| { + ImageError::Decoding(DecodingError::new( + ImageFormatHint::PathExtension("jxl".into()), + e, + )) + })?; + let stream = render.stream(); + + let stride_base = stream.width() as usize * stream.channels() as usize; + if self.is_float() && !self.image.pixel_format().is_grayscale() { + let stride = buf_stride.unwrap_or(stride_base * std::mem::size_of::()); + stream_to_buf::(stream, buf, stride); + } else if self.need_16bit() { + let stride = buf_stride.unwrap_or(stride_base * std::mem::size_of::()); + stream_to_buf::(stream, buf, stride); + } else { + let stride = buf_stride.unwrap_or(stride_base * std::mem::size_of::()); + stream_to_buf::(stream, buf, stride); + } + + Ok(()) + } +} + +impl image::ImageDecoder for JxlDecoder { + fn dimensions(&self) -> (u32, u32) { + (self.image.width(), self.image.height()) + } + + fn color_type(&self) -> image::ColorType { + let pixel_format = self.image.pixel_format(); + + match ( + pixel_format.is_grayscale(), + pixel_format.has_alpha(), + self.is_float(), + self.need_16bit(), + ) { + (false, false, false, false) => ColorType::Rgb8, + (false, false, false, true) => ColorType::Rgb16, + (false, false, true, _) => ColorType::Rgb32F, + (false, true, false, false) => ColorType::Rgba8, + (false, true, false, true) => ColorType::Rgba16, + (false, true, true, _) => ColorType::Rgba32F, + (true, false, _, false) => ColorType::L8, + (true, false, _, true) => ColorType::L16, + (true, true, _, false) => ColorType::La8, + (true, true, _, true) => ColorType::La16, + } + } + + fn read_image(mut self, buf: &mut [u8]) -> ImageResult<()> + where + Self: Sized, + { + let crop = CropInfo { + width: self.image.width(), + height: self.image.height(), + left: 0, + top: 0, + }; + + self.read_image_inner(crop, buf, None).map_err(|e| { + ImageError::Decoding(DecodingError::new( + ImageFormatHint::PathExtension("jxl".into()), + e, + )) + }) + } + + fn read_image_boxed(mut self: Box, buf: &mut [u8]) -> ImageResult<()> { + let crop = CropInfo { + width: self.image.width(), + height: self.image.height(), + left: 0, + top: 0, + }; + + self.read_image_inner(crop, buf, None).map_err(|e| { + ImageError::Decoding(DecodingError::new( + ImageFormatHint::PathExtension("jxl".into()), + e, + )) + }) + } + + fn icc_profile(&mut self) -> ImageResult>> { + Ok(Some(self.image.rendered_icc())) + } + + fn set_limits(&mut self, limits: image::Limits) -> ImageResult<()> { + use image::error::{LimitError, LimitErrorKind}; + + if let Some(max_width) = limits.max_image_width { + if self.image.width() > max_width { + return Err(ImageError::Limits(LimitError::from_kind( + LimitErrorKind::DimensionError, + ))); + } + } + + if let Some(max_height) = limits.max_image_height { + if self.image.height() > max_height { + return Err(ImageError::Limits(LimitError::from_kind( + LimitErrorKind::DimensionError, + ))); + } + } + + let alloc_tracker = self.image.ctx.alloc_tracker(); + match (alloc_tracker, limits.max_alloc) { + (Some(tracker), max_alloc) => { + let new_memory_limit = max_alloc.map(|x| x as usize).unwrap_or(usize::MAX); + if new_memory_limit > self.current_memory_limit { + tracker.expand_limit(new_memory_limit - self.current_memory_limit); + } else { + tracker + .shrink_limit(self.current_memory_limit - new_memory_limit) + .map_err(|_| { + ImageError::Limits(LimitError::from_kind( + LimitErrorKind::InsufficientMemory, + )) + })?; + } + + self.current_memory_limit = new_memory_limit; + } + (None, None) => {} + (None, Some(_)) => { + return Err(ImageError::Limits(LimitError::from_kind( + LimitErrorKind::Unsupported { + limits, + supported: image::LimitSupport::default(), + }, + ))); + } + } + + Ok(()) + } +} + +impl image::ImageDecoderRect for JxlDecoder { + fn read_rect( + &mut self, + x: u32, + y: u32, + width: u32, + height: u32, + buf: &mut [u8], + row_pitch: usize, + ) -> ImageResult<()> { + let crop = CropInfo { + width, + height, + left: x, + top: y, + }; + + self.read_image_inner(crop, buf, Some(row_pitch)) + .map_err(|e| { + ImageError::Decoding(DecodingError::new( + ImageFormatHint::PathExtension("jxl".into()), + e, + )) + }) + } +} + +fn stream_to_buf( + mut stream: crate::ImageStream<'_>, + buf: &mut [u8], + buf_stride: usize, +) { + let stride = + stream.width() as usize * stream.channels() as usize * std::mem::size_of::(); + assert!(buf_stride >= stride); + assert_eq!(buf.len(), buf_stride * stream.height() as usize); + + if let Ok(buf) = bytemuck::try_cast_slice_mut::(buf) { + if buf_stride == stride { + stream.write_to_buffer(buf); + } else { + for buf_row in buf.chunks_exact_mut(buf_stride / std::mem::size_of::()) { + let buf_row = &mut buf_row[..stream.width() as usize]; + stream.write_to_buffer(buf_row); + } + } + } else { + let mut row = Vec::with_capacity(stream.width() as usize); + row.fill_with(Sample::default); + for buf_row in buf.chunks_exact_mut(stride) { + stream.write_to_buffer(&mut row); + + let row = bytemuck::cast_slice::(&row); + buf_row[..stride].copy_from_slice(row); + } + } +} diff --git a/crates/jxl-oxide/src/lib.rs b/crates/jxl-oxide/src/lib.rs index 56cd462c..e38107e0 100644 --- a/crates/jxl-oxide/src/lib.rs +++ b/crates/jxl-oxide/src/lib.rs @@ -141,12 +141,13 @@ //! //! # Feature flags //! - `rayon`: Enable multithreading with Rayon. (*default*) +//! - `image`: Enable integration with `image` crate. //! - `lcms2`: Enable integration with Little CMS 2. use std::sync::Arc; -use image::BitDepth; use jxl_bitstream::{Bitstream, ContainerDetectingReader, ParseEvent}; use jxl_frame::FrameContext; +use jxl_image::BitDepth; use jxl_oxide_common::{Bundle, Name}; use jxl_render::ImageBuffer; use jxl_render::ImageWithRegion; @@ -165,6 +166,8 @@ pub use jxl_image::{ExtraChannelType, ImageHeader}; pub use jxl_threadpool::JxlThreadPool; mod fb; +#[cfg(feature = "image")] +pub mod integration; #[cfg(feature = "lcms2")] mod lcms2; @@ -1066,7 +1069,7 @@ impl ExtraChannel { } /// Cropping region information. -#[derive(Debug, Default, Copy, Clone)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] pub struct CropInfo { pub width: u32, pub height: u32, diff --git a/crates/jxl-render/src/lib.rs b/crates/jxl-render/src/lib.rs index cd2024cc..a1d4d5fc 100644 --- a/crates/jxl-render/src/lib.rs +++ b/crates/jxl-render/src/lib.rs @@ -92,35 +92,50 @@ impl RenderContextBuilder { pub fn build(self, image_header: Arc) -> Result { let color_encoding = &image_header.metadata.colour_encoding; - let requested_color_encoding = if let ColourEncoding::Enum(encoding) = color_encoding { - ColorEncodingWithProfile::new(encoding.clone()) - } else if image_header.metadata.xyb_encoded { - ColorEncodingWithProfile::new(EnumColourEncoding::srgb( - jxl_color::RenderingIntent::Relative, - )) - } else { - let ColourEncoding::IccProfile(color_space) = color_encoding else { - unreachable!(); - }; - match ColorEncodingWithProfile::with_icc(&self.embedded_icc) { - Ok(parsed_icc) => { - let header_is_gray = *color_space == ColourSpace::Grey; - let icc_is_gray = parsed_icc.is_grayscale(); - if header_is_gray != icc_is_gray { - tracing::error!( - header_is_gray, - icc_is_gray, - "Color channel mismatch between header and ICC profile" - ); - return Err(jxl_bitstream::Error::ValidationFailed( - "Color channel mismatch between header and ICC profile", - ) - .into()); + let requested_color_encoding = match color_encoding { + ColourEncoding::Enum(encoding) => ColorEncodingWithProfile::new(encoding.clone()), + ColourEncoding::IccProfile(color_space) => { + let header_is_gray = *color_space == ColourSpace::Grey; + + let parsed_icc = match ColorEncodingWithProfile::with_icc(&self.embedded_icc) { + Ok(parsed_icc) => { + let icc_is_gray = parsed_icc.is_grayscale(); + if header_is_gray != icc_is_gray { + tracing::error!( + header_is_gray, + icc_is_gray, + "Color channel mismatch between header and ICC profile" + ); + return Err(jxl_bitstream::Error::ValidationFailed( + "Color channel mismatch between header and ICC profile", + ) + .into()); + } + + let is_supported_icc = parsed_icc.icc_profile().is_empty(); + if !is_supported_icc { + tracing::trace!( + "Failed to convert embedded ICC into enum color encoding" + ); + } + + let allow_parsed_icc = + !image_header.metadata.xyb_encoded || is_supported_icc; + allow_parsed_icc.then_some(parsed_icc) } - parsed_icc - } - Err(e) => { - tracing::warn!(%e, "Malformed embedded ICC profile"); + Err(e) => { + tracing::warn!(%e, "Malformed embedded ICC profile"); + None + } + }; + + if let Some(profile) = parsed_icc { + profile + } else if header_is_gray { + ColorEncodingWithProfile::new(EnumColourEncoding::gray_srgb( + jxl_color::RenderingIntent::Relative, + )) + } else { ColorEncodingWithProfile::new(EnumColourEncoding::srgb( jxl_color::RenderingIntent::Relative, )) @@ -128,6 +143,11 @@ impl RenderContextBuilder { } }; + tracing::debug!( + default_color_encoding = ?requested_color_encoding, + "Setting default output color encoding" + ); + let full_image_region = Region::with_size( image_header.width_with_orientation(), image_header.height_with_orientation(),