LR-EDIT-LIGHT-HIGHLIGHTS: merge main; binned previews' clip mask on top of #607's reconstruction (#548)

Main's reconstruction (#607) replaces this branch's rewrite of highlight::reconstruct. Kept from the
branch: develop_binned_masked (clipping decided per sample before averaging) and the denoised twin
using the same mask. New: highlight::reconstruct_masked, #607's reconstruction with the clipped
channels told by the mask (block means as lower bounds), shared with reconstruct_with through a
generic clip test (no slowdown on full-size images), and CameraColourModel::rebuild_highlights_masked
so binned previews keep the colour model's clipped white. RENDER_CACHE_VERSION 38.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-10 03:37:51 -04:00
co-authored by Claude Opus 5.5
238 changed files with 35074 additions and 1935 deletions
+36 -4
View File
@@ -1,6 +1,6 @@
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW and Olympus ORF starting look
ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
@@ -12,9 +12,13 @@ The JPEG's colourfulness also depends on brightness, as a camera's per-channel t
Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately, by quantile matching: knot *i* pairs the median of the *i*-th 1/32 of the sorted scene luminances with the median of the *i*-th 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each scene quantile to the same JPEG quantile; this needs no pixel-exact registration and is increasing by construction. The earlier fit (the JPEG's median per scene-luminance bin, then pooled-adjacent-violators isotonic regression) broke on lens-corrected, sharpened JPEGs of busy scenes: neighbouring bins disagreed by more than the gap between them, pooling turned them into flat steps (13 of 31 segments on a CC0 Z 6II forest shot) that rendered as posterised patches with hard edges, and regression toward the mean flattened both ends of the curve (issue #475, also #211, #454). On the 1,781 files of the CC0 archive that render, 384 of the 1,023 accepted fits had at least one flat segment (1,073 in total, up to 14 of 31); with quantile matching 1,017 of the 1,023 have none. The quantile fit is slightly worse by least squares than the conditional medians (median held-out RMS 0.0331 against 0.0330, worst +0.013), and on 7 files (compacts, bridge cameras, early Micro Four Thirds) the look cleared the acceptance gates only with the old curve. The search therefore runs once with the quantile fit (all pixels, away from edges, with and without the camera profile's colour) and, only when no look passes, once more with the earlier conditional-median fit, kept as `fit_tone_conditional`; the gates are the same, so no file loses its look. Those 7 files render exactly as before, and 6 of them still have flat segments (35 in total). The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder.
**Clipped sensor pixels are left out of the fit.** A raw pixel with a channel at the clip level (0.99 of white, as in highlight reconstruction) no longer records the scene's colour: a white overcast sky with the green channel clipped and red and blue still rising comes out magenta or blue through the camera matrix, while the camera JPEG shows it white. As training pairs such pixels pulled the colour matrix toward grey and made the chroma curve remove colour at their display luminance. On the CC0 Z 6II forest sample (raw.pixls.us 4160) 24% of the proxy pixels are clipped; every chroma sample above display luminance 0.36 was one of them, and the curve cut colourfulness there to 13-19% (`[1.10, 0.94, 0.69, 0.19, 0.13, 0.19, 0.89, 1.0]`); the colours of the forest were muted by the matrix as well (spread of a\*/b\* on the proxy 63% of the JPEG's before the curve, 48% after). Over the 940 accepted looks with a chroma curve, 37% of the curve's downward pull came from clipped pixels whose JPEG pixel is near neutral, 53% in the 245 curves with a node below 0.4. The proxies therefore carry a clip mask (any sensor sample a proxy pixel covers, aligned with the CR3 framing), and the look is refitted without those pixels. The search on all pixels still decides whether a photo gets a look and which attempt (tone fit, profile or own colour, all pixels or away from edges) gives it; only that attempt is refitted, and when the refit misses the gates the look on all pixels is kept, so no photo loses its look or switches attempt. A photo without a look gets one when the search without the clipped pixels finds it. Camera-profile pairs (`calibrate`) leave clipped pixels out too. On the 1,781 files of the CC0 archive that render: 1,023 looks before, 1,027 after (none lost, 4 gained); 618 renders change (all with a look), 8 keep their earlier look through the fallback; median CIEDE2000 to the camera JPEG over the changed files 3.24 -> 3.07 (worse by more than 0.5 on 3, better by more than 0.5 on 59); median colourfulness (mean C\* of the render over the camera JPEG's) 0.973 -> 0.982 over the files with a look. The rest of the chroma curve's pull is mostly misregistration (a near-neutral JPEG pixel with a matching colour within one proxy pixel: 21%); a registration-free estimate of the curve (band means of colourfulness by each image's own luminance) brought the median colourfulness ratio to 0.999 but did not lower CIEDE2000, and is not used.
The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged.
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Monochrome, singular, unrelated, aspect-mismatched and poor-fitting references retain the documented fallback. Both fits use the same fixed proxy at every decode resolution.
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Monochrome, singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG. Both fits use the same fixed proxy at every decode resolution.
**A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9).
## Source metadata and white balance
@@ -74,9 +78,27 @@ This is a per-file camera-look estimate, **not measured spectral calibration or
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
### Sony ILCE-7CR
Fitted from 125 usable photos of 131 ILCE-7CR ARWs (one photographer, September 2025 to September 2026, Standard creative style with DRO off, Lv3 or Auto; 127 uncompressed, 2 compressed, 2 lossless compressed), 448,230 colour pairs. Measured as above: rendered at 1200 px with `lightcraft-cli render`, mean CIE76 ΔE against each file's embedded JPEG at 200 px.
Without a profile, 123 of the 131 photos accept their own fit; the other 8 open with the neutral fallback at ΔE 6–26. For validation every fifth photo in sorted name order (26) was withheld and a profile fitted to the other 105 (100 usable). On the withheld photos mean ΔE went from 4.07 with per-file fitting to 3.29 with the profile. Both withheld photos whose own fit is rejected are accepted (ΔE 26.0 → 6.1 on a night scene, 6.2 → 5.5). Withheld photos whose own fit was accepted are about unchanged and move both ways: 12 of 24 improve, mean ΔE 3.06 → 3.08, largest regression 1.8. On that photo (gold tinsel bokeh) the profile turns a blue light blue again where the per-file fit had made it teal, but renders the gold slightly more saturated than the camera. With the final profile all 131 photos are accepted, and the 8 previously rejected ones land at ΔE 1.3–5.7; that comparison is in-sample.
The nine public raw.pixls.us ILCE-7CR files are one scene in every coding the camera writes (uncompressed, lossless compressed L, M and S, compressed, full frame and APS-C crop). They were not used for fitting. All nine accept their own fit at ΔE 1.3–2.1 and stay accepted with the profile, at ΔE 1.5–2.9 (mean 1.59 → 1.99). On this colourful daylight scene the per-file fit is already close. The subsampled M and S codings, which decode with unity white-balance gains, move most (2.0 → 2.8). This is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity.
## Sony Dynamic Range Optimizer (DRO)
Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree.
The ARW decoder reads the setting from maker-note tag `0xb025` (ExifTool's Sony tag documentation: 0 Off; 1 Standard, 2 Advanced Auto, 3 Auto, 8–12 Advanced Lv1–5, 16–23 Lv1–8; other values are treated as unknown). It was present on all 264 Sony raws checked (98 bodies, DSLR-A100 to ILCE-7CR); the second tag of that name, `0xb04f`, only says off or on and is missing on the older bodies. With DRO on, the look keeps the colour fitted to the JPEG (matrix, hue/saturation table, chroma curve) and its tone curve is lowered, knot by knot, to Sony's curve without DRO wherever that is darker (`assets/camera-tone/sony-dro-off.json`, listed in `assets/ATTRIBUTION.md`). Past its last knot a curve continues as a shoulder whose rate comes from its last two knots, so the second-last knot is lowered only as far as keeps that shoulder at or below the fitted one; the result is never brighter than the fitted curve at any scene luminance. Sony's curve is quantile-matched, as one photo's tone is, over the pooled pairs of 12 CC0 raw.pixls.us ARWs shot with DRO off and the Standard creative style on 10 interchangeable-lens bodies (ILCE-3500, -5000, -6600, -7M3, -7S, -7CR, -9M2, ILCA-77M2, SLT-A57, NEX-F3; the file lists their URLs and SHA-256); pooled the same way from 55 private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance 0.01 and 0.5. DRO only brightens, so the photo's own curve bounds the curve without it from above: the 1″ compacts map raw values darker (with DRO off, the DSC-RX100M2, -RX100M5 and -RX10M4 curves lie 8–16 L* below Sony's at scene luminance 0.18) and keep their own curve where it is the darker one. Photos with DRO off or unknown, and other makers, are unchanged.
Measured before → after (mean L* and CIE76 ΔE at 200 px): on the 24 ILCE-7CR photos with Lightroom references, the 8 with DRO on go from +6.4 L* (+4.4 to +10.5) to +0.8 L* (−0.4 to +1.9) against Lightroom's default, median ΔE 7.5 → 3.3; by Lightroom lightness band (L* 0–25, 25–50, 50–75, 75–100) they now differ from it by +2.3, +1.4, −0.8 and −3.0 L* (before +7.8, +9.4, +3.8, −0.8), like the 16 with DRO off (+2.5, −0.1, −2.0, −3.2), which render byte-identically. Of all 131 ILCE-7CR photos, the 56 fitted with DRO on now render 6.1 L* darker than their camera JPEGs on average (0.3 lighter before), as Lightroom renders the measured ones 5.7 L* darker; the 71 with DRO off are byte-identical. Of 116 CC0 Sony files from 100 bodies, 79 were shot with DRO on (71 of them Auto); the 57 changed of the 69 fitted ones render 2.1 L* darker on average (at most 10.8), the 12 others (six compacts, four ILCE-7M4 shots in Vivid, an ILCE-1 and an ILCE-7RM4, whose own curves are already darker) and all 35 DRO-off files are unchanged. The issue's ILCE-7M3 file goes from +0.8 to −6.5 L* against its camera JPEG. Applied to photos shot with DRO off, whose JPEG shows the camera's own curve, the rule would move the 68 fitted ILCE-7CR photos by −0.4 L* (median; at most −4.1) and 25 of 28 fitted CC0 ones by at most −1.8 L*; the exceptions are an ILCE-3500 (−4.7) and the cine bodies FX3 and FX30 (−7.2 and −5.1), whose picture profiles are not the Standard style.
Picture styles other than Standard are lowered to the Standard curve too where it is darker; Lightroom ignores them as well. Nikon Active D-Lighting, Canon Auto Lighting Optimizer and Fujifilm D-Range settings are likely to have the same effect and are not handled yet. Render cache version 23 redraws thumbnails; smart previews built before keep the brightened tone curve in their header until rebuilt.
## Nikon crop and preview colour metadata
Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing.
@@ -96,3 +118,13 @@ CR2 and PEF decode without a camera colour matrix too, but were left out of the
flat and desaturated: the camera's preview first, then the neutral fallback). They now use the same fit, gates and
relative white balance. This change was not measured on the corpus: the gates keep the fallback for any file whose fit
doesn't pass, so a file can only stay as before or move closer to its camera JPEG. Per-model numbers are still to come.
### Olympus ORF
The ORF decoder (uncompressed files) supplies the as-shot white balance (ImageProcessing `WB_RBLevels`) and no colour
matrix, so ORF takes the same fit, gates and relative white balance. The reference is the camera's preview JPEG (on the
E-1 the 1280 × 960 one the maker note points to). All three decodable corpus files are accepted (held-out RMS against
the neutral fallback): E-1 0.126 → 0.014, E-400 0.087 → 0.022, XZ-2 0.061 → 0.023. Compressed ORFs (E-M1, E-M10 III)
still open from their embedded JPEG. Against Lightroom Classic's render of a CC0 E-1 photo (2000 px, ΔE00 of the
unedited render) the starting look moved from 11.25 to 8.30. Lightroom frames that E-1 file about 4 × 11 sensor pixels
differently from the crop the file records (`CropLeft`/`CropTop` 36, 34); the file's own crop is kept.
+12 -3
View File
@@ -30,18 +30,20 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes) |
| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
| `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) |
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press |
| `ui.text` | `{text}` | Text input |
| `ui.clipboard` | `{action: cut\|copy\|paste, text?}` | Cut, copy or paste in the focused text field, as the system does for ⌘X / ⌘C / ⌘V; `paste` pastes `text`, or the system clipboard without it. Refused when no text field has the focus (outside one, ⌘C / ⌘V are the commands `develop.copy` / `develop.paste`). In the desktop app cut and copy write the system clipboard. `ui.inspect` → `copied` is the text the UI last put on the clipboard |
| `ui.scroll` | `{dx, dy, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms |
| `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` |
| `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state |
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog |
| `ui.resize` | `{width, height}` | Resize the window |
| `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) |
| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically |
| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
@@ -55,6 +57,12 @@ scrolling pans in both axes and respects the operating system's scrolling direct
These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference
views, and only apply over their image areas. Panning stops at the image edges.
Mask brushing uses the UI command `tool.brush` through `engine.execute`. With `{}` it activates
painting on the current mask. `{"new": true}` creates a separate brush mask; `{"op": "add"}` or
`{"op": "subtract"}` appends a brush component to the selected mask. Creating either starts in
paint mode with the overlay visible. Brush Erase (or Alt while painting) removes strokes' coverage
within that brush component; it is distinct from subtracting the component from the whole mask.
### When the library can't be saved
With a persistent library, every command that changes something is written to the catalog journal (fsynced) before
@@ -100,7 +108,8 @@ Headless screenshot dimensions must be finite and at least one logical point, wi
positive scale. Rounded output must be at least one pixel per edge, at most 16,384 pixels
per edge and 64 million pixels in total. `snapshot` and `ui.resize` reject requests outside
these limits before layout or rasterization; supplied resize dimensions must be numbers.
UI zoom changes text and control sizes while preserving the snapshot viewport's requested
An interface zoom (egui's zoom factor; the keyboard never changes it — ⌘= / ⌘− / ⌘0 zoom the
photo, issue #566) changes text and control sizes while preserving the snapshot viewport's requested
physical pixel dimensions. Later `ui.resize` requests use current egui points; validation
and rasterization both convert them to native viewport points before rounding the output
pixels. A native edge below one point after zooming out is valid if it rounds to at least
+7
View File
@@ -88,6 +88,13 @@ from a check tile; this estimate can choose imperfectly on unmeasured hardware.
Settings offers Automatic, Graphics card and Processor. GPU tests cover supported
synthetic networks against the scalar executor; a real-model test compares CPU/GPU.
The convolution kernel stores its shared input tile as scalar elements, each with
one writer, and gathers vectors after a workgroup barrier. Writing separate lanes
of the same shared vector from different invocations is not safe under WGSL
([component references](https://www.w3.org/TR/WGSL/#vector-component-reference));
this caused incorrect Metal results (issue #479). Synthetic equivalence tests include
repeated partial tiles and concurrent calls, and reject non-finite outputs.
### Historical performance, measured 2026-10-07
These are the contributor's measurements **before the current upstream integration**,
+44
View File
@@ -0,0 +1,44 @@
# Display (monitor) profiles
LightCraft can show photos through the monitor's ICC profile, so colours are right on wide-gamut and calibrated
displays. Without one, previews are sRGB, which a wide-gamut panel shows oversaturated.
## Using it
Settings ▸ Display ▸ **Choose Profile…** picks an `.icc` / `.icm` file (the dialog opens where the system keeps
them: `~/.local/share/icc` on Linux, ColorSync's folders on macOS, `…\spool\drivers\color` on Windows). **Use sRGB**
goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) and applies at once; a file that
can't be used is reported there and previews stay sRGB.
Agents and scripts: `app.displayProfile {"path": "/path/to/monitor.icc"}` (`""` or `null`: none; no `path`: report
only) returns `{path, description, kind: "matrix" | "lut", primaries: {red, green, blue: [x, y]}}`.
## How it works
- **The loupe renders into the display's gamut.** Views (Detail, Before, Compare / Reference, preset and profile
hover, the second window) ask the pipeline for a render in the display's own primaries
(`lightcraft_pipeline::DisplaySpace`, `RenderRequest::display`): the per-pixel stage converts from the working space
to the display's linear RGB, gamut maps into *its* gamut and encodes with the sRGB curve, on the CPU and the GPU
alike (only `FinishParams::to_out` / `out_luma` change). Colours an sRGB render would clip stay as saturated as the
display can show. For a display that is exactly a standard space the result equals an 8-bit render into that space.
- **Then to the display's device values** (`lightcraft_codecs::display::DisplayProfile::correct`): for a matrix/TRC
profile, three 256-entry tables from the sRGB curve to the profile's tone curves (nothing at all when they are the
sRGB curve, as for Display P3); for a LUT-based profile, a `moxcms` transform through its B2A tables. A LUT-only
profile (no colorant tags) gets its primaries measured through its A2B tables.
- **Everything made for sRGB is converted** (`lightcraft_engine::display::present`, on the render workers):
thumbnails, cached view previews, embedded camera JPEGs and merge previews.
- **What stays sRGB:** the histogram (of the same sampled pixels, converted back to sRGB), the rendered-thumbnail and
view-preview caches (a profile change doesn't invalidate them), exports, and the UI itself.
- **Soft proofing:** the blue "display" gamut warning shows what the monitor can't show, from its profile.
- Conversions are relative colorimetric: the display's white is the image's white.
## Cost
Measured on a 2-core cloud VM, 2560 × 1707 preview: the tone-curve tables take about 5 ms (single-threaded); converting the sampled
histogram pixels a few ms; the sRGB copy for the view-preview cache (full-quality renders only) about 30 ms; a
LUT-based profile's correction about 65 ms. Drafts during slider drags skip the full sRGB copy.
## Not yet
- Picking up the profile the operating system assigns (X11 `_ICC_PROFILE`, colord, ColorSync, WCS) and following
the window across monitors with different profiles.
+11
View File
@@ -60,6 +60,17 @@ up (`gpu::warm_up` from the first frame's settings), and never while GPU renderi
Settings ▸ Performance ▸ *Use the GPU for rendering* unchecked (applied before the window opens),
`LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off`, no GPU driver is loaded for rendering at all.
**Quitting.** The devices are never dropped, and renders run on worker threads: a render still inside
the driver while the process exits crashes there (a SIGSEGV in the NVIDIA Vulkan driver on Linux,
issue #620), which no panic hook catches. So every entry into a device counts as work in flight
(`crates/gpu/src/exit.rs`), and the desktop app ends with `LightcraftApp::shutdown` (from `on_exit`,
after settings and library are saved): `gpu::begin_shutdown()` — from then on `gpu::render` returns
`None`, no device is created, denoise tiles fail over and a render job gives up instead of rendering
on the CPU — then the render pool stops (queued jobs dropped, running ones waited for) and
`gpu::wait_idle` waits for GPU work on other threads (export, denoise, the device warm-up). One
deadline of 2 s covers it all; past it the app quits anyway. A job pool that is dropped (headless
hosts, tests) waits for its running jobs the same way (`JobPool::shutdown`, `crates/preview/src/pool.rs`).
**Crash sentinel.** The desktop app writes `gpu-init.marker` into its settings folder (next to
`ui.json`: `%APPDATA%\LightCraft`, `~/Library/Application Support/LightCraft`,
`~/.config/lightcraft`) just before the compute device is created and removes it as soon as creation
+8 -1
View File
@@ -6,11 +6,12 @@ Select photos in Photo Grid or Square Grid, then use:
|---|---|
| `0` | Clear the star rating |
| `1`–`5` | Set the star rating |
| `[` / `]` | Decrease / increase each selected photo's rating by one, within 0–5 |
| `6`, `7`, `8`, `9` | Set a red, yellow, green or blue label |
| `P` | Flag as a pick |
| `X` | Flag as rejected |
| `U` | Clear the flag |
| `Shift` + any key above | Apply the action and select the next photo |
| `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo |
Purple and clearing a colour label are available in Photo → Set Color Label and the photo context
menu. Purple has no default number key, matching Lightroom Classic. Rejection marks a photo for
@@ -22,6 +23,12 @@ footer (translucent, over the bottom of the photo) and Detail filmstrip surround
The label confirmation uses a pale matching background with dark text; ratings and errors retain
their neutral HUD styling. Custom names use the underlying label's colour.
Bracket ratings use each photo's own starting rating, and the changed photos form one Undo / Redo
step. AutoWrite uses the normal rating metadata path. In Detail, including Masking and Remove,
`[` / `]` keep resizing the brush and shifted brackets keep adjusting feather. Saved overrides,
remapping and disabling the default brush bindings take precedence over their grid bracket actions.
The separate Decrease Rating / Increase Rating commands can also be assigned in Help → Keyboard Shortcuts.
Grid actions apply to all selected photos and support Undo / Redo. In Compare and Survey, culling
actions apply to the active photo. Auto Advance moves forward after an action; holding Shift with
Auto Advance on still advances only once. The last photo stays selected. Text fields keep their
+8 -1
View File
@@ -38,9 +38,16 @@ WITHOUT ROWID or virtual table produces an explicit error, handled as a warning
Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported
mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI
models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped
fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots
fields are reported. Lightroom's `ProcessVersion` is not mapped: newly imported photos are on
LightCraft's latest rendering process, and photos already in the library keep theirs
([process-versions.md](process-versions.md)). When a recovery archive is saved, it retains source settings/history/snapshots
and original smart-collection rules. Smart collections become regular albums with current
membership. Archived history and snapshots are source data, not native LightCraft history yet.
A Custom white balance on a raw LightCraft develops relative to its as-shot look (ARW, NEF, RW2,
RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvin: the catalog keeps no
as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported
(issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is
shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)).
Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings;
it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone
is not evaluated by the importer; LightCraft's Auto control remains available after migration.
+4
View File
@@ -24,5 +24,9 @@ LightCraft は日本語・簡体字中国語・英語(English)の表示に
英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。
`LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。
スマートアルバムの「はい/いいえ」で答える条件(編集、切り抜きなど)の値は、文脈付きキー
`"smart album value|Yes"`・`"smart album value|No"` で「あり/なし」と表示します。共有の
`"Yes"`・`"No"`(はい/いいえ)は他の画面でそのまま使います([`localization.md`](localization.md) の文脈の説明を参照)。
表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は
`cargo test -p lightcraft-ui-egui i18n::tests` で検証します(グリフの検査は `CRAFT_FONTS_DIR` 指定時のみ実行)。
+14 -3
View File
@@ -2,7 +2,7 @@
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`) and French (`fr`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md).
@@ -29,14 +29,17 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json");
}
```
The fields are the BCP-47 code (also the settings-file value), the **endonym** shown in the
Language menu, the ISO 15924 script (`Latn`, `Jpan`, `Hans`, `Hant`, `Kore`…), and the embedded
catalog. `build.rs` picks up `<code>-formats.json` by name, so the macro and the format lookup
follow automatically — nothing else in the UI needs to change. The menus, the settings row, the
keyboard-shortcut sheet and the control channel all read the table.
follow automatically. Add the corresponding `app.language.<name>` entry to
`LANGUAGE_COMMANDS` and `language_from_command` in `src/menus.rs`, and to the
`language_commands_cover_every_language` test. Settings read the locale table; menus and
the control channel share the language-command mapping.
3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a
language whose script has no face in the build shows boxes (the test says so instead of failing).
Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a
@@ -49,6 +52,14 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are
presentation-only: command ids, file names and user-entered metadata are never translated.
A few messages take a **context**, for a word whose shared translation reads wrongly in one place.
The catalog may then carry `"<context>|<English>"`, which wins there; without it the plain message
is used, so only the languages that need it add one. Today the one context is `smart album value`:
the Yes / No of a yes/no rule (Has Edits, Cropped…). Japanese uses it because はい / いいえ answer a
question rather than describe a photo (`"smart album value|Yes": "あり"`, `"smart album value|No":
"なし"`); every other language uses its plain Yes / No. A contextual key that names no real value
fails the i18n tests.
`tr_format!` is generated by `crates/ui-egui/build.rs`: one arm per English message, which formats
the active language's translation with `format!` (English is the fallback arm). Every catalog's
format string is therefore checked by Rust against the call site's own arguments:
+50 -2
View File
@@ -52,6 +52,53 @@ Or check a project-scoped `.mcp.json` into your repo:
}
```
### Codex
The macOS release includes a separate `lightcraft-cli-<version>-macos-universal.zip`;
unpack it alongside the desktop app if you do not want to build from source. Use the CLI
and app from the same release. Verify both downloads against that release's `SHA256SUMS.txt`.
Register the CLI's stdio server with Codex using its **absolute** installed path:
```sh
# Headless: a persistent library whose edits survive MCP restarts.
codex mcp add lightcraft -- "/absolute/path/lightcraft-cli" mcp --library "/absolute/path/library"
# Live desktop: start the app on the matching loopback port first.
"/absolute/path/LightCraft.app/Contents/MacOS/LightCraft" --library "/absolute/path/library" --control 7980
codex mcp add lightcraft-app -- "/absolute/path/lightcraft-cli" mcp --connect 127.0.0.1:7980
```
Choose one mode for a library: a headless server cannot open a library already locked by
the desktop app. Connect mode edits the window's actual library and reconnects after the
app restarts. An external library path keeps the catalog on that drive. Copy/move imports
default to the library's `Originals/` folder; add-mode imports keep the originals at their
existing paths. The library path does not relocate settings, logs or optional models (see
the README's settings/log locations). Make sure an external drive is mounted before
starting either process.
`codex mcp list` confirms registration, not a successful tool call. After adding the server,
restart the client's MCP connections and verify `doc_inspect` or `query_photos` against the
expected library before editing. Configuration options are in the
[Codex MCP documentation](https://developers.openai.com/codex/mcp/).
### From an installed release
The release packages ship `lightcraft-cli` alongside the desktop app, so no build is needed:
| Install | CLI |
|---|---|
| Windows (MSI) | `C:\Program Files\LightCraft\lightcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
| Linux (deb, rpm) | `/usr/bin/lightcraft-cli` |
| macOS | the separate `lightcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
```sh
# Windows, default install folder
claude mcp add lightcraft -- "C:\Program Files\LightCraft\lightcraft-cli.exe" mcp
# Linux, or macOS with the CLI unzipped onto PATH
claude mcp add lightcraft -- lightcraft-cli mcp
```
### Other clients (Claude Desktop, Cursor, …)
Every stdio MCP client takes the same shape: a `command` plus `args`. For example
@@ -116,7 +163,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) |
| `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were |
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |
| `get_develop {id?}` | Full develop-settings JSON |
| `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) |
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable |
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` |
@@ -132,8 +179,9 @@ Tools taking `id` make that photo active first; without it they act on the activ
| `screenshot {maxSize?, format?, path?}` | The app window as an image, after pending renders finish |
| `inspect_ui` | View, panel, window/image rects, selection, status |
| `set_ui {state}` | Merge UI state, e.g. `{"view": "detail"}` |
| `list_widgets {filter?}` / `click {widget \| x,y, count?}` | Widgets by automation id; real egui clicks |
| `list_widgets {filter?}` / `click {widget \| x,y, count?, button?}` | Widgets by automation id; real egui clicks (`button: "right"` right-clicks) |
| `press_key {key, cmd?, shift?, alt?}` / `type_text {text}` | Keyboard input (shortcuts) |
| `clipboard {action, text?}` | Cut, copy or paste in the focused text field (⌘X / ⌘C / ⌘V; refused when none has the focus; cut and copy write the system clipboard); `inspect_ui` → `copied` is what was copied |
| `pointer_gesture {events}` | Gestures in normalized image coordinates (brush strokes, gradients, crop handles) |
In headless mode these return a tool error explaining how to start the app.
+60 -56
View File
File diff suppressed because one or more lines are too long
+75
View File
@@ -0,0 +1,75 @@
# Process versions
Every photo's develop settings record the **rendering process** they are interpreted by
(`DevelopSettings::process`, a `ProcessVersion` in `crates/develop/src/settings.rs`). Lightroom calls
this the process version. It lets the look of LightCraft improve (a new default tone curve, issue #146;
stronger Light sliders, issue #196) without changing a single photo someone has already edited.
Today there is one process, **V1**: LightCraft's rendering from before process versions existed.
## The rule
**A change that alters how existing settings render ships as a new process. V1 is frozen.**
- A deliberate change of look (tone model, slider response, default curve, how a stage interprets a
value) goes into a new process. Photos stay on the process they have until someone runs Update to
Current Process (or Reset).
- Bug fixes that make output correct where it was broken (a crash, NaN or black output, a decoder
error, CPU and GPU disagreeing) are not looks and can land in place, as before.
- Changes to how a file is decoded or which camera colour it gets change the source, not how settings
are interpreted; the process doesn't reach that stage today.
## Adding a process
1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in
`Process::version` and make it `Process::LATEST`.
2. The compiler then points at every `match` on `Process`. The base tone map is chosen in one place,
`lightcraft_pipeline::finish::base_tone` (both the CPU and the GPU renderer take it from there): give
V2 its own arm and leave V1's alone. A process that changes another stage branches there the same
way, on `s.process.process()`; if that stage is cached (`Plan::lin_key`, the planes in `local.rs`),
add the process to its key.
3. Keep `settings_saved_before_process_versions_load_as_v1` (`crates/develop/src/tests_process.rs`)
passing: settings saved without the field must stay V1, not become the new latest. That is the
field's `#[serde(default = "ProcessVersion::legacy")]`; the struct-level `Default` gives new
settings the latest instead.
4. Render a few procedural scenes on both processes and compare, and update `docs/parity.md`.
## What gets which process
| | Process |
|---|---|
| A newly imported, merged or Lightroom-imported photo | the latest |
| Settings saved without the field (catalogs and their snapshots, versions and history, `lc:settings` in XMP sidecars, browser libraries: everything from before process versions), or with a damaged value | V1 |
| Reset (`develop.reset`) | the latest |
| Reset of a section or a slider | unchanged |
| Update to Current Process (`develop.updateProcess`) | the latest, for photos on an older one; one undo step. The menu item follows the selection; a call with `ids` / `id` acts on those photos whatever is selected |
| Virtual copy, duplicate | the source's |
| Copy / paste / sync / Auto Sync / presets | unchanged (see below) |
| `crs:` edits (XMP from other raw developers, Lightroom catalogs, imported presets) | unchanged; a new photo has the latest |
| Restoring a version or history step | the one it had |
**Copy, paste, sync and presets** never carry the process: it is in no settings group, so the photos
they change keep their own and interpret the pasted values with it. (Lightroom offers the process
version as a separate checkbox when syncing; LightCraft's groups are coarser and keep it out.) Partial
settings JSON that names `process` itself (`develop.merge`, a hand-written preset) sets it.
**Lightroom edits** are re-interpreted by LightCraft's mapper (`crates/engine/src/crs.rs`), which is
written against the current rendering. `crs:ProcessVersion` numbers Adobe's renderer, not ours, and is
ignored; a photo imported with Lightroom edits is on LightCraft's latest process, and an existing
LightCraft photo that receives them keeps its own.
**A number this build doesn't know** (a library or sidecar written by a newer LightCraft) loads, is
kept when saved, and renders with the newest process this build has. Update to Current Process never
moves a photo to an older process; Reset does move it to this build's latest.
**A damaged value** (anything but a whole number from 0 to 2^32 - 1; `2.0` counts as 2) reads as V1,
like a missing field, and costs nothing else: the rest of the settings load from the catalog, a
snapshot, the op log or an `lc:settings` sidecar as usual. The damaged value isn't kept: the photo is
saved as V1.
## Storage
The process is saved as `"process": <number>` in the settings JSON, except V1, which is left out.
Settings made before process versions existed are therefore saved exactly as before, and their
preview-cache keys (`DevelopSettings::hash64`) don't change. `develop.get` (MCP `get_develop`) always
states the process.
+133 -1
View File
@@ -2,11 +2,124 @@
## October 2026
### Smart album rules
- The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then
submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted
Culling.
- New fields: Keyword Count ("2 or more", "exactly 2"…), People and People Count (named faces), Shutter Speed (written
as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long
Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white).
Size rules measure the photo as shown: Megapixels now counts the cropped photo, like Long Edge, Short Edge and
Aspect Ratio (a 24 MP photo cropped to a square is 16 MP).
- Choice values read as words in every language: "Public Domain", "Picked", "Black & White" rather than ids like
`publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`).
- Rule values are checked: a rating of 9, a date like 2026-13 or "banana", a "between" whose dates are the wrong
way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an
album that is gone, or an empty group is reported with its rule ("rule 2.1: no rating 0–5 is 9")
instead of quietly matching nothing or everything. New date rules start at the current year.
- A smart album can include or exclude another smart album: "Keywords contain travel" and "Album isn't Excluded Photos"
leaves out whatever Excluded Photos matches, as its rules change. The album picker shows your albums as in the sidebar
(folders open on click) or finds them as you type part of a name or folder (↑ / ↓ and Enter to pick); albums that
can't be picked, such as one that would make an album include itself, are greyed with the reason. Esc with a dropdown or calendar open in a dialog
closes just that.
- Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the
two dates of a "between" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a
time is typed, and picking a day replaces one). Typing still works, and "2026-10-01 10:00" (a space for the
T) now matches.
- Rule problems are shown in your language and name the field the way the menu does ("Title: needs something to look
for").
- A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip
says what to fix, and `albums.list` reports `problems`.
- Editing a smart album's name and rules is one step: both or neither, and one undo takes both back
(`album.setRules` takes `name`).
- The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An
Album rule is "is" or "isn't" an album, picked with the album picker.
- Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `"false"` now
means no (it used to count as yes), and a value that is neither yes nor no is refused.
- Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.
### Keyword lists
- File ▸ Import Keywords… and Export Keywords… read and write keyword list files as Lightroom Classic does (a keyword
a line, a tab per level, synonyms in braces, keywords left out of export in brackets). Capture One, darktable, Adobe
Bridge and Photo Supreme (its Formatted Vocabulary File) use the same files, so keyword lists move between them and
LightCraft. Importing adds the keywords you don't have, in one undo step; a file it can't read says which line, or
to save it as UTF-8. Exporting warns about keywords Capture One won't import, and names any keyword the format can't
hold (such as a name in brackets), which it leaves out so the file always reads back.
### Keyword sets
- Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it (or saves the edits as a new
set). An empty field leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming
them saves a new set.
### Keywording box
- The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a
keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from
every selected photo; a chip's menu acts on the selection (add to all, remove, show photos, edit) and no longer
deletes a keyword from the whole library.
- Switch to & Containing to see the keywords with the keywords containing them, or to Will Export to see what
exported files will carry for the selection.
- The keyword set's buttons and the suggestions follow the selection too; the typing boxes get the Cut / Copy / Paste
menu, and Return in the painter's box starts painting.
### Keyword List
- The Keywords panel ends with a Keyword List, as in Lightroom Classic: every keyword with its photo count, those no
photo has yet too. Filter it, tick a keyword to give it to the selected photos (a dash: only some have it), or click
the arrow to see its photos.
- Create keywords with + (inside the one you picked, with synonyms, and given to the selection if you like), edit one
by double-clicking it, delete one with − after a confirmation. Right-click for more: create inside, Put New Keywords
Inside This Keyword, Purge Unused Keywords.
- Drag a keyword onto another to nest it, or onto the list's title to bring it back to the top level; photos dragged
from the grid onto a keyword get it.
- Right-click ▸ Put New Keywords Inside This Keyword: new keywords, made with + or typed in the box above, go inside
it. The list is there with no photo selected too, to set up keywords first.
- Keyword tag options decide what exported files carry: Include on Export, Export Containing Keywords, Export
Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`, and keywords
imported from files (or read from sidecars) keep the hierarchy `lr:hierarchicalSubject` gives them.
### Text fields
- Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename
Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for
Cut, Copy, Paste and Select All. Esc in them gives back the text from before you started typing (in the Info panel it
no longer saves it). Return in those dialogs now confirms them, and their name opens selected. More fields will
follow.
- On Windows and Linux, Ctrl+C outside a text field copies edit settings and Ctrl+V pastes them (⇧Ctrl+V: Paste
Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit
Settings.
### Synchronize Folder
- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize
Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose
file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then
choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently
Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing
runs in the background with a progress window and Cancel; everything it does is one undo step, and no file on disk
is touched. Agents use `folder.scanChanges` and `folder.synchronize`.
### Folder colour labels
- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row
shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders`
reports each row's `label`.
- A label follows its folder when it is renamed or moved in LightCraft, together with the labels of the folders
inside it; undo and redo carry them back and forth.
- The catalog format is now version 4 (`Catalog.folder_records`, `Op::SetFolderRecord`). Once this version has
opened a library, older versions refuse it, as with every format change.
### Monitor profiles
- Settings ▸ Display ▸ Choose Profile… shows photos through your monitor's ICC profile (matrix/TRC or LUT-based), so
colours are right on wide-gamut and calibrated displays instead of oversaturated. The Detail view renders straight
into the display's own gamut; thumbnails and other previews are converted to it. Histograms, the preview caches
and exports don't change. Agents use `app.displayProfile`. See [display profiles](display-profiles.md).
### Masking
- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated
ellipses and radial components in compound masks. Edge and rotation handles keep their existing
functions, and one Undo restores the entire drag.
### Crop rotation
- Outside the crop box, where dragging rotates the photo, the pointer is a curved double arrow, and the angle is shown
next to it while you rotate (issue #534). For an exact angle, type it in the new Angle field under Straighten and press Return
(Esc keeps the old angle; a decimal comma works too), or click the Straighten value; hovering any slider's value now says it can be typed.
### Albums tree
- Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords.
- Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…
@@ -37,9 +150,17 @@
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
### RAW decoding
- Olympus ORF raws get the starting look fitted to the camera's own JPEG too, instead of opening flat and grey (the
E-1, E-400 and XZ-2 corpus files: all accepted), with white balance relative to the as-shot look as for the other
formats. On an E-1 photo the starting render moved from ΔE00 11.3 to 8.3 against Lightroom's. Photos already
imported pick it up when re-rendered.
- Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1
and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their
camera JPEG. Photos already imported pick the change up on Reload.
- Canon CR2 raws no longer open too dark with blocked-up shadows: the black level was measured over border columns
that light already reaches (left of the image area Canon records), 4 % of the range too high on the EOS 6D. It is
now measured on the masked columns alone (8 corpus bodies, EOS 40D to 5DS R). On a 6D photo the starting render
moved from ΔE00 7.6 to 5.2 against Lightroom's.
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
- JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);
@@ -83,6 +204,12 @@
- HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind
codecs' `heif` feature — 8- and 10-bit, alpha, grid tiles, the container's rotation/mirror/crop, ICC, EXIF and XMP.
Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`.
- HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own
full-range/matrix signalling is honoured (iPhone photos are full range), chroma is upsampled like libheif, grids
take their tiles' ICC profile, and `imir` mirrors the way libheif writes it. Importing a HEIC reads its size from
the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages
(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports
"HEIC/HEIF support isn't included in this build" for each `.heic`/`.heif` at import.
### Presets and profiles
- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip`
@@ -94,7 +221,8 @@
white, empty Point Color slots…) as settings that couldn't be carried over.
- A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW
and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere.
- A red / green / blue curve in an XMP preset without the master curve is ignored, as Lightroom ignores it.
- XMP presets apply their red / green / blue curves only when they also have the master curve and all three channel
curves, as Lightroom does: a preset with just one channel curve (or no master curve) leaves the channels alone.
- Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay
favourites across restarts (issue #328).
@@ -140,6 +268,10 @@
)
### Reliability
- The Windows installer asks where to install LightCraft (Program Files by default; upgrades keep the folder you
chose) and ends on a page saying it was installed, with a "Launch LightCraft" box. It used to finish without a word,
so a successful install looked like nothing had happened (issues #18, #399). Silent installs (`/qn`, `/passive`)
show no dialogs and take `INSTALLFOLDER=...`.
- If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a
message box that names the log file, instead of quitting without a trace (issue #260).
- On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,
+7 -5
View File
@@ -26,7 +26,7 @@ What we write (standard namespaces, so other tools can read the metadata):
| Capture time, GPS | `exif:DateTimeOriginal`, `photoshop:DateCreated`, `exif:GPSLatitude`/`GPSLongitude` |
| Pick / reject flag | `lc:flag` (`pick`, `reject`, `none`) |
| Location | `lc:location` |
| Develop settings | `lc:settings` — our complete `DevelopSettings` as JSON (exact round trip, incl. masks, spots, crop) |
| Develop settings | `lc:settings` — our complete `DevelopSettings` as JSON (exact round trip, incl. masks, spots, crop and the rendering process; settings without `process` are V1, see [process-versions.md](process-versions.md)) |
`lc:` is `http://ns.lightcraft.app/lc/1.0/`.
@@ -100,15 +100,17 @@ Our pipeline renders differently, so **values carry over but the look is approxi
We read these fields; we never write them. Only fields in the packet are applied: the result is a partial settings
object that gets merged like a preset, so everything else keeps its current or default value. Packets marked
`crs:AlreadyApplied="True"` are skipped, because those pixels already contain the edit. Only process-version 2012+ field
names are read (e.g. `Exposure2012`, not the older `Exposure`).
names are read (e.g. `Exposure2012`, not the older `Exposure`). `crs:ProcessVersion` itself numbers the other
application's renderer and is ignored: the photo keeps LightCraft's own process, the latest for a newly imported photo
([process-versions.md](process-versions.md)).
| `crs:` field(s) | LightCraft control | Notes |
|---|---|---|
| `Exposure2012` | `light.exposure` | EV, 1:1 |
| `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 |
| `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom |
| `Temperature`, `Tint` | `wb.temp`, `wb.tint` | Kelvin / tint for raw files (and presets) |
| `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider) |
| `Temperature`, `Tint` | `wb.temp`, `wb.tint` | Kelvin / tint as written for raws with a measured illuminant (DNG) and for presets. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510) |
| `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider); a rendered file with only `Temperature` shifts from `AsShotTemperature` like the raws above when it is present |
| `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 |
| `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 |
| `HueAdjustment<Band>`, `SaturationAdjustment<Band>`, `LuminanceAdjustment<Band>` | `mixer.<band>.hue/sat/lum` | bands Red, Orange, Yellow, Green, Aqua, Blue, Purple, Magenta |
@@ -117,7 +119,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`).
| `ParametricShadows`, `ParametricDarks`, `ParametricLights`, `ParametricHighlights` | `curve.shadows/darks/lights/highlights` | |
| `ParametricShadowSplit`, `ParametricMidtoneSplit`, `ParametricHighlightSplit` | `curve.split_shadows/split_mid/split_highlights` | |
| `CurveRefineSaturation` | `curve.refine_saturation` | 0..100 (100 = curve saturation unchanged) |
| `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve; the red / green / blue curves are read only with `ToneCurvePV2012` (Lightroom Classic ignores them without it) |
| `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve; the red / green / blue curves are read only when `ToneCurvePV2012` and all three channel curves are present (Lightroom Classic ignores them otherwise: a master + red-only packet renders without the red curve) |
| `SplitToningShadowHue/Saturation`, `SplitToningHighlightHue/Saturation` | `grading.shadows/highlights.hue/sat` | |
| `ColorGradeShadowLum`, `ColorGradeHighlightLum` | `grading.shadows/highlights.lum` | |
| `ColorGradeMidtoneHue/Sat/Lum`, `ColorGradeGlobalHue/Sat/Lum` | `grading.midtones/global.*` | |