The decoders keep the alpha plane, but to_working converted only the
colour, so transparent pixels kept the black most encoders store under
them: renders, previews, exports and contact sheets showed it. The
working image is now flattened onto white, in linear light like the
rest of the pipeline.
With HDR output on, a photo edited in HDR exports as a 16-bit PNG with the PQ
curve in Rec. 2020 (SDR white at HDR_REFERENCE_WHITE_NITS), described by a
`cICP` chunk (PNG third edition: BT.2020 primaries, PQ, RGB, full range) and
a `cLLI` content light level chunk (the levels the HDR AVIF records). EXIF,
XMP and print resolution are kept; no ICC profile next to cICP. Photos without
an HDR edit, and HDR edits with HDR output off, export as before.
`codecs::encode_png_pq` sits next to `encode_avif_pq` and takes the same
input. Follow-up to #646, ported from #466.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
`lightcraft_codecs::gainmap`:
- `compute` measures a gain map (luminance or RGB, log2, block-averaged)
from an SDR and an HDR rendition; `apply` rebuilds the HDR rendition.
- ISO 21496-1 metadata: writer (fixed denominator) and reader (also the
common-denominator form; refuses unknown versions, backward maps and zero
denominators). Adobe `hdrgm` XMP reader and writer, per-channel values as
rdf:Seq; Apple's HDRGainMap/HDRToneMap description of the same map.
- `encode_jpeg`: base with CIPA DC-007 MPF index (gain map typed 0x050000),
the ISO marker and Container XMP merged into the user's packet, followed by
the gain map JPEG. `read_jpeg` / `is_gain_map_jpeg` find and decode the map.
Also: gain map images are no longer picked as embedded thumbnails (an Ultra
HDR JPEG could show its greyscale gain map in the grid). The JPEG APP-segment
building is shared with `encode_jpeg`. Fuzzed with the other decoders.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
heic-rs reconstructs iPhone HEVC tiles bit-exactly (checked against libde265), but
its colour stage was 2-27 code values off libheif on every iPhone photo: it ignores
the HEVC VUI and assumes BT.709 limited range. lightcraft-heif now converts YCbCr
itself (nclx, else VUI, else H.265 defaults; nearest chroma, bilinear for odd clap
offsets, as libheif), inherits tile ICC/nclx on grids, follows libheif's imir axis
and clap rounding, and outputs 16-bit RGB. Measured on 30 real and synthetic
files: within 1 code value of libheif (pillow-heif 1.8 / libheif 1.23).
Also: HEIF header reader for import (no pixel decode), embedded thumbnail for small
decodes, nclx/VUI colour hint (shared CICP mapping with PNG), clear 4:2:2/4:4:4
Unsupported error, Format::not_decodable as the one source of "why not" (import
reports "HEIC/HEIF support isn't included in this build"), `.heif` imports.
Release packaging and Nix build with the heif feature, as whats-new promised.
lightcraft_heif::testdata writes lossless (PCM) synthetic HEICs for the tests.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fixture wrote each row as one literal run of w bytes, which PackBits
can't express past 128 (a 160-pixel row's header byte read as a repeat
run). The old decoder clipped such rows silently; with the PSD fix in
this batch an overrun is an error, as it should be, and the fixture now
splits rows into valid runs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
The import probe of a non-raw image decoded it (at a 64 px fit, which for
JPEG still entropy-decodes every block and for PNG inflates everything)
only to learn its stored size and orientation: ~95 % of import CPU.
lightcraft-codecs gets `read_header(bytes) -> Header { format, width,
height, orientation }`: JPEG from its markers (EXIF orientation), PNG from
its chunks (IHDR, eXIf anywhere), TIFF from the first IFD (tiff crate,
no strips read), WebP from its RIFF chunks / bitstream header (image-webp),
PSD from its header and image resources. GIF, BMP and JPEG XL keep the
small decode. `files::probe_bytes` uses it; raw handling is unchanged.
The TIFF colour-layout check is shared with the decoder (`layout`).
Measured (release, 4 cores, throwaway benchmark, per file, bytes in
memory): 200 generated 4000x3000 JPEGs (3.6 MB): 72.7 ms -> 2.1 ms
(read_header alone 1.05 ms; the rest is the content hash and metadata);
20 PNGs (30.8 MB): 236-265 ms -> 9.0-9.6 ms.
Corrupt files (behaviour note): the header readers also refuse what a
decode refuses before the pixels (unsupported layout, compression, frame
type, bad IHDR, ...) and truncated files: a JPEG whose markers don't reach
its EOI, a PNG cut before the chunk after its IDAT run, TIFF strips/tiles
that start past the end, a WebP image chunk or PSD merged data past the
end. So truncated files (interrupted copies, partial downloads) are still
marked `error` in the import review, as before; tests check that whenever
the old decode accepted a (cut or byte-mutated) file the header agrees.
Changed: damage *inside* compressed data that is all present (flipped or
garbled bytes) was refused at scan and is now imported, and shows as
unreadable ("!" in the grid) when rendered. Also: tiny JPEGs (< 128 px)
were decoded leniently by the old probe, so a truncated one imported; it
is now refused. A TIFF truncated inside its last strip is not detected
(StripByteCounts are often overstated, so only strip starts are checked).
Tests: header readers vs decode on generated JPEG/PNG/TIFF/WebP/PSD/BMP
with and without EXIF/TIFF orientation; truncations and random mutations
agree with decode; hostile headers (zero/absurd sizes, bad lengths,
truncation, garbage) are errors; read_header added to the fuzz tests;
probe_bytes oriented dims match the decode-based values.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
Bilevel scans, CCITT fax images and 2- or 4-bit gray TIFFs were rejected with
"sample buffer too short". The `tiff` crate returns such samples packed, most
significant bits first, eight, four or two to a byte with each row padded to a
whole byte; the converter expects one byte per sample. They are now spread out
one per byte and scaled to 0–255 (255, 85 and 17 times the sample: exact) before
conversion. 3-, 5-, 6- and 7-bit samples, and planar images with more than one
sample narrower than a byte, are refused with an "unsupported" error.
The new test builds 1-bit (both photometric conventions, a width that leaves row
padding, set padding bits), 2-bit and 4-bit strips in code, plus a truncated
strip that must fail cleanly; it fails without the fix with the old error. A
600×400 bilevel image, uncompressed and CCITT G4, now decodes to the same pixels
PIL reads from it.
The module documentation no longer claims palette support: the `tiff` crate
refuses RGBPalette images on every read path, so the palette branch here has
never been reached. The parity row lists palette TIFF as missing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A WhiteIsZero (PhotometricInterpretation 0) gray TIFF opened as a negative at
every bit depth. The `tiff` crate (0.11) already inverts those samples as it
reads them, for every layout it accepts, and refuses the WhiteIsZero layouts it
can't invert; the decoder here then inverted them a second time. WhiteIsZero is
common in scanner and fax output, and CCITT fax images use it by definition.
The second inversion is removed. The new test builds 8- and 16-bit WhiteIsZero
and BlackIsZero strips in code and fails without the fix (the lowest sample
came out black instead of white).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Non-JPEG thumbnail fallbacks decode at source resolution before resizing, so
the output size does not bound memory. ThumbnailOptions gains max_pixels
(default 64 MP, previously about 1 GP via DecodeOptions::fit); larger sources
return Error::TooLarge. Nothing in the workspace calls decode_thumbnail yet.
WebP RIFF chunk lengths are untrusted: set_memory_limit(bytes.len()) stops a
tiny truncated file requesting gigabytes for metadata. In image-webp 0.2.4
the limit applies only to ICC, EXIF and XMP chunks. Fuzz tests cover both;
the roundtrip test only switches to struct update syntax.
Split from a327b98 (T0 thumbnail work), where it was bundled.
Co-authored-by: Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Resize modes: long edge, short edge, width, height, W x H (either orientation), megapixels,
percent; "don't enlarge" (default on); ppi written to JFIF density / PNG pHYs / TIFF tags.
- Full-size export of a cropped photo now renders the crop at native resolution (it was
upscaled to the uncropped long edge).
- Formats: Original (file copied byte for byte + XMP sidecar with the edits, named after the
output) and DNG (raw photos re-encoded losslessly, edits in the embedded XMP).
- Export dialog: Resize-to dropdown, values, Don't enlarge, Resolution; DNG/Original hide
pixel options.
- lightcraft-cli render goes through the shared exporter (colour space, metadata, ppi) and
takes any app.export option via --opt KEY=VALUE; MCP export schema lists the new params.
- xtask resolves the workspace root at run time (a moved checkout broke ci).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Property tests (random bytes behind every magic, mutated/truncated files),
corpus tests (corpus/images, pngsuite, raw routing, system ICC profiles),
ignored 24 MP JPEG benchmark, decode_thumbnail_with(min_embedded_edge),
fast deflate levels for PNG/TIFF, README with formats, licences, gaps.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Decoders (JPEG via zune-jpeg + jpeg-decoder for DCT scaling/CMYK, PNG,
TIFF, WebP, GIF, BMP, PSD composite, JPEG XL via jxl-oxide) produce linear
Rgb32f in source primaries with SourceSpace; ICC matrix/TRC applied exactly,
LUT/CMYK profiles through moxcms; to_working() to linear Rec.2020 D65.
Encoders: JPEG, PNG 8/16, TIFF 8/16/32f, lossless WebP, AVIF (native).
Co-Authored-By: Claude Opus 5.5 <[email protected]>