mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
feat(export): expose JPEG XL Save As and Export As, document limitations and test output
This commit is contained in:
Generated
+1
-1
@@ -4298,6 +4298,7 @@ dependencies = [
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
@@ -4308,7 +4309,6 @@ dependencies = [
|
||||
"zune-core 0.5.3",
|
||||
"zune-jpeg 0.5.15",
|
||||
"zune-jpegxl",
|
||||
"jxl-oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -56,6 +56,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("OpenRaster", &["ora"]),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg"]),
|
||||
("JPEG XL", &["jxl"]),
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# 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 supports 8/16-bit integer gray and RGB images with optional alpha through the pure-Rust `zune-jpegxl` encoder. Flat JPEG XL import is not yet available (see PR #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: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. 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`
|
||||
codestream with 8- or 16-bit grayscale, grayscale+alpha, RGB or RGBA samples.
|
||||
The pure-Rust encoder writes the bare codestream (without ICC, EXIF, XMP or DPI
|
||||
metadata boxes), so export converts non-sRGB tagged images to sRGB and surfaces
|
||||
metadata losses as fidelity warnings. Opening ordinary JPEG XL files remains a
|
||||
separate feature 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.
|
||||
|
||||
@@ -46,6 +46,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
|
||||
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
|
||||
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
|
||||
| JPEG XL (.jxl) | **no** (see #2868) | yes | U8, U16 | Gray, GrayA, RGB, RGBA | yes | no (bare codestream) | no | no | no | no | no | `zune-jpegxl` |
|
||||
|
||||
"Native" means the data is stored and read back without conversion. Anything else is converted by
|
||||
the encode plan, and `fidelity_warnings` reports the conversion when it loses information:
|
||||
@@ -62,6 +63,15 @@ the same.
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
* **JPEG XL export** writes lossless modular `.jxl` codestreams in pure Rust with
|
||||
`zune-jpegxl`, preserving 8/16-bit integer pixels and optional alpha for gray/RGB
|
||||
sources. The writer does not embed ICC, EXIF or XMP boxes; untagged RGB images are
|
||||
converted to sRGB by the document export path and `fidelity_warnings` report
|
||||
dropped metadata. JPEG XL *reading* is not available on this branch; the
|
||||
independent read-support PR #2868 has not merged. JPEG XL therefore remains
|
||||
listed in `ASYMMETRIC_EXCEPTIONS` until an importer lands.
|
||||
|
||||
|
||||
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
and develops them, and `photocraft-io` routes them there.
|
||||
|
||||
@@ -59,7 +59,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;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! 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::GrayA, 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(1, 1, ChannelLayout::Rgb, &[0.0, 0.5, 1.0]).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!(),
|
||||
};
|
||||
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, channels, "{layout:?} {sample:?}");
|
||||
let mut output = vec![0u16; count];
|
||||
assert_eq!(stream.write_to_buffer(&mut output), count);
|
||||
assert_eq!(output, expected, "{layout:?} {sample:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use crate::state::DialogKind;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{ExportSettings, PhotocraftApp};
|
||||
|
||||
const FORMATS: [(&str, &str); 5] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA")];
|
||||
const FORMATS: [(&str, &str); 6] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("jxl", "JPEG XL")];
|
||||
|
||||
pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
|
||||
Reference in New Issue
Block a user