- finish_8bit/finish_deep hand back the render itself when there is no
output sharpening and no watermark. Before, every export cloned the
image (~100 MB at 24 MP) and output_sharpen built a ~290 MB float copy
of it even for SharpenFor::None, all on one thread.
- Output sharpening's box blurs run row-parallel (par_rows); each pixel
is computed as before.
- The parallel JPEG encoder converts each band's rows to YCbCr inside the
band's task instead of first filling whole-image planes (~290 MB for
4:4:4 at 24 MP, written once and read back).
- fdct_quant rounds with plain arithmetic (round_i16) instead of
f32::round, a libm call per coefficient without SSE4.1; it equals
`x.round() as i16` for all 2^32 inputs.
Output is byte-identical (pinned by encoded_bytes_are_pinned and
sharpened_pixels_are_pinned, whose hashes come from main).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Padding counted as pixel data: a file padded with junk could still claim a canvas up to
max_pixels, which the decoder allocates before it finds the data missing.
- Uncompressed BMP: the rows the header describes (bits per pixel, 4-byte row padding) must
all be present after the pixel-data offset; the decoder reads every row in full.
- RLE BMP: the commands are walked as the decoder paints them (runs clipped at the row's end,
end-of-row, delta, end-of-bitmap); bytes after the end draw nothing.
- GIF: the first frame's LZW codes are walked up to the end code, keeping only the length of
each table string; data sub-blocks after the end code draw nothing.
A canvas the data leaves mostly blank stays limited to 4 MP. New tests: one padded file per
case (all four were accepted before), and the LZW walk against frames the encoder wrote.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TuX1qw9ieyuJ1ZFrrUxzc
Conflict in crates/codecs/src/other.rs with #2769, which added gated_first_frame (a GIF's
first frame bounded by max_pixels before decode). Both gates are kept: #2769's runs first in
decode, then check_size and check_fillable; the two test modules are merged into one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TuX1qw9ieyuJ1ZFrrUxzc
* security: bound codec allocation to what a hostile file declares
A GIF's first image descriptor could name a frame up to four times the
canvas budget and be charged that allocation before any size check saw
it; PNG's compressed text chunks were expanded through an unbounded
inflate; JPEG handed hostile frame headers to the allocating decoders
(zune for the common cases, jpeg-decoder for CMYK/scaled/lossless) and
let an APP1 flood drive quadratic metadata reassembly. Each now refuses
on the same max_pixels budget the other decoders honour, with a
regression test per gate.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Fix clippy collapsible_if in DCT-scale size check
* Check jpeg-decoder header on scaled path; cap APP1 markers in decode_codes8
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
From CodeRabbit's review of #2607: when the blocks before the first image descriptor could not
be walked, check_fillable fell back to treating the whole file as pixel data and the whole
screen as covered, so padding could let a large canvas through the bound. A GIF with no first
frame has nothing to decode, so it is now Malformed ("no readable first frame").
The image crate's GIF decoder also stops at such files when it opens them, so this was not
reachable through read_header or decode in the cases tried; the bound no longer relies on the two
walks agreeing. Unit test a_gif_with_no_readable_first_frame_is_refused calls check_fillable on a
GIF whose comment runs to the end of the file; the old fallback returned Ok for its 400 MP screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtjHGt5gnCHXgLt4NbGorf
From echelon's review of #2607:
- A GIF's LZW data now bounds its first frame's own width x height, not the logical screen's,
so a valid GIF with a small first frame on a larger screen imports.
- A canvas the file's data leaves mostly blank (a GIF screen larger than its first frame, or an
RLE BMP larger than its runs could cover at 255 pixels per 2 bytes) still costs its whole size
in from_decoder, so it is limited to 4 MP (SPARSE_MAX_PIXELS) and refused before the decoder
allocates it. Densely coded canvases keep the max_pixels limit.
Tests: a 64-byte RLE BMP declaring 2001 x 2000 or 32000 x 32000 is refused ("mostly blank");
the RLE BMP that ends early still opens at 2000 x 2000; a GIF with a 1 x 1 first frame opens on
a 2000 x 2000 screen and is refused on a 32767 x 32767 one. The first three fail without this
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtjHGt5gnCHXgLt4NbGorf
Review follow-ups on check_fillable:
- RLE8/RLE4 BMPs skip the density check: an end-of-bitmap code zero-fills the
rest, so a few bytes are a valid picture of any size (check_size still caps it).
Uncompressed BMPs need at least 1 byte per 8 pixels (rows are read in full).
- A GIF's bound counts the first frame's LZW sub-block bytes, not comments or
palettes, at 2731 pixels per byte (a 4096-pixel code takes 12 bits).
- The limits() comment no longer claims it stops the canvas allocation:
DynamicImage::from_decoder allocates without consulting max_alloc.
- whats-new states the 4 MP cutoff.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read_header had no reader for GIF or BMP and fell back to a full decode, so a few-dozen-byte file
declaring a canvas just under the 1-gigapixel limit made the import probe allocate ~4.3 GB; a
failed allocation aborts the process. A decode had the same allocation.
- other::header reads the size from the header. A canvas up to 4 MP is still decoded there, so
truncated small files are refused at import as before.
- check_fillable refuses a canvas larger than the file's bytes can describe (BMP: 128 pixels per
byte, covering 1-bit and RLE; GIF: 11,000, an LZW code of at least 3 bits expanding to at most
4096 pixels), in both the probe and the decode, before the decoder allocates.
- The GIF and BMP decoders get an allocation limit (the RGBA buffer of max_pixels).
The approach follows the abandoned #2182 (PierrunoYT). JPEG XL still decodes in the probe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K93HTyCiwYvbRGaAkjPvXn
On wasm32 usize is 32-bit, so 65536 x 65536 wraps to 0 and products like w * h,
w * h * cpp, fw * fh * nc and row_bytes * ch wrapped past their own validation and let
later true-dimension indexing panic (profile.web sets panic = abort). All the enumerated
sites now validate through a checked u64 gate:
- lightcraft-raw gains checked_pixel_count(width, height) (u64, capped at MAX_SAMPLES),
used by the ARW decode head (the strip-length equality now compares the true sample
count, not a wrapped one) and by RawImage::check's data-length validation;
- the CR2 lossless-JPEG frame size is checked in u64 on both the Full and Header paths
(it used to be clamped AFTER wrapping), and the no-slices width fallback is checked;
- the TIFF DEFLATE chunk's inflate length is checked and capped before the cast;
- lightcraft-codecs gains checked_sample_buf_len for the finish() buffer bound, compared
in u64 via try_from so no cast truncates on 32-bit.
Tests pin the gate arithmetic with values that would wrap 32-bit usize (65536 x 65536,
u32::MAX x 2, 2^30 x 4). cargo check --target wasm32-unknown-unknown passes for both
crates. The issue's suggested wasm32 arithmetic-side-effects clippy pass is a follow-up.
Retain the pre-frame size check and allocation budget while avoiding untracked input-sized buffers. Add the reported heap regression and container/progressive compatibility checks.
Co-authored-by: Codex <noreply@openai.com>
At Fit the loupe was softer than a good downscale of the same file. Four
resamples stacked up between the source and the screen:
- the render edge is rounded up to a 64 px step, so the texture was drawn up
to ~6 % smaller than its size and the GPU shrank it with 2-tap bilinear
sampling;
- the fitted texture sat on a fractional pixel position, so even at the right
size every screen pixel was averaged with its neighbour (#1831 fixed the
same for 100 %);
- the render box truncated its short side (2560 x 1706 for a 2560 x 1707
source), a slightly anisotropic resample that blurred one axis only;
- the codec's decode-time shrink at 2:1 and more, and SourceRef::Fit, used a
box filter, which halves the contrast of detail near the output's limit.
RenderJob::display_fit shrinks a full-quality render that is larger than its
drawn size to exactly that size with Lanczos-3 on the worker (after the caches
have the full render; drafts untouched; part of the key). The Detail view sets
it for the After and Before renders at rest, and snap_fit_rect draws a texture
that is its drawn size on whole physical pixels. The render boxes round their
short side, and the 2:1-and-more shrinks use Lanczos-3.
Refs #1472, #1042.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move nested conditions into match guards and simplify equivalent Boolean
expressions without changing validation or short-circuit behavior. Keep
the explicit permitted Testkit dev-dependency arm in the layer checker.
Co-Authored-By: OpenAI Codex <noreply@openai.com>
`header` accepts a lossless frame but the decoder routing only sent
CMYK/cropped/12-bit/non-interleaved files to jpeg-decoder: an 8-bit SOF3
file went to zune-jpeg, which refuses lossless, so a listed file could not
open at all. The files that did reach jpeg-decoder were wrong too:
jpeg-decoder emits its lossless samples native-endian (its `to_ne_bytes`),
so `from_be_bytes` swapped every sample (a 12-bit 2048 decoded as 8), the
raw `precision`-bit value was never left-justified into the 16-bit range
`Buf::U16` feeds onward, and a 3- or 4-component frame over 8 bits still
reports `RGB24`/`CMYK32` while holding two bytes per sample, so it was
wrapped as 8-bit data over double-length rows. `scale()` (a DCT size, not
a lossless resample) also cut a lossless frame to the top-left when a
thumbnail box was requested.
Every SOF3 file now routes to jpeg-decoder; lossless frames skip `scale`
(`finish` scales after); lossless samples are read native-endian and
left-justified; high-precision RGB/CMYK become `Buf::U16`.
Four hand-built SOF3 regression tests (12-bit gray, 12-bit RGB, 12-bit
CMYK, and 8-bit through the public `decode`); all four fail before the fix
(byte-swapped samples, U8-wrapped high-precision buffers, and the zune
refusal) and pass after. The crate's other tests stay green.
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.
codecs::print converts a composed print page (16-bit RGB in the Rec2020 output encoding) to the
samples a print-ready file carries:
- PrintTarget::Profile(icc): a printer / paper profile, RGB, CMYK or grey, matrix or LUT based,
converted with moxcms; PrintTarget::Managed(space): the printer manages colour and the page goes
in sRGB or Adobe RGB with that profile embedded.
- Perceptual or relative colorimetric (ProofIntent, as soft proofing).
- Black point compensation for relative colorimetric: the device's darkest is measured by a round
trip of black through the profile and linear values are scaled to land the page's black there
(Y' = Y(1 - Yb) + Yb, the published mapping for a neutral destination black), so shadows below
the ink's black no longer clip to one value. Perceptual tables keep their own black mapping.
- Print adjustment brightness / contrast (-100..100) on the sRGB-encoded values, black and white
held, monotonic at every setting.
Everything before the profile is per channel and folds into one 16-bit table; pages convert in
bounded batches (parallel with the `parallel` feature). Hostile profiles, half pixels and NaN
settings are errors or neutral, never panics. Not wired to the print job yet.
AVIF HDR export takes hdrTransfer = pq | hlg (export dialog: HDR curve;
MCP export schema lists it). HLG writes CICP transfer 18 and no clli box.
The window logs whether it shows HDR; today always SDR (eframe configures
an 8-bit surface), with the platform / surface format / headroom / opt-out
rules unit-tested. Also satisfies clippy 1.99's chunks_exact_to_as_chunks
in spots.rs.
Part of #868.
Sources: ITU-R BT.2100 (HLG OETF, inverse OOTF with system gamma 1.2),
ITU-R BT.2408 (203 cd/m2 reference white at 75 % HLG); own design for
the display decision.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Settings ▸ General ▸ External Editing: the PhotoCraft location and Other editor
fields move from egui::TextEdit to TextField (edit menu, Esc restores).
- Edit In with the system default on Windows starts explorer.exe with the file
as one argument instead of cmd /C start, which read & ^ % | in file names as
shell syntax.
- psd::packbits walks the row with first()/get()/split_at_checked instead of
direct indexing and slicing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4
Photo > Edit In > Edit in PhotoCraft (Cmd+Shift+E, also on the photo
context menu) renders a copy with the edits as a flat 16-bit PSD, which
PhotoCraft saves back to in place, stacks it on the original and opens
it in PhotoCraft, found where its installers put it. Edit in Other
Editor (Cmd+Alt+E) keeps the configured program. Per-editor format,
colour space and bit depth in Settings > External Editing. Export gains
a flat PSD format.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4kqvR9KVCPia3SSXyB8WG
View > Export Slideshow... (and Export PDF / JPEG / Video in Settings >
Slideshow) writes the photos in view as their composed slides, between
the intro and ending screens that are on:
- PDF: a page per slide (1024/1920/3840 px long side, JPEG quality),
/Dur and /Trans fades per page, optional /PageMode /FullScreen. The
contact sheet's writer moves to engine/src/pdf.rs and streams to disk.
- JPEG: <name>-001.jpg... in a folder.
- Video: AV1 (rav1e, speed 10, low latency) in MP4 or WebM written by
our own muxers (codecs/src/video.rs); fades rendered as blended frames,
held slides one key-frame sample with a long duration; 720p/1080p,
24/30 fps; no audio; capped at 2 hours.
slideshow.export runs in the background with a row in the activity
stack (activity.cancel stops it); outputs are written as .part and
renamed when complete, so cancel or failure leaves nothing behind. A
photo that can't be rendered is skipped with a warning.
slideshow.exportStatus reports the result.
Soft Proofing's Profile menu takes a printer / paper ICC profile (Other…;
`view.softProof {profile, intent, paperInk}`), RGB or CMYK, matrix or LUT.
The CMS (moxcms) builds a 33^3 proof table over the working space: forward with
the chosen intent (Perceptual / Relative), back relative colorimetric; the
destination gamut warning comes from the colorimetric round trip. Simulate
Paper & Ink renders absolute colorimetric: the profile's media white replaces
screen white and the ink black is unscaled. The pipeline gets the table by id
(`Proof::profile`, `register_proof_profile`), so Proof stays Copy and render
keys include it. Proof copies are named after the profile.
Sources: ICC.1:2022 (rendering intents, media white point, absolute
colorimetric); own design for the proof table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Readers that don't read the PNG cICP chunk show the PQ samples as if they
were sRGB (flat, washed out): expected for PQ PNG, but it surprises people.
Noted in What's new (with the gain map JPEG as the look-right-everywhere
choice), as the export dialog's HDR PNG hint, and in the option and encoder
docs. Review suggestion on #664.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>