Commit Graph
904 Commits
Author SHA1 Message Date
Jason Baldwin 1064e9847d Merge origin/main into enter-applies-copy-paste (parity rows applied to docs/parity-checklist.md) 2026-10-10 21:07:39 +02:00
Steffen SauderandClaude Sonnet 5.5 e363d298d9 LR-IMP-FORMATS: point to the two Olympus patents as a clean-room base for compressed ORF
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 20:51:23 +02:00
Brandon ThomasandClaude Opus 5.5 964d9338ba Progress docs: target-app parity, RAW coverage vs Adobe, gaps, alpha gate, readiness by audience (#788)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, model-by-model RAW coverage against Adobe's camera list (docs/raw/), and format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:25:15 -04:00
radialmonster 0041aef955 LR-EDIT-OPTICS-PROFILE: apply DNG WarpRectilinear2 lens corrections (Pixel DNGs, #635) 2026-10-10 14:21:13 -04:00
lloydandClaude Opus 5.5 d7eb6b2ab4 LR-PREF-DISPLAY: macOS previews follow the screen's profile, so they match exports (#787)
Previews looked more saturated than the exported files on Mac screens. The
pixels are the same: preview and export match to within one level on the CPU
and GPU paths, and exports embed a correct sRGB profile. But wgpu leaves the
window's Metal layer without a colour space for sRGB surfaces, and Core
Animation shows such a layer without colour matching ("If nil, no
colormatching occurs", CAMetalLayer.h). On a P3 screen the sRGB previews
were therefore shown too saturated, while Preview and Photos convert the
exports properly.

While no monitor profile is chosen in Settings > Display, the desktop app now
shows previews through the profile of the screen the window is on
(NSWindow.screen.colorSpace.ICCProfileData, safe objc2 calls), through the
existing display-profile path. It is read again about once a second, so
moving the window to another screen is followed. A chosen profile still wins;
"Use Screen Profile" goes back to it. app.displayProfile reports it as
path: null, screen: true. Other platforms and headless sessions are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:51:01 +02:00
radialmonster 70103a7e55 LR-EDIT-LIGHT-HIGHLIGHTS: keep local contrast under Highlights/Shadows, as process V2 (#632)
The highlights/shadows base (fast guided filter of log luminance, eps 0.35)
followed thin high-contrast detail, so the per-pixel shift flattened twigs,
lines and texture in bright areas (and dark ones under Shadows); Exposure
kept them.

This changes how every existing edit with Highlights or Shadows renders, so
it ships as a new rendering process (docs/process-versions.md):

- Process::V2 (version 2) is added and made LATEST. New photos and Reset get
  it; existing edits stay on V1 until Update to Current Process. Settings
  saved without the field still load as V1.
- From V2 the base is the same guided filter of a separable median of log
  luminance (radius 0.15 % of the long edge, <= 24 px): fine detail takes its
  surroundings' shift and keeps its contrast, region steps stay put (no new
  halos). V1's base is untouched: local::tone_base_detail is 0 under V1 and
  tone_base then is exactly guided_fast(l, sigma, BASE_EPS), as before.
- CPU (local::prepare) and GPU prepare (median_h / median_v kernels, bit for
  bit with the CPU median) both branch on it via PlaneSigmas::base_detail.
- The base plane's cache key is now (sigma, process, median radius)
  (local::BaseKey), so a warm cache never serves one process the other's
  base. plane_at is shared with the GPU crate.
- finish::base_tone: V2 uses V1's tone map.

Tests: V2 keeps twig/local contrast (>= 0.9 of Exposure's) with no halo; V1
plane sigmas have no median and its base equals guided_fast bit for bit; V1
and V2 render alike without Highlights/Shadows; warm-cache renders across
V1/V2 switches equal fresh ones (fails if the key drops the process); GPU
matches CPU on both processes at 720 and 2400 px, cached and not.

Pentax 645D 2401 at 4000 px: main and V1 renders bit-identical (plain,
Highlights -100, Shadows +100). Highlights -100 bright rel. detail
0.095 (V1) -> 0.147 (V2), Exposure -1: 0.124; Shadows +100 dark rel. detail
0.126 -> 0.148.

RENDER_CACHE_VERSION 54 (adjust at merge).
2026-10-10 13:46:14 -04:00
Nathaniel LedfordandClaude Opus 5.5 02a21b860d LR-BEHAV-SIDEBAR-SECTIONS: review fixes for the header button and the pinned header
A keyword made from Keywords' + is shown (the section and its parents open); a
press on a header button never starts a section drag; a section dragged to the
top edge scrolls past a pinned header again (only a drag picked up by the
pinned header waits until it has left it). Tests that could not fail are
replaced, and the parity rows say what the code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:14:07 -04:00
Nathaniel LedfordandClaude Opus 5.5 d331829770 LR-BEHAV-SIDEBAR-SECTIONS: a section declares its header button; Keywords gets + (Create Keyword Tag)
Albums' + is no longer a special case of the header: header_button says which
section has a button and whether it acts or opens a menu, and the frame draws
it in the header and its pinned copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:04:02 -04:00
Jason Baldwin bf49935fe7 LR-BEHAV-DIALOGS: dialogs fit the window; options scroll, title and buttons stay visible (#781)
Every dialog but Import draws its options in a vertical scroll area capped at the
window height minus the title bar, frame and button row (measured on the previous
frame), so on a 1366x768 screen the Export dialog's title and Cancel / OK stay on
screen. Dialogs that fit keep their size. Lists that scroll on their own (What's
New, All Metadata, shortcuts, smart-album rules, credits) get shorter first rather
than scrolling inside a scrolling dialog. Import keeps its resizable window.
2026-10-10 18:57:37 +02:00
Nathaniel LedfordandClaude Opus 5.5 9cd9e29614 LR-BEHAV-SIDEBAR-STICKY: rows beneath the pinned header are not drop targets
An album or photos released on the pinned Albums header went to the row
scrolled under it. Folding from the pinned header keeps the sideways scroll,
its hand-off to the next frame can't go stale, and a section picked up by the
pinned header doesn't start scrolling at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:51:55 -04:00
Jason Baldwin 19833e56e3 MENU-HELP-SYSINFO: System Info, All Metadata and What's New end with one Close button (#782)
Their confirm does nothing, so they join About, Shortcuts and Settings in the
footer's informational list: one Close, no Cancel / OK. System Info keeps Copy
to Clipboard; Esc still closes them.
2026-10-10 18:50:12 +02:00
Nathaniel LedfordandClaude Opus 5.5 5e68d92c9f LR-BEHAV-SIDEBAR-STICKY: a long section's header stays in sight; sections are easier to tell apart
Pinned header at the top of the sidebar, hairlines between sections, the fold
chevron in the rows' triangle column, a count on folded sections, fainter zero
counts. From review: a move nobody could see is no move, ui.set refuses
`sidebar` together with `collapsedSidebar` and unknown names in the latter.
Translations and What's New for the sidebar work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:44:46 -04:00
Nathaniel LedfordandClaude Opus 5.5 5ac3e556ac LR-BEHAV-SIDEBAR-REORDER: drag sidebar sections by their header, Move Up / Down, Recently Deleted joins My Photos
view.sidebarMoveSection; a section with nothing to list stays with a note
instead of disappearing (so every move is one you can see). ui.set reads
`sidebar` strictly and still takes the older collapsedSidebar list; an album
folder's triangle and row can no longer toggle twice in one click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:36:13 -04:00
Nathaniel LedfordandClaude Opus 5.5 a64efb149c LR-BEHAV-SIDEBAR-HIDE: show and hide sidebar sections from View ▸ Sidebar Sections and their headers
view.sidebarSection / view.sidebarReset; the same list opens from a section
header's and the My Photos title's context menu. A new album brings a hidden
Albums section back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:29:28 -04:00
Nathaniel LedfordandClaude Opus 5.5 338b67ffbe LR-BEHAV-SIDEBAR-SECTIONS: sidebar sections share one frame, one tree toggle and one saved arrangement
Albums, Local, By Date, Folders and Keywords are drawn by one section frame from
SidebarLayout (order, hidden, collapsed per section), which is whole whatever a
settings file says. The folded sections of older settings files stay folded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:26:29 -04:00
Jason Baldwin 61df5d304f LR-MASK-BRUSH: holding the brush still adds no coverage; flow builds up by passes (#518)
A stationary hold recorded one pointer sample per frame and each sample laid
another dab at the same spot, so flow accumulated with hold time and the
feathered edge hardened. brush_dabs now lays no dab for a sample that hasn't
moved from the last one, and the canvas no longer records repeated samples at
the same position. Moving strokes and passes back over an area are unchanged.
2026-10-10 17:21:23 +02:00
Jason Baldwin 923b1cde5e LR-MASK-BRUSH: the live brush stroke places dabs like the committed one, no gaps on fast strokes (#517)
While the button was held the Detail view drew one hard tinted circle per
pointer sample, so a fast stroke showed separate dabs; the committed stroke
interpolates dabs a quarter radius apart with feather and flow. The live
stroke now uses the pipeline's dab placement (push_dabs, shared with
brush_dabs), dab falloff and flow accumulation in an incremental screen
buffer (crates/ui-egui/src/brush_live.rs): each frame stamps only the new
samples' dabs and uploads only the rectangle they touched. push_dabs also
caps the dabs of a hostile segment instead of looping without bound.
2026-10-10 17:21:22 +02:00
Jason Baldwin bbcbbebf8e LR-EDIT-COPY-PASTE: Enter applies the Copy and Paste Settings dialogs (#735)
Return presses OK in Choose Edit Settings to Copy and Paste Selected
Settings when no widget has the keyboard focus (a Tab-focused button or
checkbox takes Return itself, as egui does). Esc already cancelled them;
tests now cover both keys.
2026-10-10 16:49:39 +02:00
radialmonster e7f8faf77d LR-IMP-FORMATS: EOS R5 Mark II crop-mode CR3s develop without a black band
The R5 Mark II's APS-C crop-mode files (5376 x 3574) state an IAD1 active area of x 132..5243,
but columns 132..~256 sit at the black level and the image runs to the right edge (measured on
raw.pixls.us 7883/7884), while their recommended crop, x 272..5359 (5088 x 3392, the camera JPEG's
size), lies on the image. The recommended crop didn't fit inside the stated active area, so the
whole active area was kept: a black band down the left and ~130 image columns lost.

When the recommended crop doesn't fit inside the stated active area, it is now the valid area.
On 7883 the render's high-pass correlation with the camera JPEG goes from -0.13 to 0.99. The
active area and crop of the other 108 decodable CC0 CR3s on raw.pixls.us are unchanged.

RENDER_CACHE_VERSION 49.
2026-10-10 10:14:32 -04:00
Jason Baldwin 76e633c9e1 LR-EDIT-COLOR-TEMP: Kelvin white balance for raws whose camera has colour matrices (#730)
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.

Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
  catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
  the same from the decoded file. A corpus test asserts they agree.
  Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
  the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
  (JPEG fit / profile / spectral fallback) is unchanged, and the slider
  adapts from that white with Bradford. The camera-space rebalance stays
  reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
  crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
  #510 as-shot shift.

Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
  white balance without a scale keeps its old meaning. On a spectral-Kelvin
  raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
  adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
  cone space (local::legacy_white). Von Kries scalings compose exactly, so
  the matrix matches to f32 and the pixels are identical
  (pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
  media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
  reset) first runs local::normalize_wb: a legacy value becomes the Custom
  Kelvin value with the same look and is marked Kelvin. Partials without a
  scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
  by older imports when the file is next decoded or reloaded (system op,
  not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.

Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
2026-10-10 16:09:31 +02:00
Jason Baldwin b8f868830b LR-CROP-STRAIGHTEN: every way of setting the crop angle keeps the crop inside the photo (#742)
develop.set / develop.adjust / develop.quickAdjust / develop.resetControl on crop.angle
(MCP set_develop, the control channel, lightcraft-cli render --set) and crop.set {angle}
now turn the crop as crop.straighten does, through the new CropGeometry::with_angle:
kept if it still fits, else the largest crop of its aspect inside the rotated photo.
Before, they left the full rect, and the export took in smeared edge pixels in the corners.
2026-10-10 16:01:25 +02:00
Luniiya c8bca35210 Merge remote-tracking branch 'upstream/main' into feat/system-appearance
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/whats-new.md
2026-10-10 16:00:16 +02:00
radialmonster d803d905b4 LR-BEHAV-LOCALIZE: craft-fonts faces of other scripts are UI fallbacks (#736)
Thai folder and file names drew boxes: the UI fell back only on craft-fonts' CJK faces, so a
Thai face (craft-fonts #15) or the Arabic one already there was never used. fonts::ui_fallback
keeps the CJK faces first, in their per-language order, then appends every face of another
non-Latin script, whatever the interface language (a Latin-only face is no fallback: Inter covers
Latin). theme.rs builds the Proportional, SemiBold and Monospace families from it.

Tests: the fallback order on synthetic faces; with a craft-fonts build that has a Thai face,
every UI family falls back on it, and the shipped Thai and Arabic faces have their script's glyphs.
2026-10-10 09:57:14 -04:00
UnemarsandClaude Opus 5.5 5bcedbe6fc NEF: measured image area and white level for the Nikon D4
The D4 records no CropArea. Its 4992 x 3292 data keeps 2 columns of optical
black left of the image and 42 right of it (then 8 empty ones), which showed
as purple stripes on both sides; the camera's own 4928 x 3280 JPEG sits at
(8, 6). Green saturates at 15546-15672 while red and blue run on to 16383,
so the data's own top left clipped highlights magenta; one level of 15520
keeps them white without moving the white balance.

Measured on 148 uncompressed 14-bit NEFs from one body by aligning the
sensor data with the embedded camera JPEG.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 15:38:18 +02:00
Jason Baldwin 60844cf162 LR-EDIT-CROP: no artefact inside the crop overlay when the crop is rotated (#731)
The dimming outside the crop box was four quads, each joining a side of the
photo to the matching side of the box. Aligned, that is the photo minus the
box; rotated, the quads cross themselves and each other, so wedges inside the
box were dimmed and wedges outside doubly dark, growing with the angle (the
reporter's -45° shows it best). The export was never affected: the overlay
alone was wrong.

The dimming is now `outside_crop_mesh`: the photo's rectangle minus the box,
as one mesh of four disjoint half-strips (outside edge i, inside edge i+1,
clipped to the photo). A flat or non-finite box dims nothing.

Tests: a coverage test over several box sizes and angles (every photo point
outside the box in exactly one triangle, none inside the box or outside the
photo), a degenerate-box test, and a headless pixel test at -45° (inside the
box the frame matches the 0° frame; the photo's corner is dimmed), which
failed on the old mesh.
2026-10-10 15:12:03 +02:00
radialmonster 943448e7c9 LR-EDIT-OPTICS-PROFILE: apply embedded Fujifilm RAF distortion and vignetting tables
Fujifilm RAFs carry per-shot GeometricDistortionParams (0xf00b) and VignettingParams (0xf010)
in the raw IFD. Derived by measurement against the cameras' own JPEGs and LightCraft's
uncorrected renders: [scale, n knots, n values], scale x n = half diagonal of the recorded image,
last knot = farthest corner, distortion in percent indexed by the source radius with the
frame-filling zoom, vignetting as 100/V gain. They become a WarpRectilinear and a
FixVignetteRadial for 59 validated bodies (same path as the Sony ARW and Panasonic RW2
corrections); unmatched bodies and tables with another scale stay uncorrected.

On 67 body/lens combinations the corrected render's radial shape error against the camera JPEG
falls from a median of 0.72 % to 0.05 % of the half diagonal. The lateral CA table (0xf00f) is
real but is not applied: our demosaic already registers the colour planes and the stored shift
over-corrects. See docs/fuji-lens-corrections.md.

RENDER_CACHE_VERSION 48.
2026-10-10 08:38:41 -04:00
Jason Baldwin 90875cf8f2 LR-IMP-FORMATS: probe compressed NEFs from their headers, not the Huffman stream (#708)
`probe_info` (every file an import adds) ran the whole Nikon Huffman
decode on a compressed NEF because `nef::decode` had no header mode and
the masked-trailing-column test wanted the samples: 386 ms per 24 MP
file, the full decode time, where every other format probes in < 1 ms.

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

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

Tests: a synthetic lossless NEF whose stream is cut off after two
thirds of the rows probes to the same info as the whole file while the
full decode reports the truncation; synthetic uncompressed NEFs with
masked columns in the top rows, lower rows, all or none give the same
active area in both modes. Both failed before the fix. Docs: the
`probe_info` doc comment, docs/parity.md (LR-IMP-FORMATS),
docs/gpu-pipeline.md.
2026-10-10 13:58:20 +02:00
Jason Baldwin 3285e0282d LR-LIB-DELETE: importing a deleted photo's file again brings it back (#706)
Import a photo, delete it, import the same file again: the import skipped
it as a duplicate "already in the library" while the grid showed it in
Recently Deleted, and the user was stuck (issues #298, #706). The default
for a file whose photo is in Recently Deleted is now to restore the photo,
with its edits, rating and albums (`onDeleted: restore`; `skip` keeps the
old behaviour, `fresh` imports a clean copy). A photo matched by content
whose file is gone is relinked to the imported file. The import review
checks such files, badges them "Will be restored", counts them as "in
Recently Deleted" and starts its choice on Restore them; dropped files
say "Restored 1 photo from Recently Deleted".

Browsing a folder in Local, Auto Import and a Lightroom catalog import
keep skipping: only an import the user asked for brings a photo back.
2026-10-10 13:51:10 +02:00
Jason Baldwin 0de789bac9 LR-LIB-LOCK: parity row notes the explicit release (#680) 2026-10-10 13:48:13 +02:00
Jason Baldwin 58f7e74f2a LR-IMP-FORMATS: file extensions match whatever their case (#705)
Issue #705 (Linux, Nikon `.JPG` hidden by the Add Photos dialog) is the
symptom of #342, fixed by e2d35de8 after the 0.4.0 release and shipped in
0.5.0: 0.4.0 handed GTK / the desktop portal `*.jpg`-style globs, which
they match case-sensitively. Every dialog filter now goes through
`add_filter_nocase` (`*.jpg`, `*.JPG`, `*.[jJ][pP][gG]`).

An audit of every other extension match (folder scan, Browse Folder,
drag-and-drop, the CLI, `codecs` sniffing, the XMP sidecar lookup,
raw+JPEG stems, export format by extension) found them all folding case
already. This adds the regression test that proves it: a folder with
`a.JPG`, `b.Jpg`, `c.JPEG`, `d.PNG`, `e.NEF`, an `a.XMP` sidecar and a
`b.Jpg.xmp` sidecar imports all five (both sidecars read) and
`library.browse` shows all five. Docs note what was case-sensitive and
when it was fixed.
2026-10-10 13:33:32 +02:00
Fabio LussiandClaude Opus 5.5 584a26085c Hold Shift while dragging a crop handle to keep proportions
crop.drag gains an optional keepRatio param that constrains the drag to
the frame's current aspect even when the lock is off. The crop panel
sends it while Shift is held.

Closes #614

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:28:58 +02:00
jazzautomationsandClaude Fable 5.1 9c2bad1146 Linux/FreeBSD: file dialogs are children of the app window
Every rfd dialog is built from one template that has the main window as
its parent (set once in `services` from the `CreationContext`). Without
a parent the xdg-desktop-portal picker on Wayland is a stray top-level
("Failed to associate portal window with parent window"): tiling
compositors put it beside the app, and a fullscreen window hides it
entirely, so Import Photos… looks like a hang. rfd exports the Wayland
surface through xdg_foreign; `FileDialog` is `Clone` and `Send`, so the
template also serves the picker on its own thread (#191). Not set on
macOS (sheet) or Windows (modal), which this does not change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 08:09:28 -03:00
Jason Baldwin 47e9181f69 LR-PREF-PERFORMANCE: GPU fallback reason per render; the export fallback test no longer reads process-wide state (#675)
`export_falls_back_to_the_cpu_when_gpu_work_is_lost` asserted on
`lightcraft_gpu::last_fallback()`, one value for the whole process that
every other engine test's GPU render overwrites between the export and
the assertion (out of device memory under parallel load, once "a GPU
render panicked"). It also switched the GPU off for the whole process
through the injected fault and cleared everyone's state with
`reset_failures()`.

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

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

The engine test asserts on its own export's reason and checks that
another thread still has the GPU; `crates/gpu/tests/fallback.rs` uses
`try_render` and checks the same; a new batch test checks `gpuFallback`
lands on the file whose render fell back. Docs: gpu-pipeline.md,
control-protocol.md (app.export result), parity.md.
2026-10-10 12:45:42 +02:00
Jason Baldwin 7883333420 LR-IMP-CAMERA-COVERAGE: Sony ILCE-7CM2 ARW codings in the corpus (#697)
Issue #697 (ILCE-7CM2 lossless compressed M-size ARW "washed out" in 0.4
and 0.5) is the 512-vs-1024 YCbCr pedestal of #535 item 3, fixed on main
by 3100cc1a after v0.5.0. Measured on the reporter's file: 0.5.0 renders
the darkest half of the dusk scene +11 L* and blue against the camera
JPEG, with the lamplight turned white (mean dE 11.8); main is within
+3 L* (dE 8.9). Its darkest 0.1 % per channel sits at 1017/1020/1024,
confirming the 1024 pedestal on a fifth body.

- Corpus: two CC0 raw.pixls.us ILCE-7CM2 files, lossless L and the
  compressed "M-size" coding, which is Bayer at 4736x3132 with black 512
  (only the lossless M/S codings are YCbCr), so a size-based pedestal
  rule would break it.
- Tests: corpus_sony_a7cm2_codings (model, layout, levels, crop, as-shot
  gains, header/full agreement) and both files in the #535 data-floor
  test.
- docs/parity.md: the ILCE-7CM2 verification on the Sony coverage row.
2026-10-10 12:42:51 +02:00
Jason Baldwin 8166185a0f LR-LIB-LOCK: release the library lock explicitly on drop (#680)
LibraryLock freed the library by letting its File close. On Unix the
flock lock belongs to the open file description and lasts while any
descriptor on it is open. When another thread spawns a process,
posix_spawn copies the whole descriptor table into the child and
O_CLOEXEC only closes the copies at execve, so a LibraryLock dropped in
that window stayed locked until the child exec'd. Re-acquiring the
library then failed with LockError::InUse(None) ("already open in
another LightCraft program") although nobody had it open. That is the
flake in tests_lock::second_opener_is_refused_until_the_first_lets_go,
whose neighbour lock_held_by_another_process_is_released_when_it_dies
spawns the test binary on a parallel libtest thread.

Drop now removes the owner note while still holding the lock (so it
can't delete the next holder's), then calls File::unlock(): LOCK_UN
through our descriptor frees the lock whatever copies a child still
holds. An unlock error is logged, never a panic; the handle still closes
right after, which releases the lock once every copy is gone, as before.

Tests: the_library_is_free_once_dropped_even_if_a_child_holds_a_copy
holds a try_clone of the lock's descriptor across the drop (the same
open file description a child gets between fork and exec) and
re-acquires; dropping_the_lock_frees_the_library_while_another_thread_
spawns_processes runs a thread spawning /bin/true against two seconds of
acquire/drop cycles. Both fail every run without the fix (the first
deterministically) and pass with it; the flaky pair went from 3/100 to
0/100 failed runs here. Both are cfg(unix): Windows handles aren't
inherited by CreateProcess.
2026-10-10 12:36:16 +02:00
aayush113002andClaude Fable 5.1 ec1e26545e M8: spot.findBlemishes — heal small blemishes on detected faces
A sibling of spot.findDust for portraits: with the YuNet detector installed, small
round spots on each face's skin that are darker or redder than the surrounding skin
become heal spots (one undo step). The detector's box and landmarks define the skin
(ellipse, skin-colour gate, eye band / brows / nostrils / mouth cut out); a candidate
must stand out from a blurred copy beyond the sensitivity threshold and 2.5x the local
texture, then grow into a small roundish blob. Conservative: faces under 160 px get
nothing, faces with more than 12 candidates (stubble, freckles, pores) get nothing, at
most 6 per face (maxPerFace). Classical, no model beyond the detector.

Tests (synthetic, no model): cheek spots found at their positions, clean face empty,
spots on features ignored, small face ignored, speckled face treated as texture,
hostile boxes and an empty image do not panic. New LR-REM-BLEMISH parity row, Find
Blemishes section in docs/faces.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:48:31 +05:30
aayush113002andClaude Fable 5.1 997bd7a4ed M8: read Lightroom spot removal (crs:RetouchAreas, RetouchInfo) from sidecars and presets
Heal and clone spots stored as crs:RetouchAreas (Mask/Ellipse, Mask/CircularGradient
or a brushed Mask/Paint target; SourceX/OffsetY as the source's absolute position,
honoured only when the source was set explicitly; Opacity and Feather 0..1) and the
legacy crs:RetouchInfo list become our spots when a sidecar, embedded XMP, XMP preset
or .lrtemplate is read. Width fractions are converted to long-edge fractions with the
target's aspect, like radial masks. Unsupported spot masks are reported as
`Spot: <kind>`. Format established by observing what a sidecar written for a healed
photo contains; clean-room.

Tests: heal with explicit source, clone with automatic source, brushed removal,
circular-gradient spot, unsupported mask reported, portrait-target scaling, legacy list
on its own, and a render test. docs/xmp-interop.md gains a Spot removal section;
LR-META-XMP row updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:47:59 +05:30
radialmonster ea7777107d LR-IMP-FORMATS: decode Kodak DCS520C raw TIFF (two-component lossless JPEG with response curve) 2026-10-10 06:06:18 -04:00
Jason Baldwin c739f12e45 Merge origin/main into #690 (both sides added locale catalog entries: kept both) 2026-10-10 11:53:09 +02:00
Jason Baldwin 8a60e3cd30 LR-PREF-PEOPLE: Recognise faces stays off until a recognition model is chosen (#678)
Settings ▸ Faces let the switch be turned on with no recognition model
chosen or installed: the box ticked, Settings closed without a word, and
recognition was on with nothing to run. Now the box is enabled only with a
chosen, installed recogniser (and a build that can run models), says
"Choose a model below (Use) to start." on hover, and that hint stays under
it on or off; a box left on by an older build can still be switched off.
faces.enable {enabled: true} refuses without such a model, so the control
channel and MCP cannot reach that state either, and removing the last
recogniser switches recognition off instead of leaving it on with nothing
chosen. Installing a model still chooses it and switches recognition on.

Tests: tests_face_models::recognition_cannot_be_turned_on_without_a_chosen_model
and tests_panels::recognise_faces_stays_off_until_a_model_is_chosen (fails
on main at the first click). Docs: faces.md, parity LR-PREF-PEOPLE.
2026-10-10 11:51:56 +02:00
Jason Baldwin 94fed226df MENU-BAR: a scrolled menu draws no scrollbar over its rows (#691)
A menu taller than the room under the menu bar scrolls inside an egui
ScrollArea whose floating scrollbar is drawn over the rows' right edge,
where the shortcut text ends. At 1.0x the 2-point handle grazes the last
glyph; at 1.75x the bar is in its wide hovered state with a semi-opaque
background and hides the last character of every shortcut, which reads as
clipping. The scroll content, its clip and the rows are the level's width
in both modes (instrumented), so nothing else needs to move: the scroll
area hides its bar. The up/down strips, the wheel and dragging were already
the designed ways to scroll a menu, like native menus; the native macOS
menu bar does not go through this code.

Test: tests_menubar::a_scrolled_menu_draws_no_scrollbar_over_its_shortcuts
(a 700 pt and a 1400 pt window: no tall narrow rectangle along the level's
right edge, equal widths), via a new HeadlessView::painted_shapes; fails on
main with the bar's track [512, 59]-[514, 660] and handle.
2026-10-10 11:39:28 +02:00
Luniiya 28a773d460 Merge remote-tracking branch 'upstream/main' into feat/system-appearance
# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/state.rs
#	crates/ui-egui/src/theme.rs
#	docs/control-protocol.md
#	docs/parity.md
#	docs/whats-new.md
2026-10-10 11:31:38 +02:00
Elliot GrahamandCodex 9682caae2a LR-EDIT-OPTICS-PROFILE: guard Sony aspect crops and validate the full corpus
Keep image framing inside the validated 3:2 scope even when DefaultCrop retains native geometry. Cover all 23 Sony fixtures, preserve crop precedence, invalidate old renders, and record fresh Sony reference measurements.

Co-authored-by: Codex <noreply@openai.com>
2026-10-10 10:30:44 +01:00
radialmonster ba65d0c8d1 LR-PREF-PERFORMANCE: cover every GPU kernel build with the crash marker, catch internal compile errors (issue #250) 2026-10-10 05:23:48 -04:00
Jason Baldwin bec920f67d LR-LIB-ALBUM: albums hold library photos; Local photos are skipped, a Local-only selection is refused (#684)
Album views list library photos only, but album.create {addSelected},
album.addPhotos and album.toggleTarget (B) took the selection as is, so a
browsed Local photo became an album member that never shows: count 1, an
empty album, a cover that can't render.

Engine: the three commands drop Local photos from their targets and report
`skipped`; a selection of nothing but Local photos is an error that says to
Add to My Photos first; album.addPhotos and album.toggleTarget use a new
has_library_selection enabler. album.create still makes the album, minus
the Local photos.

UI: for a Local-only selection the grid's right-click Add to Album is off
with a hover hint, Photo ▸ Add to Album starts with a disabled "Add to My
Photos First" item and its entries and New Album… are off, a folder row's
Add Selected Photos is off, and a drag over an album row shows not-allowed
and toasts the hint on release. A mixed selection runs and toasts how many
Local photos were skipped (drop, context menu, folder row, New Album
dialog). Context-menu album entries are button:contextAlbum:<id>.

Messages added to every locale catalog. Tests: tests_albums_local.rs
(engine) and tests_panels::local_photos_cannot_be_added_to_albums_until_
they_join_my_photos (UI, fails on main). Docs: parity rows LR-LIB-ALBUM,
LR-IMP-LOCAL, MENU-PHOTO-ADDTOALBUM.
2026-10-10 10:54:07 +02:00
Jason Baldwin 4721627d31 LR-LIB-ALBUM: right-click ▸ Add to Album offers New Album… (#676)
The grid context menu's Add to Album submenu listed only the existing plain
albums, so in a library without one it came up empty. The Photo menu's
submenu already ends with New Album…, and album.create adds the selection
by default; the context menu now has the same entry (after a separator
when albums exist). The submenu and the entry are registered as
button:contextAddToAlbum / button:contextNewAlbum for the control channel.

Test: right_click_add_to_album_offers_a_new_album_when_there_is_none
deletes every plain album, right-clicks a thumbnail, hovers Add to Album,
clicks New Album…, names and confirms it; the album exists and holds the
photo. Fails on main with "no widget button:contextAddToAlbum".
2026-10-10 10:29:08 +02:00
Luniiya 216efab838 UI: Add system appearance and light themes 2026-10-10 10:18:31 +02:00
Brandon ThomasandClaude Opus 5.5 0b1d80f08d LR-MASK-OVERLAY: test the mask overlay as a zoomed window
There was no zoom/pan reset on main to fix: what this PR changes is that a
zoomed view with the mask overlay shown is now a canvas-sized frame plus a
sharp window carrying the overlay, as without it, instead of a whole-frame
render at the zoomed size. Tests: the overlay on a window matches the same
pixels of the whole render for radial, refined linear and sky masks in
three views (pipeline tests_window.rs), and at 1:1 the loupe uses a window
with the overlay, keeps zoom and pan, and swaps tiles when the overlay is
hidden or shown (ui-egui tests_preview_limit.rs). Parity row updated. No
render output change (no cache bump).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:41 -04:00
Brandon Thomas 2bd974d583 Merge PR #590
# Conflicts:
#	crates/engine/src/cmd/previews.rs
#	docs/camera-preview-colour.md
2026-10-10 04:01:27 -04:00
Daniel Spruce d2ec802902 LR-CROP-ASPECT: resolve conflicts with upstream main 2026-10-10 18:53:11 +11:00