mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Add shared JPEG XL decoder and encoder
This commit is contained in:
1 parent
b5b8a7750d
commit
4d3e920954
8 files changed
+624
No files matched your search
Generated
+15
@@ -1719,6 +1719,15 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-jxl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jixel",
|
||||
"jxl-oxide",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
@@ -5009,6 +5018,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"
|
||||
|
||||
@@ -76,6 +76,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" }
|
||||
@@ -154,6 +155,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,23 @@
|
||||
[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 }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
jixel = { workspace = true, optional = true }
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
|
||||
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,130 @@
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage};
|
||||
|
||||
use crate::{DecodeLimits, Decoded, Error, Layout, Metadata, 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> {
|
||||
catch_unwind(AssertUnwindSafe(|| decode_inner(bytes, limits)))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed("decoder failed while reading this file".into())))
|
||||
}
|
||||
|
||||
fn decode_inner(bytes: &[u8], limits: DecodeLimits) -> 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::LimitExceeded(format!("{width}×{height} image exceeds the {} pixel limit", 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 header = image.image_header();
|
||||
let bit_depth = header.metadata.bit_depth.bits_per_sample() as u8;
|
||||
let float_samples = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
|
||||
let grayscale = header.metadata.grayscale();
|
||||
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 = pixel_format.has_alpha();
|
||||
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 frame_pixels = (buffer.width() as u128) * (buffer.height() as u128);
|
||||
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) {
|
||||
out.extend_from_slice(&pixel[..keep]);
|
||||
}
|
||||
|
||||
let metadata = Metadata {
|
||||
icc: image.original_icc().map(ToOwned::to_owned),
|
||||
exif: match image.aux_boxes().first_exif() {
|
||||
Ok(AuxBoxData::Data(exif)) => Some(exif.payload().to_vec()),
|
||||
_ => None,
|
||||
},
|
||||
xmp: match image.aux_boxes().first_xml() {
|
||||
AuxBoxData::Data(xmp) => Some(xmp.to_vec()),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
Ok(Decoded {
|
||||
width,
|
||||
height,
|
||||
layout,
|
||||
bit_depth,
|
||||
float_samples,
|
||||
pixels: out,
|
||||
metadata,
|
||||
source_orientation: header.metadata.orientation,
|
||||
rendered_cicp: image.rendered_cicp(),
|
||||
rendered_icc: image.rendered_icc(),
|
||||
xyb_encoded: header.metadata.xyb_encoded,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_error(error: impl std::fmt::Display) -> Error {
|
||||
Error::Malformed(error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use crate::{Error, ImageRef, Layout, 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,157 @@
|
||||
#![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,
|
||||
}
|
||||
|
||||
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 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,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// Encode a JPEG XL image.
|
||||
#[cfg(feature = "encode")]
|
||||
pub use encode::{Effort, EncodeOptions, HDR_REFERENCE_WHITE_NITS, JxlColour, encode};
|
||||
@@ -0,0 +1,44 @@
|
||||
use craft_jxl::{DecodeLimits, EncodeOptions, ImageRef, Layout, Metadata, Samples, decode, encode};
|
||||
|
||||
#[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 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 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);
|
||||
}
|
||||
|
||||
#[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::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[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(_))));
|
||||
}
|
||||
Reference in new issue
Block a user