mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Merge pull request #1229 from storytold/codex/craft-jxl-extraction
Add shared JPEG XL decoder and encoder
This commit is contained in:
8 files changed
+1176
No files matched your search
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()));
|
||||
}
|
||||
Reference in new issue
Block a user