LR-EXP-TYPE: JPEG XL export (#247)
Export as JPEG XL from the dialog, CLI, MCP and control channel (format jxl): lossy with quality or lossless, 8/16-bit, effort fast/normal, progressive, EXIF/XMP boxes; HDR edits as 16-bit Rec. 2020 PQ. Encoder: jixel (pure Rust, BSD-3-Clause OR Apache-2.0, native only: it spawns its own threads), wrapped in catch_unwind. Lossy files declare an enum encoding (sRGB / P3 / Rec. 2020; Adobe RGB and ProPhoto written as Rec. 2020); lossless files embed the ICC profile. Decoder fix: jxl-oxide desaturated colours outside sRGB when rendering XYB images to wider primaries; lossy wide-gamut JXL now renders to linear sRGB primaries and is converted to Rec. 2020 here. jixel's slowest effort is left out: jxl-oxide misdecodes its (valid) files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b70ae1741e
commit
afbc0f9744
|
|
@ -2612,6 +2612,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"
|
||||
|
|
@ -3031,6 +3037,7 @@ dependencies = [
|
|||
"bytemuck",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jixel",
|
||||
"jpeg-decoder",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
|
|
@ -3110,6 +3117,7 @@ version = "0.5.0"
|
|||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ tiff = "0.11"
|
|||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
jixel = "0.3.4" # BSD-3-Clause OR Apache-2.0, pure-Rust JPEG XL encoder (export; port of libjxl-tiny)
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
|
||||
| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export |
|
||||
| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF / JPEG XL and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
|
@ -96,7 +96,7 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
|
|||
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) |
|
||||
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
|
||||
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) |
|
||||
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JPEG XL export ✅ (native only)) |
|
||||
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
|
||||
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
|
||||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
|
|
|
|||
|
|
@ -51,13 +51,13 @@ USAGE:
|
|||
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
|
||||
--compact list only the helper tools (every command stays reachable via run_command)
|
||||
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl or .dng by extension) with the
|
||||
same encoder as the app's Export dialog. Options:
|
||||
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
|
||||
--settings FILE merge a partial develop-settings JSON file
|
||||
--preset ID apply a preset (see `commands`/presets.list)
|
||||
--size N long edge in pixels (default: full size, cropped)
|
||||
--quality Q JPEG/AVIF quality 1..100 (default 92)
|
||||
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
|
||||
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
|
||||
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
|
||||
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ pub fn mime_for(name: &str) -> &'static str {
|
|||
Some("tif" | "tiff") => "image/tiff",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
Some("jxl") => "image/jxl",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,14 @@ jxl-oxide = { workspace = true, optional = true }
|
|||
ravif = { workspace = true, optional = true }
|
||||
rav1e = { workspace = true, optional = true }
|
||||
avif-serialize = { workspace = true, optional = true }
|
||||
# spawns threads of its own (std::thread), which wasm32 has not got
|
||||
jixel = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
## JPEG XL decode (jxl-oxide) and, on native targets, encode (jixel); both pure Rust.
|
||||
jxl = ["dep:jxl-oxide", "dep:jixel"]
|
||||
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
|
||||
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
|
||||
|
|
@ -45,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
|
|||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
# JPEG XL tests read encoded samples without our colour conversion
|
||||
jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Lossless synthetic HEIC files for the `heif` feature's tests.
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
|
|||
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
|
||||
encode_webp_lossless(&img, &meta)?;
|
||||
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
|
||||
encode_jxl(&img, &JxlOptions { quality: 90, ..Default::default() }, &meta)?; // native + feature `jxl`
|
||||
|
||||
// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back
|
||||
let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
|
||||
|
|
@ -51,7 +52,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
|||
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
|
||||
| GIF / BMP | yes (first GIF frame) | — | via `image` |
|
||||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
|
||||
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
|
||||
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
|
||||
|
|
@ -87,6 +88,7 @@ All pure Rust, verified with `cargo info` / `cargo tree`:
|
|||
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
|
||||
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
|
||||
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
|
||||
| jixel | BSD-3-Clause OR Apache-2.0 | JPEG XL encode (native only) |
|
||||
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
|
||||
|
||||
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and,
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is
|
||||
//! embedded where the format allows.
|
||||
//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`); JPEG XL is in `jxl_encode`.
|
||||
//! Metadata (ICC/EXIF/XMP) is embedded where the format allows.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
|
@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> {
|
|||
EncodeImage { width, height, channels, samples }
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
if !(1..=4).contains(&self.channels) {
|
||||
return Err(Error::Encode(format!("unsupported channel count {}", self.channels)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear
|
||||
//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path.
|
||||
//! XYB (lossy) images are rendered to linear sRGB primaries and converted to Rec.2020 here:
|
||||
//! jxl-oxide 0.12 desaturates colours outside sRGB when it renders XYB to wider primaries.
|
||||
//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
|
|
@ -24,11 +26,13 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
|||
let gray = header.metadata.grayscale();
|
||||
let icc = img.original_icc().map(|v| v.to_vec());
|
||||
let mut meta = Meta { orientation: Some(1), ..Default::default() };
|
||||
// linear sRGB primaries, unclamped, then our own matrix to Rec.2020 (see the module docs)
|
||||
let via_srgb = icc.is_none() && !gray && header.metadata.xyb_encoded;
|
||||
if icc.is_none() {
|
||||
img.request_color_encoding(EnumColourEncoding {
|
||||
colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb },
|
||||
white_point: WhitePoint::D65,
|
||||
primaries: Primaries::Bt2100,
|
||||
primaries: if via_srgb { Primaries::Srgb } else { Primaries::Bt2100 },
|
||||
tf: TransferFunction::Linear,
|
||||
rendering_intent: RenderingIntent::Relative,
|
||||
});
|
||||
|
|
@ -55,11 +59,19 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
|||
let keep = n_color + has_alpha as usize;
|
||||
let src = fb.buf();
|
||||
let mut v = Vec::with_capacity(fw * fh * keep);
|
||||
let to_2020 = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).to_f32();
|
||||
for px in src.chunks_exact(ch) {
|
||||
let start = v.len();
|
||||
v.extend_from_slice(&px[..keep.min(ch)]);
|
||||
if keep > ch {
|
||||
v.push(1.0);
|
||||
}
|
||||
if via_srgb && let Some(rgb) = v.get_mut(start..start + 3) {
|
||||
let [r, g, b] = [rgb[0], rgb[1], rgb[2]];
|
||||
for (o, m) in rgb.iter_mut().zip(&to_2020) {
|
||||
*o = m[0] * r + m[1] * g + m[2] * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = Raw {
|
||||
width: fw,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
//! JPEG XL encode via `jixel` (pure Rust, BSD-3-Clause OR Apache-2.0; native targets with the `jxl`
|
||||
//! feature: it spawns threads of its own). Lossy files are VarDCT in XYB, tagged with an enum colour
|
||||
//! encoding the decoder renders back to; lossless files are Modular and keep their samples as they
|
||||
//! are, tagged with the ICC profile when one is given. EXIF and XMP go in `Exif` / `xml ` boxes.
|
||||
|
||||
use crate::encode::{EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, pq_encode};
|
||||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Encoder effort: time spent searching for a smaller file at the same quality. jixel's slowest
|
||||
/// search (about 20 % smaller again, five times slower) is not offered: its files are valid, but
|
||||
/// jxl-oxide, our decoder, renders blocks of them wrongly, so LightCraft could not read back its own
|
||||
/// exports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JxlEffort {
|
||||
/// Plain 8×8 transforms only: quickest, largest (about a third larger than `Normal`).
|
||||
Fast,
|
||||
#[default]
|
||||
Normal,
|
||||
}
|
||||
|
||||
impl JxlEffort {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"fast" => Self::Fast,
|
||||
"normal" => Self::Normal,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour encoding a JPEG XL file declares. Integer samples are display-encoded with its curve.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum JxlColour {
|
||||
#[default]
|
||||
Srgb,
|
||||
/// P3 primaries, D65, sRGB curve.
|
||||
DisplayP3,
|
||||
/// Rec. 2020 primaries with the BT.709 curve.
|
||||
Rec2020,
|
||||
/// HDR: Rec. 2020 primaries with the PQ curve (SMPTE ST 2084). Float samples are linear light
|
||||
/// with SDR white at 1.0 (written with SDR white at [`HDR_REFERENCE_WHITE_NITS`]); 16-bit
|
||||
/// samples are already PQ-encoded.
|
||||
Rec2020Pq,
|
||||
}
|
||||
|
||||
/// JPEG XL encoder settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct JxlOptions {
|
||||
/// 1..=100 on a libjpeg-like scale (90 ≈ visually lossless). Ignored when `lossless`.
|
||||
pub quality: u8,
|
||||
/// Mathematically lossless (Modular) instead of lossy (VarDCT).
|
||||
pub lossless: bool,
|
||||
pub effort: JxlEffort,
|
||||
/// Lossy: two passes, a coarse image first; lossless: the Squeeze transform (a low-resolution
|
||||
/// preview that refines to the exact image).
|
||||
pub progressive: bool,
|
||||
pub colour: JxlColour,
|
||||
}
|
||||
|
||||
impl Default for JxlOptions {
|
||||
fn default() -> Self {
|
||||
Self { quality: 90, lossless: false, effort: JxlEffort::Normal, progressive: false, colour: JxlColour::Srgb }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode JPEG XL from 8- or 16-bit samples (float samples only with [`JxlColour::Rec2020Pq`]).
|
||||
/// Gray is widened to RGB; alpha is kept only when some pixel is not opaque. The ICC profile in
|
||||
/// `meta` is embedded in lossless files (lossy ones carry the enum encoding of `o.colour`); `ppi`
|
||||
/// has no place in the format. Returns [`Error::Encode`] on wasm32 or without the `jxl` feature.
|
||||
pub fn encode_jxl(img: &EncodeImage, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
img.validate()?;
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
{
|
||||
let pixels = Pixels::from(img, o.colour)?;
|
||||
// jixel asserts internally; a panic there is an export error, not a crash
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&pixels, img.width as usize, img.height as usize, o, meta)))
|
||||
.unwrap_or_else(|_| Err(Error::Encode("JPEG XL encoder panicked".into())))
|
||||
}
|
||||
#[cfg(not(all(feature = "jxl", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (o, meta);
|
||||
Err(Error::Encode("JPEG XL encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved RGB or RGBA samples for jixel.
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
enum Pixels {
|
||||
U8 { data: Vec<u8>, alpha: bool },
|
||||
U16 { data: Vec<u16>, alpha: bool },
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
impl Pixels {
|
||||
fn from(img: &EncodeImage, colour: JxlColour) -> Result<Self> {
|
||||
let n = (img.width as usize).checked_mul(img.height as usize).ok_or_else(|| Error::Encode("image too large".into()))?;
|
||||
let ch = img.channels as usize;
|
||||
// keep alpha only when it says something
|
||||
fn rgba<T: Copy>(s: &[T], ch: usize, n: usize, opaque: impl Fn(T) -> bool) -> (Vec<T>, bool) {
|
||||
let px = s.chunks_exact(ch).take(n);
|
||||
let alpha = (ch == 2 || ch == 4) && s.chunks_exact(ch).take(n).any(|c| c.last().is_some_and(|a| !opaque(*a)));
|
||||
let out_ch = if alpha { 4 } else { 3 };
|
||||
let mut v = Vec::with_capacity(n * out_ch);
|
||||
for c in px {
|
||||
match c {
|
||||
[g] => v.extend_from_slice(&[*g, *g, *g]),
|
||||
[g, a] => v.extend_from_slice(&[*g, *g, *g, *a][..out_ch]),
|
||||
[r, g, b] => v.extend_from_slice(&[*r, *g, *b]),
|
||||
[r, g, b, a, ..] => v.extend_from_slice(&[*r, *g, *b, *a][..out_ch]),
|
||||
[] => {}
|
||||
}
|
||||
}
|
||||
(v, alpha)
|
||||
}
|
||||
Ok(match img.samples {
|
||||
Samples::U8(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u8::MAX);
|
||||
Pixels::U8 { data, alpha }
|
||||
}
|
||||
Samples::U16(s) => {
|
||||
let (data, alpha) = rgba(s, ch, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(s) if colour == JxlColour::Rec2020Pq => {
|
||||
let pq = |v: f32| (pq_encode(if v.is_finite() { v.max(0.0) } else { 0.0 } * HDR_REFERENCE_WHITE_NITS) * 65535.0).round() as u16;
|
||||
let alpha = |a: f32| (if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 } * 65535.0).round() as u16;
|
||||
let s: Vec<u16> = s
|
||||
.chunks_exact(ch)
|
||||
.take(n)
|
||||
.flat_map(|c| match c {
|
||||
[g] => [pq(*g), pq(*g), pq(*g), u16::MAX],
|
||||
[g, a] => [pq(*g), pq(*g), pq(*g), alpha(*a)],
|
||||
[r, g, b] => [pq(*r), pq(*g), pq(*b), u16::MAX],
|
||||
[r, g, b, a, ..] => [pq(*r), pq(*g), pq(*b), alpha(*a)],
|
||||
[] => [0; 4],
|
||||
})
|
||||
.collect();
|
||||
let (data, alpha) = rgba(&s, 4, n, |a| a == u16::MAX);
|
||||
Pixels::U16 { data, alpha }
|
||||
}
|
||||
Samples::F32(_) => return Err(Error::Encode("JPEG XL float samples are written as HDR (Rec. 2020 PQ) only".into())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
|
||||
fn run(px: &Pixels, w: usize, h: usize, o: &JxlOptions, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
use jixel::{ColorEncoding, EncodeConfig, Primaries, Speed, TransferFunction};
|
||||
let colour = match o.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 c = EncodeConfig::default()
|
||||
.with_quality(f32::from(o.quality.clamp(1, 100)))
|
||||
.with_color_encoding(colour)
|
||||
.with_lossless(o.lossless)
|
||||
.with_progressive(o.progressive)
|
||||
.with_speed(match o.effort {
|
||||
JxlEffort::Fast => Speed::Fastest,
|
||||
JxlEffort::Normal => Speed::Fast,
|
||||
});
|
||||
if o.colour == JxlColour::Rec2020Pq {
|
||||
// PQ code values are absolute: 1.0 = 10 000 cd/m²
|
||||
c = c.with_intensity_target(10_000.0);
|
||||
}
|
||||
if o.lossless
|
||||
&& let Some(icc) = meta.icc
|
||||
{
|
||||
c = c.with_icc_profile(icc.to_vec());
|
||||
}
|
||||
if let Some(exif) = meta.exif {
|
||||
c = c.with_exif(exif.to_vec());
|
||||
}
|
||||
if let Some(xmp) = meta.xmp {
|
||||
c = c.with_xmp(xmp.as_bytes().to_vec());
|
||||
}
|
||||
let r = match px {
|
||||
Pixels::U8 { data, alpha: false } => jixel::encode_image(data, w, h, &c),
|
||||
Pixels::U8 { data, alpha: true } => jixel::encode_image_with_alpha(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: false } => jixel::encode_image_16bit(data, w, h, &c),
|
||||
Pixels::U16 { data, alpha: true } => jixel::encode_image_with_alpha_16bit(data, w, h, &c),
|
||||
};
|
||||
r.map_err(|e| Error::Encode(format!("JPEG XL: {e}")))
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@
|
|||
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
|
||||
//! decoding pixels (import probes).
|
||||
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
|
||||
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
|
|
@ -27,6 +28,7 @@ mod jpeg;
|
|||
pub mod jpeg_par;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
mod jxl_encode;
|
||||
mod other;
|
||||
mod png_codec;
|
||||
mod psd;
|
||||
|
|
@ -39,6 +41,7 @@ pub use encode::{
|
|||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
|
||||
encode_png, encode_tiff, encode_webp_lossless,
|
||||
};
|
||||
pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl};
|
||||
pub use sniff::{Format, sniff};
|
||||
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ fn bench_24mp_jpeg() {
|
|||
let rgba = d.to_srgb8();
|
||||
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
|
||||
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
|
||||
#[cfg(feature = "jxl")]
|
||||
for (label, o) in [
|
||||
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
|
||||
("encode JPEG XL q90 effort normal", JxlOptions::default()),
|
||||
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
|
||||
] {
|
||||
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
|
||||
println!(" size {:.1} MB", b.len() as f64 / 1e6);
|
||||
}
|
||||
}
|
||||
|
||||
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -635,3 +635,137 @@ fn print_resolution_is_written() {
|
|||
let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]);
|
||||
}
|
||||
|
||||
/// Largest per-channel difference between two linear images.
|
||||
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossless_exact_with_metadata() {
|
||||
let img = gradient(97, 61);
|
||||
let icc = write_named(NamedSpace::Srgb);
|
||||
let exif = minimal_exif(1);
|
||||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
|
||||
let o = JxlOptions { lossless: true, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
|
||||
assert_eq!(sniff(&b), Some(Format::Jxl));
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (97, 61));
|
||||
assert!(!d.has_alpha, "opaque alpha is dropped");
|
||||
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
|
||||
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
|
||||
assert_eq!(d.xmp.as_deref(), Some(xmp));
|
||||
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
|
||||
|
||||
// 16-bit lossless keeps every code value
|
||||
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.bit_depth, 16);
|
||||
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
|
||||
let want = decode(&png, DecodeOptions::default()).unwrap();
|
||||
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_quality_and_effort() {
|
||||
let img = smooth(160, 120);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
|
||||
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
|
||||
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
|
||||
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
|
||||
for (q, b) in [(95, &hi), (40, &lo)] {
|
||||
let d = decode(b, DecodeOptions::default()).unwrap();
|
||||
let p = psnr8(&opaque, &d.to_srgb8());
|
||||
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
|
||||
}
|
||||
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
|
||||
for progressive in [false, true] {
|
||||
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
|
||||
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
|
||||
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
|
||||
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
|
||||
// profile, decode to the same colours in the working space
|
||||
let img = smooth(96, 64);
|
||||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||||
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
|
||||
let icc = write_named(named);
|
||||
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
|
||||
let e = EncodeImage::rgba8(&opaque);
|
||||
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
|
||||
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
|
||||
let a = decode(&lossy, DecodeOptions::default()).unwrap();
|
||||
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
|
||||
let b = decode(&exact, DecodeOptions::default()).unwrap();
|
||||
let d = max_diff(&a.to_working(), &b.to_working());
|
||||
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_alpha_and_gray() {
|
||||
let mut img = gradient(32, 24);
|
||||
img.data[5][3] = 0;
|
||||
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert!(d.has_alpha);
|
||||
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
|
||||
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
|
||||
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
|
||||
.unwrap();
|
||||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_hdr_pq() {
|
||||
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
|
||||
let (w, h) = (64usize, 32usize);
|
||||
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
|
||||
for lossless in [false, true] {
|
||||
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
|
||||
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
|
||||
// the file's own samples: PQ code values for 203 and 812 cd/m²
|
||||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
|
||||
let m = &img.image_header().metadata;
|
||||
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
|
||||
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
|
||||
let r = img.render_frame(0).unwrap();
|
||||
let fb = r.image_all_channels();
|
||||
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
|
||||
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
|
||||
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
|
||||
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
|
||||
}
|
||||
}
|
||||
// float samples are HDR only
|
||||
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
|
||||
assert!(matches!(sdr, Err(Error::Encode(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "jxl")]
|
||||
fn jxl_bad_input_is_an_error() {
|
||||
let px = [0u8; 12];
|
||||
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||||
// a 1×1 image and quality 1 still encode
|
||||
let one = [10u8, 20, 30];
|
||||
let b =
|
||||
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||||
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
|||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# HDR JPEG XL tests read the file's colour encoding
|
||||
jxl-oxide = { workspace = true }
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
libc = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF (or copy the original / write a
|
||||
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF / JPEG XL (or copy the original / write a
|
||||
//! DNG, each with the edits in XMP) in sRGB, Display P3, Adobe RGB
|
||||
//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the
|
||||
//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
|
||||
//! app, CLI, MCP and the web build share it; writing the file is the caller's job.
|
||||
|
||||
pub use lightcraft_codecs::TiffCompression;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, encode, icc};
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, JxlColour, JxlOptions, NamedSpace, Samples, encode, icc};
|
||||
pub use lightcraft_codecs::{JxlEffort, TiffCompression};
|
||||
use lightcraft_meta::{DateTime, Gps, Metadata};
|
||||
pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace};
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
|
@ -20,6 +20,8 @@ pub enum ExportFormat {
|
|||
Tiff,
|
||||
Webp,
|
||||
Avif,
|
||||
/// JPEG XL: lossy (VarDCT) or lossless, 8/16-bit, HDR as Rec. 2020 PQ.
|
||||
Jxl,
|
||||
/// The original file, unchanged, with an XMP sidecar holding the edits.
|
||||
Original,
|
||||
/// A DNG of the raw data (raw photos only), the edits embedded as XMP.
|
||||
|
|
@ -34,6 +36,7 @@ impl ExportFormat {
|
|||
"tiff" | "tif" => Self::Tiff,
|
||||
"webp" => Self::Webp,
|
||||
"avif" => Self::Avif,
|
||||
"jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl,
|
||||
"original" => Self::Original,
|
||||
"dng" => Self::Dng,
|
||||
_ => return None,
|
||||
|
|
@ -46,6 +49,7 @@ impl ExportFormat {
|
|||
Self::Tiff => "tif",
|
||||
Self::Webp => "webp",
|
||||
Self::Avif => "avif",
|
||||
Self::Jxl => "jxl",
|
||||
// the original keeps its own extension (see `ExportOptions::file_name_for`)
|
||||
Self::Original => "",
|
||||
Self::Dng => "dng",
|
||||
|
|
@ -564,7 +568,7 @@ pub enum MetadataPolicy {
|
|||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportOptions {
|
||||
pub format: ExportFormat,
|
||||
/// 1–100 (JPEG, AVIF).
|
||||
/// 1–100 (JPEG, AVIF, lossy JPEG XL).
|
||||
pub quality: u8,
|
||||
/// Output size (`None` = full size).
|
||||
pub resize: Option<Resize>,
|
||||
|
|
@ -595,16 +599,24 @@ pub struct ExportOptions {
|
|||
pub remove_location: bool,
|
||||
/// Text watermark (none when absent or the text is empty).
|
||||
pub watermark: Option<Watermark>,
|
||||
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile).
|
||||
/// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile; lossy JPEG XL
|
||||
/// writes Adobe RGB and ProPhoto as Rec. 2020, see [`ExportOptions::effective_space`]).
|
||||
pub color_space: OutputSpace,
|
||||
/// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
|
||||
/// Bits per channel: 8, 16 (PNG, TIFF, JPEG XL), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the
|
||||
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
|
||||
pub bit_depth: Option<u8>,
|
||||
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
|
||||
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
|
||||
/// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR
|
||||
/// white kept. Other formats, and photos without an HDR edit, are SDR.
|
||||
/// not applied), AVIF as 10-bit Rec. 2020 PQ, JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float
|
||||
/// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR
|
||||
/// edit, are SDR.
|
||||
pub hdr: bool,
|
||||
/// JPEG XL: mathematically lossless instead of lossy (`quality` is then unused).
|
||||
pub jxl_lossless: bool,
|
||||
/// JPEG XL: encoder effort (time spent for a smaller file).
|
||||
pub jxl_effort: JxlEffort,
|
||||
/// JPEG XL: progressive file (a coarse image first, refined as it loads).
|
||||
pub jxl_progressive: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
|
|
@ -629,6 +641,9 @@ impl Default for ExportOptions {
|
|||
color_space: OutputSpace::Srgb,
|
||||
bit_depth: None,
|
||||
hdr: false,
|
||||
jxl_lossless: false,
|
||||
jxl_effort: JxlEffort::Normal,
|
||||
jxl_progressive: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -665,6 +680,9 @@ pub const OPTION_PARAMS: &[&str] = &[
|
|||
"colorSpace",
|
||||
"bitDepth",
|
||||
"hdr",
|
||||
"jxlLossless",
|
||||
"jxlEffort",
|
||||
"jxlProgressive",
|
||||
];
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase).
|
||||
|
|
@ -766,7 +784,7 @@ impl ExportOptions {
|
|||
match k.as_str() {
|
||||
"format" => {
|
||||
if ExportFormat::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original")));
|
||||
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|jxl|dng|original")));
|
||||
}
|
||||
}
|
||||
"quality" => {
|
||||
|
|
@ -796,7 +814,7 @@ impl ExportOptions {
|
|||
}
|
||||
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
|
||||
}
|
||||
"dontEnlarge" | "removeLocation" | "background" | "hdr" => {
|
||||
"dontEnlarge" | "removeLocation" | "background" | "hdr" | "jxlLossless" | "jxlProgressive" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
|
|
@ -806,6 +824,7 @@ impl ExportOptions {
|
|||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"jxlEffort" => one_of(k, v, &["fast", "normal"])?,
|
||||
"dngCompression" => {
|
||||
serde_json::from_value::<DngCompression>(v.clone())
|
||||
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?;
|
||||
|
|
@ -930,6 +949,9 @@ impl ExportOptions {
|
|||
color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space),
|
||||
bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8),
|
||||
hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr),
|
||||
jxl_lossless: p.get("jxlLossless").and_then(Value::as_bool).unwrap_or(d.jxl_lossless),
|
||||
jxl_effort: s("jxlEffort").and_then(JxlEffort::parse).unwrap_or(d.jxl_effort),
|
||||
jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1004,6 +1026,9 @@ impl ExportOptions {
|
|||
(ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Avif, _) => OutputDepth::U8,
|
||||
(ExportFormat::Jxl, _) if self.hdr => OutputDepth::F32Hdr,
|
||||
(ExportFormat::Jxl, Some(10 | 16 | 32)) => OutputDepth::U16,
|
||||
(ExportFormat::Jxl, _) => OutputDepth::U8,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1014,24 +1039,46 @@ impl ExportOptions {
|
|||
ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")],
|
||||
ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")],
|
||||
ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")],
|
||||
ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF
|
||||
/// (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
|
||||
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF or
|
||||
/// JPEG XL (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
|
||||
pub fn hdr_output(&self) -> bool {
|
||||
self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg | ExportFormat::Avif, _) | (ExportFormat::Tiff, Some(32)))
|
||||
self.hdr
|
||||
&& matches!(
|
||||
(self.format, self.bit_depth),
|
||||
(ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Jxl, _) | (ExportFormat::Tiff, Some(32))
|
||||
)
|
||||
}
|
||||
|
||||
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR).
|
||||
/// The colour space the file is actually written in (AVIF: sRGB; AVIF and JPEG XL HDR: Rec.
|
||||
/// 2020; lossy JPEG XL: Adobe RGB and ProPhoto as Rec. 2020, the widest space its encoder can
|
||||
/// declare without a profile).
|
||||
pub fn effective_space(&self) -> OutputSpace {
|
||||
match self.format {
|
||||
ExportFormat::Avif if self.hdr => OutputSpace::Rec2020,
|
||||
ExportFormat::Avif | ExportFormat::Jxl if self.hdr => OutputSpace::Rec2020,
|
||||
ExportFormat::Avif => OutputSpace::Srgb,
|
||||
ExportFormat::Jxl if !self.jxl_lossless && matches!(self.color_space, OutputSpace::AdobeRgb | OutputSpace::ProPhoto) => {
|
||||
OutputSpace::Rec2020
|
||||
}
|
||||
_ => self.color_space,
|
||||
}
|
||||
}
|
||||
|
||||
/// The JPEG XL encoder settings for these options, writing samples in `space`.
|
||||
fn jxl(&self, space: OutputSpace, hdr: bool) -> JxlOptions {
|
||||
let colour = match space {
|
||||
_ if hdr => JxlColour::Rec2020Pq,
|
||||
OutputSpace::DisplayP3 => JxlColour::DisplayP3,
|
||||
OutputSpace::Rec2020 => JxlColour::Rec2020,
|
||||
// lossless files carry the ICC profile; lossy ones never get here (`effective_space`)
|
||||
OutputSpace::Srgb | OutputSpace::AdobeRgb | OutputSpace::ProPhoto => JxlColour::Srgb,
|
||||
};
|
||||
JxlOptions { quality: self.quality, lossless: self.jxl_lossless, effort: self.jxl_effort, progressive: self.jxl_progressive, colour }
|
||||
}
|
||||
|
||||
/// Output file name for photo `p` at 1-based position `seq` in a batch (the original's
|
||||
/// extension is kept for [`ExportFormat::Original`]).
|
||||
pub fn file_name_for(&self, p: &lightcraft_catalog::Photo, seq: usize) -> String {
|
||||
|
|
@ -1178,6 +1225,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
|||
ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta),
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta),
|
||||
f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
|
|
@ -1289,6 +1337,10 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
|
|||
lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta)
|
||||
}
|
||||
(ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
(ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => {
|
||||
lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, true), &meta)
|
||||
}
|
||||
(ExportFormat::Jxl, DeepSamples::U16(_)) => lightcraft_codecs::encode_jxl(&e, &o.jxl(img.space, false), &meta),
|
||||
(f, _) => return Err(format!("{f:?} export is 8-bit only")),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
|
|
@ -2418,6 +2470,75 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_options() {
|
||||
assert_eq!(ExportFormat::parse("JPEG-XL"), Some(ExportFormat::Jxl));
|
||||
let o = ExportOptions::from_params(&json!({"format": "jxl", "jxlLossless": true, "jxlEffort": "fast", "jxlProgressive": true})).unwrap();
|
||||
assert_eq!((o.format, o.jxl_lossless, o.jxl_effort, o.jxl_progressive), (ExportFormat::Jxl, true, JxlEffort::Fast, true));
|
||||
assert_eq!(ExportOptions::from_json(&o.to_json()), o, "saved presets keep the JPEG XL settings");
|
||||
assert!(ExportOptions::from_params(&json!({"jxlEffort": "best"})).is_err());
|
||||
assert!(ExportOptions::from_params(&json!({"jxlLossless": "yes"})).is_err());
|
||||
let space = |lossless, color_space, hdr| {
|
||||
ExportOptions { format: ExportFormat::Jxl, jxl_lossless: lossless, color_space, hdr, ..Default::default() }.effective_space()
|
||||
};
|
||||
assert_eq!(space(false, OutputSpace::DisplayP3, false), OutputSpace::DisplayP3);
|
||||
assert_eq!(space(false, OutputSpace::ProPhoto, false), OutputSpace::Rec2020, "lossy has no ProPhoto enum");
|
||||
assert_eq!(space(true, OutputSpace::ProPhoto, false), OutputSpace::ProPhoto, "lossless keeps the profile");
|
||||
assert_eq!(space(true, OutputSpace::Srgb, true), OutputSpace::Rec2020);
|
||||
let o = ExportOptions { format: ExportFormat::Jxl, hdr: true, ..Default::default() };
|
||||
assert!(o.hdr_output());
|
||||
assert_eq!(o.effective_depth(), OutputDepth::F32Hdr);
|
||||
assert_eq!(ExportFormat::Jxl.extension(), "jxl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_every_space_round_trips() {
|
||||
// wide-gamut green stays outside sRGB in P3, Rec. 2020 and (lossless) ProPhoto files
|
||||
for (lossless, space) in [
|
||||
(false, OutputSpace::Srgb),
|
||||
(false, OutputSpace::DisplayP3),
|
||||
(false, OutputSpace::Rec2020),
|
||||
(true, OutputSpace::DisplayP3),
|
||||
(true, OutputSpace::AdobeRgb),
|
||||
(true, OutputSpace::ProPhoto),
|
||||
] {
|
||||
let o = ExportOptions { format: ExportFormat::Jxl, color_space: space, jxl_lossless: lossless, quality: 95, ..Default::default() };
|
||||
let png = ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() };
|
||||
let img = p3_green(space);
|
||||
let bytes = encode_image(&img, &o).unwrap();
|
||||
assert_eq!(lightcraft_codecs::sniff(&bytes), Some(lightcraft_codecs::Format::Jxl));
|
||||
let (got, _) = decoded_in_srgb(&bytes);
|
||||
let (want, _) = decoded_in_srgb(&encode_image(&img, &png).unwrap());
|
||||
for c in 0..3 {
|
||||
assert!((got[c] - want[c]).abs() < 0.01, "{space:?} lossless={lossless}: {got:?} vs PNG {want:?}");
|
||||
}
|
||||
if space != OutputSpace::Srgb {
|
||||
assert!(got[0] < -0.03 || got[2] < -0.03, "{space:?}: outside sRGB after the round trip: {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_sixteen_bit() {
|
||||
let o = ExportOptions {
|
||||
format: ExportFormat::Jxl,
|
||||
bit_depth: Some(16),
|
||||
jxl_lossless: true,
|
||||
color_space: OutputSpace::DisplayP3,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(o.effective_depth(), OutputDepth::U16);
|
||||
let (levels8, _) = distinct_levels(
|
||||
&encode_rendered(&ramp(OutputDepth::U8, OutputSpace::DisplayP3), &ExportOptions { bit_depth: Some(8), ..o.clone() }, None).unwrap(),
|
||||
);
|
||||
let (levels, d) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &o, None).unwrap());
|
||||
assert_eq!(d.bit_depth, 16);
|
||||
assert!(levels > 10 * levels8, "{levels} vs {levels8}");
|
||||
// lossy 16-bit encodes too
|
||||
let lossy = ExportOptions { jxl_lossless: false, ..o };
|
||||
assert!(encode_rendered(&ramp(OutputDepth::U16, OutputSpace::DisplayP3), &lossy, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_depth_options_per_format() {
|
||||
let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth();
|
||||
|
|
@ -2428,7 +2549,9 @@ mod tests {
|
|||
assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16);
|
||||
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16));
|
||||
assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None);
|
||||
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif] {
|
||||
assert_eq!(d(ExportFormat::Jxl, Some(16)), OutputDepth::U16);
|
||||
assert_eq!(d(ExportFormat::Jxl, None), OutputDepth::U8);
|
||||
for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif, ExportFormat::Jxl] {
|
||||
let first = ExportOptions::bit_depths(f)[0].0;
|
||||
let def = d(f, None);
|
||||
assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default");
|
||||
|
|
|
|||
|
|
@ -440,3 +440,37 @@ fn hdr_avif_export_is_pq_rec2020() {
|
|||
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
|
||||
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_jxl_export_is_pq_rec2020() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "jxl", "hdr": true, "quality": 90}));
|
||||
assert!(o.hdr_output());
|
||||
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
|
||||
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
let encoding = |b: &[u8]| {
|
||||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(b)).unwrap();
|
||||
let m = &img.image_header().metadata;
|
||||
(format!("{:?}", m.colour_encoding), m.bit_depth.bits_per_sample())
|
||||
};
|
||||
let (c, bits) = encoding(&hdr.bytes);
|
||||
assert!(c.contains("Bt2100") && c.contains("Pq"), "{c}");
|
||||
assert_eq!(bits, 16);
|
||||
// a photo without an HDR edit exports as an ordinary SDR file
|
||||
let (c, _) = encoding(&sdr.bytes);
|
||||
assert!(!c.contains("Pq"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jxl_export_writes_metadata() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = sunset(&mut s);
|
||||
let o = ExportOptions::from_json(&json!({"longEdge": 120, "format": "jxl", "quality": 80}));
|
||||
let e = export_photo(&mut s, id, &o, 1).unwrap();
|
||||
let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap();
|
||||
assert_eq!(d.width.max(d.height), 120);
|
||||
assert!(d.xmp.as_deref().is_some_and(|x| x.contains("Golden horizon")), "title in the XMP box");
|
||||
assert!(d.exif.is_some(), "EXIF box");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ impl Backend for Headless {
|
|||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
|
|
@ -183,7 +183,7 @@ pub fn expand_paths(paths: &[String]) -> Vec<String> {
|
|||
}
|
||||
|
||||
/// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless),
|
||||
/// `.avif` — via the shared export encoder (embeds an sRGB profile).
|
||||
/// `.avif`, `.jxl` — via the shared export encoder (embeds an sRGB profile).
|
||||
pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result<Vec<u8>, String> {
|
||||
use lightcraft_engine::export::{ExportFormat, ExportOptions};
|
||||
let format = ExportFormat::parse(ext).unwrap_or(ExportFormat::Png);
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
|||
"conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]},
|
||||
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]},
|
||||
"dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]},
|
||||
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
|
||||
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "jxl", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
|
||||
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
|
||||
"shortEdge": {"type": "integer", "description": "Output short edge in px"},
|
||||
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"},
|
||||
|
|
@ -248,7 +248,10 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
|||
"percent": {"type": "number", "description": "Output size as a percentage of full size"},
|
||||
"dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"},
|
||||
"ppi": {"type": "integer", "description": "Print resolution written into the file (default 240)"},
|
||||
"quality": {"type": "integer", "description": "JPEG/AVIF quality 1..100"},
|
||||
"quality": {"type": "integer", "description": "JPEG/AVIF/lossy JPEG XL quality 1..100"},
|
||||
"jxlLossless": {"type": "boolean", "description": "JPEG XL: mathematically lossless (quality unused); default false"},
|
||||
"jxlEffort": {"type": "string", "enum": ["fast", "normal"], "description": "JPEG XL encoder effort: normal (default) makes files about a quarter smaller than fast, in about twice the time"},
|
||||
"jxlProgressive": {"type": "boolean", "description": "JPEG XL: progressive file, a coarse image first (default false)"},
|
||||
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
|
||||
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
|
||||
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
|
||||
|
|
@ -256,9 +259,9 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
|||
"startNumber": {"type": "integer", "description": "First {seq} value (default 1)"},
|
||||
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
|
||||
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10, JPEG XL 8|16; JPEG/WebP are 8-bit"},
|
||||
"hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"},
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
|
||||
}),
|
||||
&[],
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
ui,
|
||||
"Format",
|
||||
"exportFormat",
|
||||
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")],
|
||||
&[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")],
|
||||
&mut opts.format,
|
||||
);
|
||||
if !opts.format.is_rendered() {
|
||||
|
|
@ -553,8 +553,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd);
|
||||
opts.bit_depth = Some(bd);
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif) {
|
||||
let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit Rec. 2020 PQ; TIFF needs 32-bit float";
|
||||
if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Jxl) {
|
||||
let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float";
|
||||
ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip);
|
||||
if opts.hdr && !opts.hdr_output() {
|
||||
ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim));
|
||||
|
|
@ -563,6 +563,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
if !rendered {
|
||||
} else if opts.format == F::Avif && opts.hdr {
|
||||
ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim));
|
||||
} else if opts.format == F::Jxl && opts.hdr {
|
||||
ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR JPEG XL)").color(t.text_dim));
|
||||
} else if opts.format == F::Avif {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim));
|
||||
} else {
|
||||
|
|
@ -580,13 +582,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
],
|
||||
&mut opts.color_space,
|
||||
);
|
||||
if opts.effective_space() != opts.color_space {
|
||||
let note = "Lossy JPEG XL writes Adobe RGB and ProPhoto as Rec. 2020; choose Lossless to keep them.";
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Avif) {
|
||||
if opts.format == F::Jxl {
|
||||
let tip = "Mathematically lossless: every pixel is kept exactly (larger files)";
|
||||
ui.checkbox(&mut opts.jxl_lossless, crate::i18n::tr("Lossless")).on_hover_text(crate::i18n::tr(tip));
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Avif) || (opts.format == F::Jxl && !opts.jxl_lossless) {
|
||||
let mut q = opts.quality as f64;
|
||||
if num(ui, &QUALITY, &mut q) {
|
||||
opts.quality = q as u8;
|
||||
}
|
||||
}
|
||||
if opts.format == F::Jxl {
|
||||
use lightcraft_engine::export::JxlEffort as E;
|
||||
choices(ui, "Effort", "exportJxlEffort", &[(E::Fast, "Fast"), (E::Normal, "Normal")], &mut opts.jxl_effort);
|
||||
let tip = "Shows a coarse version first and refines it as the file loads";
|
||||
ui.checkbox(&mut opts.jxl_progressive, crate::i18n::tr("Progressive")).on_hover_text(crate::i18n::tr(tip));
|
||||
}
|
||||
if opts.format == F::Jpeg {
|
||||
let mut k = *limit_kb as f64;
|
||||
if num(ui, &LIMIT_KB, &mut k) {
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
|
|||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` |
|
||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif`; `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |
|
||||
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |
|
||||
|
||||
Tools taking `id` make that photo active first; without it they act on the active photo.
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -168,6 +168,8 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not
|
|||
- **Preset files** (import/export `.json`) aren't wired to browser pickers yet.
|
||||
- **Control channel / MCP.** These are desktop-only, because they need a TCP socket.
|
||||
- **AVIF export** is untested in the browser; it's the one encoder that may not be wasm-safe.
|
||||
- **JPEG XL export** is native only: the encoder (`jixel`) starts threads of its own, which wasm32 has not got; the
|
||||
web build reports "not available in this build". JPEG XL import works.
|
||||
|
||||
## Measuring
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
## October 2026
|
||||
|
||||
### JPEG XL export
|
||||
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI
|
||||
(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a
|
||||
smaller file; Progressive files show a coarse version first. 8- or 16-bit, sRGB, Display P3 or Rec. 2020 (lossless
|
||||
files also Adobe RGB and ProPhoto), metadata as for JPEG. With HDR output, photos edited in HDR are written as
|
||||
16-bit Rec. 2020 PQ. Not in the web version yet.
|
||||
- Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in
|
||||
noticeably duller.
|
||||
|
||||
### Activity stack
|
||||
- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and
|
||||
folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
|
||||
|
|
|
|||
Loading…
Reference in New Issue