30 Commits
Author SHA1 Message Date
Brandon Thomas 4ee6a4888b Merge PR #1126 2026-10-13 02:05:14 -04:00
bflatastic 692ab7f7c2 Merge PR #1235
# Conflicts:
#	docs/whats-new.md
2026-10-13 11:05:55 +07:00
dev-null-undefined 63bcaa147f M5.6: a zoomed view keeps the GPU copy of its original while idle (re-uploaded after 3 s)
The idle trim (3 s without renders) released the shared device copy of the original a zoom
window is cut from, so pausing between pans made the next pan upload it again: 800 MB for a
64 MP raw, ~1.4 s of "gpu sample" measured (5 ms when it is still there). While a Detail window
texture is held the renderer is not idle; once the windows are gone (zoomed out, another photo,
or another view, which now frees the Detail view's windows) the trim runs as before.
2026-10-12 14:08:33 +02:00
Andrew FrahnandClaude Opus 5.5 b538b23d40 Gap 27: CPU-GPU equivalence verified on an AMD Radeon 8060S (Vulkan, Linux)
First non-Apple GPU verification: 16 tests / 135 cases pass, 124 at
max 1 LSB, 10 bit-identical, one at max 2 LSB (0.0002% of pixels), worst
mean 0.0003 LSB. Recorded in gpu-pipeline.md, hardware-parity.md and
gaps.md. Also as_chunks in spots.rs for the newer clippy lint.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:52:43 +11:00
THEtheChadandClaude Opus 5.5 a61a52ed8b perf(gpu): LR-BEHAV-GPU: horizontal box blur reads adjacent pixels
The horizontal box pass gave each thread 16+ consecutive pixels to slide
a running sum over, so a workgroup's threads read pixels 16 apart. On a
Radeon 780M (DX12) that pass took 9-15x as long as the vertical one, and
it sits under every GPU Gaussian and guided filter (NR, texture, dehaze,
clarity/base). Each thread now sums one pixel's 2r+1 window: adjacent
threads read adjacent pixels. Same 64x4 workgroups, parameters and
bindings; box_v unchanged.

Measured on the Radeon 780M (6000x4000): guided filter at sigma 1.75
828 -> 146 ms, colour-NR Gaussian 495 -> 104 ms. Full-size heavy-edit
export of the CC0 Sony ARW: GPU 3.6-4.0 s -> 1.9-2.0 s (CPU 1.9-2.1 s);
GPU-vs-CPU output distance unchanged.

Adds gaussian_matches_cpu (GPU blur vs lightcraft_raster::blur::gaussian,
1-3 channels, partial workgroups, windows wider than the image) and
per-pass rows to bench_kernels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:04:27 +00:00
Claude b6ad706aa6 Merge PR #712
# Conflicts:
#	crates/engine/src/export.rs
2026-10-11 11:50:06 +00:00
Claude 76cf8ca4c9 Merge PR #694
# Conflicts:
#	crates/gpu/src/backend.rs
#	docs/parity-checklist.md
2026-10-11 11:49:53 +00:00
Claude 82f74e31fd Merge PR #727
# Conflicts:
#	crates/raw/src/vendor/nef.rs
#	docs/parity-checklist.md
2026-10-11 11:46:01 +00:00
Andrew FrahnandClaude Opus 5.5 88e4188e78 Fix #812: the window's renderer can't kill the start-up (DX12 shader errors)
On some Windows machines FXC fails to compile egui's own shaders on DX12
("The parameter is incorrect. (0x80070057)" in egui_pipeline) and wgpu's
default error handler panicked. Each renderer (DX12, Vulkan, GL, glow) is
now probed with error scopes and a logging error handler before the window
opens; the one that works is remembered; a start that dies leaves a marker
and relaunches with the next renderer. LIGHTCRAFT_GPU_BACKEND=glow added.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 21:45:02 +11:00
Jason Baldwin 90875cf8f2 LR-IMP-FORMATS: probe compressed NEFs from their headers, not the Huffman stream (#708)
`probe_info` (every file an import adds) ran the whole Nikon Huffman
decode on a compressed NEF because `nef::decode` had no header mode and
the masked-trailing-column test wanted the samples: 386 ms per 24 MP
file, the full decode time, where every other format probes in < 1 ms.

The header has no source for those columns (the D5100 and D7000 write 8
masked columns at value 0, compressed and uncompressed alike, with no
CropArea or other maker-note tag), so the probe cannot skip the test.
Instead both modes now judge the active width from the first 128 rows
(`nef::MASK_ROWS`) against the nominal white level: the header probe
decodes only those rows (`nefc::decode_rows`, the stream is sequential;
uncompressed files read only the strips holding them) and the full
decode uses the same rule, so `probe_info == RawImage::info` still
holds. On every corpus NEF/NRW the answer is the same for 8 to 512 rows
as for the whole frame. The whole-image size and truncation checks still
apply in both modes; the "lossy after split" end-of-strip rule is only
checked when the whole strip is decoded.

Release, same files: probe 66 -> 3.4 ms (D5100 lossless), 72 -> 3.2 ms
(D7000 lossy 12), 88 -> 3.1 ms (D7500 lossless 12), 92 -> 3.7 ms (Z f
lossless), 23 -> 1.5 ms (D5100 uncompressed), 54 -> 2.7 ms (B700 NRW).

Tests: a synthetic lossless NEF whose stream is cut off after two
thirds of the rows probes to the same info as the whole file while the
full decode reports the truncation; synthetic uncompressed NEFs with
masked columns in the top rows, lower rows, all or none give the same
active area in both modes. Both failed before the fix. Docs: the
`probe_info` doc comment, docs/parity.md (LR-IMP-FORMATS),
docs/gpu-pipeline.md.
2026-10-10 13:58:20 +02:00
Jason Baldwin 47e9181f69 LR-PREF-PERFORMANCE: GPU fallback reason per render; the export fallback test no longer reads process-wide state (#675)
`export_falls_back_to_the_cpu_when_gpu_work_is_lost` asserted on
`lightcraft_gpu::last_fallback()`, one value for the whole process that
every other engine test's GPU render overwrites between the export and
the assertion (out of device memory under parallel load, once "a GPU
render panicked"). It also switched the GPU off for the whole process
through the injected fault and cleared everyone's state with
`reset_failures()`.

A render now reports its own fallback: `lightcraft_gpu::try_render`
returns `Result<Rendered, Fallback>` (`NotTried` when the GPU was not
used, `Failed(reason)` when it was tried and the CPU must render), and
`render` is a thin `.ok()` over it. `media::develop_reporting` passes
the reason on as `RenderResult::gpu_fallback`, an export carries it as
`Exported::gpu_fallback`, and each file in an `app.export` batch result
has `gpuFallback` when its render fell back. `last_fallback()` stays as
the process-wide diagnostic for `ui.inspect` → `perf.gpuFallback`.

A simulated failure (`inject_fault`) is thread-local, and so are its
consequences now: a fatal injected fault stops GPU use on the injecting
thread only (`available()` false, `unavailable_reason()` set there);
`reset_failures()` clears that and nothing else, so neither reaches the
other tests in the process. Real failures still stop the whole process.

The engine test asserts on its own export's reason and checks that
another thread still has the GPU; `crates/gpu/tests/fallback.rs` uses
`try_render` and checks the same; a new batch test checks `gpuFallback`
lands on the file whose render fell back. Docs: gpu-pipeline.md,
control-protocol.md (app.export result), parity.md.
2026-10-10 12:45:42 +02:00
radialmonster ba65d0c8d1 LR-PREF-PERFORMANCE: cover every GPU kernel build with the crash marker, catch internal compile errors (issue #250) 2026-10-10 05:23:48 -04:00
hxwandClaude Opus 5.5 5a537d5d39 LR-BEHAV-GPU: no render is inside the GPU driver when the app quits (issue #620)
A render still running on a worker thread while the process exits crashes in the
driver (SIGSEGV in the NVIDIA Vulkan driver on Linux), where neither the panic
hook nor catch_unwind helps: pool workers were detached, the devices are statics
that are never dropped, and nothing on the way out waited for GPU work.

- gpu: every entry into a device (render, device creation, pool trim, denoise
  runner and tiles) is counted as work in flight (exit.rs). begin_shutdown()
  lets nothing new in; wait_idle(timeout) waits for what is inside.
- preview: JobPool keeps its workers. shutdown(timeout) and Drop discard the
  queued jobs and wait, bounded, for the running ones (2 s on drop); never for
  the calling thread, and not twice.
- engine: a render job caught by the shutdown gives up instead of rendering on
  the CPU, so quitting doesn't wait for it.
- app: on_exit ends with LightcraftApp::shutdown (2 s for everything), after
  settings and library are saved; the macOS menu-bar Quit goes through on_exit.

Not changed: the export, merge, denoise and import threads are still detached;
their GPU work goes through the gate, their CPU work is cut off at exit as before.

Tests: pool drop waits / is bounded / survives a panicked job and its own job
dropping it; the gate; GPU renders refused and the device not created after
begin_shutdown; the app's shutdown leaves no render worker. The crash itself
needs Linux + NVIDIA and was not reproduced here (Windows, DX12): 9 of 9 quits
of the app mid-render exit with code 0 in under 0.8 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 00:46:24 -05:00
hxwandClaude Opus 5.5 da62ea8c49 LR-BEHAV-GPU: compile DX12 shaders with FXC, never a stray dxcompiler.dll (issue #471)
wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL
search path finds first. LightCraft ships none, so it picked up another
program's copy; one without dxil.dll beside it (e.g. DXC 1.7) warns that
the DXIL is unsigned, wgpu treats that as a compile error, its
indirect-validation pipelines fail, the device is lost and the window
never opens ("Parent device is lost", exit code 1).

Every wgpu instance (window and both compute devices) now uses
lightcraft_gpu::backend::backend_options(): wgpu's env options with the
DX12 compiler pinned to FXC (d3dcompiler_47.dll, part of Windows), unless
WGPU_DX12_COMPILER asks for another.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 04:34:51 -05:00
Claude 3e88a60dd6 Merge PR #351 2026-10-09 00:14:03 +00:00
Claude bf97105cf7 Fix #374: troubleshooting for the NVIDIA shader-cache startup hang
With Shader Cache Size disabled in NVIDIA Control Panel, the driver walks
the whole system drive inside D3D12CreateDevice (traced by the reporter
to NvMemMapStoragex.dll!TotalDiskUsage), before the window's first
frame; Driver Default fixes it (confirmed in #374, PhotoCraft too). The
launch-argument walk found on the way was fixed in #394.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 23:46:07 +00:00
Nathaniel LedfordandClaude Sonnet 5.5 826ba91075 #323: docs: measured drag times, the Before/After memory, what stays canvas-sized
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:48:20 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 f1a513ffe2 #323: docs: drag timings on two canvases, what a fit view decodes, the shared source
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 eb06b65fdb #323: docs: the loupe's render plan, Before/After windows, drag timings
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:28:45 -04:00
Nathaniel LedfordandClaude Sonnet 5.5 63cfc6ab70 #323: parity, GPU and control docs: sharp zoomed views by window render
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 18:28:45 -04:00
wstockandClaude Fable 5.1 c1861e5416 Issues #164, #169: --memory sessions write nothing — no ui.json, no settings folder
`--help`, the module doc and the README promise that a `--memory` session
"writes nothing", and the desktop app broke that in two places:

- `PrefsWriter` saved `ui.json` whenever the UI state changed (issue #164),
  so a throwaway session's `thumbSize`, window layout or language replaced
  the user's saved settings — the README showcase script does exactly that.
- The GPU crash sentinel from #136 armed itself in every session except
  `LIGHTCRAFT_NO_PREFS` (issue #169); `with_init_marker` creates the
  settings folder to hold `gpu-init.marker`, removes the marker and leaves
  the folder, so a `--memory` run on a fresh home left an empty
  `Library/Application Support/LightCraft` behind.

Now `PrefsWriter` carries its target path (`None` for `--memory`,
`LIGHTCRAFT_NO_PREFS` or no home folder) and writes nothing without one; it
also writes nothing while the session is the one offered when the library
can't be opened (the question window and Continue Without Saving, which
`docs/control-protocol.md` already describes as writing nothing) and
resumes when `app.openLibrary` ends it. `gpu_crash_check(in_memory)` neither
arms the sentinel nor removes a marker in a `--memory` session; it still
reads one (new `read_init_marker`), so GPU rendering starts off there too
and the next ordinary launch reports and clears it. A `--memory` session
still reads `ui.json`, but a damaged one is reported and left in place
rather than set aside. Normal launches are unchanged.

Tests: `prefs_writer_writes_nothing_in_a_memory_or_temporary_session`,
`memory_session_leaves_the_gpu_marker_and_folder_alone`,
`memory_session_leaves_a_damaged_ui_json_alone` (apps/lightcraft), and
`read_init_marker` coverage in the gpu crate's marker test.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 11:17:38 +00:00
radialmonsterandClaude Opus 5.5 1237e198d5 GPU: build the warp coverage mask per block with interval bounds
The coverage mask from 94fe300 evaluated the f64 warp for every output
pixel. coverage_mask now asks Warp::block_coverage for each 32 x 16 block
(the shared warp formulas evaluated with outward-rounded intervals, no
edge margin) and evaluates only undecided blocks near the edge per pixel
with Warp::covers; the bits are unchanged
(coverage_mask_matches_per_pixel_decision, incl. partial words and
padding bits; coverage_mask_with_the_horizon_in_view, a perspective
denominator exactly 0 inside the image, so the clamp and
undecided-division paths go through block_coverage and the mask).

Measured (release, synthetic source, embedded lens warp + CA +
perspective as in embedded_lens_perspective_edges_match):
6000x4000 ~30 ms -> 2.7 ms (32 threads, 11x), ~580 ms -> 24.5 ms
(1 thread, 24x); 2048x1365 3.6 ms -> 1.0 ms (3.5x), 67.5 ms -> ~5 ms
(12-14x). Rotate270 the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 19:29:29 -04:00
radialmonsterandClaude Opus 5.5 94fe30015c GPU: decide warped-geometry edges with a CPU coverage mask
The lens + perspective warp classified source-edge pixels in f32 on the GPU.
A position 0.000035 px outside the edge rounded inside, so the GPU sampled
photo pixels where the CPU reference draws blank canvas (geometry_variants,
"embedded lens + perspective", max 91 LSB). The CPU now evaluates the f64
framing decision into a one-bit-per-pixel mask; sampling and colour stay on
the GPU. The embedded-lens cases move to their own test,
embedded_lens_perspective_edges_match, with the same tolerances.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:49:59 -04:00
Brandon ThomasandClaude Opus 5.5 b66cdb23a3 Issue #136: Windows: use DX12 by default, honour WGPU_BACKEND, never let GPU init block startup
wgpu loads the driver of every backend in an instance's set while it
enumerates adapters. The compute device (crates/gpu) hard-coded
Backends::PRIMARY (Vulkan + DX12 + Metal) and ignored WGPU_BACKEND, and
was created from LightcraftApp::new on a background thread at launch, so
a crashing Vulkan driver (Intel UHD 630, igvk64.dll) killed the process
before the window appeared; the window (egui-wgpu) also listed Vulkan.

- lightcraft_gpu::backend: DX12 only on Windows (Metal on macOS, as before
  on Linux) for both the compute device and the window;
  LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|gl|auto|off, else WGPU_BACKEND;
  backends missing from the build fall back to the default.
- The device is created only when GPU rendering is enabled, after the
  first frame; the preference is applied before the window opens.
- Crash sentinel: gpu-init.marker in the settings folder around device
  creation; if left behind, the next launch starts with GPU rendering off
  and a notice saying how to turn it back on.
- Tests: backend parsing/selection, sentinel, window defaults, headless
  snapshot with LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off.
- Docs: gpu-pipeline.md (backends, env vars, troubleshooting), whats-new,
  parity LR-BEHAV-GPU.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 10:16:05 -04:00
Brandon ThomasandClaude Opus 5.5 2218ca5a4c Issue #78: GPU export: respect device limits, fall back to CPU on GPU errors
A GPU render no longer hands back an image it cannot vouch for:
- set the wgpu device-lost callback (wgpu never reports a lost device as an
  error, so a driver reset went unnoticed); readback waits with a timeout and
  checks the poll result and map callback instead of blocking forever
- each render runs in OutOfMemory/Validation/Internal error scopes; errors
  fail that render (OOM and limit failures for that render only, device
  errors for the process) and it is redone on the CPU
- every buffer is checked against the device limit when created (no
  validation error, no process-wide disable); LIGHTCRAFT_GPU_LIMITS=webgpu|<MiB>
  creates the device with lower limits to reproduce small adapters
- the output buffer is cleared and the per-pixel kernel writes alpha 255:
  any unwritten pixel (work silently dropped, the issue's all-black export)
  fails the render; an all-black result is re-rendered on the CPU
- renders submit per stage and every 16 M invocations, the per-pixel stage
  runs in ~4 MP bands: no long submission for a slow iGPU's watchdog to reset
- ui.inspect perf.gpuReason / perf.gpuFallback, app.gpu reason/lastFallback,
  System Info and Settings say why the GPU is off (e.g. llvmpipe skipped) and
  why the last render fell back

Tests: crates/gpu/tests/fallback.rs (limit, mid-render limit, validation
error, dropped work, black photo), engine export falls back to the CPU image,
banded 3000x2000 GPU/CPU equivalence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 05:06:24 -04:00
Brandon ThomasandClaude Opus 5.5 7266f4153f M5.6c: one memory budget for caches, decode gate and GPU pool; peak RSS 1.9-2.3 GB -> 0.83-0.99 GB
- memory::budget (default min(25% RAM, 1.5 GiB); LIGHTCRAFT_MEMORY_MB, app.memoryBudget):
  half for engine caches (sources + rendered previews, LRU across them via a global
  use tick; the photo on screen stays), a quarter for decodes in flight (WorkGate:
  thumbnails, prefetch and import probes wait; loupe/export loads never wait), an
  eighth for the GPU free pool.
- GPU: best-fit buffer reuse (<= 25% slack), buffers released outside a render are
  pooled at once (no stranded retired lists), pool trimmed after 3 s idle.
- Decodes drop file bytes and raw samples early, colour/crop/orient in place.
- macOS: apps install malloc_zone_pressure_relief as the release hook (after each
  decode, import and idle trim): freed large blocks no longer stay resident.

14 raws + loupe + 12 steps (M4 Pro): peak RSS 1.9-2.3 GB -> 0.83-0.99 GB, footprint
2.5-2.6 GB -> 0.91-0.96 GB, heap peak 930 -> 656 MB; stepping median ~13 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 11:35:08 -04:00
Brandon ThomasandClaude Opus 5.5 78e50b5fc5 M5.6a: memory report (library.memory, ui.inspect memory) and opt-in DHAT heap profile
Per-cache bytes: decoded thumbnail/preview/full sources, rendered previews,
GPU device buffers (allocated/pooled/retired), loupe stage caches (CPU and
GPU) and textures. lightcraft-cli --features dhat-heap adds live/peak heap and
writes dhat-heap.json.

Baseline (14 raws, loupe, 12 steps): peak RSS 1.9-2.3 GB, footprint 2.5-2.6 GB;
heap peak 634 MB after import (probes decode every raw), 930 MB during grid
thumbnails (parallel decodes); GPU free pool grows to 2 GB (exact-size reuse).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 10:28:13 -04:00
Brandon ThomasandClaude Opus 5.5 570d03e202 M5.4c: prefetch filmstrip neighbours; GPU device created at startup off the UI thread
- Once the current photo's loupe render is done, the next and previous photos in filmstrip
  order get a background loupe job (Slot::Prefetch, priority below on-screen thumbnails,
  once per settings): the decoded 2560 px source is kept in the engine's bounded preview
  LRU and the view render is cached, so stepping to a neighbour shows its developed image
  in ~50 ms and then re-renders only on the GPU.
- lightcraft_gpu::warm_up (called when the app is created) builds the device and kernels on
  a background thread; gpu::ready() answers without blocking and ui.inspect uses it, so the
  UI thread never waits for device creation.
- docs/gpu-pipeline.md: opening-a-photo section.

Stepping through 24 MP raws in the loupe (real app): full render 220–580 ms → 45–62 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 09:23:36 -04:00
Brandon ThomasandClaude Opus 5.5 30aac43fab M5.2e: renders and exports use the GPU; render_bench GPU column
`media::develop` renders on the GPU when available (views, render_now, exports;
thumbnails stay on the CPU) and falls back to the CPU otherwise. `app.gpu
{enabled?}` toggles it at runtime, `LIGHTCRAFT_GPU=0` disables it for the process,
`ui.inspect` reports the adapter under perf.gpu.

Performance: dropped device buffers are recycled once their thread has submitted
(fresh 100-300 MB allocations per pass cost as much as the passes), Gaussian
passes ping-pong between two buffers and read all channels of a pixel at once,
and long box radii slide over longer chunks. LIGHTCRAFT_PROFILE prints real GPU
stage timings. render_bench prints CPU and GPU columns and the CPU/GPU difference.

24 MP ARW, loaded machine, min of 7: cold loupe 415 -> 32 ms, exposure drag
22-42 -> 3.9 ms, NR drag (1920) 301+ -> 18 ms, export render 1037+ -> 291 ms;
max 1 LSB difference. docs/gpu-pipeline.md describes the implementation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 08:19:44 -04:00
Brandon ThomasandClaude Opus 5.5 4195556551 M5.2: GPU pipeline design note (wgpu compute, CPU oracle, stage-cache mapping)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 02:01:33 -04:00