* security(compose): bound dense working sets and repeated group evaluation
Smart-filter mixing and stroke fitting work tile by tile instead of over the
union of sparse surfaces; render_layer admits its rect like render; hidden
layers no longer disable the proxy and uniform-area shortcuts; Blend If
groups nested deeper than two are judged by their result, so nesting costs
polynomial instead of exponential work; effect builds past an absolute byte
cap are skipped.
* CodeRabbit #8949: exact tile skips in filter_mask, allocated-tile scan in fit_stroke, contrasting backdrop in the nested-group test
- filter_mask mix/unmix: bare tiles (no tiles on either side) skip for
non-alpha formats too when the two surfaces' default pixels are equal;
differing defaults still get processed, so the clone's default pixel is
never left wrong under a non-unit mask
- shape_split fit_stroke: scan the union of the three surfaces' allocated
tiles (exact skip when all three defaults are transparent) instead of
every tile of a 300k-canvas document; non-transparent defaults fall
back to the full scan
- tests: the nested-groups assertion now uses a black backdrop so the
asserted white can only come from rendering through the group chain
fixes CodeRabbit review comments (major/minor) on filter_mask.rs:27,29,
shape_split.rs:126, tests.rs:2354
* compose: skip a BTreeSet in fit_stroke — a sorted Vec compiles under the wasm toolchain (E0599 on ::drain there)
The 32-bit branch cut the slider to floor(|s| * 255) / 255, which only matches
Photoshop near 25 and 50 %: measured at every whole percent from -100 to +100
on a 32-bit document, Photoshop lowers by 1 - floor(|s| * 256) / 256 and raises
by 255 / (255 - floor(s * 254)) taken to 1/256 and capped at 32767/256 (201 of
201 exact). +100 gave 512x where Photoshop gives 128x, +90 9.8x for 9.44x.
It also kept the HSL saturation unclipped, which is inf at HSL lightness 1 and
0 and negative above 1: (2, 0, 0), red at +1 stop, turned NaN with any edit,
Hue +40 alone included, where Photoshop gives (2, 1.333, 0). The 32-bit branch
now scales the chroma around the lightness instead (same result in gamut),
on the CPU and in the shader, in whole numbers on both. Colorize clamps its
saturation before the 32-bit scaling, so a PSD's out-of-range value renders
the same on the GPU.
Tests (fail without this): thirty_two_bit_saturation_steps_match_photoshop,
thirty_two_bit_hue_saturation_keeps_bright_colours, the GPU parity
hdr_hue_saturation_bright_colours. Photoshop-oracle floor 188 -> 190 of 258,
measured after merging main.
Co-authored-by: Timothy Daly <100327160+timothydaly1@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR's test checks L at full opacity only, so it still passes when a and b are left unrounded or
the rounding moves before the opacity mix, or truncates. New test: Curves taking a 140 to 151.4 at
50% opacity in an 8-bit Lab document must show a 146 (145.7 unrounded, 145.5 rounded before the
mix, 145 truncated: all three fail).
Co-authored-by: timothydaly1 <100327160+timothydaly1@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSVC's floor quiets a signalling NaN while branchless_floor keeps its payload, so the Windows job failed the bitwise compare; every other input still compares bit for bit.
crates/engine/src/file_cmds.rs: batch keeps the save-point closure (takes the output path) and also copies the session's authorize hook into each scratch session, as main now does.
crates/raw/src/sensor.rs: both sides cap the memory of retained tiles; kept the existing retained_segment_bytes check (checked multiplication, same limit) and dropped main's duplicate inline loop.
Resolutions: CLI usage, flag lists and flag parsing carry both --tiff-compression (t15) and
--jpeg-format (t16); file.saveACopy, batch, imageProcessor and layersToFiles param specs carry
both tiffCompression and jpegFormat; file_cmds keeps tiff_compression_param and
jpeg_format_param; atn_cmds keeps flag_set/applies_at_canvas_bounds (t15) and UNHOLDABLE (t16),
edit.transform uses the pixel_target guard (t15) with layer_ref(p)? (t16); ui-egui open paths
build ImportOptions (t16) and apply raw_defaults (t15); docs headers/rows unioned; atn tests keep
both sides; tr.tsv "Keep Using CPU" kept once.
crates/io/tests/corpus.rs: kept main's pass_floor 188 (the PR's 187 would lower it; not re-measured here)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
crates/io/tests/corpus.rs: kept main's pass_floor 188 (TAKE-FIX: drop the PR's 186 hunk)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
build_mask derives its working area from sparse mask geometry grown by
feather, so two distant changed pixels could span a canvas-sized
rectangle. It then allocated f32 planes (coverage, pixel-mask values,
blur scratch) over that area, gigabytes during even a one-pixel render.
Check the estimated working bytes before the dense allocation and fall
back to the constant default mask above 512 MiB, so pathological
geometry degrades feather detail instead of exhausting memory.
Finding: 8c04c5ef-a5bd-48f4-bfcf-83615bcf11c0 (CWE-770)
When a pattern exceeded the preparation cache budget, every lookup still
called Tile::new_in, expanding the whole sparse pattern into RGBA floats
on each output tile (and possibly concurrently). The cache budget only
controlled retained entries, not this conversion work.
Reject patterns whose full RGBA conversion would exceed an absolute
256 MiB cap on the uncached path, so oversized patterns are skipped
instead of re-expanded per tile. The cap is independent of the cache
budget, so small patterns still convert on demand when nothing is cached.
Finding: cc0b2f77-5578-48ed-aba0-1f3beccd21cd (CWE-400)
fx_region_bytes counted the shape plus one map per enabled effect. Strokes
also build an inside distance and one or two outside fields, and overlays
and strokes build no map of their own, so a stroke-heavy document could
stay under the estimate while thrashing, and an overlay-only one could be
clipped though it fits. effects::map_count, next to build_maps_prepared,
counts what it builds; the test checks it equals FxMaps::bytes per layer.
crates/compose/src/effects.rs: kept main's par_units row loop in bevel_maps and ported the PR's change into it: k maps shade and the flat-face level through the gloss contour (lut_at), and the closure returns (hi, sh) without the apply_contour pair.
A render needs every layer's effect maps. On a large document whose
ordinary layers' whole-region maps take more than the effect cache budget
together, building one evicted the next one needed, so every render, of
any size, built them all again: 1.8-4.4 s per render on a 9000x6000
document with three full drop-shadowed layers. When they don't fit, local
maps are now built over the render rect padded by twice the effects'
reach, as a shapeless layer's already are (#1909), which gives the same
pixels there.
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
sharing interior Arc<Tile> allocations in O(1) and activating the
solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
canvas so navigator_gpu is not evicted into a synchronous full-document
CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
add a direct read_rgba_into fast path for unmasked Normal layers over
an empty backdrop.
Look in from each edge through strips that double in thickness, so a subtracted
or intersected subpath's empty margin costs a few renders instead of one per row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A soft shape's transform frame, snap rectangle and Properties X/Y/W/H came from
the path plus the full stroke width on every side, so a centred or inside stroke
grew the frame when Density or Feather was set. The frame is now the pixels the
same shape renders at full Density without Feather (edge strips only, clipped to
the canvas), identical to the hard shape's for every stroke alignment.
feather_matches_photoshops_ramp now enforces the 1-level claim the scorecard
makes (measured max deviation 1 at 8, 16 and 32 bit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baseline x86-64 has no SSE4.1 roundss, so f32::floor is a software routine there, called once per sample of the backdrop under every adjustment layer of an 8- or 16-bit document. branchless_floor rounds through 2^23 and steps down where that went up: bit-identical to floor for every f32 (a new test sweeps every 4099th bit pattern and the edges around whole numbers and 2^23), and with no branch the loop vectorizes.
An adjustment layer's result was blended back into the backdrop pixel by pixel through blend_rgb, which looks up the HDR flag and dispatches on the mode, three channel calls deep, for every pixel. Normal (nearly every adjustment layer) gives the source exactly, so blend_back skips the dispatch for it, and walks the backdrop, the result and the coverage in step instead of indexing by coordinates. Bit-identical.
Baseline x86-64 has no SSE4.1 roundss, so f32::floor is an out-of-line libm call there. lut, which every Levels, Curves, Hue/Saturation range and modern Brightness/Contrast sample goes through, floored its non-negative table position. Truncating is the same there (NaN casts to 0 either way) and inlines. Bit-identical.
color_balance_channel recomputed the folded black point, white point and midtone gamma, and four of the five powf calls of the gamma > 1 Hermite join, for every sample. Those depend only on the sliders, so BalanceCurve works them out once per channel and the per-sample step is the clamp and one powf (or the Hermite polynomial). The CPU compositor builds the three curves once per buffer and the GPU plan once per LUT (it sampled color_balance_channel 3 x 4096 times). Results are bit-identical: a new test compares the curve against the per-sample formula it replaced, to the bit, over 123 slider settings, both Preserve Luminosity modes and a 1/1024 ramp.
docs/file-format-parity.md: main added three 2026-10-15 revision rows
(DDS save, PNG sTER, PSD guides) where the PR added its own; keep all four.
Co-authored-by: varanus-salvator <262257003+varanus-salvator@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>