Commit Graph
174 Commits
Author SHA1 Message Date
stephen schureskoandFredApps 56d1cc2865 pipeline: apply 8x8 ordered Bayer dithering to prevent display banding (#3509)
* 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>
2026-11-05 06:47:45 +01:00
FredAppsandClaude Opus 5.5 2e66a00d55 Tests: two catalog flakes; a readback waits as long as a render (#3777)
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>
2026-11-05 06:39:08 +01:00
fc97c72864 LR-BEHAV-RENDER-FIDELITY: calibrated Contrast and Blacks in process V3 (#523) (#1383)
* 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>
2026-11-05 06:15:05 +01:00
d977d3e1a4 LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757) (#762)
* 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>
2026-11-05 05:21:13 +01:00
OneSlipandClaude Opus 5.5 077f4a507f GPU-FULL: more of the render on the GPU, loupe drafts from GPU textures (#1065)
* 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>
2026-11-05 05:10:32 +01:00
FredApps 9423635e8c LR-EDIT-OPTICS-PROFILE: merge main (#3550 took catalog format 10; EmbeddedLens.source moves to format 11) 2026-11-04 13:21:54 +01:00
FredApps ca557c7d4e Merge remote-tracking branch 'origin/main' into pr-3550
# Conflicts:
#	docs/parity-checklist.md
2026-11-04 12:19:58 +01:00
FredApps 36b244286a Merge remote-tracking branch 'origin/main' into pr-3073
# Conflicts:
#	docs/gaps.md
2026-11-04 12:11:30 +01:00
Dylan M. Taylor 03e03f382e security(tests): test fixture directories are created exclusively, never reused from a predictable name (#3721) 2026-11-03 17:49:04 -05:00
Brandon Thomas d6a947bea0 Merge PR #3370 2026-11-01 22:06:44 -05:00
OneSlipsandClaude Opus 5.5 432fbed3d5 LR-MASK-GEOMETRY: review fixes: old masks keep their region and frame, clicks and handles in mask coordinates
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>
2026-11-02 04:34:36 +10:00
OneSlipsandClaude Opus 5.5 2ad7207127 LR-MASK-GEOMETRY: masks stay on the photo under lens corrections, Upright, Transform and Rotate
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>
2026-11-02 03:14:38 +10:00
Chad ElliottandClaude Opus 5.5 21a7d64d46 docs(gpu): #1050 run the GPU tests locally on a software adapter
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>
2026-10-31 22:26:51 -05:00
Chad ElliottandClaude Opus 5.5 fc99d212f6 test(gpu): #1050 opt-in software adapter for the equivalence tests
`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>
2026-10-31 22:26:43 -05:00
OneSlipsandClaude Opus 5.5 29443e117c LR-EDIT-OPTICS-PROFILE: merge main (#3430 integration round 3, #3390)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:45:27 +10:00
NetterSilverandClaude Haiku 5.5 4cb62dcfaa Reject overflowing LIGHTCRAFT_GPU_LIMITS values instead of panicking
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>
2026-10-31 13:00:19 +00:00
aiongg ee0a627536 Merge track r3-t10
# Conflicts:
#	crates/engine/src/cmd/masks.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/ka.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-31 02:31:38 +00:00
aiongg 56c842d8fe Merge track r3-t09
# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/ui-parity.md
#	docs/whats-new.md
2026-10-31 02:31:25 +00:00
aiongg c524a2d12c Merge PR #3419: Issue #1764: running out of device memory falls back without stopping the GPU 2026-10-31 02:11:06 +00:00
aiongg 799beaf5e4 Merge PR #2989: GPU-TESTS: every WGSL kernel parses and validates in the test run, without a GPU 2026-10-31 02:11:06 +00:00
aiongg c2155326d6 Merge PR #2942: Issue #2936: build the GPU kernels on parallel threads 2026-10-31 02:11:04 +00:00
aiongg f12012bc9f Merge PR #2844: GPU: black pixels take the colour tools and a light vignette as on the CPU 2026-10-31 02:10:48 +00:00
aiongg 4a50aacb8a Merge PR #2731: Brush dabs: build only as far as the caller will look (#1520)
crates/pipeline/src/masks.rs: main moved the tests to masks/tests.rs; the PR's test ported there
2026-10-31 02:10:46 +00:00
aiongg 5d08f8632f Merge PR #2631: LR-EDIT-DETAIL-DENOISE: GPU denoise doesn't hang when its tiles in flight fail (#2577) 2026-10-31 02:10:23 +00:00
aiongg 78e70ab8f8 Merge PR #2545: Issue #136: applying the GPU setting never waits for the device on the UI thread 2026-10-31 02:10:21 +00:00
aiongg 06a94d2d7f Merge PR #3015: Never crash: an oversize export size is an error, not a multiply overflow
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
2026-10-31 02:08:30 +00:00
aiongg 1b32dab094 Merge PR #999: Alt/Option-drag previews for clipping and sharpening (#937)
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)
2026-10-31 02:08:19 +00:00
Antonio Sokic 35c7abb7cc Issue #1764: keep a failed upload out of the shared source
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.
2026-10-31 02:01:05 +01:00
Antonio Sokic 003ab49b5c Release shared source after out-of-memory fallback
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.
2026-10-31 01:31:12 +01:00
Antonio Sokic 99d4de32c7 Issue #1764: running out of device memory falls back without stopping the GPU
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
2026-10-30 21:43:50 +01:00
Bruno-Alumn 06e2ec43ba LR-MASK-COLORRANGE: a colour-range mask keeps at most 64 samples however it is loaded (#2556)
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.
2026-10-30 12:03:03 +01:00
radialmonsterandClaude Opus 5.5 1bfa617a5a GPU: black pixels take the colour tools and a light vignette as on the CPU
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
2026-10-28 13:58:46 -04:00
OneSlipsandClaude Opus 5.5 698352009d LR-EDIT-OPTICS-PROFILE: merge main (59a5d00c6, the ui-egui split of #3111)
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>
2026-10-28 18:57:22 +10:00
Dylan M. Taylor 9c1ea73116 refactor(gpu): extract neural plan validation 2026-10-28 03:24:18 +00:00
Dylan M. Taylor 2f42ff8944 refactor(pipeline): group pixel finish parameters 2026-10-28 03:24:18 +00:00
OneSlips ef2b209030 Merge remote-tracking branch 'origin/main' into pr/lens-database-download 2026-10-28 07:38:33 +10:00
NetterSilverandClaude Opus 5.5 93487af3b3 Cut serial work from raw development and GPU transfers
- 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>
2026-10-27 15:43:41 +01:00
OneSlips 2ec4add0d6 LR-EDIT-OPTICS-PROFILE: lensfun database downloaded on first run, library looked up, Optics names the profile
- 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)
2026-10-27 01:14:00 +10:00
Andrew FrahnandClaude Opus 5.5 0b38101924 Never crash: an oversize export size is an error, not a multiply overflow
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>
2026-10-26 11:22:28 +11:00
OneSlipsandClaude Opus 5.5 7c9b6d88e5 GPU-TESTS: every WGSL kernel parses and validates in the test run, without a GPU
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>
2026-10-26 01:59:34 +10:00
Ingmar LübbeandClaude Opus 5.5 c9b123ab9e Issue #2936: the doc comment gives the kernel time, not the whole start-up
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QWU3BMJMENFqFqxDHzV3b
2026-10-25 16:53:34 +01:00
Ingmar LübbeandClaude Opus 5.5 fa4bccff64 Issue #2936: build the GPU kernels on parallel threads
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
2026-10-25 16:38:12 +01:00
OneSlipsandClaude Opus 5.5 caba780e1e Issue #136: the GPU preference and the warm-up flag use one ordering
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>
2026-10-25 21:00:50 +10:00
OneSlipsandClaude Opus 5.5 8541131cd8 Issue #136: a GPU start lost to an off/on switch is retried
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>
2026-10-25 18:58:20 +10:00
OneSlipsandClaude Opus 5.5 9a6ac9aea7 Issue #136: a non-waiting GPU query starts the device; Settings repaints while it starts
- `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>
2026-10-25 13:48:25 +10:00
Muhammad Saad a3dbe2ab91 Brush dabs: build only as far as the caller will look (#1520)
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.
2026-10-23 23:49:37 +04:00
francoisgnandClaude Opus 5.5 f74178cdae Merge upstream/main (#2710 one test binary per crate, #2713) into round3a-integration
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>
2026-10-23 21:06:33 +03:00
aiongg f3bb7a9f30 CI: one test binary per crate, line tables only, one WASM check (#2710)
* 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)
2026-10-23 13:34:30 -04:00
Dylan M. Taylor 6fb6815cd9 fix: document the deliberate buffer leak during thread teardown (SonarCloud)
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>
2026-10-23 09:44:05 -04:00
francoisgn 0304d6acfe Merge PR #2550: #2550 c21714a37 Dehaze no longer crushes dark pixels next to bright ones (#2504), spli 2026-10-23 16:38:37 +03:00