Merge pull request #1229 from storytold/codex/craft-jxl-extraction

Add shared JPEG XL decoder and encoder
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 19:39:17 -05:00
commit 6d620e971e
8 files changed
+1176

No files matched your search

Generated
+16
View File
@@ -1751,6 +1751,16 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "craft-jxl"
version = "0.1.0"
dependencies = [
"craft-cms",
"jixel",
"jxl-oxide",
"thiserror 2.0.21",
]
[[package]]
name = "craft-keymap"
version = "0.1.0"
@@ -5041,6 +5051,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jixel"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3"
[[package]]
name = "jni"
version = "0.22.4"
+3
View File
@@ -79,6 +79,7 @@ craft-indd = { path = "crates/indd" }
craft-jmap = { path = "crates/jmap" }
craft-ical = { path = "crates/ical" }
craft-jpeg = { path = "crates/jpeg" }
craft-jxl = { path = "crates/jxl" }
craft-kiwi = { path = "crates/kiwi" }
craft-keymap = { path = "crates/keymap" }
craft-assuan = { path = "crates/assuan" }
@@ -157,6 +158,8 @@ craft-web-host = { path = "crates/web-host" }
craft-webp = { path = "crates/webp" }
egui = { version = "0.37", default-features = false }
md5 = { package = "md-5", version = "0.10", default-features = false }
jxl-oxide = { version = "0.12", default-features = false }
jixel = "=0.3.4"
rayon = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "craft-jxl"
description = "JPEG XL image decoding and encoding with bounded input and allocation tracking."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[features]
default = ["decode", "encode"]
decode = ["dep:jxl-oxide"]
encode = ["dep:jixel"]
[dependencies]
jxl-oxide = { workspace = true, optional = true }
thiserror = { workspace = true }
[dev-dependencies]
craft-cms = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
jixel = { workspace = true, optional = true }
+25
View File
@@ -0,0 +1,25 @@
# craft-jxl
JPEG XL decoding and encoding for still raster images.
The decoder reads a codestream incrementally, checks its dimensions before loading the remainder,
and applies a caller-selected compressed-input, pixel-count, and tracked-decoder-allocation budget.
`jxl-oxide`'s allocation tracker cannot account for every allocation made by the decoder, and the
input slice is already owned by the caller. The decoder returns the first frame as interleaved
`f32` samples together with source and rendered color metadata. It does not perform an external
ICC transform; callers that need color-managed output should use the returned profiles with their
CMS. CMYK output is rejected because this crate does not bundle an external CMS.
`decode_with_color` can request linear Rec. 2020 for enum-encoded images. XYB is rendered through
linear sRGB and converted to Rec. 2020 to avoid the known wide-gamut XYB rendering issue in
`jxl-oxide`. If a source embeds an ICC profile, the decoder keeps the source-profile samples and
returns the profile; it does not claim to transform ICC data without a CMS. `decode` retains the
decoder's default rendering target.
The encoder accepts interleaved 8-bit, 16-bit, or HDR floating-point samples. Floating-point input
is linear-light and is currently available only with the Rec. 2020 PQ option; one input unit maps
to 203 cd/m². Gray input is expanded to RGB, and opaque alpha is omitted. ICC data is embedded in
lossless output; Exif and XMP boxes are written when provided. Encoding is unavailable on
`wasm32` targets.
`jxl-oxide` is MIT OR Apache-2.0. `jixel` is BSD-3-Clause OR Apache-2.0.
+259
View File
@@ -0,0 +1,259 @@
use std::panic::{AssertUnwindSafe, catch_unwind};
use jxl_oxide::color::{ColourEncoding, ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
use jxl_oxide::{AllocTracker, AuxBoxData, ExtraChannelType, InitializeResult, JxlImage};
use crate::{DecodeLimits, Decoded, Error, Layout, Metadata, OutputColor, Result};
const INPUT_CHUNK: usize = 4096;
const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8;
const MIN_TRACKED_BYTES: u64 = 64 << 20;
/// Decode the first frame, keeping the decoder's tracked allocations within `limits`.
///
/// The allocator budget applies to allocations registered with `jxl-oxide`'s `AllocTracker`.
/// The decoder may also make untracked allocations, including the input buffer supplied by the
/// caller. Header data is fed in small chunks so the dimensions are checked before the rest of
/// the codestream is loaded.
pub fn decode(bytes: &[u8], limits: DecodeLimits) -> Result<Decoded> {
decode_with_color(bytes, limits, OutputColor::DecoderDefault)
}
/// Decode the first image frame using an explicit rendered-pixel color target.
pub fn decode_with_color(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) -> Result<Decoded> {
catch_unwind(AssertUnwindSafe(|| decode_inner(bytes, limits, output_color)))
.unwrap_or_else(|_| Err(Error::Malformed("decoder failed while reading this file".into())))
}
fn decode_inner(bytes: &[u8], limits: DecodeLimits, output_color: OutputColor) -> Result<Decoded> {
if bytes.len() > limits.max_input_bytes {
return Err(Error::LimitExceeded(format!("{} input bytes exceed the {} byte input limit", bytes.len(), limits.max_input_bytes)));
}
let budget = limits.max_tracked_bytes.unwrap_or_else(|| limits.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES));
let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX));
let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit();
let mut rest = bytes;
let mut window = INPUT_CHUNK;
let mut image = loop {
let feed = rest.get(..window).unwrap_or(rest);
let consumed = uninit.feed_bytes(feed).map_err(decode_error)?;
rest = rest.get(consumed..).unwrap_or_default();
match uninit.try_init().map_err(decode_error)? {
InitializeResult::Initialized(image) => break image,
InitializeResult::NeedMoreData(next) => uninit = next,
}
if consumed == 0 {
if window >= rest.len() {
return Err(Error::Malformed("truncated JPEG XL header".into()));
}
window = window.saturating_mul(2);
}
};
let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 {
return Err(Error::Malformed("zero image dimension".into()));
}
let pixels = u64::from(width).saturating_mul(u64::from(height));
if pixels > limits.max_pixels {
return Err(Error::PixelLimitExceeded { width, height, max_pixels: limits.max_pixels });
}
// `JxlImageBuilder::read` feeds the full input. Continue in bounded chunks so trailing frame
// data and boxes are not copied into an untracked buffer all at once.
while !image.is_loading_done() && !rest.is_empty() {
let consumed = image.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(decode_error)?;
if consumed == 0 {
break;
}
rest = rest.get(consumed..).unwrap_or_default();
}
image.finalize().map_err(decode_error)?;
let (bit_depth, float_samples, grayscale, source_orientation, xyb_encoded, extra_channels, colour_encoding) = {
let metadata = &image.image_header().metadata;
(
metadata.bit_depth.bits_per_sample() as u8,
matches!(metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }),
metadata.grayscale(),
metadata.orientation,
metadata.xyb_encoded,
metadata.ec_info.iter().map(|info| info.ty).collect::<Vec<_>>(),
metadata.colour_encoding.clone(),
)
};
let source_icc = image.original_icc().map(ToOwned::to_owned);
let has_source_icc = source_icc.is_some();
if has_black_channel(&extra_channels) || image.pixel_format().has_black() {
return Err(Error::Unsupported("CMYK JPEG XL rendering requires an external color-management transform".into()));
}
let alpha_channel = first_alpha(&extra_channels);
let alpha_associated = alpha_channel.is_some_and(|(_, associated)| associated);
let via_srgb = output_color == OutputColor::LinearRec2020 && source_icc.is_none() && !grayscale && xyb_encoded;
// Associated samples must be divided while still in their coded nonlinear space. Leave these
// frames in their own encoding so the caller can apply its color transform after unpremultiplying.
// XYB and unsupported transfer curves stay on the renderer's linear target path; those cases
// are premultiplied after decoding instead.
let enum_source_has_supported_curve = matches!(
&colour_encoding,
ColourEncoding::Enum(encoding) if enum_encoding_has_supported_curve(encoding)
);
let preserve_source_encoding =
output_color == OutputColor::LinearRec2020 && alpha_associated && !xyb_encoded && (source_icc.is_some() || enum_source_has_supported_curve);
if output_color == OutputColor::LinearRec2020 && source_icc.is_none() && !preserve_source_encoding {
image.request_color_encoding(EnumColourEncoding {
colour_space: if grayscale { ColourSpace::Grey } else { ColourSpace::Rgb },
white_point: WhitePoint::D65,
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
tf: TransferFunction::Linear,
rendering_intent: RenderingIntent::Relative,
});
}
let colors = if grayscale { 1 } else { 3 };
let pixel_format = image.pixel_format();
if pixel_format.has_black() {
return Err(Error::Unsupported("CMYK JPEG XL rendering requires an external color-management transform".into()));
}
let has_alpha = alpha_channel.is_some();
let layout = match (grayscale, has_alpha) {
(true, false) => Layout::Gray,
(true, true) => Layout::GrayAlpha,
(false, false) => Layout::Rgb,
(false, true) => Layout::Rgba,
};
let keep = colors + usize::from(has_alpha);
let frame = image.render_frame(0).map_err(decode_error)?;
let buffer = frame.image_all_channels();
let channels = buffer.channels();
if channels < colors || (has_alpha && channels < keep) {
return Err(Error::Malformed("decoded frame has too few channels".into()));
}
let color_channels = channels
.checked_sub(extra_channels.len())
.filter(|&count| count >= colors)
.ok_or_else(|| Error::Malformed("decoded frame has too few color channels".into()))?;
let alpha_index = alpha_channel.map(|(index, _)| color_channels + index);
let (rendered_width, rendered_height) = (
u32::try_from(buffer.width()).map_err(|_| Error::LimitExceeded("rendered width exceeds 32-bit dimensions".into()))?,
u32::try_from(buffer.height()).map_err(|_| Error::LimitExceeded("rendered height exceeds 32-bit dimensions".into()))?,
);
let frame_pixels = u128::from(rendered_width) * u128::from(rendered_height);
if frame_pixels != u128::from(pixels) {
return Err(Error::Malformed("decoded frame dimensions do not match the image header".into()));
}
let sample_count = usize::try_from(frame_pixels.checked_mul(keep as u128).ok_or_else(|| Error::LimitExceeded("sample count overflows".into()))?)
.map_err(|_| Error::LimitExceeded("sample count exceeds addressable memory".into()))?;
let mut out = Vec::new();
out.try_reserve_exact(sample_count).map_err(|e| Error::LimitExceeded(format!("cannot reserve decoded pixels: {e}")))?;
for pixel in buffer.buf().chunks_exact(channels) {
let start = out.len();
out.extend_from_slice(&pixel[..colors]);
if let Some(index) = alpha_index {
let alpha_value = pixel.get(index).copied().ok_or_else(|| Error::Malformed("decoded frame is missing its alpha channel".into()))?;
if preserve_source_encoding && alpha_associated {
let divisor = alpha_value.clamp(0.0, 1.0);
if divisor > 0.0 {
for channel in &mut out[start..] {
*channel /= divisor;
}
}
}
out.push(alpha_value);
}
if via_srgb {
let [r, g, b] = [out[start], out[start + 1], out[start + 2]];
out[start] = 0.627_403_9 * r + 0.329_283_04 * g + 0.043_313_067 * b;
out[start + 1] = 0.069_097_29 * r + 0.919_540_4 * g + 0.011_362_316 * b;
out[start + 2] = 0.016_391_44 * r + 0.088_013_31 * g + 0.895_595_25 * b;
}
}
let metadata = Metadata {
icc: source_icc,
exif: match image.aux_boxes().first_exif() {
Ok(AuxBoxData::Data(exif)) => {
usize::try_from(exif.tiff_header_offset()).ok().and_then(|offset| exif.payload().get(offset..)).map(ToOwned::to_owned)
}
_ => None,
},
xmp: match image.aux_boxes().first_xml() {
AuxBoxData::Data(xmp) => Some(xmp.to_vec()),
_ => None,
},
};
let pixels_linear_rec2020 = output_color == OutputColor::LinearRec2020 && !has_source_icc && !preserve_source_encoding;
Ok(Decoded {
width: rendered_width,
height: rendered_height,
layout,
bit_depth,
float_samples,
pixels: out,
metadata,
source_orientation,
rendered_cicp: image.rendered_cicp(),
rendered_icc: image.rendered_icc(),
xyb_encoded,
pixels_linear_rec2020,
pixels_premultiplied: alpha_associated && !preserve_source_encoding,
})
}
fn first_alpha(channels: &[ExtraChannelType]) -> Option<(usize, bool)> {
channels.iter().enumerate().find_map(|(index, channel)| match channel {
ExtraChannelType::Alpha { alpha_associated } => Some((index, *alpha_associated)),
_ => None,
})
}
fn has_black_channel(channels: &[ExtraChannelType]) -> bool {
channels.iter().any(|channel| matches!(channel, ExtraChannelType::Black))
}
fn enum_encoding_has_supported_curve(encoding: &EnumColourEncoding) -> bool {
matches!(encoding.colour_space, ColourSpace::Rgb | ColourSpace::Grey)
&& match &encoding.tf {
TransferFunction::Linear | TransferFunction::Srgb | TransferFunction::Bt709 | TransferFunction::Dci => true,
TransferFunction::Gamma { g, .. } => *g > 0,
_ => false,
}
}
fn decode_error(error: impl std::fmt::Display) -> Error {
Error::Malformed(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const STRAIGHT: ExtraChannelType = ExtraChannelType::Alpha { alpha_associated: false };
const ASSOCIATED: ExtraChannelType = ExtraChannelType::Alpha { alpha_associated: true };
const SPOT: ExtraChannelType = ExtraChannelType::SpotColour { red: 1.0, green: 0.0, blue: 0.0, solidity: 1.0 };
#[test]
fn alpha_is_selected_by_type_and_order() {
assert_eq!(first_alpha(&[SPOT, STRAIGHT]), Some((1, false)));
assert_eq!(first_alpha(&[STRAIGHT, SPOT]), Some((0, false)));
assert_eq!(first_alpha(&[ExtraChannelType::Depth, ASSOCIATED, STRAIGHT]), Some((1, true)));
assert_eq!(first_alpha(&[ExtraChannelType::Black, SPOT, ExtraChannelType::Depth]), None);
}
#[test]
fn black_extra_channel_is_detected_by_type() {
assert!(has_black_channel(&[SPOT, ExtraChannelType::Black]));
assert!(!has_black_channel(&[SPOT, STRAIGHT]));
}
#[test]
fn only_modeled_nonlinear_enum_encodings_are_preserved_for_unpremultiplication() {
let mut encoding = EnumColourEncoding::srgb(RenderingIntent::Relative);
assert!(enum_encoding_has_supported_curve(&encoding));
encoding.tf = TransferFunction::Pq;
assert!(!enum_encoding_has_supported_curve(&encoding));
encoding.tf = TransferFunction::Linear;
assert!(enum_encoding_has_supported_curve(&encoding));
encoding.colour_space = ColourSpace::Xyb;
assert!(!enum_encoding_has_supported_curve(&encoding));
}
}
+236
View File
@@ -0,0 +1,236 @@
#[cfg(not(target_arch = "wasm32"))]
use std::panic::{AssertUnwindSafe, catch_unwind};
#[cfg(not(target_arch = "wasm32"))]
use crate::Layout;
use crate::{Error, ImageRef, Metadata, Result, Samples};
/// Encoder search effort.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Effort {
/// Quickest search, with larger output files.
Fast,
/// Balanced search.
#[default]
Normal,
}
/// Color encoding declared by a JPEG XL image.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum JxlColour {
/// sRGB primaries and transfer curve.
#[default]
Srgb,
/// Display P3 primaries, D65, with the sRGB transfer curve.
DisplayP3,
/// Rec. 2020 primaries with the BT.709 transfer curve.
Rec2020,
/// Rec. 2020 primaries with the SMPTE ST 2084 PQ transfer curve.
Rec2020Pq,
}
/// JPEG XL encoder settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EncodeOptions {
/// Lossy quality on a 1..=100 scale (90 is approximately visually lossless). Ignored for
/// mathematically lossless output.
pub quality: u8,
/// Use mathematically lossless Modular coding.
pub lossless: bool,
/// Encoder search effort.
pub effort: Effort,
/// Use progressive encoding; lossless output uses the Squeeze transform.
pub progressive: bool,
/// Color encoding to write.
pub colour: JxlColour,
}
impl Default for EncodeOptions {
fn default() -> Self {
Self { quality: 90, lossless: false, effort: Effort::Normal, progressive: false, colour: JxlColour::Srgb }
}
}
/// SDR reference white used when converting linear float input to HDR PQ, in cd/m².
pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0;
/// Encode a still image to JPEG XL.
///
/// Gray input is widened to RGB. Alpha is omitted when every sample is opaque. Float input is
/// encoded only with `JxlColour::Rec2020Pq`; its linear values use SDR white at 203 cd/m².
/// ICC data is embedded only for lossless output, where the explicit ICC profile takes precedence
/// over the enum color encoding. Exif and XMP are embedded when supplied.
pub fn encode(image: &ImageRef<'_>, options: EncodeOptions, metadata: &Metadata) -> Result<Vec<u8>> {
validate(image)?;
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
{
let pixels = Pixels::from(image, options.colour)?;
catch_unwind(AssertUnwindSafe(|| run(&pixels, image.width as usize, image.height as usize, options, metadata)))
.unwrap_or_else(|_| Err(Error::Encode("encoder failed while writing this image".into())))
}
#[cfg(any(target_arch = "wasm32", not(feature = "encode")))]
{
let _ = (image, options, metadata);
Err(Error::Unsupported("JPEG XL encoding is unavailable on this target".into()))
}
}
fn validate(image: &ImageRef<'_>) -> Result<()> {
if image.width == 0 || image.height == 0 {
return Err(Error::InvalidImage("zero dimension".into()));
}
let pixels = u128::from(image.width) * u128::from(image.height);
let samples = pixels * image.layout.channels() as u128;
if samples > usize::MAX as u128 {
return Err(Error::InvalidImage("image sample count exceeds addressable memory".into()));
}
let have = match image.samples {
Samples::U8(data) => data.len(),
Samples::U16(data) => data.len(),
Samples::F32(data) => data.len(),
};
if have < samples as usize {
return Err(Error::InvalidImage(format!("sample buffer has {have} samples, expected at least {samples}")));
}
Ok(())
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
enum Pixels {
U8 { data: Vec<u8>, alpha: bool },
U16 { data: Vec<u16>, alpha: bool },
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
impl Pixels {
fn from(image: &ImageRef<'_>, colour: JxlColour) -> Result<Self> {
let n = usize::try_from(u64::from(image.width) * u64::from(image.height))
.map_err(|_| Error::InvalidImage("image pixel count exceeds addressable memory".into()))?;
Ok(match image.samples {
Samples::U8(samples) => {
let (data, alpha) = convert(samples, image.layout, n, |a| a == u8::MAX)?;
Pixels::U8 { data, alpha }
}
Samples::U16(samples) => {
let (data, alpha) = convert(samples, image.layout, n, |a| a == u16::MAX)?;
Pixels::U16 { data, alpha }
}
Samples::F32(samples) if colour == JxlColour::Rec2020Pq => {
let count = n.checked_mul(4).ok_or_else(|| Error::InvalidImage("pixel buffer size overflows".into()))?;
let mut encoded = Vec::new();
encoded.try_reserve_exact(count).map_err(|e| Error::InvalidImage(format!("cannot reserve pixel buffer: {e}")))?;
for pixel in samples.chunks_exact(image.layout.channels()).take(n) {
let (rgb, alpha) = pixel_to_rgba(pixel, image.layout);
for channel in rgb {
encoded.push(pq_sample(channel));
}
encoded.push(alpha_sample(alpha));
}
let (data, alpha) = convert(&encoded, Layout::Rgba, n, |a| a == u16::MAX)?;
Pixels::U16 { data, alpha }
}
Samples::F32(_) => return Err(Error::InvalidImage("float samples require Rec. 2020 PQ encoding".into())),
})
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
fn convert<T: Copy>(samples: &[T], layout: Layout, pixels: usize, opaque: impl Fn(T) -> bool) -> Result<(Vec<T>, bool)> {
let channels = layout.channels();
let input = samples
.get(..pixels.checked_mul(channels).ok_or_else(|| Error::InvalidImage("sample count overflows".into()))?)
.ok_or_else(|| Error::InvalidImage("sample buffer is too short".into()))?;
let alpha = layout.has_alpha() && input.chunks_exact(channels).any(|p| p.last().is_some_and(|a| !opaque(*a)));
let output_channels = if alpha { 4 } else { 3 };
let output_count = pixels.checked_mul(output_channels).ok_or_else(|| Error::InvalidImage("converted sample count overflows".into()))?;
let mut out = Vec::new();
out.try_reserve_exact(output_count).map_err(|e| Error::InvalidImage(format!("cannot reserve converted samples: {e}")))?;
for pixel in input.chunks_exact(channels) {
match (layout, pixel) {
(Layout::Gray, [g]) => out.extend_from_slice(&[*g, *g, *g]),
(Layout::GrayAlpha, [g, a]) if alpha => out.extend_from_slice(&[*g, *g, *g, *a]),
(Layout::GrayAlpha, [g, _]) => out.extend_from_slice(&[*g, *g, *g]),
(Layout::Rgb, [r, g, b]) => out.extend_from_slice(&[*r, *g, *b]),
(Layout::Rgba, [r, g, b, a]) if alpha => out.extend_from_slice(&[*r, *g, *b, *a]),
(Layout::Rgba, [r, g, b, _]) => out.extend_from_slice(&[*r, *g, *b]),
_ => return Err(Error::InvalidImage("unsupported pixel layout".into())),
}
}
Ok((out, alpha))
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
fn pixel_to_rgba(pixel: &[f32], layout: Layout) -> ([f32; 3], f32) {
let finite = |v: f32| if v.is_finite() { v } else { 0.0 };
match (layout, pixel) {
(Layout::Gray, [g]) => ([finite(*g); 3], 1.0),
(Layout::GrayAlpha, [g, a]) => ([finite(*g); 3], finite(*a).clamp(0.0, 1.0)),
(Layout::Rgb, [r, g, b]) => ([finite(*r), finite(*g), finite(*b)], 1.0),
(Layout::Rgba, [r, g, b, a]) => ([finite(*r), finite(*g), finite(*b)], finite(*a).clamp(0.0, 1.0)),
_ => ([0.0; 3], 1.0),
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
fn pq_sample(value: f32) -> u16 {
let linear_nits = (if value.is_finite() { value.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS;
(pq_encode(linear_nits) * 65535.0).round().clamp(0.0, 65535.0) as u16
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
fn alpha_sample(value: f32) -> u16 {
((if value.is_finite() { value.clamp(0.0, 1.0) } else { 1.0 }) * 65535.0).round() as u16
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
fn pq_encode(nits: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "encode"))]
fn run(pixels: &Pixels, width: usize, height: usize, options: EncodeOptions, metadata: &Metadata) -> Result<Vec<u8>> {
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
let colour = match options.colour {
JxlColour::Srgb => ColorEncoding::srgb(),
JxlColour::DisplayP3 => ColorEncoding::display_p3(),
JxlColour::Rec2020 => ColorEncoding { primaries: Primaries::Bt2020, transfer: TransferFunction::Bt709, ..ColorEncoding::srgb() },
JxlColour::Rec2020Pq => ColorEncoding::bt2020_pq(),
};
let mut config = EncodeConfig::default()
.with_quality(f32::from(options.quality.clamp(1, 100)))
.with_color_encoding(colour)
.with_lossless(options.lossless)
.with_progressive(options.progressive)
.with_speed(match options.effort {
Effort::Fast => Speed::Fastest,
Effort::Normal => Speed::Fast,
});
if options.colour == JxlColour::Rec2020Pq {
config = config.with_intensity_target(10_000.0);
}
if options.lossless
&& let Some(icc) = &metadata.icc
{
config = config.with_icc_profile(icc.clone());
}
if let Some(exif) = &metadata.exif {
config = config.with_exif(exif.clone());
}
if let Some(xmp) = &metadata.xmp {
config = config.with_xmp(xmp.clone());
}
let result = match pixels {
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, width, height, &config),
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, width, height, &config),
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, width, height, &config),
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, width, height, &config),
};
result.map_err(|error| Error::Encode(error.to_string()))
}
+186
View File
@@ -0,0 +1,186 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#![warn(missing_docs)]
//! JPEG XL image decoding and encoding.
//!
//! Decoding incrementally feeds the input, checks image dimensions before loading remaining
//! frames, and applies a tracked-allocation budget. The allocation tracker covers allocations
//! made through `jxl-oxide`; it cannot account for every allocation inside a decoder dependency.
//! The first image frame is returned, with orientation already applied to its pixels.
#[cfg(feature = "decode")]
mod decode;
#[cfg(feature = "encode")]
mod encode;
/// Pixel channel layout.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Layout {
/// One grayscale channel.
Gray,
/// Grayscale and straight alpha.
GrayAlpha,
/// Red, green, blue.
Rgb,
/// Red, green, blue and straight alpha.
Rgba,
}
/// Color encoding requested for rendered pixels.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OutputColor {
/// Use `jxl-oxide`'s default rendering target and report its rendered profile.
#[default]
DecoderDefault,
/// For files with enum color encoding, render linear Rec. 2020. XYB files render to linear
/// sRGB first and are converted to Rec. 2020 to avoid a `jxl-oxide` wide-gamut XYB issue.
/// Files with an embedded ICC profile remain rendered in that profile's space; the source
/// ICC is returned in [`Metadata::icc`] because this crate does not bundle an ICC CMS.
LinearRec2020,
}
impl Layout {
/// Number of interleaved channels.
pub const fn channels(self) -> usize {
match self {
Layout::Gray => 1,
Layout::GrayAlpha => 2,
Layout::Rgb => 3,
Layout::Rgba => 4,
}
}
/// Whether this layout includes alpha.
pub const fn has_alpha(self) -> bool {
matches!(self, Layout::GrayAlpha | Layout::Rgba)
}
}
/// Sample data accepted by the encoder.
#[derive(Clone, Copy, Debug)]
pub enum Samples<'a> {
/// Unsigned 8-bit samples, already encoded using the selected color transfer curve.
U8(&'a [u8]),
/// Unsigned 16-bit samples, already encoded using the selected color transfer curve.
U16(&'a [u16]),
/// Floating point samples. These are accepted only for Rec. 2020 PQ encoding.
F32(&'a [f32]),
}
/// A borrowed image to encode. Samples are interleaved and alpha is straight.
#[derive(Clone, Copy, Debug)]
pub struct ImageRef<'a> {
/// Width in pixels.
pub width: u32,
/// Height in pixels.
pub height: u32,
/// Channel layout.
pub layout: Layout,
/// Interleaved sample data.
pub samples: Samples<'a>,
}
/// Metadata embedded in the JPEG XL container.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Metadata {
/// ICC profile bytes, when present.
pub icc: Option<Vec<u8>>,
/// TIFF-structured Exif payload, without an `Exif\0\0` prefix.
pub exif: Option<Vec<u8>>,
/// XMP packet bytes.
pub xmp: Option<Vec<u8>>,
}
/// JPEG XL operation error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The image is malformed or cannot be decoded.
#[error("JPEG XL: malformed image: {0}")]
Malformed(String),
/// The image exceeds a caller supplied size or allocation limit.
#[error("JPEG XL: limit exceeded: {0}")]
LimitExceeded(String),
/// The encoded dimensions exceed the caller's pixel-count limit.
#[error("JPEG XL: image dimensions {width}×{height} exceed the {max_pixels} pixel limit")]
PixelLimitExceeded {
/// Encoded image width.
width: u32,
/// Encoded image height.
height: u32,
/// Maximum accepted pixel count.
max_pixels: u64,
},
/// The image layout or samples are unsupported.
#[error("JPEG XL: invalid image: {0}")]
InvalidImage(String),
/// Encoding failed.
#[error("JPEG XL: encode failed: {0}")]
Encode(String),
/// The requested operation is not available in this build.
#[error("JPEG XL: unsupported: {0}")]
Unsupported(String),
}
/// Result returned by this crate.
pub type Result<T> = std::result::Result<T, Error>;
/// Decoded first frame and source metadata.
#[cfg(feature = "decode")]
#[derive(Clone, Debug)]
pub struct Decoded {
/// Width after applying the codestream orientation.
pub width: u32,
/// Height after applying the codestream orientation.
pub height: u32,
/// Output channel layout.
pub layout: Layout,
/// Bits per sample signalled in the file.
pub bit_depth: u8,
/// Whether the source samples use a floating-point representation.
pub float_samples: bool,
/// Interleaved floating point samples in the decoder's rendered color encoding.
pub pixels: Vec<f32>,
/// Metadata from the source image.
pub metadata: Metadata,
/// Original orientation value. Pixels in `pixels` already have this orientation applied.
pub source_orientation: u32,
/// CICP value describing the rendered pixel encoding, when the decoder supplies one.
pub rendered_cicp: Option<[u8; 4]>,
/// ICC profile describing the rendered pixel encoding.
pub rendered_icc: Vec<u8>,
/// Whether the codestream uses XYB encoding.
pub xyb_encoded: bool,
/// Whether `pixels` are linear Rec. 2020. This is false when associated alpha requires keeping
/// a modeled nonlinear source encoding so the caller can unpremultiply before its color transform.
pub pixels_linear_rec2020: bool,
/// Whether color samples in `pixels` remain associated with alpha. When true, divide them by
/// alpha after any necessary transfer conversion; at zero alpha the color values are undefined.
pub pixels_premultiplied: bool,
}
/// Options for bounded decoding.
#[cfg(feature = "decode")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DecodeLimits {
/// Largest accepted pixel count. Defaults to 1 gigapixel.
pub max_pixels: u64,
/// Maximum allocation budget passed to `jxl-oxide`'s tracked allocator. `None` scales the
/// budget from `max_pixels` and preserves fixed decoder overhead for small images.
pub max_tracked_bytes: Option<u64>,
/// Largest compressed input size. Defaults to `usize::MAX`.
pub max_input_bytes: usize,
}
#[cfg(feature = "decode")]
impl Default for DecodeLimits {
fn default() -> Self {
Self { max_pixels: 1 << 30, max_tracked_bytes: None, max_input_bytes: usize::MAX }
}
}
/// Decode the first image frame.
#[cfg(feature = "decode")]
pub use decode::{decode, decode_with_color};
/// Encode a JPEG XL image.
#[cfg(feature = "encode")]
pub use encode::{Effort, EncodeOptions, HDR_REFERENCE_WHITE_NITS, JxlColour, encode};
+425
View File
@@ -0,0 +1,425 @@
use craft_jxl::{
DecodeLimits, EncodeOptions, HDR_REFERENCE_WHITE_NITS, ImageRef, JxlColour, Layout, Metadata, OutputColor, Samples, decode, decode_with_color,
encode,
};
fn raw_codestream(bytes: &[u8]) -> Vec<u8> {
if bytes.starts_with(b"\xff\x0a") {
return bytes.to_vec();
}
let mut raw = Vec::new();
let mut at = 12_usize;
while at.checked_add(8).is_some_and(|end| end <= bytes.len()) {
let Some(size) = bytes.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes) else { break };
let end = if size == 0 { bytes.len() } else { at.saturating_add(size as usize) };
let Some(kind) = bytes.get(at + 4..at + 8) else { break };
if end < at + 8 || end > bytes.len() {
break;
}
let header = if kind == b"jxlp" { 12 } else { 8 };
if (kind == b"jxlc" || kind == b"jxlp")
&& let Some(data) = bytes.get(at + header..end)
{
raw.extend_from_slice(data);
}
if end <= at {
break;
}
at = end;
}
raw
}
fn box_bytes(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
}
#[test]
fn lossless_rgba_roundtrip_keeps_alpha_layout() {
let samples = [255, 0, 0, 255, 0, 255, 0, 64];
let image = ImageRef { width: 2, height: 1, layout: Layout::Rgba, samples: Samples::U8(&samples) };
let exif = b"II*\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x01\0\0\0\0\0\0\0";
let xmp = b"<xmp/>";
let metadata = Metadata { exif: Some(exif.to_vec()), xmp: Some(xmp.to_vec()), ..Metadata::default() };
let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &metadata);
assert!(encoded.is_ok(), "valid image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = decode(&encoded, DecodeLimits::default());
assert!(decoded.is_ok(), "encoded image should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert_eq!((decoded.width, decoded.height), (2, 1));
assert_eq!(decoded.layout, Layout::Rgba);
assert_eq!(decoded.pixels.len(), 8);
assert_eq!(decoded.pixels[3], 1.0);
assert!((decoded.pixels[7] - 64.0 / 255.0).abs() < 0.01);
assert_eq!(decoded.metadata.exif.as_deref(), Some(exif.as_slice()));
assert_eq!(decoded.metadata.xmp.as_deref(), Some(xmp.as_slice()));
}
#[test]
fn exif_payload_starts_at_its_declared_tiff_header_offset() {
let image = ImageRef { width: 2, height: 1, layout: Layout::Rgb, samples: Samples::U8(&[10, 20, 30, 40, 50, 60]) };
let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "valid image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let raw = raw_codestream(&encoded);
let exif = b"II*\0\x08\0\0\0\0\0\0\0";
for offset in [0_u32, 6, 64] {
let mut exif_box = offset.to_be_bytes().to_vec();
exif_box.resize(4 + offset as usize, 0xab);
exif_box.extend_from_slice(exif);
let mut container = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
box_bytes(&mut container, b"ftyp", b"jxl \0\0\0\0jxl ");
box_bytes(&mut container, b"jxlc", &raw);
box_bytes(&mut container, b"Exif", &exif_box);
let decoded = decode(&container, DecodeLimits::default());
assert!(decoded.is_ok(), "container with Exif offset {offset} should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert_eq!(decoded.metadata.exif.as_deref(), Some(exif.as_slice()), "TIFF header offset {offset}");
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn associated_alpha_is_unpremultiplied_before_color_conversion() {
let straight = [[200_u8, 100, 50, 128], [30, 180, 240, 192], [100, 80, 60, 255], [220, 110, 40, 160]];
let mut premultiplied = Vec::with_capacity(straight.len() * 4);
for pixel in &straight {
let alpha = f32::from(pixel[3]) / 255.0;
premultiplied.extend(pixel[..3].iter().map(|channel| (f32::from(*channel) * alpha).round() as u8));
premultiplied.push(pixel[3]);
}
let metadata = jixel::FlMeta { alpha_premultiplied: true, ..jixel::FlMeta::srgb() };
let encoded = jixel::encode_fast_lossless(&premultiplied, 2, 2, jixel::ColorSpace::Rgb, true, &metadata);
assert!(encoded.is_ok(), "associated-alpha image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020);
assert!(decoded.is_ok(), "associated-alpha image should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert_eq!(decoded.layout, Layout::Rgba);
assert!(!decoded.pixels_linear_rec2020, "the source encoding is needed for the caller's color transform");
assert!(!decoded.pixels_premultiplied, "associated color samples must be divided before nonlinear conversion");
for (i, source) in straight.iter().enumerate() {
let alpha = f32::from(source[3]) / 255.0;
assert!((decoded.pixels[i * 4 + 3] - alpha).abs() < 1e-6);
for (channel, &value) in source[..3].iter().enumerate() {
let expected = f32::from(value) / 255.0;
assert!((decoded.pixels[i * 4 + channel] - expected).abs() < 0.01, "pixel {i}, channel {channel}");
}
}
}
#[test]
fn decoder_enforces_input_and_pixel_limits() {
let samples = [10_u8, 20, 30];
let image = ImageRef { width: 1, height: 1, layout: Layout::Rgb, samples: Samples::U8(&samples) };
let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "valid image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let input_limit = DecodeLimits { max_input_bytes: encoded.len() - 1, ..DecodeLimits::default() };
assert!(matches!(decode(&encoded, input_limit), Err(craft_jxl::Error::LimitExceeded(_))));
let pixel_limit = DecodeLimits { max_pixels: 0, ..DecodeLimits::default() };
assert!(matches!(decode(&encoded, pixel_limit), Err(craft_jxl::Error::PixelLimitExceeded { width: 1, height: 1, max_pixels: 0 })));
}
#[test]
fn malformed_input_returns_an_error() {
assert!(decode(b"not a JPEG XL file", DecodeLimits::default()).is_err());
}
#[test]
fn encoder_rejects_short_sample_buffers() {
let image = ImageRef { width: 2, height: 1, layout: Layout::Rgb, samples: Samples::U8(&[1, 2, 3]) };
assert!(matches!(encode(&image, EncodeOptions::default(), &Metadata::default()), Err(craft_jxl::Error::InvalidImage(_))));
}
#[test]
fn lossless_gray_input_is_expanded_to_rgb() {
let samples = [0, 40, 128, 255];
let image = ImageRef { width: 4, height: 1, layout: Layout::Gray, samples: Samples::U8(&samples) };
let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "valid grayscale image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = decode(&encoded, DecodeLimits::default());
assert!(decoded.is_ok(), "grayscale image should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert_eq!(decoded.layout, Layout::Rgb);
assert_eq!(decoded.pixels.len(), 4 * 3);
let (pixels, remainder) = decoded.pixels.as_chunks::<3>();
assert!(remainder.is_empty());
for (pixel, gray) in pixels.iter().zip(samples) {
let expected = f32::from(gray) / 255.0;
assert!(pixel.iter().all(|channel| (channel - expected).abs() < 0.01));
}
}
#[test]
fn lossless_u16_samples_keep_their_bit_depth_and_values() {
let samples: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
let image = ImageRef { width: 40, height: 30, layout: Layout::Rgb, samples: Samples::U16(&samples) };
let encoded = encode(&image, EncodeOptions { lossless: true, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "valid 16-bit image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = decode(&encoded, DecodeLimits::default());
assert!(decoded.is_ok(), "16-bit image should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert_eq!(decoded.bit_depth, 16);
assert_eq!(decoded.layout, Layout::Rgb);
assert_eq!(decoded.pixels.len(), samples.len());
for (got, sample) in decoded.pixels.iter().zip(samples) {
let expected = f32::from(sample) / 65535.0;
assert!((got - expected).abs() < 1e-5, "16-bit value differs: {got} vs {expected}");
}
}
#[test]
fn enum_encoded_pixels_can_be_rendered_as_linear_rec2020() {
let samples = [180, 40, 20, 10, 140, 60, 20, 80, 180];
let image = ImageRef { width: 3, height: 1, layout: Layout::Rgb, samples: Samples::U8(&samples) };
let encoded = encode(&image, EncodeOptions { lossless: true, colour: JxlColour::Rec2020, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "valid image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020);
assert!(decoded.is_ok(), "valid image should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert!(!decoded.xyb_encoded, "lossless enum encoding should use Modular coding");
assert_eq!(decoded.layout, Layout::Rgb);
assert_eq!((decoded.width, decoded.height), (3, 1));
assert!(decoded.pixels.iter().all(|sample| sample.is_finite()));
assert!(decoded.rendered_icc.len() > 128, "requested Rec. 2020 linear target has a rendered profile");
}
#[test]
fn xybcoded_pixels_use_the_linear_srgb_workaround_before_rec2020() {
let samples = [
250, 5, 5, 128, 10, 240, 10, 255, 5, 10, 250, 255, 190, 130, 40, 255, 120, 20, 170, 255, 20, 160, 180, 255, 240, 230, 10, 255, 80, 90, 100,
255,
];
let image = ImageRef { width: 4, height: 2, layout: Layout::Rgba, samples: Samples::U8(&samples) };
let encoded = encode(&image, EncodeOptions { quality: 95, colour: JxlColour::Srgb, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "valid image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020);
assert!(decoded.is_ok(), "valid image should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert!(decoded.xyb_encoded, "lossy RGB JPEG XL should use XYB");
assert_eq!(decoded.layout, Layout::Rgba);
assert_eq!(decoded.pixels.len(), 4 * 2 * 4);
let (decoded_pixels, decoded_remainder) = decoded.pixels.as_chunks::<4>();
assert!(decoded_remainder.is_empty());
assert!(decoded_pixels.iter().all(|pixel| (0.0..=1.0).contains(&pixel[3])));
assert!(decoded.pixels.iter().all(|sample| sample.is_finite()));
let reference = jxl_oxide::JxlImage::builder().read(encoded.as_slice());
assert!(reference.is_ok(), "reference decoder should accept the encoded image: {reference:?}");
let Ok(mut reference) = reference else { return };
reference.request_color_encoding(jxl_oxide::color::EnumColourEncoding {
colour_space: jxl_oxide::color::ColourSpace::Rgb,
white_point: jxl_oxide::color::WhitePoint::D65,
primaries: jxl_oxide::color::Primaries::Srgb,
tf: jxl_oxide::color::TransferFunction::Linear,
rendering_intent: jxl_oxide::color::RenderingIntent::Relative,
});
let reference_frame = reference.render_frame(0);
assert!(reference_frame.is_ok(), "reference frame should render: {reference_frame:?}");
let Ok(reference_frame) = reference_frame else { return };
let reference = reference_frame.image_all_channels();
assert_eq!(reference.channels(), 4);
let (actual_pixels, actual_remainder) = decoded.pixels.as_chunks::<4>();
let (reference_pixels, reference_remainder) = reference.buf().as_chunks::<4>();
assert!(actual_remainder.is_empty());
assert!(reference_remainder.is_empty());
for (actual, linear_srgb) in actual_pixels.iter().zip(reference_pixels) {
let [r, g, b] = [linear_srgb[0], linear_srgb[1], linear_srgb[2]];
let expected = [
0.627_403_9 * r + 0.329_283_04 * g + 0.043_313_067 * b,
0.069_097_29 * r + 0.919_540_4 * g + 0.011_362_316 * b,
0.016_391_44 * r + 0.088_013_31 * g + 0.895_595_25 * b,
];
for (got, want) in actual[..3].iter().zip(expected) {
assert!((got - want).abs() < 1e-6, "linear Rec. 2020 channel differs: {got} vs {want}");
}
assert_eq!(actual[3], linear_srgb[3], "color conversion leaves alpha unchanged");
}
}
#[test]
fn icc_encoded_pixels_keep_the_embedded_profile_under_linear_target_request() {
let samples = [180, 40, 20, 10, 140, 60, 20, 80, 180];
let image = ImageRef { width: 3, height: 1, layout: Layout::Rgb, samples: Samples::U8(&samples) };
let icc = craft_cms::Builtin::DisplayP3.profile().to_bytes();
let metadata = Metadata { icc: Some(icc.as_ref().clone()), ..Metadata::default() };
let encoded = encode(&image, EncodeOptions { lossless: true, colour: JxlColour::DisplayP3, ..EncodeOptions::default() }, &metadata);
assert!(encoded.is_ok(), "valid image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoder_default = decode(&encoded, DecodeLimits::default());
let linear_request = decode_with_color(&encoded, DecodeLimits::default(), OutputColor::LinearRec2020);
assert!(decoder_default.is_ok(), "ICC image should decode: {decoder_default:?}");
assert!(linear_request.is_ok(), "ICC image should decode: {linear_request:?}");
let (Ok(decoder_default), Ok(linear_request)) = (decoder_default, linear_request) else { return };
assert_eq!(decoder_default.metadata.icc.as_deref(), Some(icc.as_slice()));
assert_eq!(linear_request.metadata.icc.as_deref(), Some(icc.as_slice()));
assert_eq!(decoder_default.pixels, linear_request.pixels, "without a bundled CMS, the requested target preserves ICC-space samples");
}
#[test]
fn lossy_quality_effort_and_progressive_options_produce_decodable_images() {
let (width, height) = (160_u32, 120_u32);
let mut samples = Vec::with_capacity(width as usize * height as usize * 4);
for y in 0..height {
for x in 0..width {
let fx = x as f32 / width as f32;
let fy = y as f32 / height as f32;
samples.extend_from_slice(&[(fx * 255.0) as u8, (fy * 255.0) as u8, ((1.0 - fx) * 200.0) as u8, (((fx + fy) * 0.5) * 255.0) as u8]);
}
}
let image = ImageRef { width, height, layout: Layout::Rgba, samples: Samples::U8(&samples) };
let encode_options = |quality, effort, progressive| EncodeOptions { quality, effort, progressive, ..EncodeOptions::default() };
let hi = encode(&image, encode_options(95, craft_jxl::Effort::Normal, false), &Metadata::default());
let lo = encode(&image, encode_options(40, craft_jxl::Effort::Normal, false), &Metadata::default());
assert!(hi.is_ok(), "high-quality image should encode: {hi:?}");
assert!(lo.is_ok(), "low-quality image should encode: {lo:?}");
let (Ok(hi), Ok(lo)) = (hi, lo) else { return };
assert!(lo.len() < hi.len(), "quality 40 output {} should be smaller than quality 95 output {}", lo.len(), hi.len());
for bytes in [&hi, &lo] {
let decoded = decode(bytes, DecodeLimits::default());
assert!(decoded.is_ok(), "encoded image should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert_eq!((decoded.width, decoded.height), (width, height));
assert_eq!(decoded.layout, Layout::Rgba);
let threshold = if bytes.len() == hi.len() { 38.0 } else { 28.0 };
let psnr = psnr8(&samples, &decoded.pixels, 4);
assert!(psnr > threshold, "PSNR {psnr:.2} dB must exceed {threshold:.1} dB");
}
for effort in [craft_jxl::Effort::Fast, craft_jxl::Effort::Normal] {
for progressive in [false, true] {
let encoded = encode(&image, encode_options(90, effort, progressive), &Metadata::default());
assert!(encoded.is_ok(), "{effort:?} progressive={progressive} should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = decode(&encoded, DecodeLimits::default());
assert!(decoded.is_ok(), "{effort:?} progressive={progressive} should decode: {decoded:?}");
let Ok(decoded) = decoded else { return };
let psnr = psnr8(&samples, &decoded.pixels, 4);
assert!(psnr > 34.0, "{effort:?} progressive={progressive} PSNR {psnr:.2} dB");
}
}
}
fn psnr8(source: &[u8], decoded: &[f32], channels: usize) -> f64 {
let mut squared_error = 0.0;
for (source, decoded) in source.chunks_exact(channels).zip(decoded.chunks_exact(channels)) {
for channel in 0..3 {
let difference = f64::from(source[channel]) - f64::from(decoded[channel] * 255.0);
squared_error += difference * difference;
}
}
let samples = (source.len() / channels * 3) as f64;
let mse = squared_error / samples;
if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0 * 255.0 / mse).log10() }
}
#[test]
fn rec2020_pq_float_samples_are_written_as_hdr() {
let (width, height) = (64_u32, 32_u32);
let samples: Vec<f32> =
(0..(width * height) as usize).flat_map(|i| [if i % (width as usize) < width as usize / 2 { 1.0 } else { 4.0 }; 3]).collect();
let image = ImageRef { width, height, layout: Layout::Rgb, samples: Samples::F32(&samples) };
for lossless in [false, true] {
let encoded =
encode(&image, EncodeOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "HDR image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let decoded = jxl_oxide::JxlImage::builder().read(encoded.as_slice());
assert!(decoded.is_ok(), "reference decoder should read HDR output: {decoded:?}");
let Ok(decoded) = decoded else { return };
let metadata = &decoded.image_header().metadata;
assert_eq!(metadata.tone_mapping.intensity_target, 10_000.0);
assert!(format!("{:?}", metadata.colour_encoding).contains("Pq"), "{:?}", metadata.colour_encoding);
let frame = decoded.render_frame(0);
assert!(frame.is_ok(), "reference decoder should render HDR output: {frame:?}");
let Ok(frame) = frame else { return };
let buffer = frame.image_all_channels();
let channels = buffer.channels();
assert!(channels >= 3);
let sample_at = |x: usize| buffer.buf()[((height as usize / 2) * width as usize + x) * channels + 1];
for (x, nits) in [(width as usize / 4, HDR_REFERENCE_WHITE_NITS), (width as usize * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
let encoded_pq = pq_encode(nits);
let actual = sample_at(x);
assert!((actual - encoded_pq).abs() < 0.005, "lossless={lossless}: {nits} nits encoded as {actual}, expected {encoded_pq}");
}
}
assert!(matches!(encode(&image, EncodeOptions::default(), &Metadata::default()), Err(craft_jxl::Error::InvalidImage(_))));
}
fn pq_encode(nits: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
}
#[test]
fn bounded_feeds_keep_metadata_after_the_final_progressive_part() {
let samples: Vec<u8> = (0..8 * 8 * 4).map(|i| (i * 13 % 256) as u8).collect();
let image = ImageRef { width: 8, height: 8, layout: Layout::Rgba, samples: Samples::U8(&samples) };
let encoded = encode(&image, EncodeOptions { lossless: true, progressive: true, ..EncodeOptions::default() }, &Metadata::default());
assert!(encoded.is_ok(), "valid image should encode: {encoded:?}");
let Ok(encoded) = encoded else { return };
let raw = raw_codestream(&encoded);
assert!(raw.starts_with(b"\xff\x0a"), "extract the JXL codestream from the encoder output");
let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl ");
let split = raw.len() / 2;
let mut part = 0_u32.to_be_bytes().to_vec();
part.extend_from_slice(&raw[..split]);
box_bytes(&mut bytes, b"jxlp", &part);
let padding = vec![0; 4096usize.saturating_sub(bytes.len() + 8)];
box_bytes(&mut bytes, b"free", &padding);
let mut part = 0x8000_0001_u32.to_be_bytes().to_vec();
part.extend_from_slice(&raw[split..]);
box_bytes(&mut bytes, b"jxlp", &part);
let exif = b"II*\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x01\0\0\0\0\0\0\0";
let mut exif_box = vec![0; 4];
exif_box.extend_from_slice(exif);
box_bytes(&mut bytes, b"Exif", &exif_box);
let xmp = b"<xmp/>";
box_bytes(&mut bytes, b"xml ", xmp);
assert!(bytes.len() < 8192, "metadata fixture should fit in two decoder feeds");
let reference = jxl_oxide::JxlImage::builder().read(bytes.as_slice());
assert!(reference.is_ok(), "reference decoder should read the fixture: {reference:?}");
let Ok(reference) = reference else { return };
let reference_exif = match reference.aux_boxes().first_exif() {
Ok(jxl_oxide::AuxBoxData::Data(data)) => Some(data.payload().to_vec()),
_ => None,
};
let reference_xmp = match reference.aux_boxes().first_xml() {
jxl_oxide::AuxBoxData::Data(data) => Some(data.to_vec()),
_ => None,
};
assert!(reference_exif.is_some(), "reference decoder should preserve the Exif box");
assert!(reference_xmp.is_some(), "reference decoder should preserve the XMP box");
let (Some(reference_exif), Some(reference_xmp)) = (reference_exif, reference_xmp) else { return };
let decoded = decode(&bytes, DecodeLimits::default());
assert!(decoded.is_ok(), "shared decoder should read the fixture: {decoded:?}");
let Ok(decoded) = decoded else { return };
assert_eq!(decoded.metadata.exif.as_deref(), Some(reference_exif.as_slice()));
assert_eq!(decoded.metadata.xmp.as_deref(), Some(reference_xmp.as_slice()));
}