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
* CI: dev profile keeps line tables for workspace crates and no debug info for dependencies
* Control server tests: persistent_requests_and_slow_replies_outlive_the_idle_read_timeout no longer races the idle timeout
Each request (and the closing half-shutdown) reaches the socket before the
worker reads, so on a slow machine only the reply waits outlast the 100 ms
idle timeout, as the test intends. The idle test sends its input the same
way, and the no-frames test allows 5 s instead of 2 s to give up.
* CI: one integration-test binary per crate
Each crate's tests/*.rs become modules of tests/it/main.rs (51 test files
into 13 binaries, plus 6 that stay their own). Inner #![cfg] attributes
move to the mod lines. Files that change state for the whole process keep
a binary of their own: codecs jxl_tail_heap and raw sr2_heap (dhat global
allocator), gpu shutdown, shutdown_first and memory, ui-egui shutdown.
Docs, comments and the GPU probe name the new paths and targets
(--test it <module>::); the CI report names an it binary's skips after
the crate.
* CI: tests under nextest, doctests apart, one wasm check
xtask ci runs the tests with cargo nextest run --lib --bins --tests when
cargo-nextest is installed, else cargo test with the same targets and a
one-line notice; doctests run after them with cargo test --workspace --doc.
The heif and hardware GPU steps use the same runner. Examples stay out of
the test build (clippy --all-targets still checks them). Concurrent tests
under nextest follow the RUST_TEST_THREADS cap (NEXTEST_TEST_THREADS).
The wasm check is one cargo check --keep-going over the same crates, and
clippy runs with --keep-going.
* CI: tests run under cargo test by default, nextest with LIGHTCRAFT_NEXTEST=1 (per-process scene cache)
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.
libtest has no skipped status and hides the output of passing tests, so a
test that returns early without a GPU, a corpus file, a craft-fonts face or
a model passed silently. `lightcraft-testkit` (the testkit slot the layering
table already reserves, dev-dependency only, std only) adds
`skip!(Need, "reason")`: it prints the note as before and, when
LIGHTCRAFT_SKIP_LOG names a file, appends one JSON line (need, reason, test,
binary, file:line). Plain `cargo test` is unchanged. The 61 existing skip
sites use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.