mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge approved PhotoCraft PR #3387: feat(codecs): add lossless JPEG XL export for 8/16-bit images
Port behavior and regression coverage into current modules; preserve current upstream behavior. Integrate lossless export-only JXL and independent decoder regression matrix, preserving current JPEG/TIFF save extensions and 12-bit/arithmetic JPEG decoder dependency.
This commit is contained in:
Generated
+2
@@ -5096,6 +5096,7 @@ dependencies = [
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
@@ -5105,6 +5106,7 @@ dependencies = [
|
||||
"tiff",
|
||||
"zune-core 0.5.3",
|
||||
"zune-jpeg 0.5.15",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -92,6 +92,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("OpenRaster", &["ora"]),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg", "jpeg"]),
|
||||
("JPEG XL", &["jxl"]),
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif", "tiff"]),
|
||||
("Targa", &["tga"]),
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
# Raster formats
|
||||
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868.
|
||||
|
||||
## Layered TIFF
|
||||
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
|
||||
## JPEG XL
|
||||
|
||||
Save As, Export As and the CLI can write a lossless, single-image `.jxl` bare
|
||||
codestream at 8 or 16 bits per sample. Gray, RGB and RGBA are encoded
|
||||
natively. Grayscale with alpha is expanded to RGBA at the original bit depth
|
||||
to preserve both grayscale and alpha samples. For 16-bit RGBA the encoder's
|
||||
incorrect default 8-bit alpha header is corrected to explicitly describe
|
||||
16-bit alpha, with an independent decoder oracle checking alpha values.
|
||||
The bare codestream has no ICC, EXIF, XMP or DPI boxes; the export path
|
||||
converts tagged RGB to sRGB and surfaces metadata losses as fidelity warnings.
|
||||
JPEG XL opening remains under review in #2868.
|
||||
|
||||
## WebP
|
||||
|
||||
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
|
||||
|
||||
@@ -33,6 +33,8 @@ zune-core = "0.5"
|
||||
jpeg-encoder = "0.6"
|
||||
# 12-bit and arithmetic-coded JPEG, which zune-jpeg does not decode.
|
||||
craft-jpeg.workspace = true
|
||||
# Pure-Rust modular JPEG XL lossless encoder.
|
||||
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
|
||||
photocraft-heif = { path = "../heif", optional = true }
|
||||
|
||||
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
|
||||
@@ -46,5 +48,9 @@ proptest.workspace = true
|
||||
# Oracle decoders for cross-checking in tests only.
|
||||
image = { workspace = true, features = ["png", "jpeg", "tiff"] }
|
||||
|
||||
# Independent JPEG XL decoder oracle; not linked into the application.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
jxl-oxide = { version = "=0.12.6", default-features = false, features = ["rayon"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -46,6 +46,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
|
||||
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
|
||||
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
|
||||
| JPEG XL (.jxl) | **no** (see #2868) | yes | U8, U16 | Gray, RGB, RGBA (GrayA expands to RGBA) | yes | no (bare codestream) | no | no | no | no | no | `zune-jpegxl` |
|
||||
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
|
||||
| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in |
|
||||
| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in |
|
||||
@@ -73,6 +74,17 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
* **JPEG XL export.** Writes lossless 8/16-bit bare codestreams with `zune-jpegxl`.
|
||||
Native layouts are Gray, RGB and RGBA. GrayA is expanded to RGBA at the
|
||||
original depth. For 16-bit RGBA (including expanded GrayA), we correct
|
||||
zune-jpegxl 0.5.2's extra-channel header, which otherwise describes the
|
||||
16-bit alpha samples as 8-bit. This preserves alpha precision; the independent
|
||||
jxl-oxide decoder test checks the actual gray and alpha samples. The expansion
|
||||
is not a native GrayA layout. No ICC, EXIF, XMP or DPI container boxes are written; the
|
||||
document pipeline converts tagged images to sRGB and reports metadata loss.
|
||||
Reading remains in the separate #2868 PR. The `ASYMMETRIC_EXCEPTIONS`
|
||||
entry stays until import is merged.
|
||||
|
||||
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
|
||||
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
|
||||
with the legacy header or the DX10 one. Uncompressed row padding follows the header's
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) mod gif_palette;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod iff;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod jxl;
|
||||
pub(crate) mod pcx;
|
||||
pub(crate) mod png;
|
||||
pub(crate) mod pnm;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Pure-Rust lossless JPEG XL (Modular) writer.
|
||||
//!
|
||||
//! Currently writes a bare JPEG XL codestream. The encoder accepts 8- or 16-bit
|
||||
//! gray, RGB and RGBA samples, but does not write ICC/EXIF/XMP boxes.
|
||||
//! Gray+alpha sources are expanded losslessly to RGBA by the export plan.
|
||||
//! The flat export pipeline converts tagged RGB documents to sRGB first.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions as ZuneOptions};
|
||||
use zune_jpegxl::JxlSimpleEncoder;
|
||||
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
|
||||
const F: Format = Format::Jxl;
|
||||
|
||||
// zune-jpegxl 0.5.2 emits ExtraChannelInfo(all_default=true) for every alpha
|
||||
// channel. In JPEG XL that flag means *8-bit* alpha, even when the modular
|
||||
// payload was encoded from 16-bit samples. Replace only that metadata bundle,
|
||||
// leaving the entire encoded frame and its TOC untouched. The replacement is
|
||||
// exactly 16 bits longer, so subsequent byte-aligned data stays aligned.
|
||||
//
|
||||
// This deliberately verifies the pinned encoder's header layout rather than
|
||||
// trying to parse or rewrite arbitrary JPEG XL codestreams. A different header
|
||||
// must fail closed, not produce a file that silently corrupts transparency.
|
||||
fn correct_16_bit_alpha_header(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, CodecError> {
|
||||
fn read(bytes: &[u8], pos: &mut usize, count: usize) -> Option<u32> {
|
||||
let mut value = 0u32;
|
||||
for bit in 0..count {
|
||||
let offset = *pos;
|
||||
let byte = *bytes.get(offset / 8)?;
|
||||
value |= u32::from((byte >> (offset % 8)) & 1) << bit;
|
||||
*pos += 1;
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn read_dimension(bytes: &[u8], pos: &mut usize) -> Option<u32> {
|
||||
let bits = match read(bytes, pos, 2)? {
|
||||
0 => 9,
|
||||
1 => 13,
|
||||
2 => 18,
|
||||
_ => 30,
|
||||
};
|
||||
read(bytes, pos, bits)?.checked_add(1)
|
||||
}
|
||||
|
||||
fn write_bits(out: &mut Vec<u8>, pos: &mut usize, count: usize, value: u32) {
|
||||
for bit in 0..count {
|
||||
if (*pos).is_multiple_of(8) {
|
||||
out.push(0);
|
||||
}
|
||||
out[*pos / 8] |= (((value >> bit) & 1) as u8) << (*pos % 8);
|
||||
*pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
let header_matches = read(bytes, &mut pos, 16) == Some(0x0aff)
|
||||
&& read(bytes, &mut pos, 1) == Some(0)
|
||||
&& read_dimension(bytes, &mut pos) == Some(height)
|
||||
&& read(bytes, &mut pos, 3) == Some(0)
|
||||
&& read_dimension(bytes, &mut pos) == Some(width)
|
||||
&& read(bytes, &mut pos, 1) == Some(0) // metadata not all-default
|
||||
&& read(bytes, &mut pos, 1) == Some(0) // no extra fields
|
||||
&& read(bytes, &mut pos, 1) == Some(0) // integer samples
|
||||
&& read(bytes, &mut pos, 2) == Some(3) // custom bit depth
|
||||
&& read(bytes, &mut pos, 6) == Some(15) // 16 bits
|
||||
&& read(bytes, &mut pos, 1) == Some(0) // uses >14 bits
|
||||
&& read(bytes, &mut pos, 2) == Some(1); // one extra channel
|
||||
let alpha_flag_pos = pos;
|
||||
let header_matches = header_matches
|
||||
&& read(bytes, &mut pos, 1) == Some(1) // incorrect default 8-bit alpha
|
||||
&& read(bytes, &mut pos, 1) == Some(0) // not XYB
|
||||
&& read(bytes, &mut pos, 1) == Some(1) // default sRGB encoding (RGBA)
|
||||
&& read(bytes, &mut pos, 2) == Some(0) // no extensions
|
||||
&& read(bytes, &mut pos, 1) == Some(1); // default transform
|
||||
|
||||
let frame_offset = pos.div_ceil(8);
|
||||
let header_matches = header_matches && frame_offset <= bytes.len() && (pos..frame_offset * 8).all(|bit| bytes[bit / 8] & (1 << (bit % 8)) == 0);
|
||||
if !header_matches {
|
||||
return Err(CodecError::encode(F, "unexpected zune-jpegxl header for 16-bit alpha"));
|
||||
}
|
||||
|
||||
let mut fixed = Vec::with_capacity(bytes.len() + 2);
|
||||
let mut cursor = 0;
|
||||
for bit in 0..alpha_flag_pos {
|
||||
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
|
||||
}
|
||||
|
||||
// JPEG XL ExtraChannelInfo, non-default, straight 16-bit alpha:
|
||||
// type=kAlpha (Enum=0), bit_depth=uint16, dim_shift=0, name_len=0,
|
||||
// alpha_associated=false. Adds exactly 16 bits versus all_default=true.
|
||||
write_bits(&mut fixed, &mut cursor, 1, 0);
|
||||
write_bits(&mut fixed, &mut cursor, 2, 0);
|
||||
write_bits(&mut fixed, &mut cursor, 1, 0);
|
||||
write_bits(&mut fixed, &mut cursor, 2, 3);
|
||||
write_bits(&mut fixed, &mut cursor, 6, 15);
|
||||
write_bits(&mut fixed, &mut cursor, 2, 0);
|
||||
write_bits(&mut fixed, &mut cursor, 2, 0);
|
||||
write_bits(&mut fixed, &mut cursor, 1, 0);
|
||||
|
||||
for bit in alpha_flag_pos + 1..frame_offset * 8 {
|
||||
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
|
||||
}
|
||||
if cursor != (frame_offset + 2) * 8 {
|
||||
return Err(CodecError::encode(F, "JPEG XL alpha header alignment mismatch"));
|
||||
}
|
||||
fixed.extend_from_slice(&bytes[frame_offset..]);
|
||||
Ok(fixed)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(image: &Image, plan: Plan) -> Result<Vec<u8>, CodecError> {
|
||||
let image = image.converted(plan.layout, plan.sample);
|
||||
let space = match image.layout() {
|
||||
ChannelLayout::Gray => ColorSpace::Luma,
|
||||
ChannelLayout::GrayA => ColorSpace::LumaA,
|
||||
ChannelLayout::Rgb => ColorSpace::RGB,
|
||||
ChannelLayout::Rgba => ColorSpace::RGBA,
|
||||
_ => return Err(CodecError::unsupported(F, "JPEG XL export supports grayscale and RGB, with optional alpha")),
|
||||
};
|
||||
let depth = match image.sample_type() {
|
||||
SampleType::U8 => BitDepth::Eight,
|
||||
SampleType::U16 => BitDepth::Sixteen,
|
||||
_ => return Err(CodecError::unsupported(F, "JPEG XL lossless export supports 8 or 16-bit integer pixels")),
|
||||
};
|
||||
let (width, height) = image.dimensions();
|
||||
let samples = (width as usize)
|
||||
.checked_mul(height as usize)
|
||||
.and_then(|n| n.checked_mul(image.layout().channels()))
|
||||
.and_then(|n| n.checked_mul(image.sample_type().bytes()))
|
||||
.ok_or_else(|| CodecError::encode(F, "image dimensions overflow the buffer size"))?;
|
||||
if image.data().len() != samples {
|
||||
return Err(CodecError::InvalidImage("incorrect JPEG XL pixel buffer length".into()));
|
||||
}
|
||||
// A panic in an external image encoder must not terminate the host application.
|
||||
catch_unwind(AssertUnwindSafe(|| {
|
||||
let options = ZuneOptions::new(width as usize, height as usize, space, depth);
|
||||
let mut output = Vec::new();
|
||||
JxlSimpleEncoder::new(image.data(), options).encode(&mut output).map_err(|e| CodecError::encode(F, e.to_string()))?;
|
||||
if depth == BitDepth::Sixteen && image.layout() == ChannelLayout::Rgba { correct_16_bit_alpha_header(&output, width, height) } else { Ok(output) }
|
||||
}))
|
||||
.unwrap_or_else(|_| Err(CodecError::encode(F, "the JPEG XL encoder failed")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn alpha_header_correction_rejects_unexpected_or_truncated_headers() {
|
||||
for bytes in [&[][..], &[0xff, 0x0a][..], &[0; 32][..]] {
|
||||
assert!(correct_16_bit_alpha_header(bytes, 2, 2).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ pub enum Format {
|
||||
Fits,
|
||||
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
|
||||
Dpx,
|
||||
/// JPEG XL: lossless export-only until the separate importer lands.
|
||||
Jxl,
|
||||
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from).
|
||||
Cineon,
|
||||
/// DICOM single-frame images (uncompressed): read-only.
|
||||
@@ -85,6 +87,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
|
||||
),
|
||||
(Format::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."),
|
||||
(
|
||||
Format::Heif,
|
||||
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
|
||||
@@ -160,6 +163,7 @@ impl Format {
|
||||
Format::Heif => "HEIF",
|
||||
Format::Fits => "FITS",
|
||||
Format::Dpx => "DPX",
|
||||
Format::Jxl => "JPEG XL",
|
||||
Format::Cineon => "Cineon",
|
||||
Format::Dicom => "DICOM",
|
||||
Format::Iff => "Amiga IFF",
|
||||
@@ -189,6 +193,7 @@ impl Format {
|
||||
Format::Heif => &["heic", "heif", "hif"],
|
||||
Format::Fits => &["fits", "fit", "fts"],
|
||||
Format::Dpx => &["dpx"],
|
||||
Format::Jxl => &["jxl"],
|
||||
Format::Cineon => &["cin"],
|
||||
Format::Dicom => &["dcm", "dicom", "dc3"],
|
||||
Format::Iff => &["iff", "lbm", "ilbm"],
|
||||
@@ -216,6 +221,7 @@ impl Format {
|
||||
Format::Heif => "image/heif",
|
||||
Format::Fits => "image/fits",
|
||||
Format::Dpx => "image/x-dpx",
|
||||
Format::Jxl => "image/jxl",
|
||||
Format::Cineon => "image/x-cin",
|
||||
Format::Dicom => "application/dicom",
|
||||
Format::Iff => "image/x-iff",
|
||||
@@ -276,6 +282,8 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
|
||||
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
|
||||
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
|
||||
// GrayA is widened to RGBA at the same depth; the 16-bit alpha header is corrected.
|
||||
Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..base },
|
||||
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
|
||||
@@ -292,6 +300,10 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
||||
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return Some(Format::Jpeg);
|
||||
}
|
||||
// JPEG XL bare codestream and ISO/IEC 18181-2 container signature box.
|
||||
if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n") {
|
||||
return Some(Format::Jxl);
|
||||
}
|
||||
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
|
||||
return Some(Format::Tiff);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
/// Decode as a specific format.
|
||||
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
|
||||
if !caps(format).read && format != Format::Heif {
|
||||
if !caps(format).read && !matches!(format, Format::Heif | Format::Jxl) {
|
||||
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
|
||||
}
|
||||
let l = &opts.limits;
|
||||
@@ -89,6 +89,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
||||
Format::Iff => iff::decode(bytes, l),
|
||||
Format::OpenExr => exr::decode(bytes, l),
|
||||
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
|
||||
Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
|
||||
}?;
|
||||
// Without a resolution of the format's own (PNG pHYs, TIFF tags, JPEG's metadata), the
|
||||
@@ -307,6 +308,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
||||
Format::Iff => iff::encode(image, plan),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
|
||||
Format::Jxl => jxl::encode(image, plan),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Lossless JPEG XL export and honest export-only capability reporting.
|
||||
|
||||
use photocraft_codecs::{
|
||||
ASYMMETRIC_EXCEPTIONS, ChannelLayout, CodecError, EncodeOptions, Format, Image, SampleType, caps, decode, detect, encode, from_extension,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn jpeg_xl_is_export_only_and_detects_both_signatures() {
|
||||
let c = caps(Format::Jxl);
|
||||
assert!(c.write && !c.read);
|
||||
assert_eq!(c.depths, &[SampleType::U8, SampleType::U16]);
|
||||
assert_eq!(c.layouts, &[ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Rgba]);
|
||||
assert!(c.alpha && !c.icc && !c.exif && !c.xmp && !c.dpi && !c.lossy);
|
||||
assert_eq!(Format::Jxl.mime_type(), "image/jxl");
|
||||
assert_eq!(from_extension("image.JXL"), Some(Format::Jxl));
|
||||
assert_eq!(detect(&[0xff, 0x0a, 0, 0]), Some(Format::Jxl));
|
||||
assert_eq!(detect(b"\0\0\0\x0cJXL \r\n\x87\n"), Some(Format::Jxl));
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(format, reason)| *format == Format::Jxl && !reason.is_empty()));
|
||||
let err = decode(&[0xff, 0x0a]);
|
||||
assert!(matches!(err, Err(CodecError::Unsupported { format: Format::Jxl, .. })), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_xl_encoding_is_deterministic_and_converts_float_to_integer() {
|
||||
let input =
|
||||
Image::from_u8(3, 2, ChannelLayout::Rgba, vec![0, 255, 17, 127, 20, 40, 80, 255, 255, 0, 2, 0, 10, 20, 30, 64, 0, 0, 0, 1, 90, 80, 70, 128]).unwrap();
|
||||
let opts = EncodeOptions::default();
|
||||
let one = encode(&input, Format::Jxl, &opts).unwrap();
|
||||
let two = encode(&input, Format::Jxl, &opts).unwrap();
|
||||
assert_eq!(one, two);
|
||||
assert_eq!(detect(&one), Some(Format::Jxl));
|
||||
let float = Image::from_f32(2, 2, ChannelLayout::Rgb, &[0.0, 0.5, 1.0, 0.25, 0.5, 0.75, 0.75, 0.5, 0.25, 1.0, 0.0, 0.5]).unwrap();
|
||||
let bytes = encode(&float, Format::Jxl, &opts).unwrap();
|
||||
assert_eq!(detect(&bytes), Some(Format::Jxl));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn jpeg_xl_decodes_losslessly_with_independent_decoder() {
|
||||
use jxl_oxide::JxlImage;
|
||||
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
let channels = layout.channels();
|
||||
for sample in [SampleType::U8, SampleType::U16] {
|
||||
let (w, h) = (7, 5);
|
||||
let count = w as usize * h as usize * channels;
|
||||
let expected = match sample {
|
||||
SampleType::U8 => (0..count).map(|i| (((i * 37 + 19) % 256) as u16) * 257).collect::<Vec<_>>(),
|
||||
SampleType::U16 => (0..count).map(|i| ((i * 9973 + 27) % 65536) as u16).collect::<Vec<_>>(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let input = match sample {
|
||||
SampleType::U8 => {
|
||||
let data = expected.iter().map(|v| (v / 257) as u8).collect();
|
||||
Image::from_u8(w, h, layout, data).unwrap()
|
||||
}
|
||||
SampleType::U16 => Image::from_u16(w, h, layout, &expected).unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
// GrayA is losslessly widened to RGBA. Keep the oracle comparison
|
||||
// against original gray and alpha samples, not only against a converted image.
|
||||
let (output_channels, expected_output) = if layout == ChannelLayout::GrayA {
|
||||
let expanded: Vec<u16> = expected.as_chunks::<2>().0.iter().flat_map(|[gray, alpha]| [*gray, *gray, *gray, *alpha]).collect();
|
||||
(4, expanded)
|
||||
} else {
|
||||
(channels, expected.clone())
|
||||
};
|
||||
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
|
||||
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
|
||||
assert_eq!((decoder.width(), decoder.height()), (w, h));
|
||||
let render = decoder.render_frame(0).unwrap();
|
||||
let mut stream = render.stream();
|
||||
assert_eq!(stream.channels() as usize, output_channels, "{layout:?} {sample:?}");
|
||||
let mut output = vec![0u16; expected_output.len()];
|
||||
assert_eq!(stream.write_to_buffer(&mut output), expected_output.len());
|
||||
assert_eq!(output, expected_output, "{layout:?} {sample:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn jpeg_xl_preserves_sixteen_bit_alpha_across_dimension_header_sizes() {
|
||||
use jxl_oxide::JxlImage;
|
||||
|
||||
// Exercise each size-header coding tier, and non-opaque alpha extremes.
|
||||
for width in [2u32, 513, 8193] {
|
||||
let height = 2u32;
|
||||
let mut samples = Vec::with_capacity(width as usize * height as usize * 4);
|
||||
for i in 0..(width as usize * height as usize) {
|
||||
samples.extend_from_slice(&[(i as u16).wrapping_mul(127), 65535, (i as u16).wrapping_mul(9973), [0u16, 1, 32768, 65535][i % 4]]);
|
||||
}
|
||||
let input = Image::from_u16(width, height, ChannelLayout::Rgba, &samples).unwrap();
|
||||
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
|
||||
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
|
||||
let render = decoder.render_frame(0).unwrap();
|
||||
let mut stream = render.stream();
|
||||
assert_eq!(stream.channels(), 4, "width={width}");
|
||||
let mut output = vec![0u16; samples.len()];
|
||||
assert_eq!(stream.write_to_buffer(&mut output), samples.len());
|
||||
assert_eq!(output, samples, "width={width}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_xl_rejects_one_pixel_images_without_panicking() {
|
||||
// zune-jpegxl currently requires at least two pixels on each axis.
|
||||
let image = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![42, 100, 255]).unwrap();
|
||||
assert!(matches!(encode(&image, Format::Jxl, &EncodeOptions::default()), Err(CodecError::Encode { format: Format::Jxl, .. })));
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use crate::state::DialogKind;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{ExportSettings, PhotocraftApp};
|
||||
|
||||
const FORMATS: [(&str, &str); 6] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("ico", "ICO")];
|
||||
const FORMATS: [(&str, &str); 7] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("ico", "ICO"), ("jxl", "JPEG XL")];
|
||||
|
||||
/// Preferences ▸ remembered dialog settings: Export As writes the settings it was used with
|
||||
/// last under its command id and seeds the next dialog from them, as Fill, Liquify and the
|
||||
|
||||
Reference in New Issue
Block a user