Commit Graph
2435 Commits
Author SHA1 Message Date
Jeremy Hooke bb414d220a LR-EDIT-LIGHT-SHADOWS: fix Highlights/Shadows block artefacts in fine detail; 4× faster GPU median
Fixes #3460
Related: #2936 (slow GPU kernel compiles on Windows/DX12). This PR builds the GPU kernels without wgpu's workgroup-memory zeroing, which took a DX12 release start from ~87 s to 1.4 s on a GTX 1070 with the new median kernels; it may shorten the compiles there too, but does not close it.

## Problem

Raising Shadows on a photo with dark lettering on a bright panel makes bright blocks grow beside the
letters. On a Canon CR3 of a bus (6960×4640) with a black fleet number on a yellow panel, Shadows +75
puts bright yellow rectangles in the gaps where the digits pinch together and inside the 4's counter.
At Shadows +5 there is none of this; at +75 it shows at any zoom. Narrow gaps between dark objects
generally can do it: the pinned corpus has the same thing in the gap between two posts under a machine
(Hasselblad, `coverage-6568.fff`).

## Cause

From process V2 the Highlights/Shadows base (`local::tone_base`) is the edge-aware filter of a
separable median of log luminance, at a radius of 0.15 % of the long edge (10 px here), so that thin
detail (twigs, lines, #632) takes its surroundings' shift. Text strokes and gaps are about that wide,
so each row and column window is close to half letter and half panel, and the median picks one tone
or the other in rectangles: it fills the gaps between the digits and the 4's counter with the letters'
tone. The guided filter keeps those blocks, so panel pixels whose base reads as deep shadow get
Shadows' full lift (`2^(1.7·s·w^1.5)`, up to ~1.3 EV at +75).

## Examples

Top: before, bottom: after.

The fleet number on the bus photo, Whites +54, Blacks +31, Shadows +75, at 300 %:

<img width="1020" height="976" alt="example_photo_pair" src="https://github.com/user-attachments/assets/cc5b164f-612d-4d7d-bda3-db6afd8b177f" />


The gap between two posts in the corpus' Hasselblad `coverage-6568.fff` (CC0, raw.pixls.us),
Shadows +100, at 300 %:

<!-- image: example_corpus_pair.png -->
<img width="900" height="1216" alt="example_corpus_pair" src="https://github.com/user-attachments/assets/feb4b240-194e-40b1-b5b9-c4419f840df6" />

Synthetic lettering at the scale of a bus number in a 7000 px photo, Shadows +75, at 200 %:

<!-- image: example_pair.png -->
<img width="880" height="820" alt="example_pair" src="https://github.com/user-attachments/assets/bd02e600-dda0-4648-85e9-f89ce046eb08" />

## Change

`local::detail_free` (CPU) and its GPU twin: each value of log luminance is clamped between its
separable median at r (as before) and the median of a 3× wider neighbourhood (the median at r of a
box-downscaled copy, enlarged bilinearly, like `guided_fast`'s coefficients). The base is the guided
filter of that plane.

- A twig or speck in an even surround has both medians on the surround's side and takes the nearer,
  as under the median alone (#632 unchanged).
- The gap between two strokes sits in a wider neighbourhood that is mostly panel: the medians
  disagree and the gap keeps its own tone. The strokes themselves count as fine dark detail on the
  panel (both medians agree) and keep their contrast, as twigs do.
- Steps between regions stay exact (the fine median keeps them).
- GPU: the coarse median reuses the box/bilinear resize, plus one `clamp_between_k` map kernel.
  CPU and GPU match (max 1 level).
- GPU median kernels rewritten (`median_h` / `median_v`): each workgroup loads its taps into
  workgroup memory and selects by rank, 4 neighbouring outputs per thread. The old partial selection
  sort over a private 49-tap array sat in scratch memory. Same selection, so bit for bit the CPU's
  (`median_matches_cpu_bit_for_bit`, every r 1–24); renders are byte-identical to the old kernels.
  On 6960×4640 at r = 10 (M2 Max) the median takes 191 → 47 ms.
- GPU kernels are built without wgpu's zeroing of workgroup memory (as `nn.rs` and `faces.rs` already
  are): on DX12 it made FXC take ~87 s over the new median kernels at every start of a release build
  (GTX 1070, 1.4 s without it). Every element of `med_tile` the kernels read is loaded before the barrier;
  renders are byte-identical.
- Cost on the 6960×4640 bus CR3 with Whites +54, Blacks +31, Shadows +75 (M2 Max): GPU planes
  231 ms on `main` → 113 ms here (257 ms with this PR's first kernels); CPU base ~388 → ~453 ms.
  The CPU path enlarges the coarse median while it clamps, into the fine median in place
  (`detail_free_matches_its_reference`, bit for bit).
- New process V8 (`Process::LATEST`). Existing edits keep their process and render as before, so `RENDER_CACHE_VERSION` is unchanged (it is 76 on `main`).
- Docs: `process-versions.md` (V8 row, a paragraph on the lettering fix, revision history), `parity-checklist.md`
  (LR-EDIT-LIGHT-HIGHLIGHTS / -SHADOWS notes).

An earlier revision of this PR clamped each value to the quartiles of its window instead. It fixed
the lettering but stopped treating isolated lines between r/2 and r wide as detail (twigs of 3–6 px
against a dark wall were lifted less than the wall, as under V1), which the corpus run below caught.
A fixed window can't tell an isolated line from a gap between strokes; a wider neighbourhood can.

## Process version

The new base applies from a new process, **V8** (V7 plus `local::detail_free`; `local::base_keeps_gaps` is true from V8, CPU and GPU). v0.19.0 shipped V3 to V6, and crystal grain (#3630) took V7 on `main`, so V1 to V7 render exactly as before: `tests_legacy` still locks V1 and V2, and `only_process_v8_on_takes_the_detail_free_base` checks that every earlier process keeps the plain median. New photos get V8; photos already edited keep the blocks until Update to Current Process.

The faster median kernels and the workgroup-memory change don't change pixels, so they apply to every process. They stay in this PR: the zeroing only costs time on the new median kernels (on `main` the kernels compile in 0.8 s on the GTX 1070), so on its own it would speed up nothing.

## How it was checked

- [x] New test `gaps_between_letters_keep_their_own_tone_in_the_base`: a row of synthetic "8"s
  sized like the bus number. With the median, 360 panel pixels sit more than 1 EV above their base
  (worst 2.25 EV); now none do (worst 0.8 EV). It fails with the median alone.
- [x] New test `isolated_lines_up_to_the_detail_radius_stay_out_of_the_base`: light lines 1–9 px wide
  on a dark ground (r = 10) stay out of the base, as under the median. It fails with the quartile
  clamp (a 6 px line 0.31 of the way into the base).
- [x] New test `detail_free_keeps_steps_and_regions`: steps and flat fields pass through unchanged;
  tiny, empty and NaN planes don't panic.
- [x] The #632 tests (twig/stalk contrast, local contrast, no halo at a strong edge, V1 unchanged)
  and the median reference tests pass unchanged.
- [x] `lightcraft-gpu` integration tests on a real adapter, including
  `highlights_shadows_base_matches_on_every_process` (max difference 1 level).
- [x] Corpus A/B: all 451 pinned raws (`cargo xtask corpus --download`) rendered at full size before
  and after, with Shadows +100 and with Highlights −100 (902 pairs, GPU). No failures. RMSE median
  0.18 % (Shadows) / 0.10 % (Highlights), max 0.86 %; pixels changed by more than 3 % at most 0.07 %
  of a photo; worst 8×8-block change median 10 / 7 levels, 99th percentile 44 / 41, max 61. I looked
  at the 22 largest local changes (old over new, at the worst block): no new artefacts. Most are a
  slightly different tone in small gaps among foliage or branches; one is the Hasselblad gap above.
- [x] User-visible change: rendered the bus photo at full size and at 2400 px with Whites +54,
  Blacks +31 and Shadows +5 / +75, and looked at the number, the plate, foliage against the sky and a
  brick lattice fence.
- [x] New tests `median_matches_cpu_bit_for_bit` (GPU, M2 Max) and `detail_free_matches_its_reference` (CPU).
- [x] After merging `main` and moving the base to V7 (Linux, Ryzen 5 7545U, RADV): `cargo test --release` of `lightcraft-develop`, `-pipeline` (including `tests_legacy`), `-catalog`, `-engine`, and `-gpu` on the real adapter (skipping `running_out_of_device_memory_falls_back_without_stopping_the_gpu`, which fills RAM on an APU; see above); `cargo fmt`, `cargo clippy --workspace --all-targets -D warnings`, `cargo xtask layers`, `roots`, `assets`, `parity` and `wasm`. Not run: the full `cargo xtask ci`, and the macOS-only `tests_light::v1_and_v2_render_as_before_v3` (V1 and V2 don't change, so it shouldn't need re-capturing).
- [x] After merging `main` again (with crystal grain as V7) and moving the base to V8 (Linux, Ryzen 5 7545U, RADV): `cargo test --release` of `lightcraft-develop`, `-pipeline` (including `tests_legacy` and the grain tests), `-catalog`, `-engine`, and `-gpu` on the real adapter (skipping `running_out_of_device_memory_falls_back_without_stopping_the_gpu`); `cargo fmt`, `cargo clippy --workspace --all-targets -D warnings`, `cargo xtask layers`, `roots`, `assets` and `parity`. Not run: `wasm` and the full `cargo xtask ci`.
- [x] `cargo xtask ci --quick` (with `CRAFT_FONTS_DIR`): all steps pass. After the median change: all steps pass again (without `CRAFT_FONTS_DIR`), and the CR3 renders
  byte-identical to the first kernels on GPU and CPU.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Highlights and Shadows adjustments better preserve clean gaps in fine details such as lettering while maintaining the existing treatment of broader image features.
  * Tone adjustments are less likely to include isolated, narrow lines in the base used for processing.
  * The updated detail handling helps retain fine structure while reducing unwanted influence from closely packed details.
* **Documentation**
  * Updated process notes to explain how fine-detail handling affects Highlights and Shadows adjustments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:31:45 +01:00
Fridtjof MichelsandClaude Opus 5.5 e2d45b8818 LR-VIEW-SURVEY: merge main (#3842: photo_tile also returns the painted rect; the survey ignores it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:03:14 +01:00
FredApps 4ecbd194fd LRC-LIB-CATALOG-IMPORT: reconcile backup import with main
Preserve collection ownership, check extraction space, and resolve the upstream merge.
2026-11-09 22:14:17 +01:00
FredApps f6b57b0b59 Merge pull request #3630 from jeremyh/crystal-grain
LR-EDIT-EFFECTS-GRAIN: film grain from crystal layers (process V4), resolution independent, no GPU cost
2026-11-09 11:39:02 +01:00
Luis b3b49987d4 Release: refresh contributor credits for each release (#2254) (#3898)
About > Contributors still showed an old snapshot, because nothing
regenerated contributors/contributors.json before a release. Opt in to
the shared refresh from craft-common-actions: a read-only credits job
regenerates the snapshot for the release commit, and the shared release
workflow installs it before every source build. The snapshot is not
committed back.
2026-11-08 02:12:52 +01:00
FredApps 10db03b091 LR-IMP-FORMATS: withdraw compressed ORF decoding pending provenance review (#3916)
* LR-IMP-FORMATS: withdraw compressed ORF decoding

Remove the decoder introduced in #3775 pending provenance review while preserving uncompressed ORF layouts and embedded previews. Add unsupported probe/decode regressions, remove the prior provenance narrative, and restore camera coverage records to preview-only.

* LR-IMP-FORMATS: refresh ORF support status and render caches

* LR-IMP-FORMATS: document ORF withdrawal regressions
2026-11-07 15:56:03 +01:00
Jeremy HookeandClaude Opus 5.5 622c288621 LR-EDIT-EFFECTS-GRAIN: review fix (a draft waits when nothing can stand in)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:06:06 +11:00
Jeremy HookeandClaude Opus 5.5 ed10d4486c Merge main into crystal-grain; the crystal grain moves to process V7
v0.19.0 shipped V4 to V6 with value-noise grain, so those stay frozen and
the crystal grain becomes a process of its own. V7 is V6 plus the grain:
without grain it renders as V6 (`without_grain_v7_renders_as_v6`), and the
GPU equivalence cases now cover V6's value noise.

Merged with main's float output (`deep`, `hdr`): the value noise keeps its
clamped luminance; the crystal grain already clamps, and fades out at and
above SDR white. The finish kernel's failed-render check stays ahead of
the aux upload.

The test of unknown process numbers now takes one past the latest: 7 was
hard-coded, and is a known process now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:53:18 +11:00
Jeremy HookeandClaude Opus 5.5 a850d05fe4 LR-EDIT-EFFECTS-GRAIN: review fixes (crystal grain unit tests)
Direct tests for what was only covered through whole renders, or not at
all: slider clamping and NaN fallbacks, interp, normal_quantile, the
Poisson draws, the emulsion's mix of sizes per seed, the summed-area
table and box sums against brute force (seams, negative coordinates,
other periods) and the footprint limit.

A tile redrawn is now compared with the first one directly:
seed_picks_the_pattern compared a cached tile with itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:31:14 +11:00
8a0b91a95c GPU float output for the HDR render (F32Hdr) (#3856)
* Render HDR rendition on GPU for HDR display drafts

HDR edits on HDR displays currently pay for a second CPU float render on every full render and show only SDR during drags, making slider drags several times slower.

Allow F32Hdr requests through the GPU path with a float output target matching the CPU HDR rendition, and let draft renders carry HDR again instead of being excluded.

Test: gpu_handles_f32hdr_renders_for_hdr_display failed before the change and passes after.

* Issue #2297: float output from the GPU's finish kernel for the HDR render

The kernel wrote 8 bits only, so `handles` taking F32Hdr left no float samples.
It now keeps values up to the edit's peak (highlight desaturation, vignette,
gamut map, curves above SDR white, grain, LUT profiles, as finish_with does),
encodes with the exact sRGB curve as a float render does on the CPU, and writes
three linear floats per pixel into `out`. Written words carry the sign bit, so
work that never ran still fails the render instead of reading as black.

hdr_histogram keeps its Quality::Draft exclusion: it renders on the CPU, and the
UI already runs a small render beside the drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:59:03 +01:00
8f638b33bd Treat a first intersect mask component as its base selection (#3854)
* Treat a first intersect mask component as its base selection

A mask created with Intersect as its first component currently renders as empty, so the overlay and local adjustments never appear. Using that component as the base matches how Add and Subtract first components already behave.

Change evaluate_one so the first component always sets the base alpha regardless of its combine op. Add a regression test with a single Intersect linear-gradient component asserting the start side is selected.

Test: intersect_first_component_selects_its_shape failed before the change and passes after.

* Masks: the GPU treats a first Intersect as the base too, and the process doc says so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: what changes for masks that start with Intersect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:46:11 +01:00
3943279105 Colour noise reduction: joint linear chroma filter with speck brake, CPU and GPU (#815) (#1348)
* WIP: colour NR v2 prototype (env LIGHTCRAFT_NR_V2, CPU only)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 on by default (LIGHTCRAFT_NR_V2=0 turns it off), GPU renders read back for it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: cache renders with the new colour NR apart from older ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 on the GPU (fused down / apply kernels), coefficient grid sigma / 2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 on the CPU without full-size intermediates (row-streamed passes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: report peak device-buffer use per GPU stage with LIGHTCRAFT_PROFILE

* WIP: colour NR v2 linear mode (NRV2_LINEAR=1: c - Y, additive, noise model), Jensen term, mean-drift probe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 noise model with equal-count luminance bands and a non-negative fit; probe asserts the mean over 3 noise realisations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 linear mode by default (per channel, K 64, coarse pass smooths only the coarse part); CPU on GPU renders; real-colour probe

Known: tests_window::noise_reduction_does_not_depend_on_the_window fails (the noise is measured in the
rendered window; the square-root mode failed it too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 joint guide (NRV2_JOINT=1, opt-in), luminance guide knobs, leaf-next-to-black probe, camera-look guard from 858

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 reads its env knobs once (per-pixel env reads made the linear mode 11x slower); joint stage timings with LIGHTCRAFT_PROFILE

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR without redundant passes (fused paths + next weights, none after the last scale, delta-1 weights from the path), two-component buffers, parallel reconstruction

Bit-identical to 8b89526d (test joint_matches_the_reference_bit_for_bit against a test-only copy).
Cat 32.7 MP, CPU: 6.2 -> 5.0 s at Color 25, 7.0 -> 5.6 s at 100; working buffers 2.36 -> 1.44 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR with separate H/V slice loops and zero-difference shortcuts (no division, weight 1 without exp)

Still bit-identical to 8b89526d. Interleaved 24 MP bench: 2.72 s vs 4.52 s for the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR on the GPU (nrcj.wgsl), default on; whole-image noise model from samples, halo bands above 2 GB

Kernels mirror nrc_joint step for step (same scale plan, noise model fitted on the CPU from GPU samples).
Cat 32.7 MP on a Quadro M2200: wb/nr 1.2-1.3 s vs 4-5 s CPU; against the CPU max 1 LSB; bands = single.
Waits after dropping large buffers (freed memory otherwise piled up to the whole 4 GB). Cache key includes joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: GPU joint NR plans its bands from a budget that includes the output buffer and the device buffer limit; halo 2*sum(delta) + max delta + 8; a band that cannot fit runs only this stage on the CPU

Bands vs one piece: at most 1 LSB in a few dozen pixels (running-sum boxes round differently per band start).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: GPU joint NR budgets its bands from the free device memory where it can be asked without unsafe (nvidia-smi, amdgpu sysfs), cached 3 s; fixed 2 GB otherwise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: docs/nr-color-v2-history.md: every approach tried, measured and dropped, speed numbers, known limits (no outlier brake yet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: history doc: speck behaviour stated qualitatively until measured on full-size renders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR speck brake: compact colour specks (chroma far past the noise, small luminance step, shift not reaching the 5x5 border) lose their colour before the guide is built; weak at Color 25, full at 100; CPU and GPU

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint NR CPU 5.2 -> 3.5 s (33 MP), bit for bit: guide as planes with branch-free distance rows, exponentiated paths, pilot straight into planes, speck brake in place with a min/max pre-test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint NR GPU: path and weight pairs instead of quads (48 instead of 60 bytes per pixel, 33 MP in one piece), speck and pilot kernels without loop-indexed private arrays, noise fit cached for identical samples; Quadro M2200 2.15 -> 1.47 s, HD 630 5.66 -> 4.26 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: speck brake: compactness from the second largest border share (a line or edge crosses the 5x5 border twice; one noisy border pixel no longer protects a speck)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: colour noise reduction as process V3

The joint method (linear R−Y / B−Y, edge-aware guide, speck brake) is process V3 and the latest; edits on V1 and V2 keep main's blurred chromaticity. The prototype switches (LIGHTCRAFT_NR_V2, NRV2_*) and the square-root and per-channel variants they selected are gone, with their GPU kernels; nrc_params uses the former defaults as constants, so V3 renders as the prototype did. The GPU falls back to the CPU joint filter when no band fits. vram is native only (wasm build). Docs: process-versions, nr-color-joint, parity checklist, what's new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: process routing test initialises its settings in place (clippy field_reassign_with_default)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: one GPU band plan at a time, with a reserve for every render on the device

An export batch renders two photos side by side (`export_parallelism`). Each joint colour NR planned
its bands from the device memory free at that moment and counted the shared buffer pool as its own,
so two plans made at once could together ask for more than there is. Measured on a Quadro M2200
(4 GB): two 33 MP exports side by side peaked at 3761 of 4096 MiB.

The plan and its bands now run under one lock (`BANDS`), and the budget leaves `VRAM_RESERVE` for
each render on the device (`RENDERS`), not just for this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: the joint colour NR's noise fit and pilot no longer switch at a hard bound

With main's preview-scaled luminance NR (#960) the CPU and GPU renders of a 640-px preview differed
by up to 10 levels (gpu equivalence `output_spaces_match`). Their images after luminance NR differ by
9e-7 rms, but one luminance band of the noise fit sat at exactly 4 times the first fit: the CPU's
samples dropped it, the GPU's kept it, and the fitted noise came out 2.6 % apart. The pilot's axis
choice then switched at a few pixels, and the edge-aware weights carried that on.

- robust_line: a band's weight fades to 0 between 4 and 5 times the fit instead of dropping at 4
  (identical below 4).
- pilot_pick: the smoother-axis mean fades in between 0.8 and 1.25 times its bound instead of
  switching at it (identical outside), on the CPU, in nrcj.wgsl and in the test reference.

All 19 CPU/GPU comparisons of the equivalence suite are within 1 level again. The new test
noise_line_does_not_jump_when_a_band_crosses_the_outlier_bound uses the bands of that preview and
fails on the old fit (5.6 % jump).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: the joint colour NR measures the whole frame's noise and is keyed by process

A window measured the noise model on the pixels it held, so a loupe window
could smooth differently from the same pixels of the full render or the
export. The model now comes from fixed tiles of the whole frame
(`lightcraft_pipeline::colour_noise`, `nrc_joint::tiles`), the same for every
window, for the CPU and the GPU, and is kept in the stage cache.

The linear stage's cache key now includes the colour filter in use, so a
process change (Update to Current Process, undo) no longer reuses the other
filter's denoised image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-NR: deep windows match the whole render; the VRAM query no longer stalls renders

- A U16 / F32 window with the joint colour NR is grown by the filter's reach and
  cut back, like an 8-bit one (DeepImage::crop).
- The free device memory is refreshed in the background once the cached answer is
  stale; only the first question for an adapter waits. The joint NR asks before it
  takes the BANDS lock.
- nvidia-smi starts without a console window on Windows (CREATE_NO_WINDOW).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-NR: the VRAM query finds the right card and keeps one answer per adapter

- A card is found by its PCI address (wgpu device_pci_bus_id, nvidia-smi pci.bus_id,
  the amdgpu sysfs directory) where both sides report one; else by a device id that
  only one card has. Two cards of one model without an address give no answer, and
  the fixed band budget applies.
- Each adapter keeps its own cached answer, so a late background refresh cannot
  replace another adapter's answer. A refresh not done after 10 s is retried.
- The cache tests wait on signals instead of fixed sleeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-NR: the VRAM cache tests no longer depend on timing

The two cache tests used a 20 ms TTL: on a slow machine a refreshed answer could go
stale again between assertions and start an extra query. They now use a 30 s TTL and
age an answer explicitly (Cache::expire, test only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* GPU: allow joint_band's eight arguments, as color_nr_joint does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 09:44:58 +01:00
68b464011f Import review: an optional batch-edit recipe for newly imported photos (#2940)
* Import: an optional batch-edit recipe for newly imported photos

library.import takes a recipe, a list of develop control ids and values.
Each control is set, clamped to its range like a slider's, on every photo
the import actually adds, after the XMP sidecar, the camera default and
the chosen preset, as one more History step named Import adjustments.
Photos that are skipped as duplicates, restored from Recently Deleted or
joined from a browsed folder are not touched, as the preset and keywords
never touch them.

A recipe names plain adjustments only. White balance is refused, because
a temperature means Kelvin on a raw with colour matrices and a shift from
the as-shot white on a rendered photo, so one number cannot name the same
look for a whole batch, and the as-shot white balance of a raw is never
touched. Geometry and crop controls are refused, because the slider
refits the crop around a new angle and a batch write cannot. Per-photo
controls such as pointColor.0.hueShift are refused, because a write to a
photo without that instance is dropped. The rule is on the control's
spec (a control of the shared list, outside the Geometry section, not
white balance, and one that set accepts), so new controls follow it. An
id named twice counts once, the last value winning, and a list longer
than the control list is refused. ImportJob::new checks a recipe built in
code the same way, before any file is read or placed.

preset.saveRecipe saves a recipe as a user preset that stores only the
settings the recipe names, including a control the recipe sets to its
default, and refuses a recipe that stores nothing. The preset gets its id
the way imported presets do (user.<group>-<name>, then -2, -3 ...), so no
two presets share one: an id from the list length repeats once a preset
is deleted, and the first preset with an id is the one that gets applied.
A name or group over 100 characters is refused, and saving the same
recipe under the same name and group again returns the preset that
exists.

* Import recipe: tests

Cover a recipe on import (the values, the Import adjustments step, a
render, one undo step), the refusals (unknown ids, malformed items,
per-photo controls, geometry and crop, white balance, too many entries,
by the command and by ImportJob::new), clamping and a repeated id, a
raw's as-shot white balance staying as imported, a saved recipe preset
giving the same settings as the recipe (including a reset to a default),
the photos the recipe leaves alone (duplicates by path and by content, a
restored photo, a browsed photo that joins; a photo imported afresh gets
it), preset ids staying distinct after a delete and surviving a reopened
library, and both commands being listed with their parameters.

* Import review: a Batch edits section

The import review gets a collapsible Batch edits section: sliders for
the everyday adjustments (exposure, contrast, highlights, shadows,
whites, blacks, vibrance, saturation, clarity, texture, dehaze,
sharpening, grain, vignette). The controls moved off their default go to
library.import as its recipe, only when there are any, and a summary
beside the section counts them. The recipe stays in the dialog until the
import runs, so cancelling changes neither the catalog nor the presets.

Save as Preset, which needs a name and a recipe, saves the recipe with
preset.saveRecipe. It leaves the review's own preset choice as it is, so
a preset picked for the import is never replaced by the saved one. The
name goes in a TextField, as new text boxes do.

The summary line is a new tr_format message, added with a translation to
every formats catalog; the other new messages are plain and show in
English until a catalog translates them.

* Import review recipe: tests

Unit tests hold the dialog to the engine: every control it offers is one
a recipe may name, a slider moved back to its default leaves the recipe,
and start passes a recipe to library.import only when the dialog has one.
Driving the real review, Save as Preset saves nothing without a name or
without a recipe and with both saves exactly the recipe (leaving the
review's preset choice and the catalog alone), cancelling the review
discards the recipe, and confirming imports the photos with it as one
undo step, or without an Import adjustments step when there is none.

* Import recipe: tests for the disabled Save button and MCP reachability

The disabled state of Save as Preset was not pinned: the engine refuses
a nameless or empty recipe anyway, so the preset count stayed the same
even with the button always on. The test now also expects no error
toast, which only an enabled button that asked the engine would raise.

A new MCP end-to-end test drives both commands through the public
tools: list_commands documents them, run_command takes library.import
with a recipe (a recipe naming white balance is a tool error that
imports nothing), get_develop shows the values on the imported photo,
and the generated cmd_preset_saveRecipe tool saves a recipe.

* Import recipe: follow main's register(ui) and Id::unique after the rebase

Upstream main changed widgets::register to take &egui::Ui (every other
call site passes ui) and deprecated egui::Id::new in favour of Id::unique
(same hash, so the stored open/closed state is unchanged). Adapt the
batch-edit recipe section so lightcraft-ui-egui compiles again.

* docs: parity row LR-IMP-RECIPE for batch edits during import

* Keep HDR resets when a recipe is saved as a preset

A recipe that resets an HDR control to its default was dropped when it was saved as a preset, because the HDR subtree is left out of the settings JSON while it is the default. The recipe's settings are now compared with the HDR subtree always present, so the reset is stored alone and next to other adjustments. Round-trip tests apply the saved preset to settings with a non-default headroom.

* Address review findings

* Address review findings

* Import review: the imports the merge left in body.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:23:04 +01:00
ba282d3637 LR-IMP-FORMATS: pure-Rust clean-room compressed Olympus ORF bitstream decoder (#3775)
* LR-IMP-FORMATS: pure-Rust compressed Olympus ORF bitstream decoder

* LR-IMP-FORMATS: address CodeRabbit review feedback on PR #3747

* orf: bound predictor arithmetic, bit widths, and stream consumption

* orf: use unsigned magnitude abs_diff for predictor differences

* docs: correct Olympus verified-body count in parity header

* orf: saturating wbits derivation, widen pixel shift to i64 before clamp

* docs: sync raw parity headline figures with coverage tables

* docs: record clean-room provenance and report 58 newly unlocked bodies for compressed ORF

* docs: note LibRaw derivation and pending clean-room reimplementation for compressed ORF

* LR-IMP-FORMATS: pure-Rust clean-room compressed Olympus ORF bitstream decoder

* LR-IMP-FORMATS: sync orfc path in parity docs and correct mainstream weighted depth sum

* LR-IMP-FORMATS: address CodeRabbit review feedback on ORF gap and evidence sync

* Convert Negative 1/4: lightcraft-film, the negative conversion maths from Film Lab (#2490)

The film model, levels in density space, film presets, colour and tone of
Film Lab's core (same author, MIT), without the PhotoCraft-only sharpening
and JSON scanning, plus a whole-scan measurement (base, black and white
points, neutral balance) a host stores with a photo's settings. A library
with no caller yet.

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>

* LR-IMP-FORMATS: address CodeRabbit PR #3775 review (round 2)

- orf.rs: scope sample-based bit-depth inference to Word16 branch only
  (from_word16 flag prevents heuristic from running on compressed ORF data)
- orfc.rs: reject truncated compressed streams before allocating the frame
  (minimum 1 bit per pixel check)
- orfc.rs: remove feedback_shift_644 == 15 special case (ordinary exponent)
- ROADMAP/README/target-app-parity: fix '78 bodies' to '58 more bodies
  (2 verified)' — 78 is total Olympus+OM count, 58 were newly unlocked
- target-app-parity: RAW readiness 80% -> 87%, recompute weighted sums
- parity-checklist: refresh summary via cargo xtask parity --write

Generated with Antigravity

* LR-IMP-FORMATS: address CodeRabbit PR #3775 review (round 3)

- target-app-parity: RAW camera decoding 85% -> 89% (559+721 of 1446)
- target-app-parity: mainstream practitioner row lists ORF as per-model
  verification, not as unfinished decoder alongside CR3/HE NEF
- log_writer: check_regular_directory_entry before truncate open
  (prevents symlink-following truncation of files outside the library)

Generated with Antigravity

* LR-IMP-FORMATS: regenerate parity summary after film crate merge

cargo xtask parity --write: 83.2% of 543 in-scope rows (was 83.5%/532);
sync target-app-parity.md checklist references to match.

Generated with Antigravity

---------

Co-authored-by: Yorgos S. <smyrgeorge@gmail.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 22:23:31 +01:00
7b99ed3903 Presets change only the settings they name (fixes #2159) (#2207)
* Presets compose on the photo's original state, not the visible edits (fixes #2159)

Applying a preset deep-merged it into the photo's current settings, so a
second preset inherited whatever an earlier one (or a manual edit) had put
in the groups it styles, and its amount interpolated against the visible
values: a B&W preset followed by a color one stayed black and white. Each
group a preset covers is now computed on the photo's original state (its
import settings) and replaces the photo's own, at any amount; groups the
preset doesn't name, and the photo's pixel repairs (masks, spots, red eye),
are kept. An unresolved profile reference still means "the photo keeps its
own profile", and the built-in color presets now name their treatment, so
applying one after a B&W preset brings the colors back. The UI's hover previews and thumbnails show the same composed
result.

* Presets change only the settings they name (#2159)

The commit before this one computed each group a preset names on the
photo's import state and replaced the whole group. A preset therefore
also reset the settings it leaves out: Blacks under Bright & Airy, the
mixer colours it does not name, and imported exposure, whites and
blacks under Crisp Landscape.

Now each setting a preset names is computed alone, from the photo's
import look when a default preset recorded one, and otherwise from its
current value, because sidecar and catalog imports record no import
look and the camera defaults are then not their import state. Every
other setting keeps the photo's value. Pixel repairs (masks, spots,
red eye) compose on the photo's own values as before, and masks are
still added by the preset.

A color preset applied after a black and white one still restores the
treatment and the settings it names. Amount 0 applies nothing to a
photo without an import look. An unresolved profile reference leaves
the photo's profile as it is. Each case has a test.

* Document that presets change only the settings they name

The presets row and the preset amount row of the parity checklist now
describe the composition: each named setting is measured from the import
look when one is recorded, otherwise from the photo's current value, and
the settings a preset does not name keep their values. The revision
history records the change; statuses are unchanged.

* Test amount 0 on a setting the preset names

The amount-0 test used a preset that does not name exposure, so it
passed even when the original was the camera defaults. Bright & Airy
names exposure, and the test now checks that the photo keeps its value
at amount 0 for that setting.

* Keep a preset's null in an option field, and never take one from the original

A preset that writes null into an option setting (a crop aspect, where
null means free) was dropped below full amount: the scaled patch
removed every null, including the ones the preset wrote. At 100 % the
same preset cleared the aspect, at 50 to 99 % the photo kept it.

Now a null the preset writes applies from half amount up, as its other
values do. A null in the original never changes the photo: below half
amount such a setting is left out, so the photo keeps its own value.
Numbers and other values behave as before. The null-removal step is
gone, since the scaled patch no longer writes nulls for settings the
amount does not reach.

Tests: a preset null clears an option field from half amount up and
leaves it below; an option that is unset in the original does not
overwrite the photo's value.

* Build a preset's scaling base only below full amount

The presets panel asks for every visible row at 100 % on every frame,
and on hover. Each of those calls serialized the photo's current
settings and its original although, at 100 %, the preset's own values
apply as they are and the original is not read.

Compose now builds the scaling base only below 100 %. The engine also
builds the import state, or copies the photo's settings, only below
100 %. Results are unchanged; the existing preset tests cover both paths.

* Preserve preset baselines and allocate safe mask IDs

Assisted-by: OpenAI Codex

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 22:01:47 +01:00
1b6e722292 Import review: say what the folder scan left out (#2944)
* LR-IMP-ADD-DIALOG: a folder scan counts the folders it could not read and the files it skipped

The bounded folder walk used to drop a folder it could not list, and every file its
caller refused, without a trace. It now counts both (Walked::unreadable and
Walked::skipped); which files are returned, their order, the limits and the
handling of links are unchanged, and the Limits struct is untouched.

import::expand_report keeps those counts with the walk's truncation flag
(Expansion); expand_within delegates to it. ScanOutput carries truncated,
unreadable and unsupported, filled by scan_with, and scan_report gives the
session-backed scan the same output while storing the probes for the import
that follows exactly as scan does.

library.importPreview returns truncated and skipped {unreadable, unsupported}
next to the keys it already had, and says so in its description. A walk that
stops at a bound is still logged once, in expand_report, as before.

* LR-IMP-ADD-DIALOG: tests for the scan's skipped, unreadable and truncated counts

The walk counts files its caller refuses (and not hidden files or the skipped
folder) and a folder that vanishes before it is entered. expand_report, scan_with
and library.importPreview report unsupported files, a walk cut at the depth
limit and an unlistable folder, keep the probes for the import, and leave a
file named on its own uncounted. The unlistable folder uses no permissions on
Unix and a deny entry on Windows, and does nothing where the process can list it
anyway.

* LR-IMP-ADD-DIALOG: the import review says when its scan left something out

When the folder scan stopped at the walk limit, could not read some folders, or
skipped files that are not a supported photo type, the review now shows one line
under the count (for example "Stopped at the scan limit: there may be more
photos · 2 folders could not be read · 3 files skipped (not a supported photo
type)"). It shows nothing when the scan left nothing out. The counts come from
the scan's output and are kept on the dialog.

The two messages with a count go in every formats catalog, as the build requires,
and the plain message in every plain catalog.

* LR-IMP-ADD-DIALOG: tests for the import review's scan note

Opening the review for a folder with a stray text file, for a tree deeper than
the walk goes, and for a folder of photos only shows the matching line, or none.
The line follows the counters while the review is open, is written in the
language in use, and names singular and plural counts correctly.

* LR-IMP-ADD-DIALOG: tracker note for the import review's scan note

The row says what the review reports when its scan left something out, what
library.importPreview returns for it, and which other folder walks do not report
these counts. Revision history and last-updated line added; the status stays
done and the generated summary is not touched.

* LR-IMP-ADD-DIALOG: XMP sidecars are not counted as unsupported files

The import reads a photo's XMP sidecar along with the photo, so a sidecar is not a
file the scan skipped. expand_report now leaves .xmp files (any letter case) out of
Expansion::unsupported, which feeds ScanOutput, the "skipped.unsupported" count of
library.importPreview and the review's note; every other file that is not a
supported photo type still counts. The walk itself stays generic: Walked::skipped
still counts every file its caller refused, and which files are returned is
unchanged.

* LR-IMP-ADD-DIALOG: tests for leaving XMP sidecars out of the unsupported count

A folder with a photo, sidecars in lower, upper and mixed case (one in a subfolder)
and a text file reports one unsupported file through the expansion, the scan and
library.importPreview; a folder of photos and sidecars only reports none. The review
shows no note for sidecars alone and the text file's line once one is added.

* LR-IMP-ADD-DIALOG: tracker wording for the sidecar exclusion

The row and the revision-history entry say that XMP sidecars are not counted among
the files the review reports as skipped.

* rename importPreview's walk-truncation flag to scanLimited

- avoids one reply using truncated for two meanings once #2705/#2873 land (maintainer request)

* ui: translate the import scan notes into cs, fi, id, it, ka, pl, sv and vi

* fix: register the scan note against the Ui, not the Context (main's register takes &Ui)

* Report diagnostics even when the scan finds no photos

When a scan finds no photos, the message now says what it left out: folders it could not read, files that are not photos, or a walk stopped at its limit. A complete scan with no photos keeps the plain message.

* Format the scan results the merge touched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:32:03 +01:00
098c9294aa shortcuts: H toggles mask pins while masking (#1458)
* shortcuts: H toggles the mask pins while masking

In the Masking panel an unmodified H press runs view.maskPins (the same
switch as View > Show Mask Pins) instead of opening Remove; a Remove key
reassigned to anything else keeps opening Remove there, and outside
Masking H still opens Remove.

* shortcuts: test the H mask-pin toggle

H toggles the mask pins while the Masking panel is open and does not open
Remove there; from the other panels it still opens Remove and leaves the
pins alone.

* shortcuts: keep the H docs truthful

In the Masking panel an unmodified H toggles the mask pins and does not
open Remove, a Remove key reassigned to anything else still opens Remove
there, and outside Masking H still opens Remove, so the Hide pins row
moves from done to partial: Remove's spot pins have no hide toggle. The
H comparison row, the revision histories and the parity summary are
updated to match.

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 21:25:37 +01:00
58da12bc5c Export: built-in presets for Instagram Feed, Story / Reel, Web / Portfolio, Full Resolution (#2104) (#2209)
* The named-export-preset system itself (export.presets / export.savePreset /
export.deletePreset, user presets saved with the library, the Export dialog
dropdown and File > Export with Preset) already existed; this adds the four
named built-ins the issue asks for, locks them in with an expand / round-trip /
apply test, and updates the LR-EXP-DIALOG and LR-EXP-PREVIOUS tracker rows
(the stale 'no named export presets yet' note goes away).

* #2104: test all four built-in export presets, not only Instagram Feed

The named built-in preset test expanded, validated and rendered Instagram Feed alone. It now runs the
same checks for each of the four names: the expanded settings, validation as app.export params, the
round trip through a user preset, and a render at the size the preset sets. Full Resolution Master
sets no long edge, so its render must be the photo's own size, with no resize. The rendered size is
also compared with output_size, the same calculation the exporter uses.

* The preset sets a 1920 px long edge and nothing about the shape, so a 3:2 photo exports at
1920 x 1280 and no 9:16 frame is made. The export options have no crop-to-aspect setting, and none
is added here. The preset's doc comment and the LR-EXP-DIALOG row of the parity checklist say so, and
the checklist has a revision line for this and for the translations.

* The Export dialog shows built-in preset names through builtin_label, which looks them up in the
language catalogs. Instagram Feed, Instagram Story / Reel, Web / Portfolio and Full Resolution Master
were in none of the catalogs, so every language showed them in English. Each catalog now carries the
four names next to the existing JPEG (Small) and JPEG (Large) entries. The translations are drafts
and need review by a native speaker of each language.

A new test checks that every catalog has an entry for each of the four names. The catalog comparison
only reports gaps, so without this test a missing name would pass silently.

* Address review for pull request 2209

* #2104: the four built-in export preset names in the catalogs main added after this branch was cut (Finnish, Indonesian, Swedish, Vietnamese). The i18n test that every catalog carries the four names failed on them after the rebase; each of the four now carries them next to the existing JPEG (Large) entry, in the same shape as the other thirteen catalogs. The translations are drafts and need review by a native speaker of each language.

* Say the Web / Portfolio preset keeps copyright and creator metadata

The copyright export metadata policy embeds the copyright notice, status,
usage terms and info URL together with the creator (export/encoding.rs fills
the artist field before the policy match returns), so the LR-EXP-DIALOG row
and the 2026-10-17 revision line understated the preset as keeping
copyright-only metadata. Both now say copyright and creator metadata.

* Docs: finish the parity-checklist merge

The merge left conflict markers in the revision history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:08:12 +01:00
Alex ChambersandClaude Opus 5.5 baf079f659 LR-BEHAV-RENDER-FIDELITY: process V5 rolls raw tone curves off into white in stops, so raising Exposure keeps highlights (#1632) (#3783)
* LR-BEHAV-RENDER-FIDELITY: V3 camera tone curve rolls off in stops past its last knot (#1632)

A raw's camera tone curve is fitted only to camera JPEG tones below 0.85, so its last knot sits
1 to 3 stops below the sensor's white. Past it, CameraTone::apply closes on white exponentially in
linear light: the sensor's white renders at 8-bit 246-254 at Exposure 0 and 254-255 from +1, and
raising Exposure turns the top stop the sensor recorded white.

Process V3 extends the curve with CameraTone::apply_rolloff: same value and slope at the knot,
then gap / (1 + c (2^t - 1)) at t stops past it, the gap halving every stop in the end. On 11 CC0
corpus files with a fitted look, unclipped pixels rendering white drop from up to 22.5% to 0 at
+1 EV and from up to 35% to 0 at +2 EV; Exposure 0 renders change by at most 1 code on average.
V1 and V2, the fit itself, JPEGs and raws without a fitted look are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: V3 generic raw curve rolls off in stops too; DRO-off, DNG and GPU checks (#1632)

Raws without a camera curve take ToneMap::hdr, which reached white about 1/4 stop past the
sensor's white and clipped there (o.min(1.0)). Process V3 now uses ToneMap::rolloff_hdr: from half
a stop below the white point (tone::GENERIC_KNEE_STOPS) the extended Reinhard curve hands over to
the same stop-based shoulder as the camera curve (tone::rolloff_past, shared with
CameraTone::apply_rolloff), with the same value and slope at the join. The sensor's white renders
at about 235 / 248 / 252 at Exposure 0 / +1 / +2 where V1 and V2 render 239 / 255 / 255. On 6 CC0
DNGs on the generic curve, unclipped pixels rendering white drop from up to 8.4% to 0 at +1 EV and
from up to 17.2% to 0 at +2 EV.

The DRO-off gate (look_fit::at_most) also checks the V3 shoulder; the rate bound already implies it,
so no fit changes and LOOK_VERSION stays. Tests: the generic shoulder (join, slope, monotone, below
white, no kink), a regression test that fails with the clipping curve, a DNG ProfileToneCurve under
V3, the DRO-off property tests on the V3 shoulder, and a per-process camera-curve GPU check.
Docs: process-versions, parity checklist (LR-BEHAV-RENDER-FIDELITY, LRC-DEV-PROCESS), gap 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: what's new: process 3 keeps raw highlights when Exposure is raised (#1632)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: a flat last camera-curve segment joins the V3 shoulder with its own slope (#1632)

The V3 camera shoulder took the last segment's slope with a floor of 0.1, so a flat or shallow
segment (valid for a fitted, DNG or saved curve) joined with a kink: slope 0 on the curve, about
0.1 just past it. The shoulder now keeps the segment's own slope at the knot. Below 0.1 a quadratic
term in 2^t - 1 joins the denominator: it adds no slope at the knot and makes up the 0.1 pace
further up, so a flat curve still closes on white. Segments at 0.1 or steeper are byte-identical,
and the generic curve passes no floor. Comments now say the shoulder approaches white in real
numbers and can round to it in f32. Found in an independent review of the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: V4's GPU test pins V4; the corpus DRO photos keep their knots under the V5 bound (#1632)

The GPU light-operator test assumed the default process is V4; it now sets V4, since V5 is the
default. The corpus DRO test checks that dro_off's V5 bound leaves the second-last knot where
the rate bound puts it on the Sony DRO samples, so their V1 to V4 renders don't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: the DRO-off curve is main's again, so V1 to V4 keep it (#1632)

CodeRabbit found that the V5 bound in `dro_off` could move the second-last knot of a DRO
photo whose fitted last segment is flat, and V1 to V4 read that curve too. `dro_off` and
`at_most` are back to main's, so the camera-look fit is unchanged and LOOK_VERSION stays.
The DRO tests check the V5 shoulder where the lowered segment's slope over its squared gap
is at most the fitted one's (then it stays below), and on the Sony DRO corpus samples.
Also: `rolloff_hdr`'s doc names the HDR lift, and the release note names DNGs whose curve
already reaches white.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:50:13 +01:00
Chad ElliottandClaude Opus 5.5 5981ecadbb Issue #2165: a Lightroom collection that can't nest goes under the nearest set instead of failing the import (#3145)
* Issue #2165: place a Lightroom collection that can't nest where it is under the nearest set

Lightroom Classic keeps a saved print, book, slideshow or web gallery inside a plain collection, but a
LightCraft album nests only in a folder. The importer passed that collection as the album's parent,
AddAlbum rejected it with "parent must be an existing folder", and the atomic batch rolled back every
album and every catalog-only photo (#2165: photos but no collections; #2532: nothing at all).

An album whose Lightroom parent isn't a collection set (plain, smart, a saved creation, system-only or
missing) now joins the nearest set above it, or the top level, and the import report names it under
warnings. Before each AddAlbum the parent is checked against the catalog's rule (an existing folder,
or one added earlier in the batch), so no album can fail the batch. Builds on #2168 by @endthestart.

Fixes #2165, fixes #2532.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Issue #2165: a Lightroom collection never silently loses its album; warnings name the set as the library does

- Two collections never share an album. An album id the counter hands out that is already taken (the counter
  saturates, so a damaged one repeats) stops the import with an error naming the collection, as AddAlbum's
  "album exists" did before; it no longer drops the second collection and its photos while the report counts it.
  A collection listed twice imports once when both rows are the same and stops the import when they differ. An
  index that names one existing album for two collections gives the second an album of its own.
- A relocation warning names the destination set as this library calls it (a set renamed after an earlier import),
  and a set whose earlier album isn't a folder is described as such, not as "not a collection set".
- The staged-album set is no longer called `added`, which #3142 uses for its photo count in this function.
- Tests: relocated re-import, a user-moved album on re-import, a renamed destination set, a set album that isn't a
  folder, sets nested under a plain collection, an exhausted album counter, duplicate collection rows and an index
  naming one album twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lightroom): name the Lightroom collection id in relocation warnings (#2165)

A collection imported somewhere other than where Lightroom keeps it now ends
its warning with "(Lightroom collection N)", so two collections that share a
name can be told apart, as #2165 asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lightroom): a collection listed twice costs only the duplicate (#2165)

A different collection under the id of an earlier one stopped the whole
import. Like any bad Lightroom record it now costs only itself (#1588):
the first row of each id is kept, in catalog order, and a different one
is left out with a warning naming both; the photos listed under that id
go to the first. A row repeated as it is still drops quietly. The
warning reads as the one #3152 gives, so the import behaves the same
whichever of the two lands first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(lightroom): the nearest-set search ends on a parent loop or a deep chain (#2165)

`collection_parent` follows a collection's parents to the nearest set
with a seen-set and a 64-level cap. `validate` refuses loops before it
runs, so no import test reached that guard: a unit test now gives it
A in B in A, a collection in itself, a child of that loop (with and
without a set in it) and a 100-deep chain of plain collections. The
duplicate-collection test also checks that an exact repeat gives no
warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:49:39 +01:00
Fridtjof MichelsandClaude Opus 5.5 7bdefb67bf LR-VIEW-SURVEY: merge main (#3715's × tests kept next to the glide tests; checklist header in main's new form)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:57:02 +01:00
FredApps 5cd7eb107e CI: Windows leg for pull requests that change Windows code, or labelled full-ci (#3793)
* CI: Windows leg for pull requests that change Windows code, or labelled full-ci

* ci-windows: also run on manifest and Cargo.lock changes

* deps: craft-update with flate2's miniz backend (fixes larger-than-raw ZIP TIFFs on Windows)

craft-update's zip dependency used the `deflate` feature, which pulls in
flate2's zlib-rs backend. Cargo unifies flate2's features across the app, so
on Windows the whole of Lightcraft used zlib-rs, whose level 1 (the TIFF
exporter's DeflateLevel::Fast) is a static-Huffman mode that can make a
16-bit ZIP TIFF larger than the uncompressed one. storytold/craft-libs#608
switches craft-update to `deflate-flate2` (miniz_oxide), the backend Linux
and macOS already use.

Only craft-update's rev moves (c5a1cbf -> a231e51); the other craft-libs
crates stay where they are.

* ci-windows: cover lightcraft-cli and menus/files.rs (cfg(windows) code)
2026-11-05 16:37:49 +01:00
FredAppsandCursor 326b48286e Merge the host bridge's cross-origin check into page mode
Bring #2315's current head onto the page-mode branch, including the check that a cross-origin parent gets no bridge. ?host=parent wins when a page also sets data-host="page". A page-mode event is typed by its name, listeners are Fn, and dirty state is recorded before the synchronous send.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:40:59 +01:00
FredAppsandCursor d73cf00ca8 Merge main into the host bridge
Keep the current changelog and record the embed under Unreleased. A
cross-origin parent gets no bridge, so exports keep downloading; an
opaque origin still gets none.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:36:09 +01:00
FredApps eab130ecee Docs: drop the Last updated / Change header line that every PR conflicted on (#3790)
* Docs: drop the Last updated / Change header line that every PR conflicted on

* xtask parity: the header's Changes field holds only the revision-history link; name the kept fields

* xtask parity: reject a field after the header's Changes link
2026-11-05 13:54:05 +01:00
Jeremy HookeandClaude Opus 5.5 3b23f25c1c Merge main into crystal-grain; the crystal grain moves to process V4
Main took V3 (sharpening) and V4 (calibrated Contrast and Blacks) while this was
open. Neither is in a release yet (v0.18.1's latest is V2), so the grain joins V4
rather than adding a process: V1 to V3 keep the value noise, V4 draws crystals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:16:35 +11:00
FredApps df79328e58 Merge origin/main into fix/2674-stored-frame-regions
Keep face-box (#2674) and main (monitor profile / denoise) entries in the parity checklist change line.
2026-11-05 10:54:46 +01:00
FredApps bf96a5fa79 Merge pull request #2439 from insideTheFlux/feat/windows-monitor-profile
LR-PREF-DISPLAY: on Windows, previews follow the monitor's colour profile
2026-11-05 10:36:48 +01:00
FredApps aa53064de6 Merge pull request #3650 from insideTheFlux/fix/3381-title-bar
LR-BEHAV-TITLEBAR: the Windows title bar takes LightCraft's theme at launch (#3381)
2026-11-05 10:22:34 +01:00
FredApps 1a39b60344 Merge origin/main into fix/2674-stored-frame-regions
Keep both the denoise and face-box (#2674) entries in the parity checklist change line.
2026-11-05 09:49:24 +01:00
FredApps 2bc668e42a Merge storytold/main into feat/windows-monitor-profile 2026-11-05 09:44:56 +01:00
a220d3eb68 LR-EDIT-DETAIL-DENOISE: built-in BM3D denoising with GPU acceleration (#2982)
* LR-EDIT-DETAIL-DENOISE: AI Denoise works out of the box with a built-in denoiser

Turning AI Denoise on no longer needs an ONNX model and a denoise-model.json. Without a model of the user's own, the engine runs a built-in pure-Rust denoiser (noise measured per tile, non-local means on the Bayer planes, edge-directed demosaic) through the same tiling, cache, queue, preview and export path as a model. Installing a model stays optional; Stop using returns to the built-in one, and the id `builtin` is reserved.

The set-up prompt and its resume-after-install flow (model_setup.rs, PhotoState::NoModel) are removed. Tests updated for the new behaviour, with new tests for the built-in path; docs, trackers and locale catalogs updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: built-in denoiser cleans the colour planes together and adapts to the noise

The built-in denoiser now measures the photo's noise once from the whole mosaic (the model prepares the photo before its tiles run) instead of per tile, stabilises the variance (generalised Anscombe), rotates the four Bayer planes with a Hadamard transform and runs a non-local-means filter whose patch distance spans all planes, so colour blotches are cleaned with the brightness. It cleans gently at low noise to keep fine detail, and harder, with a second pass that matches on the first pass's cleaner picture, as the noise grows. Cached pictures made by the first version are made again (builtin version 2).

Adds an opt-in benchmark (tests/denoise_builtin_quality.rs, public CC0 raw plus noise of known level, scored against the original) and tests for the whole-photo noise estimate, strong noise, bad noise values and the strength ramp. On the Sony A7 III sample the result is 3.6, 7.0, 9.1 and 9.7 dB above the noisy picture at light, medium, heavy and extreme noise, against 2.9, 6.0, 6.5 and 5.8 dB for the first version.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* contributors: add esli1283

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: docs say how to run the built-in denoiser's quality benchmark

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: built-in denoiser cleans colour further, keeps more texture and leaves no isolated specks

Colour-difference planes get a wider pass guided by the cleaned brightness plane, the first-pass threshold is lower (more texture kept), and cells with no matching patch lean on a looser average in heavy noise. Skips negligible weights for speed; bumps builtin::VERSION to 3. Benchmark adds a texture figure; on the public A7 III raw the gain over the noisy picture is +3.7 / +7.2 / +9.7 / +10.4 dB (was +3.6 / +7.0 / +9.1 / +9.7).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Windows: tests build paths one component at a time and doc_path has no unused variable

On Windows `cargo xtask ci` failed before it reached any of the work under test:
- doc_path.rs bound the gvfs path outside the cfg(not(windows)) block that
  uses it, an unused variable under -D warnings.
- tests_import and the MCP headless discovery test joined "trip/day2" onto a
  temp folder, giving mixed separators, and compared them with the normalised
  paths the folder walk returns. They now join each component.

Test and lint changes only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: built-in denoiser filters with block-matching collaborative filtering, on the GPU

The built-in denoiser's filter is now block matching with collaborative
filtering (BM3D / CBM3D, written from the paper) instead of non-local means.
On the public Sony A7 III benchmark with added noise it scores 0.25 to 0.6 dB
higher at every noise level and keeps more texture at medium to extreme noise
(light noise keeps slightly less: 0.58 against 0.62), as fast as before on the
processor. docs/denoise.md records the comparison with non-local means and
weighted nuclear norm minimisation (tried, not kept: about +0.03 texture for ten
times the time) and what the tuning showed.

- denoise-core: bm3d.rs (matching on the brightness plane, 2-D DCT plus Haar
  across the group, hard threshold then Wiener, hard threshold rising with the
  noise). Builtin::run_with takes the filter stage, so every other step is shared
  between devices. Tuning.bm3d selects it; non-local means stays as the fallback.
  builtin::VERSION is 4, so cached pictures are made again.
- gpu: bm3d.rs and wgsl/bm3d_*.wgsl run the grouping, the filtering and the
  putting back on the graphics card, in the same order as the reference (the
  results agree to rounding: at most 0.1 noise deviation at a few tie blocks,
  4e-6 on average, identical every run). Noise measurement, stabilising and the
  demosaic stay on the processor. FXC (DX12) needs the working arrays at module
  scope and no dynamically indexed vector: documented in docs/gpu-pipeline.md.
- engine: the card is set up once per process for the first photo that may use
  it, checked against the processor on a check tile (in the filter's own domain:
  the demosaic's direction choice can flip a pixel on a tiny difference), timed,
  and used where it is faster. A tile it gets wrong runs on the processor and
  three stop it. Settings' Run on choice, device line and "Try the graphics card
  again" now work for the built-in denoiser. Its set-up marker lives in the
  models folder.
- The benchmark takes LC_GPU=1, LC_NLM=1 and LC_B_* for sweeps.

On an RTX 4080 SUPER a 256-cell tile takes 22 ms on the card against 408 ms on
one processor thread, and the 1536 x 1024 benchmark crop 0.13 to 0.24 s against
1.0 to 2.1 s on four threads, with identical scores.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: the built-in denoiser measures the noise per colour plane and sets its strength from it

The first noise estimator read a picture's texture as noise: it never reported
less than about 0.003 at mid-grey whatever the real noise, so the denoiser
always believed a photo was about as noisy as ISO 12800. The threshold curve was
tuned on that over-estimate, and its levels (0.024 to 0.14) are far noisier than
real Canon bodies produce (about 0.0025 at ISO 400 up to 0.038 above ISO 25600,
measured on a private library; only aggregates are recorded in the docs).

- estimate_noise: each colour plane is read on its own (they sit at different
  levels in a raw), a second-difference operator cancels flat and sloping signal,
  12 x 12 blocks, the quietest 5 % at each brightness divided by the bias of that
  quantile of pure noise (0.67, measured), a constrained line fit. The first
  version stays as estimate_noise_v1 to compare with. On 30 public pictures with
  known added noise the 10th to 90th percentile of estimated / true deviation at
  mid-grey is 0.97 to 1.29 at the ISO 6400 level against 0.94 to 1.96.
- bm3d::Params::lambda is a curve of the noise (1.2 at 0.0028 up to 3.5 at 0.14,
  straight against its logarithm) instead of two end values and a strength, and
  plan / filter take the noise level; the GPU path shares plan, so CPU and GPU
  agree as before. Fitted on clean low-ISO photos of a Canon EOS R and R6 Mark III
  with noise of their measured levels added: the lowest threshold within about a
  quarter of a decibel of the best at each level.
- Result (PSNR, EOS R / R6 Mark III, 10 + 5 photos): ISO 25600 level 38.36 /
  38.86 from 36.18 / 36.21, ISO 51200 level 33.83 / 34.45 from 28.42 / 28.78, ISO
  6400 39.93 / 40.69 from 39.30 / 39.74, quiet photos no longer over-smoothed;
  the noise left in the result falls from 1.3 to 3.1 down to 0.73 to 0.99.
- Tests: threshold curve, flat versus textured pictures, no-flat-area pictures,
  clipped / NaN mosaics; an opt-in accuracy harness on the public corpus
  (denoise_noise_estimate) with a calibration of the quantile bias; the benchmark
  takes LC_REAL, LC_BIN, LC_FULL, LC_EST and LC_B_LOFF.
- builtin::VERSION is 5, so cached pictures are made again. docs/denoise.md has
  "How the noise is measured" and "Fitted on real cameras".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: GPU workspace pool cannot miss a wake-up; benchmark path and parity date fixed

Review findings (CodeRabbit) on the built-in denoiser pull request:

- bm3d::Bm3dGpu::forget and nn::NetRunner::forget changed the workspace count
  without the free-list lock. A take() that had found no free workspace and read
  made == WORKSPACES, but had not gone to sleep yet, could miss the wake-up and wait
  for good on a slot that was already free. forget now changes the count under the
  free lock, in the same order as take(), then notifies.
- A test for each runner holds the free lock and checks that forget waits for it;
  both fail on the old code and pass now.
- docs/denoise.md: the benchmark command resolves the raw file from the repository
  root, since the test runs from the crate's folder.
- docs/target-app-parity.md: Last updated matches its newest revision-history entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: doc comments for the functions this branch adds

CodeRabbit's pre-merge docstring check counted 49.72 % of the functions this pull request touches as documented (threshold 80 %). Every function the branch adds, the built-in denoiser's block-matching and non-local-means filters, its noise estimator, the GPU runner and their tests, now says what it does or what the test proves. Older functions the branch only touched are left as they were. Comments only: no code line changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: make the GPU set-up marker's folder; fix the benchmark's default path

The crash guard was lost when the models folder did not exist yet. The opt-in
quality benchmark resolved its default raw file against crates/engine.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: enforce the cache limit without a models folder

The built-in denoiser makes products when there is no models folder, but
eviction returned early in that case, so the cache could outgrow its limit.
It now applies the default limit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* LR-EDIT-DETAIL-DENOISE: the cache-limit test needs no 20 GiB file off Unix

The no-models-folder test now checks the limit itself on every platform and
keeps the oversized sparse file for Unix only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* S0002: review fixes for the built-in denoiser

Tell the runner a photo's noise only when it could be measured, run prepare()
after the work gate with a cancel check, reword the Settings hint for builds
without a models folder, drop untranslated placeholder locale entries (they
fall back to English) and restore the cs.json spacing; rustfmt.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* S0002: fix review findings: command descriptions, stale gpu test, self-test noise

The denoise.models.select and denoise.toggle descriptions now describe the
built-in fallback. Drop our workspace-forget test, which read fields that
upstream's Workspaces pool (#2631, same fix) replaced and so did not compile;
upstream's own tests cover it. The built-in self-test is told a noise instead
of measuring a 16-cell tile too small to measure.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: esli1283 <esli.aaron@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 09:36:22 +01:00
FredApps bde8fd9904 Merge storytold/main into fix/3381-title-bar 2026-11-05 09:30:35 +01:00
FredApps bb0f0d5e11 Merge storytold/main into feat/windows-monitor-profile 2026-11-05 09:29:59 +01:00
FredApps eb441859ca Merge storytold/main into fix/2674-stored-frame-regions 2026-11-05 09:28:45 +01:00
d729c80a5a Detail: Remove Hot Pixels, on for newly imported raws (#1345) (#1350)
* WIP: suppress isolated hot photosites in the raw mosaic (#820)

Noise model fitted to the image, candidates 5 sigma above every same-colour neighbour, kept when two or
more direct neighbours are lit (real light); adjacent hot pairs are replaced too. LIGHTCRAFT_HOT_PIXELS=0
turns it off, LIGHTCRAFT_HP_DEBUG=1 prints statistics. Renders are cached apart from older ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: hot pixel suppression off by default (LIGHTCRAFT_HOT_PIXELS=1 turns it on)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1345: mosaic outlier filter: cache key no longer names the #858 comparison switch (merged upstream)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1345: mosaic outlier filter: fixed thresholds (no LIGHTCRAFT_HP_K / _SPOT knobs), statistics under LIGHTCRAFT_PROFILE

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1345: Remove Hot Pixels, a box in Detail ▸ Noise Reduction, on for newly imported raws

`Detail::nr_outliers` replaces single outlier photosites in the raw mosaic before demosaicing. It is
true in `DevelopSettings::for_raw` and false by default, so edits saved before the box existed keep
their look. The loaders take the flag (no more `LIGHTCRAFT_HOT_PIXELS`), a decoded source records
it, and a cached raw source decoded with the other setting is decoded again. The box shows for raws
only (`check:detail.nrOutliers`).

* #1345: Remove Hot Pixels off keeps old settings' JSON; smart previews fit either setting

- `detail.nr_outliers` is left out of the stored JSON when off, so settings saved
  before it keep their hash and cached thumbnails. Copy / paste, presets (also
  at an amount), preset updates and Auto Sync work on
  `DevelopSettings::to_json_for_merge`, which states it, so turning the box off
  still carries over.
- `DecodedSource::fits_outliers` accepts a smart preview (`from_proxy`): it has
  no mosaic, so an offline raw with the box on keeps reusing the decoded proxy.

(review of #1350)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1345: the Detail eye and Reset Detail switch Remove Hot Pixels off

`DevelopSettings::removes_hot_pixels` (the box and the Detail section's
eye) decides the raw decode in the app, the web worker and the thumbnail
bench, so a Detail section that is off contributes nothing here either.
Reset Detail resets the box to the general default (off), like the
sliders it resets.

(review of #1350)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1345: the hot pixel limit counts only replaced photosites, and can be set

The 0.2 % limit was checked against the candidates before real light was
filtered out, so a dense star field reached it with its stars alone and
kept its hot pixels too. It now counts the photosites that would be
replaced, as the module docs say.

The limit is now a parameter (RawImage::suppress_hot_pixels_with, default
HOT_PIXEL_MAX_SHARE) and a setting: Settings > Performance > Hot pixel
limit, applied with the new command app.hotPixelLimit. Changing it forgets
the decoded sources and changes the render cache keys of photos that
remove hot pixels; at the default every key stays as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1345: the app's default hot pixel limit is the default

The setting is an f32: 0.2 % read back as 0.2000000029… % was a new limit,
so every start forgot the decoded sources and changed the render keys of
photos that remove hot pixels. A value within a millionth of the default
now is the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Format the imports

* Web loader test states what it decodes with

* Format the media exports

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 09:26:47 +01:00
insideTheFluxandClaude Opus 5.5 8f7798207a Merge storytold/main at 61005f3d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvoftP5JSqAEJ9t7FSczTs
2026-11-05 00:15:08 -08:00
FredApps c18081f0b9 Merge storytold/main into feat/windows-monitor-profile 2026-11-05 08:58:54 +01:00
61005f3dc1 Culling: the loupe uses built previews; photos prepared ahead and in the background (#3077)
* PERF-CULL-0: ui.inspect reports how the loupe showed each photo it was stepped to

perf.loupeSteps records, per step, the ms from the step to the first frame
with anything of the photo, with a full-size picture (camera JPEG, cached
or rendered) and with LightCraft's own render, measured on the frames the
app draws, so culling benchmarks exclude the control channel's round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95f0343848fc0a238f80f07ceb292eaf6779aeaa)

* PERF-CULL-1: browsing a folder shows its first photos within a second

The Local view read and hashed every file of the folder before showing any
of them (2,933 EOS R5 C CR3s: 30.6 s to the first photo). It now hands the
files the library lacks straight to the import, ordered the way the view
sorts them (file time for the date sorts), which adds them batch by batch:
first photo 0.9 s, twelve thumbnails 1.4 s, the whole folder in the same
~34 s as before, in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b766558e7ef4f802919a144da53e444f70e0186b)

* PERF-CULL-2: the loupe makes the next photos' camera previews ahead of the step

Culling steps through photos, and each step waited for the next raw's
embedded JPEG to be read and decoded (p50 187 ms, p95 332 ms on EOS R5 C
CR3s) and then for a full raw develop. Now:

- the loupe's stand-ins for the next five photos in the direction of
  travel and the one behind are made ahead (Slot::QuickAhead, priority
  just below the loupe's own render) and taken over at the step, so a
  step shows the camera JPEG at once (p50 0 ms);
- a slot keeps its photo while the photo stays wanted, so photos that
  shift places as the user steps are not made twice;
- decoded camera JPEGs stay in the memory LRU (never on disk), so going
  back shows them again without a decode;
- while an arrow key is held (a photo left within 0.25 s), the raw
  develop waits until the photo has stayed 0.12 s, and a render queued
  for a photo already left is dropped, so the workers make the previews
  instead of renders nobody sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0430b95636d2b6adce8887af7b9464687c95549e)

* PERF-CULL-3: CR3 previews take the smallest embedded JPEG that covers the view; plain sRGB JPEGs decode straight to 8-bit

Upstream's embedded_preview_covering (14e0d128) picks the smallest covering
JPEG of a TIFF-based raw; this extends it to Canon CR3 and adds the 8-bit
decode:
- A CR3 keeps an 8192 px JPEG track, a 1620 px PRVW box and a 160 px THMB.
  embedded_preview_covering now picks among the three (cr3_boxes lists the
  boxes), so a grid thumbnail or a loupe up to 1620 px decodes the PRVW.
  Loader at 1280 px on 20 EOS R5 C CR3s: 168-322 ms -> 31 ms a photo.
- lightcraft_codecs::decode_jpeg_srgb8_fit decodes a plain 8-bit sRGB
  JPEG (DCT-scaled) and box-averages it in linear light straight into
  8-bit sRGB, without the float image the general path builds; anything
  with an ICC profile, an Adobe RGB declaration, CMYK or 12-bit takes the
  general path. 45 MP preview fitted to 2560 px: 182 -> 151 ms (min of
  15, loaded machine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a67bb398c2f438cbe16f4db1431ae0270f1c8b04)

* PERF-CULL-4: Camera preview label; thumbnails ahead of the step; 100 % shows the camera JPEG first

- While the loupe shows a raw's embedded JPEG (or its thumbnail), a small
  'Camera preview' label at the top right says so, with what that means
  on hover (widget loupe:cameraPreview; nine languages), so colour and
  tone are not judged on the camera's rendering.
- The look-ahead also asks for the next photos' thumbnails (a small
  embedded JPEG each), so a held arrow key has something to show in full
  screen too, where no filmstrip asks for them (2400x1500 window, 40 held
  steps: camera JPEG on 36, nothing on 2; before 1 and 4). Stepping
  through a whole wedding keeps at most 600 thumbnails, those around the
  open photo.
- Zoomed in on an unedited raw, its camera JPEG at full size (8192 px for
  an EOS R5 C) is drawn under the window render until that arrives:
  100 % in ~0.9 s instead of ~5.6 s; released when not zoomed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9f212b1e16815c3c0ca33130319a7ef41fb05a26)

* PERF-CULL-5: lightcraft-cli bench-cull and tools/cull-bench.py time culling a real shoot

bench-cull FOLDER (headless) times reading the folder into the Local view,
the first screen of grid thumbnails on the app's render threads, camera
previews at 1280 / 2560 px and full size, a cold 2560 px loupe render,
rating 50 photos in a row and importing N files in place / copying M,
as 'name: X ms wall' lines (p50, p95, n) and JSON. Libraries are made
under --work; nothing is written next to the photos.

tools/cull-bench.py drives the running app over the control channel
(browse, first photo, steps at rest, a held arrow key, 100 % zoom,
rating) and reads the app's own frame timings (perf.loupeSteps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bd2eaac591b72f8258acae4389c67aa4d1da3b27)

* PERF-CULL-5: bench-cull --files K samples a slow folder; e2e test; cull-bench usage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c32c9556924ccdee6f8c103864df51c3e170df98)

* PERF-CULL: docs for culling speed (parity rows, gap 26, preview reads, opening a photo)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fb69b5975b51f882d82b2ca684e0f7ddc8373908)

* PERF-CULL: the whole-file preview decodes a plain sRGB camera JPEG the quick path's way

Upstream's quick-path test wants the bounded read and the whole file to give the same picture
byte for byte; with the branch's straight-to-8-bit decode on the quick path only, a 49 px
preview came out differently. Both paths now take it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0c8acab4c2f3e85d1c51fab5faf62b6e997d7c73)

* PERF-THUMB-FIRST: CR3 thumbnails decode the 1620 px PRVW copy, and grid thumbnails from camera JPEGs are kept in the thumbnail cache

A CR3 grid thumbnail decoded the full-size JPEG track (45 MP, ~150 ms each)
whatever size was asked; it now takes the smallest embedded copy that covers
the cell (PRVW, same shape), and the box search stops at mdat (was a scan of
the whole file). The decoded camera JPEG of an unedited raw is written to
the library's thumbnail cache, so a folder seen before fills from small
files. Measured on 250 R5 C raws (loaded machine): first screen fills in
294 ms (was 553), on a second visit 53 ms (was 610).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 12162f966dd859b7e53ac65b5c4564a8e837b7eb)

* PERF-CULL: cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: docs for culling speed (parity rows LR-IMP-LOCAL, LR-IMP-PREVIEW-READ, LR-VIEW-DETAIL; gap 26)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: review fixes (bench work folder, 8-bit APP1 cap, size sort)

- bench-cull makes its libraries in a new folder of the run's own under
  --work and never deletes what is already there (it removed lib-browse,
  lib-add and lib-copy under a folder the caller named).
- The 8-bit sRGB JPEG path refuses more than 128 APP1 segments before its
  decoders walk them again, as the general decode does.
- Browsing by file size keeps unreadable files last in both directions
  (they were sized 0, so first when ascending).
- --files K in the CLI synopsis and help.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the loupe uses built previews; photos are prepared ahead and in the background

Built standard previews were never what the loupe showed: it rendered
every photo again (~1.8 s on a raw), the rendered previews in memory were
capped at 128 MB (~84 photos) whatever the budget, the disk cache was a
fixed 2 GB (a wedding's previews pruned each other), the cache's writer
skipped disk writes when it fell behind, and Build Previews with one photo
open built only that photo though the menu said "Visible".

- The loupe's render job takes a cached view render of the same settings
  that is at least as big as asked for, fitted down (no render).
- Memory for rendered previews is a quarter of the cache share; the disk
  cache's automatic size makes room for every photo (about 1.5 MB each);
  Settings offers Automatic, 50 GB and 200 GB.
- A build skips photos whose previews are there, writes each to disk
  before counting it, builds at least the loupe's size, and with one photo
  selected builds the view.
- Prepare photos in the background (Settings > Performance), per mode
  (culling, library, editing), app-wide (kept in ui.json) or per library:
  photos ahead rendered for the loupe (a few at a time, 0 = off) and the
  whole view's previews built in the spare time once the user steps
  through photos: two lanes, waiting while the photo on screen renders,
  nearest first in the direction of travel, one cancellable row, replaced
  by a build that is asked for. Commands prefs.backgroundPrep /
  prefs.setBackgroundPrep; ui.inspect perf.previewCache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the bench work-folder test removes only a folder it made

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: browsing keeps the whole listing for the folder bookkeeping; cull-bench stops on small folders

The browse worker queues only the files the library lacks, but the folder
bookkeeping (browse times, records of files gone) needs every file listed:
a subfolder of known photos only was not stamped as browsed. The known
files now come along marked as already in the library. tools/cull-bench.py
stops waiting at min(12, photos) thumbnails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: review fixes (smart previews honour id, editing builds by the Library row, lenient prep settings)

- library.smartPreviews with an explicit id works on that photo, not the
  whole view.
- While editing, the whole view's background build follows the Library
  row (its switch and which photos); the Editing row's scope is for the
  photos kept ready ahead.
- The app-wide and per-library preparation settings are read leniently:
  a value this build doesn't know is the defaults (app) or none (library),
  and the other settings are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: cull-bench counts twelve thumbnails while the folder is still loading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: a view change stops the background build for the old view

The running background build was only re-aimed at the photo on screen; after
another folder, filter or library it went on building photos no longer
shown. It is now stopped when the view's signature changes (not counted as
stopped by the user), and the view now shown is prepared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: cull-bench picks the thumbnail threshold from the final photo count

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the view signature covers every photo; the view-change test holds the build explicitly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: cull-bench stops waiting for twelve thumbnails 30 s after the folder loaded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: in the marked scope, marking a photo changes what the view's build prepares

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: cull-bench times only the thumbnails the browse adds

Thumbnails already cached before library.browse no longer count towards
the first-thumbnail and twelve-thumbnail times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: count every thumbnail made, for the browse benchmark

thumbTextures counts resident thumbnails, and evicting old ones can hide
new ones. The renderer now also counts each grid thumbnail it stores
(perf.thumbsMade, never lowered by eviction), and cull-bench times the
browse from that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the marked-scope test waits for its builds before cleaning up

Both preview builds finish before the test library is removed, and the
removal is checked, so no worker writes into a folder being deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: stand-ins made ahead keep their order, size and photo

Review fixes for the loupe's stand-ins and the browse benchmark:
- a stand-in still queued ahead moves to its new place in the order
  when the user steps;
- one made ahead still shows when the loupe's own request failed;
- one made ahead for a smaller loupe isn't taken over by a larger one;
- quick and full-size camera-preview keys include the photo, so a
  virtual copy gets its own;
- perf.thumbsMade counts photos given a thumbnail, not a render
  replacing a stand-in.
Each has a test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a late stand-in never takes another photo's place in the loupe

A stand-in made ahead takes over after the loupe's own request failed
only while the loupe still wants that photo and has no other request
pending; the test covers stepping on before it arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a stand-in made ahead and no longer wanted is dropped when it arrives

Freeing a look-ahead slot whose job is already running now also forgets
its request, so the late result isn't kept as a hidden texture, unless
the loupe is waiting for exactly that stand-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a running stand-in the loupe waits for keeps its look-ahead slot

When the next look-ahead no longer wants the photo the loupe has just
stepped to, the slot whose job is still making that photo's stand-in stays
reserved instead of going to a new photo, so the result still reaches
the loupe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 10 (browse order ties, library skip, CR3 boxes after mdat, badge once, look-ahead crop key, cli test binary)

- browse_order: import- and edit-date sorts queue new files by name (they
  tie on those keys); ties go by name in the view's direction, as the
  catalog breaks them.
- Local browsing never lists the library's own folder (browse_files, as
  scan_with skips it).
- CR3 PRVW/THMB search skips only the mdat payload: boxes after it (a moov
  holding THMB) are still found.
- The camera-preview badge and the loupe timing are settled once, after
  the window renders (no double badge with the full-size camera JPEG).
- The loupe look-ahead key includes the crop.
- bench-cull CLI tests use binary() after upstream's test split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 11 (browse listing errors and library root, rating ties, every CR3 PRVW, checked CR3 box math, look-ahead view key, windows wait while settling)

- browse_files: a folder that can't be listed is an error (the scan ends
  as failed, nothing is forgotten as gone), and the library's folder or
  one inside it lists nothing.
- New photos tie on rating: queued by name in the view's direction.
- Every valid CR3 PRVW/THMB is a candidate (the smallest covering one is
  picked); the box offsets use checked arithmetic.
- The loupe look-ahead is keyed to the visible list's generation (a new
  sort, filter or source changes the neighbours).
- While stepping fast, zoomed-in window renders wait like the frame, and a
  window drawn before the frame is timed as the render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 12 (loupe timing: the After window, over any stand-in)

In a split view only the After side's window says the raw's render is on
screen; and a window drawn over the camera JPEG counts as the render too
(the source shown is a render only when it is "render" or "cached").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 13 (loupe timing: the Before window when the Before is shown alone)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 14 (loupe timing: a Before window side by side with \ held is not the shown view)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 15 (the split-view timing test asserts its precondition)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 16 (side by side with \ held, each window stays in its pane)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a cancelled browse stops ordering its files; browsing hands back earlier probes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the web build runs the browse job in place as FnMut (it hands the probes back)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a scan hands the probes it took back however it ends (CodeRabbit, #3077 / #3080)

- The browse worker sends a result on every path: the listing, or `Stopped` (the folder couldn't be listed, or
  another folder was chosen) with the probes `ScanInput::new` took from the session. A listing failure is never
  sent as an empty listing.
- A scan stopped without waiting (its cross, or a browse replaced by another) keeps its channel until the worker
  ends, so the probes still come back; a finished scan's probes join the session's instead of replacing them.
- A cancelled scan whose worker ended without a result is not reported as "Scan failed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: built previews are the ones on disk; stand-ins ahead follow the preparation settings (CodeRabbit, #3077)

- A build counts a photo as built only when its previews are in the disk cache (when it has one): a loupe
  render kept only in memory, its deferred write skipped, is built again and written (`stored_dims`).
- `DiskCache::put` and `PreviewCache::put_at` say whether the file was written; a view preview the cache
  folder can't take fails the photo instead of counting it as built.
- The loupe's stand-ins ahead honour Settings > Performance for the mode: none for "Only the photo on screen"
  or no photos ahead (the queued ones are dropped), only marked photos in the marked scope. The settings are
  part of what the look-ahead was made for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: a preview for a discarded cache fails the photo; cache file sizes are read from 4 KiB first (CodeRabbit, #3077 / #3080)

- `PreviewCache::put_at` returns false for a job made before the cache was cleared or retired (nothing is
  kept), so a build that overlaps Discard Preview Cache counts that photo as failed, not built. The warning
  says which of the two happened.
- `DiskCache::dims` reads a file's first 4 KiB, and up to 64 KiB only when the size marker isn't there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: a photo another build renders counts once it is built; a write pruned away is not kept (CodeRabbit, #3077 / #3080)

- A build that meets a photo another build is rendering waits for that render, then counts the photo as
  built only when its previews are there, else renders it itself (it was counted as shared at once).
- `DiskCache::put` returns false when the pruning its own write starts removes that file (a picture bigger
  than the room pruning leaves).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: put_memory holds the cache generation through its insertion (CodeRabbit, #3080)

A clear can no longer finish between put_memory's look at the generation and its insertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: browsing skips the library's folder by any name it has (a symlink, a path through ..) (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: rustfmt the library-folder check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: cancelled builds stop before their test library is removed (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the library folder is skipped by its resolved path only; lib/../outside is browsed, an unresolvable folder reports its error (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the replaced background build is held by TestHold, not a 250 ms deadline (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a browsed folder has one spelling (. and .. taken out by name) for the view, its listing and the catalog's records; an unresolvable root inside the library is still skipped (CodeRabbit, #3055 / #3077 / #3080)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: TestHold counts its holders: one test's release doesn't free a build another test holds (CodeRabbit, #3077 / #3080)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: TestHold's release without the deprecated fetch_update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: browsing resolves `..` the way the system does, and `.` is the current folder

`lexical_path` took `..` out by name, which is wrong on POSIX when the folder before it is a
symlink (`link/..` is the folder above where the link points). `browse_path` makes the root
absolute first (so `.` and `a/..` name the current folder, not an empty path), resolves a link
just before a `..` on POSIX, and keeps every other spelling as written (a mapped drive stays a
drive letter, matching the catalog's records). Windows takes `..` out by name, as before.
Tests: a folder link followed by `..` lists what the system lists there; `.` is the current folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: browsing resolves the folder on its worker, never on the window's thread

`browse()` called `browse_path` (which may canonicalize a link) and `is_dir()` before starting the
worker, so a link into a stalled network mount could freeze the window. The UI now spells the
folder with `browse_spelling` (`.`/`..` by name, no disk access) and shows it as given; the worker
resolves it with `browse_path`, checks it is a folder (else the scan ends as failed, with the
usual toast), and sends the resolved spelling, which the view takes before the listing arrives.
Tests: a browse whose resolution is held returns at once and waits, then reads the folder; a
missing folder fails on the worker; `browse_spelling` matches `browse_path` without links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the browse worker resolves the path as given, not the view's spelling (CodeRabbit, #3055 / #3077 / #3080)

On Unix `photos/link/../select` with `link` pointing at `real/day` is `real/select`; the spelling with `..`
taken out by name read `photos/select`. The view keeps the spelling until the worker answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: bench-cull measures small folders and never writes its results among the photos (CodeRabbit, #3077)

A folder with no more photos than the grid sample was reported with empty camera-preview and loupe timings: the samples now fall back to the photos it has. A --json path in the source folder is refused before the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: editing renders the loupe from its source; the loupe holds background work back only while its render is on the way (CodeRabbit, #3077)

In Develop a cached view render left the source undecoded until the first slider draft. foreground() was renewed every loupe frame even with the render shown, so background builds mostly waited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: loupeSteps.total, a step cursor that survives the 256-step list (CodeRabbit, #3077)

tools/cull-bench.py sliced the list by its length, which stops growing at 256 steps, and reported no steps on repeated runs. Also documents perf.thumbsMade and perf.previewCache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the camera preview is located once when the fast decode declines (CodeRabbit, #3077)

The general decode takes the stand-in JPEG the fast path already found instead of parsing the container again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the uncapped preview cache warns once per session when its drive runs low

The automatic preview cache budget grows with the library and has no ceiling. When the library
opens, and at most every five minutes while previews are built, the engine reads the free space of
the drive holding the cache (fs4, MIT OR Apache-2.0, pure Rust over rustix / windows-sys); under
10 % or under 20 GB free it raises one alert per session. The desktop app shows it as a notice with
the drive's free space and the cache's size and a button to Settings > Performance, where a size
limit can be set. A drive that can't say its free space gives no alert (logged). library.info
reports lowDiskSpace; the notice is in ui.inspect's notices like the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the free-space read runs off the UI thread; a queued low-disk notice goes with its library; --work outside the source folder (CodeRabbit, #3077)

- Session::check_disk_space reads the drive on a worker thread and takes the answer on a later
  call (take_disk_space_alert, library.info, the next check): a slow network share no longer
  holds up a library open or a UI frame. Test: a_slow_drive_does_not_hold_up_the_check.
- The UI keeps the low-disk notice with the library generation it was raised for and drops it
  when another library opens. Test: a_queued_notice_goes_when_another_library_opens.
- The real-drive test runs on native targets only (read is None on wasm32).
- bench-cull refuses a --work folder inside the measured folder, also one not made yet, before
  anything is created. Test: the_work_folder_never_goes_in_the_source_folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: one free-space read at a time across library switches, the cache counted on its worker; a --json link into the source folder is refused (CodeRabbit, #3077/#3080)

- A library opened while the drive's free-space read still runs keeps that read (marked stale)
  instead of starting a second worker beside it; its answer is dropped and the current library's
  drive is read once it has come. Switching libraries while a share hangs no longer piles up
  workers. Test: switching_libraries_while_a_drive_hangs_starts_no_second_read.
- When the drive is low, the preview cache is counted on the read's worker and the count travels
  with the answer: taking it on the UI thread no longer scans a big cache on a slow share.
  DiskCache::known_size reads the count without touching the disk.
  Test: the_preview_cache_is_counted_off_the_ui_thread.
- bench-cull: a --json path that exists is resolved itself (a link to a photo in the source folder
  is that photo), a link that points nowhere yet by the file writing through it would create, a
  loop of links is refused. Test: a_json_link_into_the_source_folder_is_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: bench-cull --json replaces its file instead of writing into it, so a hard link to a photo can't overwrite the photo (CodeRabbit, #3077)

The results go to a new file next to the destination, renamed over it; the
temporary file is removed on error. Test:
a_json_hard_link_to_a_photo_leaves_the_photo_alone (fails with the old write).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: bench-cull refuses a --json entry in the source folder, also a link there to a file outside (CodeRabbit, #3080)

Writing the results replaces the --json entry itself, so a link sitting in the
source folder that points outside it would be replaced, removing that entry.
check_outputs now checks where the entry sits (its folder resolved, the final
link not followed) as well as where it resolves to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a frame without the loupe ends its step timing, so time away from Detail never counts (CodeRabbit, #3055)

LoupeTiming::left was never called: a photo's record stayed open while the grid, Compare, Survey,
Reference, People or the slideshow was up, so dwell / any / full / sharp ms counted time outside
Detail. observe() marks the frame; finish_ui_pass ends the step when the loupe drew nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: Build Standard-Sized Previews keeps 2560 px before a loupe was drawn (CodeRabbit, #3077)

On Automatic with no loupe size known yet (a fresh session) the command left `edge` out, so the
engine built its 2048 px minimum and a typical fit view rendered again. The loupe-aware engine
size still applies once a loupe was drawn; a chosen limit is used as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: main's merge keeps one Russian "★ Favorites" and its browse test uses the scan's end channel

Both sides had added the same key; main's new Auto Import browse test sends through the scan
channel, which this branch carries as `ScanEnd`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: stepping fast drops the queued Before render too; the thumbnail count is per photo across eviction (CodeRabbit, #3055)

While an arrow is held the Before view's raw render waits like the loupe's own, and one still
queued for a photo left behind is dropped. `thumbsMade` counts each photo once per library, so a
thumbnail evicted and made again, or a library left and opened again, doesn't count twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the free space is read every few minutes, not only while previews are built (CodeRabbit, #3080)

Thumbnails and the loupe's previews fill the cache too, so a drive could run low after the library
opened without the notice: the low-disk check now runs whatever fills the cache (still at most
every five minutes). The engine gains two hidden test hooks (a reader and forgetting the last
read).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: a slow free-space read shows in the activity stack and an idle window wakes for the disk check (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: background preparation advances on every logic pass, hidden ones too (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a photo the loupe opens on is never a fast step; only a step inside the loupe has a predecessor (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the cull benchmark rates one named photo and puts its rating back (CodeRabbit, #3077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the cull benchmark refuses a full step history, says when thumbnails were in memory, fails on a lost key and ends a zoom that needs no region (CodeRabbit, #3055)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: a forced build renders a photo another build was rendering itself (CodeRabbit, #3080)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the cull benchmark stops on a folder with no photos and times a zoom by the region that ended it (CodeRabbit, #3055/#3080)

- browse: a folder that stays loaded with no supported photo ends the browse loop after 3 s
  instead of waiting 30 minutes, and the run stops with a clear message before the loupe
  section instead of failing on an empty selection;
- zoom to 100 %: the time reported is the moment the loop ended on (the region's arrival, or
  the settled whole frame when no region is asked for), so a whole frame that settled before
  a region was requested no longer gives a too-short zoom time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the cull benchmark says no photos are visible, keeps an earlier result, and records an unfinished zoom as missing (CodeRabbit, #3055/#3077/#3080)

- an empty selection after browsing means no photo is visible (a filter can hide them), not that
  the folder has none: say so and point at the filters;
- LABEL.json is no longer overwritten: an existing one stops the run before it starts unless
  --overwrite is given (the final write opens with "x" then);
- a zoom that did not finish within 60 s is written as null, not as a 60 s zoom time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a raw's preview pointer is read with checked bounds on its offset and size (FredApps, #3080)

The JPEGInterchangeFormat pointer and the undefined-tag JPEGs were sliced with `as usize` casts of
the file's own u64 numbers: safe on 64-bit (the slice is `get`, its end cut at the file's end), but
an offset past 4 GiB would wrap to a small, wrong one on a 32-bit build (wasm32). `file_span` now
converts with `try_from` and saturates the end.

Test: `a_preview_pointer_past_the_end_of_the_file_finds_no_preview` (pointers past the end, lengths
past it, u32::MAX for both; a real strip preview beside the lying pointer is still found; every
truncation of the file is safe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: bench-cull refuses a --work or --json inside the photo folder (FredApps, #3055)

Nothing is ever written next to the photos: a --work folder or a --json file inside FOLDER (the
folder itself, a subfolder, or a path that goes there through `..`) is refused before anything is
made. Paths are compared resolved (links followed as far as they exist). The cleanup already
removes only what the run made (its own `bench-cull-run-…` folder's `lib-copy`), never --work.

Test: `work_and_json_inside_the_photo_folder_are_refused`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: a test that building previews writes nothing next to the photos (FredApps, #3077)

`building_previews_writes_nothing_next_to_the_photos`: Build Previews and the background
preparation while culling leave the shoot folder with the same files, byte for byte; previews go
only to the library's own cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: cull-bench.py prints its usage on missing arguments and refuses a dangling LABEL.json link up front (CodeRabbit, #3055/#3077)

- PORT / FOLDER missing (or PORT not a number), or an option with no value or an unreadable one,
  stop with the usage line instead of a traceback;
- the early "LABEL.json exists" check uses `os.path.lexists`, so a dangling link there is refused
  before the run, not at the final exclusive write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a preview cut short by the end of the file is not taken (CodeRabbit, #3077)

`file_span` keeps a span the file's end cuts short only while it still holds a JPEG end marker
(an overstated length with the whole JPEG inside). A truncated JPEG passed the frame-header check,
won as the largest preview and then failed to decode, so no preview was shown.

Test: `a_preview_cut_short_by_the_end_of_the_file_is_not_taken`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-PREVIEW-PREP: the shoot-folder test waits for the background build to finish (CodeRabbit, #3077)

`building_previews_writes_nothing_next_to_the_photos` used a 5 s capped poll and then went on; it
now asserts `wait_finished` (30 s), so a late write by a slow build can't slip past the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a cut-short preview must reach its own JPEG end marker; cull-bench refuses bad options up front (CodeRabbit, #3055/#3077/#3080)

- raw: file_span keeps a span the file's end cuts short only when walking the JPEG's marker
  segments reaches its end marker inside it, and cuts it there. An FF D9 inside an APP payload no
  longer counts (it is skipped by the segment length), and bytes that don't start with FF D8 are
  refused without scanning the rest of the file. Tests: the cut-short test gains an APP1 payload
  holding FF D9 (taken before this fix) and a whole JPEG followed by other bytes;
  the_end_of_a_jpeg_is_its_own_end_marker covers stuffed bytes, restart markers, bad segment
  lengths and every truncation.
- tools/cull-bench.py: an unknown option, an option where a value belongs (--label --overwrite),
  --steps/--hold-ms below 1, or a port outside 1-65535 stop with the usage line before the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: cull-bench refuses a run whose steps wouldn't fit the app's loupe history (CodeRabbit, #3055)

The app keeps its last 256 loupe steps. A --steps that can't fit a run (1 + 11 + STEPS + 1) stops
before the run, and each phase checks the room left before it starts, so a hold whose first steps
were dropped is never reported as the whole hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: bench-cull refuses ".." in --json and --work (CodeRabbit, #3544)

A ".." after a folder the run hasn't made yet can't be resolved by the source-folder check, and
the run then makes that folder: --json NEW/../FOLDER/r.json would land in the source folder.
Test: a_dot_dot_in_the_outputs_is_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a cut-short preview needs a scan before its end and the walks share a budget; cull-bench catches dropped steps and odd arguments (CodeRabbit, #3055/#3077/#3080)

- raw: jpeg_end accepts an end marker only after a start of scan (an end with no image data
  before it is refused), and the walks of a file's pointers past its end share scan_budget, as
  scan_for_jpeg's do. Tests: a whole-looking JPEG with no scan is not taken;
  the_walks_to_a_jpegs_end_share_a_budget.
- tools/cull-bench.py: the port must be a decimal number int() takes (isdecimal, guarded); a
  FOLDER that looks like an option stops with usage; --steps goes up to 254 (only the hold's
  STEPS + 1 must fit, with one to spare); each phase starts with room to spare, so a full history
  means other steps came in and the phase stops instead of reporting a shifted window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: the camera JPEG goes only under the window render, and a stand-in decoded across a cache clear isn't kept (CodeRabbit, #3544)

- ui-egui: zoomed in, the full-size camera JPEG is painted only where the window render will go
  (window_cover), so a window capped smaller than the view leaves the developed whole-frame render
  in the strips beside it instead of an unlabelled camera JPEG. Test:
  the_camera_jpeg_covers_only_where_the_window_goes.
- engine/preview: QuickJob's camera-JPEG cache carries the cache generation it was made in and
  writes with put_deferred_at / the new put_memory_at, so a job decoding when Discard Preview
  Cache clears the cache can't put its picture back (as render jobs already do). Test:
  a_camera_jpeg_decoded_across_a_cache_clear_is_not_kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a bad build request leaves the background build alone, and preparation settings that can't be saved aren't kept (CodeRabbit, #3544)

- library.buildPreviews stops a running background build only after its own ids are known to be
  good, and a build whose thread can't start is marked finished instead of left "running".
- prep_tick clears started_for when the background build fails to start, so the view retries.
- set_prep_settings / clear_library_prep put the previous settings back when prefs.json can't be
  written. Tests: a_bad_build_request_leaves_the_background_build_running,
  a_prep_setting_that_cant_be_saved_is_not_kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a background build that can't start waits 30 s before the view tries again

Clearing `started_for` on a failed start made every tick call library.buildPreviews again while
the cause lasted (CodeRabbit on #3080). The view now waits RETRY_AFTER first. Test
`a_build_that_cant_start_waits_before_trying_again`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a build waiting to retry asks for a frame at its time; a build whose worker can't start leaves the background one running

CodeRabbit on #3077/#3080: a background build that couldn't start retried only on
the next input. The session now says how long until the retry (prep_retry_in) and the
app asks for a frame then.

CodeRabbit on #3544: a build asked for whose worker thread couldn't start cancelled the
background preparation and left nothing running. The background build is now stopped
only once the new one has started; otherwise it stays the session's build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: a retry time that has passed asks for no more frames

CodeRabbit on #3077 / #3080: once the retry deadline passed, prep_retry_in returned
zero, so a view that couldn't be tried (grid, preparation off) asked for a frame at
once on every pass and kept an idle window drawing. It returns None then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit items from #3577 that live in #3077 and #3055

- disk space: a read whose thread can't start (or whose worker ends without
  answering) is tried again after 10 s, not after the 5-minute check interval;
  a hung read is polled every 5 s after its first 5 s instead of every 250 ms
- the low-disk notice of the library before is not drawn in the frame after a
  library switch
- library.smartPreviews reads its ids before stopping the background build
- a forced build renders the grid thumbnail again instead of reading its cache
- the loupe's look-ahead asks for its stand-ins again after a renderer reset
- browse: a broken link before `..` is an error, not the link's parent folder
- the probes of two stopped scans: the later scan's wins whichever ends first
- camera JPEG fast path: a JPEG with an incomplete ICC sequence goes the
  general way; a CR3 without a JPEG track picks its box preview by size too
- tests: bench-cull compares the source photos' bytes, the disk notice test
  waits for the open's read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-CULL: CodeRabbit round 4 on #3077 and the #3583 items in its code

- A background smart-preview request cancels the running build only once its worker started;
  a worker that can't start leaves the earlier build running and the session's (test hook).
- The notice logic looks again at the next disk check (10 s after a read that couldn't start).
- CR3 boxes: largest by pixels; every top-level mdat left out; whole-file fallback takes the
  first box of a kind; a copy without an end marker isn't chosen to cover a size; a test
  decodes the chosen copy.
- The fitted JPEG decode reads a SEF file as the image before its trailer; its doc says a
  header that can't be read is Ok(None).
- Settings: a saved cache size the list doesn't offer shows as its own option.
- bench-cull rejects a second FOLDER; a browse through `..` reads again.
- cull-bench.py: --size checked, baseline waits for renders, --skip-browse claims no folder,
  a short rest phase warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Format the quick-full draw call

* Loupe stand-in choice moves to its own function

* Render results: the newer-draft and display checks move to their own functions

* Render results: a frame left on the GPU is shown by its own function

* Format the frame's texture entry

* Docs: one row each for the import and performance entries

* Format the quick view's key

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 08:32:26 +01:00
insideTheFluxandClaude Opus 5.5 4c1d2e919b Merge storytold/main at eaef01e9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvoftP5JSqAEJ9t7FSczTs
2026-11-04 23:26:09 -08:00
insideTheFluxandClaude Opus 5.5 d3727199ab Merge storytold/main at eaef01e9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvoftP5JSqAEJ9t7FSczTs
2026-11-04 23:26:09 -08:00
FredApps 0b9ff64e83 Merge storytold/main into feat/windows-monitor-profile 2026-11-05 07:10:24 +01:00
8972565249 LR-PROF-IMPORT: import the user's own DNG camera profiles (.dcp) (#1063)
* LR-PROF-IMPORT: import the user's own DNG camera profiles (.dcp)

profile.import (File > Import Profiles & Presets, drag & drop) now also
reads DNG Camera Profiles: the user's own .dcp files, e.g. made with a
colour-checker tool. They appear in the profile browser and menu under
Camera Profiles with dcp:<camera>-<file> ids; profiles.camera lists them
and profile.deleteCamera / profile.deleteImported remove them. A library
on disk keeps a copy in Profiles/. No profile ships with the app and the
repository holds none: tests build theirs in memory (write_dcp).

lightcraft_raw::dcp parses the file per DNG spec ch. 6 (magic RC in
place of 42; IFD 0 read through the existing DNG colour builders) and
DcpProfile::applied_to puts its matrices, illuminants and look in place
of a raw file's, keeping the shot's white balance, analog balance and
baseline exposure.

Choosing one (develop.profile) replaces the raw file's colour model when
it is decoded, before the starting colour and the white-balance scale
(WbScaleOf) are worked out, so the matrix path takes over from the fitted
JPEG look (CR3/CR2/ARW...) and white balance follows the profile. A
profile without a tone curve of its own (most chart-made ones) keeps the
photo's contrast: the DNG's tone curve, else the tone of the camera-JPEG
fit. The profile travels with SourceRef::File into FileLoader /
PairLoader (new Option<&DcpProfile> argument); a decoded source records
the profile it was made with and is not reused for another. Smart
previews and the web render worker keep the file's own colour; rendered
(non-raw) photos ignore it.

This reverses the documented "Adobe profile formats deliberately
unsupported" stance for user-supplied files only; docs updated.

Split out of #1012.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: build against upstream main (kelvin_presets, 3-arg loaders)

DcpProfile::applied_to keeps the file's white-balance presets (new
ColorData::kelvin_presets upstream): they are camera facts like as-shot
neutral. Upstream's new FileLoader test closures take the profile argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: hover previews and browser thumbnails develop the hovered camera profile

The loupe's hover preview (preview_job) and the profile browser's thumbnails (variant_job) took
the photo's own decoded source, so hovering an imported camera profile showed the built-in colour
(and hovering a built-in profile while one was applied kept the camera profile's). Both now decode
as the hovered settings develop: the source follows the settings' camera profile, as the main
render does, and a variant's cache key already follows its settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: build against upstream main (face-tile test loader and render_bench take the camera profile)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the catalog's staging-link test module is Unix-only (unused import broke clippy on Windows)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: same-folder export test expects the '/'-joined subfolder (failed on Windows)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: folder-drop tests build widget ids with the Folders tree's '/' paths (failed on Windows)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: rustfmt after merging main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: a real D750 camera profile as a pinned test corpus, checked against Lightroom

dieli's CC0 Nikon D750 ColorChecker set (Calibrite PROFILER dual-illuminant DCP,
matrix-only; the two chart NEFs; Lightroom Classic 15.6 renders with the profile)
is fetched by cargo xtask corpus --download into corpus/d750-colorchecker/, pinned
to commit f37f4054 and by sha256; nothing is committed.

Tests (skip without the corpus): the profile parses as matrix-only with
Cool White Fluorescent + D55; the matrices blend by inverse temperature; the
matrix-only path inverts the colour matrix and adapts to D50; it takes over the
chart NEFs' colour; imported and applied end to end, the charts develop within
mean dE2000 3.84 (flash) / 3.35 (indoor) of Lightroom's renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: a single .dcp file is a camera profile only, not also an unreadable LUT

profile.import read every plain file that was neither a folder nor a .zip as a .cube LUT, so importing
one .dcp imported the camera profile and also reported it as failed ("stream did not contain valid
UTF-8"). Only .cube files are read as LUTs now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: review fixes for imported camera profiles

- A library moved or restored elsewhere finds its camera profiles in its own Profiles/ folder.
- A grid thumbnail is cut from the decoded preview only when that preview used the same camera profile.
- Render and source cache keys carry a fingerprint of the profile file, so a deleted or replaced profile never
  has its old renders stand in.
- Camera profiles can be favourites, and favourites/recent entries survive reopening the library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: smart previews without the camera profile, current-profile source facts, unsupported files reported

CodeRabbit review of 4ef025d9:
- Build Smart Previews develops the proxy without the photo's imported camera profile and never takes a cached
  source decoded with it (the denoised picture is kept); before, only the stale-proxy refresh left it out.
- Session::source_info reports decoder facts only from a cached source developed with the camera profile the
  photo uses now, else the catalog's.
- profile.import reports a file that is neither .cube, .dcp nor .zip as failed instead of passing over it.

Each with a regression test in tests_dcp.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: the Before side decodes with its own camera profile; a failed profile copy fails that file only

- build_job (and region_job_before through it) picks the Before render's source with the Before
  settings, so it never reuses or decodes a source developed with the edit's camera profile.
- profile.import: a .dcp whose copy into the library fails is reported in `failed`; the profiles
  imported with it stay imported and are saved, and nothing half-written is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: a cached smart preview stands in while the original is offline

A smart preview carries no camera profile, so with an imported profile the profile check
rejected the cached proxy and every render decoded it again (it gives the same picture). It
now stands in, for its own level and for the thumbnail cut from it, while the original is
known to be offline; otherwise the profile check is as before, so once the original is back
the photo is decoded from it with its profile. Photos without an imported profile keep main's
behaviour. Test `tests_dcp::a_cached_smart_preview_stands_in_while_the_original_is_offline`
(fails without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: deleting a camera profile removes the library's copy before forgetting it

profile.deleteCamera now removes the library's copy of the profile file
first. A copy that is already gone is fine; any other error (file in use,
no access) fails the command and keeps the profile listed, so the library
never holds a profile file it no longer lists. Test:
a_profile_whose_copy_cannot_be_deleted_stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: deleting a camera profile never follows `..` out of the profiles folder

A listed path with a `..` component can start with the profiles folder and
still lead out of it; such a path is not treated as the library's copy, so
deleting the profile leaves that file alone. Test:
deleting_a_profile_never_follows_dot_dot_out_of_the_profiles_folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: both tone fits of a DNG see its lens opcodes; no side effect in the profile-tag comparison

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: a hover preview's key carries the camera profile file's fingerprint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: unusable camera profiles not offered; file's WB scale and the profile's tone kept

Review of #1063 (CodeRabbit, outside-diff findings):
- a camera profile whose file is missing or damaged when the library opens
  is no longer offered in the profile browser or accepted by develop.profile
  (it silently developed with the raw's own colour); the entry stays, so it is
  offered again once the file is back.
- a raw on the relative white-balance scale stays relative with a camera
  profile (its matrices turned it to Kelvin, so existing edits were read as
  absolute temperatures and the catalog and quick previews disagreed).
- with a camera profile its tone curve, then the tone kept for a curve-less
  profile, come before the fitted camera look's tone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: one Camera Profiles group; the web loader keeps a raw's own colour

Review of #1063 (CodeRabbit):
- a LUT group named "Camera Profiles" that didn't sort last made the profile
  menu list that group twice; it is now one group, last, with both kinds.
- without web workers a render ran inline and the web loader applied the
  selected camera profile, unlike the worker (the documented web behaviour
  keeps the raw's own colour); the loader now leaves it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: camera profile files are read at most to the 64 MiB limit

A .dcp larger than the parser accepts was read whole into memory before
being refused. One bounded read (lightcraft_raw::dcp::read_dcp) stops one
byte past the limit and is used for import, reopening a library and the
duplicate check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-PROF-IMPORT: a test that a hostile hue/saturation map header in a camera profile drops the map (FredApps, #1063)

Dimensions far past the data, past u32 products or zero, an entry count of 4 billion values and a
data offset past the end each drop the map while the profile's matrix still reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: parity checklist keeps both sides of the merge

* LR-PROF-IMPORT: tests added on main take the file loader's camera-profile argument

* Merge main; a thumbnail test takes the file loader's camera-profile argument

* Docs: one camera colour row, with main's notes and the import note

* Process key tests take the profile salt argument

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 07:09:30 +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
FredApps 20afbcc584 Merge storytold/main into feat/windows-monitor-profile 2026-11-05 06:37:15 +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
67782d8f65 Auto Sync: As Shot, Auto and Temp apply to each selected photo's own white balance (#3571)
* LRC-DEV-AUTOSYNC: Auto Sync carries white balance as a mode or on each photo's own scale: As Shot and Auto per photo, a Temp move keeps each photo's tint (N-A3, note 18)

Auto Sync already sets every other setting to the active photo's number.
White balance is the exception: main stores the resolved Kelvin and tint
with the mode, and Auto Sync copied those numbers. As Shot then gave every
selected photo the active photo's camera white, Auto gave them the active
photo's Auto white, a Temp move from As Shot gave them the active photo's
tint too, and a JPEG beside a raw got the raw's Kelvin read on its own scale.

Now (sync_same.rs) the mode goes on each photo and each applies it to
itself: As Shot its own camera white, Auto its own white worked out on its
own pixels, a light preset its numbers. A typed or dragged Temp or Tint
sets the same Kelvin on every raw (an old relative-scale value converted
first) and only the slider that moved, and a photo on another scale moves
by as much as the active one did. One undo step, as before.

Tests (tests/autosync_wb.rs): As Shot / Tungsten / Auto per photo with
three different as-shot whites and undo; a typed and a dragged Temp keep
each photo's tint; the old relative scale; a JPEG beside a raw. Three of
the four fail on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LRC-DEV-AUTOSYNC: Auto's per-photo pixels are read in parallel; the row says which sync does what

Review of #3571:
- When Auto is synced, the other selected photos' thumbnails that aren't
  in memory are now decoded in parallel (one per core), not one after
  another on the UI thread.
- The checklist row says the per-photo white balance belongs to Auto
  Sync, while Sync Settings copies the active photo's stored one.
- The row and the test notes now say a Temp is the same number only on a
  photo of the same scale, and that other photos move by as much as the
  active photo did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LRC-DEV-AUTOSYNC: Auto's per-photo pixels are read a core's worth at a time, each with its decoder's facts

Review of #3571:
- A large selection no longer holds every decoded thumbnail at once. They
  are decoded in batches of one per core (at most 16), and each batch's
  white balance is worked out before the next is decoded.
- Each photo's Auto uses the facts its own decoder gave, read right after
  its source is cached, so a photo pushed out of the cache by later ones
  never falls back to catalog metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LRC-DEV-AUTOSYNC: a light preset with a tint of its own on a JPEG carries that tint; Auto's facts are read before the cache can evict them (review of #3585)

- On the relative scale the stored numbers are what renders, so a preset
  the active photo overrides (`develop.wb {mode, tint}`) travels as its
  numbers, like Custom, not as the preset's tint.
- `sources_now` reads each photo's decoder facts before storing the
  decoded source: the cache's budget may evict that entry at once, and
  the header facts alone lack the camera colour Auto uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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:09:04 +01:00
FredApps 4da8573b55 Merge storytold/main into feat/windows-monitor-profile 2026-11-05 05:38:36 +01:00
2ddb43896b PERF-STEP-STALL: stepping and flagging never wait for the drive, and no change is ever dropped (#3557)
* PERF-STEP-STALL: stepping and flagging never wait for the drive; the catalog log is written and synced by its own thread, and nothing handed to it is ever dropped

On Windows a write to the catalog log waited for a sync of the same file in
flight, and each flag, rating or step synced on the UI thread, so culling on
a busy drive froze the window. FsStore now hands log appends to a thread
that writes and syncs them in order, batching what arrives during a sync.

No change is lost: the thread keeps every byte it has not yet synced. A
write or sync that fails is held and retried (cutting any fragment first),
later appends are refused with the reason (the engine keeps them queued, as
for any failed save), the held records count as unsaved so the quit prompt
warns, and a snapshot is made due. Cuts, replaces and reads act on the
logical log (synced bytes plus held ones), so the log never has a gap.
A clean quit's closing snapshot holds everything; dropping the store waits
for the thread.

Tests drive a slow drive (a sync hook that sleeps) and a failing one
through test-only fault hooks (doc-hidden, keyed by library folder): with 300 ms syncs the slowest flag is
0.2 ms (305 ms with the log written on the caller, as before); every change
made during slow saves reopens after a flush, a drop and a close; a failed
write is reported, retried and lands with no gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-STEP-STALL: review fixes: a reopen reads what the log thread was handed, the quit check reads the held status now, a covering snapshot clears it, a cut to the synced length reaches the file

- open_stores flushes the old journal's log thread before the same
  library's files are read again (Settings, Libraries; a reopen).
- Session::refresh_unsaved, called by the quit check, reads what the log
  thread holds at that moment, not as of the last frame.
- Journal::unwritten is None once a snapshot holds every op (the closing
  snapshot after a failed log reset no longer makes the quit prompt say
  changes would be lost); wants_snapshot follows it.
- A cut to exactly the synced length with bytes held cuts the file, so
  bytes a failed sync left there never come back on the next open.
- A caller that waits over a minute for the drive gets a timed-out error,
  not "the writer stopped".

Each fix has a test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-STEP-STALL: a reopen and the quit check wait for what the log thread was handed

Review of #3557:
- Opening the same library again while its log thread can't write what
  it was handed now fails and keeps the open library (with the held
  change, retried). Before, the new catalog was read from files missing
  it.
- The quit check flushes the log thread first instead of reading only
  the failure it had already reported. A change accepted but not yet
  written, then failed, is saved by the closing snapshot before the
  window may close; if that fails too, the unsaved prompt shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PERF-STEP-STALL: a reopen goes ahead when the snapshot already holds every change

Review of #3557: the reopen was refused whenever the old log's flush
failed. When a completed snapshot covers every op, nothing is missing
from the files, so the open now goes ahead and only logs the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 05:25:04 +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