Held for the owner: #995 and #999 overlap, and #995's author offered on 10-12 to
close#995 once his commit was in #999. docs/parity-checklist.md: header keeps the
#1485 note and drops the #995 part.
On a profiled display the Grey and Sharpening-mask views rendered in the display's primaries, so
their histogram counted display-primary pixels, not the photo's sRGB ones. They now render in sRGB
like the clipping views (`Overlay::renders_in_srgb`) and are converted for the display after; they
show no photo colour, so nothing is lost.
The GPU equivalence test now covers Grey (mean/max LSB limits) and the shadow/highlight clipping
views (share of differing pixels, as for Visualize Spots: both renderers draw them with the same
`visualize::apply`, and a 1-LSB difference at 0/255 flips a pixel).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Holding ⌥ (Option) while dragging a slider shows a diagnostic view in the loupe, as in
Lightroom; releasing ⌥ or the slider brings the photo back and nothing is saved:
- Blacks: shadow clipping (white; black where every channel is 0, the colour of the
unclipped channels where only some are).
- Whites / Exposure: highlight clipping (black; white where every channel is 255, the
clipped channels' colour where only some are).
- Sharpening Masking: the weight sharpening gets at each pixel, white on black.
- Sharpening Amount / Radius / Detail, luminance NR: the image in grey.
New `Overlay` variants (ShadowClip, HighlightClip, SharpenMask, Grey) are drawn by the shared
`visualize::apply`, so CPU and GPU agree; the sharpening mask needs the detail planes and
renders on the CPU (like HdrRange). The mask uses the same weight function as the render
(`finish::sharpen_weight`). With a display profile the clipping views are tested in sRGB, like
the histogram, and diagnostic views keep the photo's histogram instead of the view's.
Tracker: LR-VIEW-CLIPPING ✅ → 🟡 (J only frames the photo, it doesn't paint clipped pixels)
and LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach the render).
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>
The loupe's window rendering already evaluates every visible mask's alpha
for an `Overlay::AllMasks` (its union is composed from the same alphas the
selected-mask overlay uses), but `overlay_supports_window` refused it, so
at 1:1 and closer "Show All Masks" fell back to a whole-frame render at
the zoomed size: slower, and its detail stopped at the frame render's
resolution. Allowing it makes the zoomed view a canvas-sized frame plus a
native window, as a single mask's overlay already is.
Tests:
- tests_preview_limit::an_all_masks_overlay_at_one_to_one_is_a_window
(fails before: "native 6000: the whole-frame render is 6000")
- tests_window::an_all_masks_overlay_window_matches_the_whole_render
(a window of the union matches the whole render's pixels, for every view)
Treat a zero-width feather transition as a hard edge so exact-radius pixels remain unchanged, including at zero opacity. Add actual image regressions for all spot modes, image corners and two clone output sizes.
Validation: both new regressions failed on the original implementation and pass here. Formatting, workspace Clippy, HEIF and a generated-photo app smoke test pass. The unfiltered workspace gate remains blocked by the existing all-masks overlay test tracked by #1599; later parity, layers, assets and WASM stages are pending. This is a source checkpoint, not a green release candidate.
Contributor: @eliets2
A windowed render dehazes with the whole frame's airlight, estimated on a 384 px proxy of the frame
(`local::frame_airlight`). Every window asked for that proxy again, so every pan of a 1:1 view and every
slider step while zoomed sampled the whole original down to it on the CPU: ~200 ms of CPU on a 64 MP raw.
The proxy (taken before white balance) is now kept for the last 4 (source fingerprint, frame, size); white
balance, the dark channel and the percentile still run on it every time. Same pixels: the GPU window of
`render_bench` is byte-identical.
`optics::fingerprint` indexed the first pixel of an empty image; it now hashes only the dimensions then.
render_bench ONLY=window GPU_ONLY=1 on a 64 MP Sony ILCE-7RM6 raw (RTX 4070 Ti SUPER, 4-CPU quota), CPU time:
pan 208-212 -> 9-11 ms, exposure change 200-204 -> 4 ms, NR change 211-217 -> 13 ms.
Co-Authored-By: Claude <noreply@anthropic.com>
Grid thumbnails looked much more denoised than the loupe. `nr_params` sized
the radii from the output's scale relative to the source buffer it was given,
not the original: a 256-px cell drawn from the 512-px thumbnail level used
half the full-size radius on pixels that each average ~23 sensor pixels, and
below 0.4x (0.35x for colour) the radius stopped shrinking at all. The
guided filter's eps (the variance treated as noise) also stayed at its
full-size value although downscaling divides the noise variance by 1/scale²,
so texture in small previews was flattened as noise.
- `SourceInfo::native_long` (filled by the engine from the catalog's photo
size, kept when the decoder supplies its own facts, carried to web
workers) and `Plan::detail_scale` = output pixels per original pixel
(from `px_per_long`, so a crop no longer changes it).
- `nr_params(s, scale)`: radii x scale with no floor, eps x scale². Below
1 px the smallest blur the box Gaussian makes is blended in by the ratio
of variances (`blur::box_sigma`); a fade under 1% skips the filter.
At scale 1 the parameters are unchanged: 1:1 views and full-size exports
render as before. The GPU takes the same parameters.
- RENDER_CACHE_VERSION 63: cached thumbnails are redrawn.
Measured on a 1536x1024 noisy synthetic scene (tests_nr_scale.rs), error
of the preview against the full-size render scaled down, as a fraction of
leaving NR off, luminance NR 50 at preview scales 0.5 / 0.33 / 0.25 /
0.167 / 0.1 / 0.05: before 0.53 / 0.70 / 1.04 / 2.26 / 3.87 / 6.07, after
0.28 / 0.37 / 0.66 / 0.90 / 0.99 / 1.00. A 256-px render from the 512-px
level now matches one from the original (mean 0.07 LSB vs 0.71 before).
`Overlay::AllMasks` painted each mask's plane in turn, and every mask view
rewrites the whole image from its alpha (White on Black writes `255·a`,
Image on Black multiplies by `a`…), so each plane wiped the ones before it
and only the last visible mask showed. The planes are now merged (per-pixel
max) and painted once; the GPU path goes through the same `apply`.
The test added with #919 could not pass: besides the above, its hidden-mask
check expected the plain pixel under White on Black, where everything
outside the masks is black by definition (as the single-mask test asserts).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
Resize::apply rounds the size, then the render fit the frame into that box
again: on a frame a hair under 3:2 the refit gave 1333 x 1.4999999 = 1999.49
and lost a pixel of the long edge. Frame::fit now keeps a box that already has
the frame's aspect up to rounding, and output_size rounds the native size to
whole pixels first so float noise can't decide a half-pixel tie.
A drag renders drafts, which have no HDR histogram, so an HDR edit's histogram dropped to the
SDR layout until the drag ended. Now, during a drag of an HDR edit, a small full-quality render
(Slot::HdrHistogram, 192 px, its own stage cache) runs beside the drafts and feeds the panel at
its own pace: the drafts don't wait for it. The panel also keeps an HDR edit's last HDR histogram
when a render without HDR bins arrives (a draft, a cached thumbnail or stand-in), so it never
swaps to the SDR layout mid-drag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust 1.99's clippy rejects `chunks_exact(3)` with a constant size
(`chunks_exact_to_as_chunks`, -D warnings), which failed `cargo xtask ci` on
main. Same behaviour: the completed pixels are copied as [f32; 3].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brush strokes record a pen pressure (0..1) per point from egui touch
force while Brush > Pen Pressure is on (default). Each dab's radius goes
from a tenth to all of the brush, and its flow is proportional to the
pressure, in the committed stroke and the live preview alike. A mouse
stroke stores no pressure, so documents and renders are unchanged. The
GPU mask kernel leaves pressure strokes to the CPU. mask.brushStroke takes
`pressure`; bad lists are refused. Also satisfies clippy 1.99's
chunks_exact_to_as_chunks in spots.rs.
Sources: own design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Item 4 of #989. Same elements and the same ignored remainder as chunks_exact(3),
checked on lengths 0-9 in a scratch file; the 13 spots tests pass and clippy over
the workspace without the desktop app package is clean under -D warnings. Items
1-3 of #989 (the macOS app build, the /bin/true lock test, eleven engine tests)
are not touched here.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
On merge: the HDR histogram is applied in develop_reporting (#712 split develop), is skipped for
draft renders (a second render per drag tick), and HdrBins::bin clamps a hand-built split so it
can't underflow (test hdr_bins_with_any_split_stay_in_range).