* pipeline: apply 8x8 ordered Bayer dithering to prevent display banding
Quantizing float linear/display pixel values to 8-bit integers using
round-to-nearest produces noticeable Mach bands and contour stepping on
smooth gradients (skies, shallow depth-of-field backgrounds, skin tones).
This applies an 8x8 ordered Bayer dithering pattern centered around round-to-nearest
with sub-LSB amplitude in both CPU finish (crates/pipeline) and GPU compute
shader (crates/gpu/src/wgsl/finish.wgsl). Exact integer levels, pure black,
and white remain noise-free, while fractional gradients smoothly dissolve into
continuous gradations. Dithering coordinates account for window/tile view offsets
to maintain phase alignment across zoom tiles.
* pipeline, gpu: address review feedback on Bayer dithering
- Always pack VIG_VIEW in GPU params so windowed renders without a vignette
maintain the correct absolute view origin for Bayer dithering coordinates.
- Restore the full Bayer threshold range (0..1) without deadzones, preserving
float white via direct half-LSB rounding to prevent edge quantization drop.
- Return the true sampled bounds midpoint in geometry sub-pixel edge detector
fallbacks.
- Add docstrings to satisfy coverage checks.
* pipeline, gpu: narrow highlight white guard to allow dithering on 254.5
* tests(pipeline): assert edge contrast in manual keystone tracking windows
* docs(pipeline): add docstrings to touched test functions
* Export tests: count distinct levels however the dither orders them
* Mask geometry tests ignore a level of 8-bit dither
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
a_failed_write_is_held_reported_and_retried raced the log thread: it
could fail the first append before the second arrived, which is then
refused by design. The test now hands both over in one append.
a_file_path_rule_allocates_nothing_per_photo counted heap blocks on every
thread, so the test harness's own thread could fail it (four blocks, on
PRs that don't touch this code). An allocation per photo is thousands, so
the bound stays far below that.
read_texture waited 10 s where the render path waits 60 s, so a view
could be dropped from a headless screenshot while earlier work ran.
The GPU guide now says what the code does about the window's card.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* LR-BEHAV-RENDER-FIDELITY: split V3 Contrast and Blacks
Port calibrated global Contrast and Blacks with shared CPU and GPU tables.
Preserve V2 Whites, local adjustments, other source paths, and all V1 and V2
renders and persistent cache keys.
Generate the constants from public Contrast and Blacks fits. Cover numeric
references, legacy renders, keys that include the resolved process, windows
and GPU parity. Document the measurements, limits and provisional number.
Co-Authored-By: Codex <noreply@openai.com>
* LR-BEHAV-RENDER-FIDELITY: reduce V3 preview cost and sanitize fits
Compose calibrated CPU preview operators over linear input and skip unused
mix endpoints. Keep high-bit-depth exports on the original operator path.
Require three distinct photos per published error group and band, regenerate
the fit hashes, and cover the sanitizer with synthetic tests. Document release
measurements and identify the separate measurement tool proposed in #1181.
Co-Authored-By: Codex <noreply@openai.com>
* LR-BEHAV-RENDER-FIDELITY: preserve V3 luminance evaluation
Retain the original operator evaluation for luminance mixes, including
preceding Contrast, so preview interpolation cannot change the cutoff.
Keep the fused per-channel path and exact deep exports.
Add CPU and GPU regressions for both uniform cutoff fixtures. Document
the measured CPU cost of positive Blacks and the retained fused path.
Co-Authored-By: Codex <noreply@openai.com>
* LR-BEHAV-RENDER-FIDELITY: finish split-module integration
Import the persistent process-key helper directly in the session module. Keep the existing V3 operator selection and evaluation order in an inline helper so the combined finish-stage closure stays within the complexity limit.
Refresh the V1/V2 golden hashes only after the exact fixture produces identical hashes on plain current main. Keep the cross-platform legacy samples unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* LR-BEHAV-RENDER-FIDELITY: date the calibrated Light gap summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757)
* LR-EDIT-DETAIL-SHARPEN: an enlarging export keeps every Radius step (#757)
The sharpening blur's sigma was capped at 4 px, so a 2x export rendered
Radius 2 and 3 alike (both scaled past 4 px). The cap is now the largest
sigma the Gaussian taps support, GAUSS_SIGMA_MAX (64), which the CPU and
GPU blurs share. Test: enlarged_export_keeps_radius_steps_apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM
* LR-EDIT-DETAIL-SHARPEN: preserve process-aware previews after rebase (#757)
Use the V3 pixel-scale blur and Masking weight for the Radius, Detail and
Masking previews on both renderers. Preserve the legacy texture-band previews
for V1/V2 and compute the same Gaussian when sharpening is disabled.
Cover process selection, radius changes, and prepared/fresh plane equivalence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* GPU-FULL-1: LUT profiles, Refine Edges and soft proofs render on the GPU
These three sent the whole loupe / compare / export render back to the CPU
pipeline (media::develop and lightcraft_gpu::render declined them).
- LUT profiles: the .cube table rides in the finish kernel's aux buffer
(LUT_* parameters); lut_apply mirrors Lut3d::apply (trilinear, domain),
applied after grain and blended by the profile amount, as on the CPU.
- Refine Edges: the cross guided filter Auto Mask already ran on the device,
now with a combine mode (max for strokes, mix by sqrt(refine) for Refine),
applied to the combined alpha before the mask's invert and amount.
- Soft proofing: proof-space gamut mapping and the destination / display
gamut warnings in the kernel (PROOF_* parameters, GAMUT_WARN and warning
colours generated from the CPU constants), for output spaces and displays.
Equivalence tests (RTX 5070, DX12): LUT profiles at three amounts and a
non-default domain, Refine 15/60/100 on two masks, proofs in 16 combinations
of space, output, display and warnings - all max 1 LSB, mean <= 0.0005 LSB.
The tests also check that strong refine and the destination warning change
the image (so they aren't comparing two unchanged renders).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL-2: spot removal, defringe and the histogram on the GPU
Spots and defringe made every render that had them (and every white
balance or spot edit) read the resampled image back, run the scene-linear
stage on the CPU and upload it again. Now only the ProRAW local tone map
(off by default) does.
- Defringe: OkLab pass, separable lightness min/max filter, per-pixel
apply. Parameters come from the new optics::defringe_params, which the
CPU stage now uses too.
- Spots: new spot module (spot_prep / spot_apply over each spot's box, the
Heal tone correction as device Gaussians), spot by spot in place so each
reads what the earlier ones left, as on the CPU. Remove's automatic
source: spot_score rates the 48 candidates on the device and only the
scores come back. Layout, candidates, spoke directions, radii and ranking
are shared helpers in pipeline::spots (spots::apply uses them; behaviour
unchanged, pipeline tests green).
- Histogram: counted with atomics on the device on Histogram::of_srgb8's
sampling grid (exact), instead of a CPU pass over the readback.
Tests: spots (clone, heal, auto remove incl. near the edge where no
candidate fits, brushed strokes, feather 0, cut by the edge, overlapping)
and defringe (purple/green, hue ranges, preview and full size) max 1 LSB;
the device histogram equals the CPU count of the returned image for small,
sampled (>1 MP) and windowed renders; the scoring kernel picks the CPU's
candidate. (The edge case caught a real bug: "doesn't fit" was written as a
decimal literal that rounds below f32::MAX.) Unit tests that create the
device now take turns with the crash-sentinel test, which swaps a global.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL-2: fix the spot kernels' build; GPU tests fail when kernels don't build
07d4680b's spot module declared `const F32_MAX = bitcast<f32>(...)`, which
naga can't evaluate as a constant: the kernels failed to build, the device
was unavailable, and the equivalence tests skipped as if there were no
adapter - so that commit's GPU tests didn't actually run. f32::MAX is now a
function, and the equivalence suite (and the device unit test) fail instead
of skipping when an adapter exists but the kernels don't build.
Re-verified on the RTX 5070 (DX12), all on the device: 21 equivalence tests
incl. the 8 spot cases (max 1 LSB), defringe, the exact device histogram,
and the auto-source unit test.
Adds an ignored thumbnail throughput bench (tests/thumbs_bench.rs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL-3: thumbnails render on the GPU once the device is ready
Every cached job (grid and filmstrip thumbnails, preset and profile
previews, faces, quick stand-ins) was forced onto the CPU. They now use the
GPU when lightcraft_gpu::ready() - so they never block on device creation
at startup (1.8 s incl. kernels here) and render on the CPU until then.
Views and exports keep waiting for the device as before.
tests/thumbs_bench.rs (ignored): six workers rendering 256 px thumbnails of
512 px sources with preset-like settings (NR, sharpening, clarity, grading,
a gradient mask): RTX 5070 3.6 ms each vs 4.3-5.2 ms on 32 CPU threads,
with the cores left free for raw decoding.
Engine and UI suites: unchanged (only the 12 failures that predate this
branch on Windows: tests_sync paths, tests_model_setup).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL-4: the loupe's drafts are drawn straight from the GPU
Rendering and drawing now share one device: the desktop app creates the
window's wgpu device with the adapter's limits and hands it to the GPU
renderer (lightcraft_gpu::adopt_window_device, on a background thread at
startup, under the crash sentinel; a device that doesn't suit falls back to
one of the renderer's own, as before).
A drag's drafts of the views are RenderJob::on_screen: the render ends in an
Rgba8Unorm texture (lightcraft_gpu::render_display -> Output::Display, new
`present` kernels: unwritten-pixel count, row padding for the copy) and
egui-wgpu draws it as a native texture (ui-egui ScreenTextures, implemented
by the app) - no readback, no conversion to egui pixels, no re-upload. The
readback's checks run on the device (atomic unwritten count, device
histogram). Renders that need pixels keep the readback: the settled full
render (view cache, overlays, screenshots), thumbnails, exports, overlays,
windows that spots grew, a monitor profile.
- engine: RenderJob::on_screen, RenderResult::frame (ScreenFrame).
- ui-egui: Tex::native / Tex::id() (21 draw sites), Renderer
set_screen_textures, natives per slot (freed when pixels return),
ui.inspect perf.gpuWindowDevice / screenTextures / mainOnGpu.
- app: WindowTextures, window device limits, LIGHTCRAFT_GPU_WINDOW=0 to opt
out.
Tests: crates/gpu/tests/display.rs adopts a device in its own process and
checks the texture holds exactly the readback's pixels (padded and
unpadded widths, draft and full), the histogram matches, and overlays /
grown windows come back as pixels. In the app (RTX 5070, a 45 MP CR3 from
the user's library copy): all 90 drafts of a drag drawn from GPU textures,
1.69 ms vs 2.05 ms median per draft render with the readback; the frame
screenshot shows the image correctly; pixels return after the drag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL-2: the denoise runner tests take turns with the crash-sentinel tests
Building a denoise runner creates a device and compiles kernels under the
init marker. Running in parallel with backend::tests, it could arm the marker
the test had just set, so "is removed afterwards" failed now and then. The
runner tests now hold DEVICE_TEST_LOCK while they build, like the render tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL-4: fix the build after merging upstream main
Upstream's QuickJob panic guard builds a RenderResult (add `frame: None`),
its idle-trim test builds a Tex (add `native: None`), and its on_exit test
builds App (add the WindowTextures slot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL-2: upstream's blur tests take turns with the crash-sentinel tests
gaussian_matches_cpu and bench_kernels create the device, so they hold
DEVICE_TEST_LOCK like every other device test in lightcraft-gpu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: the same-folder export test compares paths separator-blind (Windows)
The written path is the original's folder (`\` on Windows) joined with `Exports/IMG_1.jpg`;
the expected one had `\Exports`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: the folder-drop tests find folder widgets separator-blind (Windows)
The folder tree spells its paths with `/`; the tests built widget ids from Windows paths with
`\`, so none was found and every test failed on Windows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: the Windows path fixes take the same form as in the other PR branches
So the branches merge into main without conflicts between their copies of the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: headless screenshots read back GPU-drawn views; LIGHTCRAFT_GPU_LIMITS caps the adopted device
Review of PR #1065:
- A headless `ui.screenshot` taken mid-drag showed the photo as a white
rectangle: the GPU-resident draft left no CPU copy. With keep_pixels
(control channel) the renderer now keeps each view's frame, and
`cpu_textures` reads it back through `ScreenTextures::read`
(`lightcraft_gpu::read_texture`). Drawing still never reads back, so
drags keep the speed-up.
- Adopting the window's device skipped LIGHTCRAFT_GPU_LIMITS; `Gpu::adopt`
now caps its limits the same way `Gpu::new` does.
Tests: a_headless_screenshot_reads_back_a_view_drawn_from_the_gpu (ui-egui),
the_limits_override_caps_an_adopted_device (gpu); the display test uses
read_texture and skips through lightcraft_testkit::skip!.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: review fixes (CodeRabbit 2026-10-21) and slideshow/print draw GPU-drawn views
- the window-device adoption is announced before its thread starts: until it finishes, the
settings' warm-up and renders don't create a device of their own (they raced it)
- the device error / device-lost handlers go on only after the kernels build, so a declined
window device keeps the window's own handler
- releasing a slot frees its GPU-resident texture and kept frame
- a soft proof into a colour space drafts full size on the GPU; only one to a device profile
counts as proofed for `handles`
- docs: Fill spots and device-profile soft proofs render on the CPU
- slideshow, slide layout and print draw `Tex::id()` (the GPU texture when there is one)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: the display test skips when LIGHTCRAFT_GPU=0 switches the GPU off
It panicked ("GPU rendering is off") instead of skipping like the other GPU tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: a view readback never panics on a lost device
read_texture (headless screenshots of GPU-drawn views) polled the device
directly. On a lost device wgpu 30 panics in Device::poll; it now goes
through the same guard as the renderer's own polls (poll_device): a lost
device returns an error, stops the GPU and is not polled again. Review of
PR #1065. Also quiets a browser-build dead-code warning on OnScreen::Frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: an HDR edit's histogram shows the HDR range on the on-screen GPU path too
develop_on_screen returned the GPU's SDR histogram for both its Display and Image arms; it now
applies lightcraft_pipeline::hdr_histogram as develop_reporting does (None for drafts, windows,
proofs and non-HDR edits, which keep the GPU's histogram).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: the Navigator draws the photo's texture as it is drawn in the loupe
The Navigator falls back to the main view's texture before the photo has a thumbnail. It drew
`t.tex`, which is stale (or the 1x1 placeholder) when the main view holds a GPU-resident draft;
it now draws `Tex::id()`, like every other draw site.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: the egui 0.37 lifecycle test's texture names no native id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: rustfmt the lifecycle test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: the second window fits the frame's size; freed GPU frames drop their views
The second window sized its image from the texture handle, which is only a
1x1 placeholder for a GPU-resident render; it now uses the frame's size.
When a GPU-resident render can't be shown and every native texture is
freed, the views pointing at them (Before, Region, Compare tiles) are
dropped too, so they render again as pixels instead of drawing a freed id.
Test: freeing_gpu_frames_drops_the_views_drawn_from_them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(gpu): LIGHTCRAFT_GPU_ADAPTER picks the card for rendering too
The variable chose the card only for AI Denoise and the face detector
(two copies of the same enumerate-and-match code); photo rendering
always took the first high-performance adapter, on a two-card machine
the one driving the screens.
One helper, backend::pick_adapter, now serves the render device
(Gpu::new), the denoise network and the face detector: the first
hardware card whose name contains the value (any case), else the
high-performance one. A name that matches no card is logged and falls
back instead of failing. backend::may_adopt_window_device says whether
rendering may share the window's device (only for the window's card).
Name matching is unit-tested without a GPU; the equivalence tests print
the card they ran on. Documented in docs/gpu-pipeline.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: a view switching to a GPU texture releases its pixel image
Review of #1065 (CodeRabbit nitpick): when a slot that had pixels switched to
a GPU-resident texture, its egui texture kept the full-size image, never drawn
while the native texture is set. Tex::show_native now swaps it for a 1x1
placeholder on the switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(gpu): LIGHTCRAFT_GPU_ADAPTER picks among several matches in a defined order and logs once
Review of #3428:
- several cards match: the one whose whole name it is, then dedicated before built-in before
virtual cards, then wgpu's order (backend::choose_named_adapter); the log names every match
and the one used;
- no match: the default choice, with a warning that lists the cards;
- a name matching only a software renderer: never used, and the warning says so;
- each line is logged once (backend::log_once), though the render device, AI Denoise and the
face models each ask;
- tests on fake adapter lists: a_fake_adapter_list_picks_in_a_defined_order,
a_choice_is_logged_once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: the release note gives the GPU's documented bound against the CPU (3 levels, mean under half a level)
Review of PR #3544.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU-FULL: the window's device isn't adopted when LIGHTCRAFT_GPU_ADAPTER names another card
With #3428 merged, Gpu::new already makes the render device on the named card; the window's
device was still adopted first, so the app rendered on the screen's card whatever the variable
said. backend::may_adopt_window_device (tested on names in backend.rs) now decides: another
card named renders on a device of its own, and the window stays on the screen's card.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit's review of #3550:
- An old gradient goes onto the photo through the warp's local linear part: a radial's radii and
angle, a linear's direction and width (the mean alpha error halves; the largest drops 3x).
- Old masks an edit brings in (paste, sync, preset, history step, sidecar) go onto the photo from
the settings they came with; masks an edit keeps, from the settings before it.
- A click on an old Object mask attaches the mask and the click before the model reads them.
- The handles and arrow keys read a mask after Begin Interaction attached it, so a drag never mixes
frames; a linear gradient's guides are drawn through the warp.
- A brush point the warp can't place lays no dab; a resampled zoomed-in pass takes the whole-photo
selection past its own area; mapped boxes sample 16 points per edge.
- The CPU and GPU geometry tests also cover a quarter turn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mask coordinates lived on the corrected frame, so a Transform, Upright or lens-correction change
moved the photo under them and the masked look stayed behind, offset from the person it was drawn
on (4.3 px on a 240 px render under Vertical 25 + Rotate 4: about 100 px at 6000 px). Rotate Left /
Right turned the crop but not the masks.
A mask's coordinates are now on the photo before the warp (Mask::on_photo, catalog format 10):
the CPU render, the GPU render, Auto Mask brush windows and the on-photo tools map them through
the warp (Frame::for_mask, Warp::from_source). Rotate Left / Right turns masks with the photo
(mask_space::turn). The AI models see the photo without lens corrections, Upright, Transform,
crop or flips, so stored Object / Prompt selections stay on their content and changing those
doesn't ask the model again. Older masks (no on_photo) render where they were and move onto the
photo, without moving, at their next edit (mask_space::attach).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/gpu-pipeline.md says how to run lightcraft-gpu's tests on a machine
without a GPU: LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1 admits Mesa's lavapipe on
Linux and WARP on Windows (Metal on macOS needs nothing), and
LIGHTCRAFT_SKIP_LOG shows whether anything still skipped for want of an
adapter. The comments on the variable and in equivalence.rs no longer
say CI runs them: the platform CI legs of #1148 are not landing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Gpu::new` refuses software adapters (llvmpipe / lavapipe, WARP): the CPU
pipeline is faster. CI runners have nothing else, so the CPU <-> GPU
equivalence tests have always skipped there.
`LIGHTCRAFT_GPU_ALLOW_SOFTWARE=1` admits them. The app never sets it and
the default is unchanged. On lavapipe all 134 compared renders stay within
max 1 LSB, mean <= 0.0003 LSB, the same as Metal on an M4 Pro.
The two big-source window tests used a 24 MP (288 MB) source. That is over
lavapipe's 128 MiB storage binding (the WebGPU default), so geometry ran on
the CPU and nothing was uploaded: one test failed and the other passed
without checking anything. They now use 3200 x 3200 (117 MiB): above
RETAIN_SOURCE_BYTES, so the source is still shared, and uploaded on every
adapter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/gpu/src/ctx.rs:768 (parse_limits)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
crates/engine/src/media.rs: main moved render_now and export_job to media/session.rs; the two size checks ported there
crates/engine/src/tests_export.rs: the test is appended to tests/export.rs
Conflicts resolved:
- crates/engine/src/media.rs: main split it; the render and display-profile hunks are ported into media/render.rs
- crates/ui-egui/src/lib.rs, panels/detail.rs: main split them; alt_held is set in app/frame.rs, view_overlay and overlay_supports_window ported (panels/detail/window.rs)
- crates/ui-egui/src/tests_alt_preview.rs moved to tests/alt_preview.rs
- crates/pipeline/src/finish.rs, local.rs: main's code kept, sharpen_mask_weight and texture_sigma applied to it
- docs: both sides kept; summary counts from main (regenerated later)
Record the out-of-memory failure when the staging buffer can't be allocated, and don't publish the zeroed buffer to SHARED_SOURCE when the render has failed, so a concurrent render can't reuse it.
Clear SHARED_SOURCE in the OutOfMemory branch so the next render does not reuse a zeroed buffer, and keep the OOM test's allocations within the device's max_buffer_size, failing on unexpected validation errors.
An upload whose buffer couldn't be allocated tried again through create_buffer_init, which maps the new buffer and panics inside wgpu when that allocation fails too ("Failed to get mapped range for buffer created with mapped_at_creation"). The panic stopped GPU use for the session. Uploads and kernel parameters now go through the queue into an unmapped buffer, and a failed upload is not retried, so out of memory stays an out-of-memory fallback to the CPU for that render.
Claude-Session: https://claude.ai/code/session_01SF8KiKFYZpuGzdTuzsm3cr
Render cost is pixels x samples (CPU and GPU), and only mask.add / mask.update capped the list, so a
sidecar's lc:settings, a preset, develop.merge, render --settings or a catalog / history load could
carry thousands and stall a render or lose the GPU device. MAX_COLOR_RANGE_SAMPLES moves to
lightcraft-develop and ColorRange::samples deserializes through a capped visitor (the first 64 in
order, the rest skipped as IgnoredAny); the CPU and GPU renders also take at most 64 for settings
built in code.
The GPU colour tools took the hue of every pixel with atan2(a, b). For a black pixel both are
0, and WGSL leaves atan2(0, 0) undefined: on an RTX 3090 (Vulkan) it is NaN, so with any
colour tool on (saturation, vibrance, the mixer) every exactly black pixel became NaN. NaN is
written as 0, so it stayed black and nothing showed, until a later step lightened it: with a
positive vignette the CPU lifts the black corners and the GPU left them black (up to 127
levels apart in 8-bit). A neutral pixel now gets hue 0, as Rust's atan2 gives on the CPU.
Regression test: black bars under a light vignette with saturation, vibrance or the mixer
(fails on main: mean 12.8, max 127 levels).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM
The lens-database dialog arms go into main's split dialogs module; quick.rs
and frame.rs take main's explicit imports plus what this PR uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- RawImage::develop demosaics only the default crop with AHD when there
is no OpcodeList3 (ahd_window), instead of demosaicing the margin too
and then copying the crop out of the full picture on one thread.
- Gpu::upload writes through queue.write_buffer_with and copies into
the staging buffer in parallel parts. create_buffer_init zeroed a
staging buffer and then copied over it, two single-threaded passes
over a full-size photo's ~290 MB.
- finish_and_read copies the mapped readback out in parallel parts (one
thread read the 91 MB of a 24 MP export at ~2 GB/s, ~45 ms).
Output is byte-identical (ahd_window_is_the_crop_of_the_whole,
uploads_read_back_exactly).
Export of a 24 MP ARW to full-size JPEG (examples/export_bench, 75
interleaved runs each on a shared 16-thread Ryzen):
main 802 ms median / 614 ms min -> this branch 647 / 525 (-19.3% / -14.5%).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lensfun's data/db (CC BY-SA 3.0) is fetched by the desktop app on first
run into <config dir>/lensfun, pinned to commit 43f9e001 by name, size and
SHA-256, staged until every file is verified, with an activity row (never
bundled). LIGHTCRAFT_LENSFUN_URL lists other mirrors; LIGHTCRAFT_LENSFUN_DIR
still wins. lens.downloadDatabase / lens.databaseStatus for agents.
- The database is read per folder and again after a download; when it
changes, library raws without a profile whose lens it knows are probed in
the background and get it (untouched photos switch the correction on as
import would); ticking Enable Lens Corrections looks those photos up again
(library.refreshLensProfiles).
- EmbeddedLens records its source (file / lens database); the Optics panel
says "Profile: <lens> (lens database)", "Profile: from the file" or
"No lens profile found for <lens>." (13 locales).
- Tests: pinned download and refusal of a wrong file, the lookup and its
edited / untouched rules, and presets / Copy-Paste / Sync carrying only the
switch and amounts, each photo keeping its own lens's profile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 773740fbb22eb66e7f62bada9cb32b594b6ff4d7)
render_export / export_job now refuse an output box past 65535 px per side or
2^30 pixels (lightcraft_pipeline::check_output_size) before anything is sized
from it, and the GPU render checks w*h (and its 12-byte buffers) with
checked_mul, failing with a Limit error instead of overflowing.
Sources: own design.
(cherry picked from commit e0b279ad8e)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The kernels were only compiled when a device was created, so a module that doesn't build (a
function defined twice after an automatic merge, a type error) passed CI on machines without a
GPU and failed on users' machines. naga (re-exported by wgpu) now parses and validates every
module as it is handed to the device: the finish, blur, resize, map, mask and geometry modules
with their generated constants and bindings, the denoise convolution for every block size the
planner picks, the pooling kernel and the face detector's two. A test checks that a duplicate
definition is caught.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gpu::new built the 27 compute pipelines one after another. On DX12 each one is compiled by FXC
(issue #471), single-threaded, inside create_compute_pipeline, so every process waited ~2.2 s for
them on an Intel Core Ultra 7 165U iGPU (app.gpu 2.4 s median) while the other cores idled.
build_kernels creates the shader modules and layouts on the calling thread and shares the pipelines
out to worker threads (std::thread::scope, an atomic job counter; the calling thread works too, so
every kernel is built even when no thread can be started). wgpu's error scopes are per thread, so
each worker pushes its own validation and internal scopes: a refused kernel or a panicked worker
still means no GPU, never a half-built one. app.gpu: 2.4 s -> 1.2 s median on that machine, exports
pixel-identical. The gpu: info line now reports the kernel count and build time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QWU3BMJMENFqFqxDHzV3b
set_enabled now stores the preference with SeqCst, as the warm-up thread reads it after releasing
its start, so a switch back on made meanwhile is always seen there (CodeRabbit). The start flag
uses SeqCst throughout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by radialmonster: turned off before `lc-gpu-init` created the device and back on,
`warm_up` saw the start still held and returned, then the thread released it with no device,
leaving Settings > Performance at "GPU: starting…". The thread now releases the flag first, then
reads the preference again and tries again itself when GPU rendering is back on (it stops when
another start took over, at shutdown or with the environment's off switch). Settings >
Performance also calls the idempotent `gpu::warm_up()` in its "starting" branch (their
suggestion), on the repaint it already asks for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `app.gpu {wait: false}` with GPU rendering on starts creating the device in the background
(`warm_up`), so a caller that never waits still gets an answer later. `warm_up` starts one
thread at most, and may start again if GPU rendering was turned off before it ran.
- Settings > Performance asks for a repaint every 250 ms while it shows "GPU: starting…": the
device is created on another thread, which doesn't wake the window.
- CLI test: `{enabled: true, wait: false}` answers enabled with available, adapter and reason
null (skips with LIGHTCRAFT_GPU=0, since it starts creating a device).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MAX_STROKE_DABS bounds one stroke at two million, but the two callers
that will not look at nearly that many check the length after the
vector exists, which bounds nothing: it only discards what was already
built.
gpu/render.rs rejects a brush mask past MAX_DABS, 4096 across the
whole mask, after collecting every stroke's dabs. Each stroke may
construct two million first, so the sum that rejects them is reached
only once they all exist.
spots.rs skips an Auto Mask stroke past MAX_DABS_CONSIDERED, 200,000,
after brush_dabs has built the whole thing.
brush_dabs_within takes the caller's own budget, and push_dabs_within
charges against it instead of MAX_STROKE_DABS. Both callers now ask for
one more than they accept, so a stroke past the threshold is still
recognised as past it without the rest being built: 4097 rather than
two million per stroke on the GPU path, 200,001 rather than two million
for the spot window.
Behaviour is unchanged for anything either caller keeps. A result
short of its budget was never truncated, so it is the whole stroke, and
a truncated one only ever reaches a caller that was going to reject it.
A truncated stroke is not a prefix: running out of room makes a segment
lay its remaining dabs further apart rather than stop mid-segment,
which is how push_dabs_counted already behaved at MAX_STROKE_DABS.
Conflicts:
- xtask/src/main.rs: upstream's runners (cargo test or nextest, no
examples, doctests apart, one wasm `cargo check --keep-going`) with
#2302's `ci --quick` on top: test_selected builds the same targets
(--lib --bins --tests), the wasm check takes the changed crates in one
call, the full test step is upstream's.
- apps/lightcraft/src/control_server.rs: upstream's `before` closure, with
the batch's no-delay sockets kept.
- docs/parity-checklist.md: LR-BEHAV-ACCESS from main (tests/it path),
LR-BEHAV-LOCALE / LR-BEHAV-LOCALIZE from the batch (Polish).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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)
Dropping a wgpu buffer after the device's thread-locals are gone can
abort; the device owns the memory either way.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>