206 KiB
Lightroom parity checklist
Last reviewed: 2026-10-10 · Last updated: 2026-10-11 · Change: minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from
parity.md; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed fromparity.md; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · Target: Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers does the feature exist? (feature breadth). Whether it is good enough to replace Lightroom is assessed in target-app-parity.md, and the work list is gaps.md. Whoever lands a feature updates its row in the same commit.
The rows are maintained by hand; the summary block is generated (cargo xtask parity --write), and
cargo xtask parity (part of cargo xtask ci) checks that every command id, control id and path
cited here still exists. Formerly docs/parity.md.
- Ids come from the (local, gitignored) reference in
plan/lightroom/:LR-…= feature catalog (03),MENU-…= menu items (04),KEY-…/KEYC-…= keyboard shortcuts (06, desktop / Classic),LRC-…= Classic extras (08). Ids are stable; never renumber. - Tier as in the catalog: P0 core, P1 important parity, P2 later / AI / niche, OOS out of scope.
- Status: ✅ done (usable end to end; polish may be noted) · 🟡 partial (note says what is missing, or the status is unverified) · ⬜ missing · 🚫 out of scope / not applicable.
- Evidence:
cmd:<id>= command id (enginecommand_specs()or UIUI_COMMANDS),ctl:<id>= develop control id (lightcraft-cli controls), plus source files. A trailing*matches a prefix (ctl:mixer.*). - Feature names and notes are our own words. No Adobe text, screenshots or assets belong here.
Summary
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---|---|---|---|---|---|
| A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) |
| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) |
| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) |
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
| F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) |
| G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) |
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
| I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
| J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) |
| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) |
| L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) |
| M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) |
| N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) |
| O. Merge (MERGE) | 4 | 0 | 0 | 0 | — | — |
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
| Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — |
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
| S. Export (EXP) | 15 | 4 | 0 | 0 | 7/7 (100%) | 7/7 (100%) |
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |
| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) |
| Total | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): 81.6% of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173.
Top gaps and shortcut notes
Moved on 2026-10-10: the ranked gaps are in gaps.md; shortcut conflicts, deliberate differences and missing bindings are in ui-parity.md.
A. Import (IMP)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | cmd:file.addPhotos, cmd:library.importPreview, cmd:library.import (mode, album, albumName, preset, keywords), crates/ui-egui/src/import.rs |
review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (lightcraft_engine::import::EXTENSIONS) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (apps/lightcraft/src/dialog_filter.rs, issue #342; 0.4.0 hid .JPG / .NEF, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (crates/engine/src/tests_import.rs) |
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | crates/ui-egui/src/lib.rs (dropped files → cmd:library.import) |
dropping onto a specific album not supported |
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | crates/engine/src/import.rs, crates/engine/src/tests_import.rs |
|
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | cmd:library.devices, cmd:file.addFromDevice, crates/engine/src/devices.rs |
mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, crates/engine/src/import_move.rs); no PTP/MTP (cameras that don't mount as a disk) |
| LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | cmd:library.autoImport, cmd:library.autoImportScan, crates/ui-egui/src/panels/settings.rs |
Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s |
| LR-IMP-PRESET | Preset on import | P2 | ✅ | cmd:library.import (preset) |
chosen in the import review; one History entry |
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | cmd:library.preferences, crates/engine/src/import.rs, crates/ui-egui/src/panels/settings.rs |
LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | ||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | cmd:file.importPresets, cmd:preset.import, cmd:profile.import, crates/engine/src/preset_import.rs, crates/engine/src/preset_luminar.rs |
presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an .xmp dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. Cluster, SortName, SupportsAmount2, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | cmd:library.browse, cmd:photo.addToLibrary, cmd:library.import (mode add), cmd:local.addRoot, cmd:local.hide, cmd:local.restoreHidden, crates/engine/src/cmd/browse.rs, crates/ui-egui/src/panels/left.rs (local_section), crates/ui-egui/src/panels/grid.rs (folder_header) |
Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (/ or \, trailing separator, ./.., drive-letter case; folder_key in crates/catalog/src/query.rs); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or cmd:library.forgetLocal with dryRun; files and sidecars stay, browsing again brings them back; see crates/catalog/src/local.rs for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | cmd:library.xmpPreferences |
sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | crates/codecs/src/lib.rs, crates/raw/src/lib.rs, crates/raw/src/jxl.rs |
JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in crates/codecs/src/jxl.rs); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (RowInterleaveFactor / ColumnInterleaveFactor) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated WhiteLevel, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, crates/raw/src/saturation.rs), CR2 (colour-filter layout from the file's CR2CFAPattern tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted SR2SubIFD, issue #148, its WB_RGGBLevels being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (crates/raw/src/vendor/arw6.rs, crates/raw/src/llvc.rs; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, docs/arw6-compression.md; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, crates/raw/src/vendor/nefc.rs), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; crates/raw/src/vendor/rafc.rs, full sensor verification on 13 bodies, docs/raf-compression.md), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, crates/raw/src/vendor/rw2.rs), PEF, ORF (uncompressed; colour-filter layout from the file's Exif CFAPattern; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's GrayResponseCurve table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the KodakIFD; RawFormat::KodakDcs, crates/raw/src/vendor/kodak.rs, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; RawFormat::CfaTiff, read by dng::decode_as, RGGB when the file states no CFAPattern): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), ljpeg::decode_hasselblad, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, crates/raw/src/vendor/srw.rs; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note 0x003d is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version 0x44 0x40 (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (probe_info) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III ColorData version 66: as-shot white balance and black level from the file, white level from the data, docs/cr3.md) (the shot's aspect ratio from the maker note's AspectInfo); full sensor samples match the reference on M50/R100/R8 (crates/raw/tests/cr3_corpus.rs, docs/cr3.md). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a 0x0096 table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no 0x0096 table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with preview_only = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, previewOnly in catalog.query; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as preview_only with the reason, instead of opening as a thumbnail-sized ordinary image (thumbnail_shell in crates/raw/src/lib.rs), Recognised raws LightCraft cannot decode open as preview_only with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without ImageWidth), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default heif feature, which the release packages enable: crates/heif, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the tiff crate); transparency is flattened onto white, in linear light, where the working image is built (to_working in crates/codecs/src/lib.rs): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note 0x003d is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version 0x44 0x40 (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (probe_info) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's AspectInfo); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag 0x7010) goes to the ARW reader, and the body for its Tag2010 layout comes from the maker note's SonyModelID, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block 0x8606 instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | crates/raw/tests/corpus.rs, xtask/src/main.rs |
a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of SensorInfo's border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted SR2SubIFD's WB_RGGBLevels, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose ColorData is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (docs/raf-compression.md). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose ColorData is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (docs/raf-compression.md). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no ColorData, takes its as-shot white balance from the WBInfo row (0x0029) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (0x1012 in 0x102c ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). |
| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | crates/raw/src/preview.rs (bounded_preview, embedded_preview_from_range), crates/engine/src/files.rs (preview_window), docs/raw-preview-reads.md, crates/engine/src/files.rs (preview_window, planned_preview), crates/raw/src/sparse.rs (preview_plan) |
a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; LIGHTCRAFT_PROFILE=1 reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through lightcraft_raw::sparse::preview_plan, which names the ranges embedded_preview reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's grid thumbnail is that camera JPEG itself, so no sensor decode is scheduled behind it (Session::embedded_thumb_is_final, grid::skips_the_real_render): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | cmd:photo.analyze |
run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | cmd:library.import (mode move), crates/engine/src/import_move.rs, crates/engine/src/tests_import_move.rs, crates/ui-egui/src/import.rs |
Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (kept). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | cmd:library.import (dng), crates/ui-egui/src/import.rs |
copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) |
B. Library management (LIB)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | cmd:library.source, crates/ui-egui/src/panels/left.rs |
|
| LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | cmd:library.source (recentlyAdded), crates/engine/src/view.rs (RECENT_DAYS) |
photos imported in the 30 days up to the latest import, newest import first, grouped by import day |
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | cmd:library.filter (date), crates/ui-egui/src/panels/left.rs (date_row), crates/catalog/src/query.rs (date_groups) |
year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (crates/ui-egui/src/panels/chips.rs, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters |
| LR-LIB-ALBUM | Albums | P0 | ✅ | cmd:album.create, cmd:album.rename, cmd:album.delete, cmd:album.addPhotos, cmd:album.removePhotos, cmd:dialog.newAlbum, crates/engine/src/tests_albums_local.rs, crates/ui-egui/src/tests_panels.rs |
photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): album.create {addSelected}, album.addPhotos and album.toggleTarget (B) skip browsed Local photos and return skipped (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (button:contextAddToAlbum, button:contextAlbum:<id>); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; button:contextNewAlbum); New Album's "Include selected photos" (check:albumIncludeSelected, on by default; dialog.newAlbum {includeSelected}) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) |
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | cmd:album.create (folder, parent), cmd:album.move, cmd:album.reorder, cmd:album.sort, cmd:dialog.newAlbum / cmd:dialog.newFolder / cmd:dialog.smartAlbum / cmd:dialog.newSmartAlbum (parent), crates/ui-egui/src/panels/left.rs (albums_tree, folder_menu, disclosure_triangle), crates/ui-egui/src/tests_panels.rs |
Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (albumToggle:<id>; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the dialog.new* commands take parent), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or album.move — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (cmd:album.reorder, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = cmd:album.sort) |
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | cmd:album.createSmart, cmd:album.setRules, crates/catalog/src/query.rs, crates/ui-egui/src/panels/filterbar.rs, crates/ui-egui/src/lib.rs (album_counts) |
saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists |
| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | needs a sharing service | |
| LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | not applicable: local-first library | |
| LR-LIB-TARGET | Target album | P2 | ✅ | cmd:album.setTarget, cmd:album.toggleTarget |
see LRC-LIB-COLLECTIONS |
| LR-LIB-RATING | Star ratings | P0 | ✅ | cmd:photo.rate (advance), cmd:photo.decreaseRating, cmd:photo.increaseRating, crates/ui-egui/src/shortcuts.rs |
0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) |
| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | cmd:photo.pick, cmd:photo.reject, cmd:photo.unflag, cmd:photo.flag |
P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file |
| LR-LIB-LABEL | Colour labels | P1 | ✅ | cmd:photo.label, cmd:label.setNames, cmd:label.names, cmd:dialog.labelNames, keys 6–9 in crates/ui-egui/src/shortcuts.rs |
Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels |
| LR-LIB-KEYWORD | Keywords | P0 | ✅ | cmd:panel.keywords, cmd:photo.setMeta (addKeywords/removeKeywords), cmd:keyword.list, cmd:keyword.rename, cmd:keyword.delete, cmd:keyword.merge, cmd:keyword.suggest, crates/catalog/src/keywords.rs, crates/ui-egui/src/panels/left.rs |
left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) |
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | cmd:faces.detect, cmd:view.people, cmd:view.faceBoxes, cmd:photo.removeRegion, cmd:photo.setRegion, cmd:library.filter, crates/ui-egui/src/panels/people.rs, crates/catalog/src/query.rs (people), crates/engine/src/media.rs (face_job), crates/meta/src/xmp.rs |
MVP: named face regions read from XMP (MWG-RS, read-only, see docs/xmp-interop.md; clipped to the photo; Lightroom's Rotation of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating mwg-rs:Regions, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (person in library.filter, person: search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (cmd:faces.detect) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back |
| LR-LIB-STACK | Stacks | P1 | ✅ | cmd:stack.group, cmd:stack.ungroup, cmd:stack.toggle, cmd:stack.setTop, cmd:stack.remove, cmd:stack.auto, crates/catalog/src/stacks.rs |
grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
| LR-LIB-VERSIONS | Versions | P1 | ✅ | cmd:version.create |
see section M |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | cmd:photo.delete, cmd:photo.restore, cmd:photo.deletePermanently |
Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (cmd:library.emptyRecentlyDeleted, one undo step, shown while the trash is the source); import takes onDeleted restore (default) / skip / fresh for files in the trash (cmd:library.import; the report lists restored, and with skip flags the duplicate existingDeleted); browsing a folder in Local (cmd:library.browse), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) |
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | cmd:album.removePhotos |
|
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | cmd:photo.duplicate |
Photo ▸ Duplicate: a real -copy file with the same settings, metadata and albums (virtual copies share the file) |
| LR-LIB-RENAME | Batch rename | P1 | ✅ | cmd:photo.rename, cmd:photo.renamePreview, cmd:photo.renameTokens, cmd:dialog.rename, crates/engine/src/rename.rs (TOKENS) |
template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes img_1.jpg next to IMG_1.JPG is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow |
| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | cmd:photo.setCaptureTime, cmd:dialog.captureTime, crates/catalog/src/dates.rs |
set (the other selected photos shift by the same amount, or each), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. |
| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | cmd:app.showInFinder |
⌘R (see MENU-FILE-SHOWFINDER) |
| LR-LIB-COVER | Album cover | P2 | ✅ | cmd:album.setCover |
|
| LR-LIB-CULL | Assisted culling | P2 | 🟡 | cmd:photo.analyze, cmd:dialog.cull, crates/pipeline/src/cull.rs |
Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection |
| LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | ||
| LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | cmd:album.toggleTarget, cmd:album.clearQuick |
B in the grids |
| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | cmd:photo.virtualCopy, crates/engine/src/cmd/organize.rs |
“Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” |
C. Views & navigation (VIEW)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | cmd:view.photoGrid, cmd:library.groups, cmd:library.sort (group), crates/ui-egui/src/panels/grid.rs, crates/catalog/src/dates.rs |
grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own |
| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | cmd:view.squareGrid |
same date headers as the photo grid |
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | cmd:view.detail, crates/ui-egui/src/panels/detail.rs |
a “Rendering…” pill at the bottom of the photo (widget loupe:rendering; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget loupe:renderFailed; ui.inspect → loupe.failed) |
| LR-VIEW-EDIT | Edit view | P0 | ✅ | cmd:panel.edit |
|
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | cmd:view.fullScreenPreview, cmd:view.enterFullScreen |
photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | cmd:view.filmstrip, crates/ui-egui/src/panels/detail.rs |
the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | cmd:view.zoomFit, cmd:view.zoom100, cmd:view.zoomIn, cmd:view.zoomOut, cmd:view.zoomToggle, cmd:view.clickZoom, cmd:view.navigate, crates/ui-egui/src/panels/detail.rs, crates/ui-egui/src/headless.rs |
continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (region::plan, RenderRequest::window, Session::region_job, crates/ui-egui/src/region.rs); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, profile_slider_drag); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, \\ and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (Slot::CompareRegion, panels/compare.rs), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (MediaCache::set_full_capacity), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | cmd:view.navigator, crates/ui-egui/src/panels/detail.rs |
shown while zoomed (bottom right); click/drag pans |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | cmd:view.showOriginal, cmd:view.beforeAfter, cmd:view.beforeAfterSplit, cmd:view.beforeAfterTopBottom, cmd:view.beforeAfterSplitTopBottom, cmd:beforeAfter.setBefore, cmd:beforeAfter.copyAfterToBefore, cmd:beforeAfter.copyBeforeToAfter, cmd:beforeAfter.swap, cmd:beforeAfter.resetBefore |
all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (crates/ui-egui/src/tests_before_after.rs). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | cmd:view.compare, cmd:compare.swap, cmd:compare.makeSelect, cmd:compare.set, crates/ui-egui/src/panels/compare.rs, crates/ui-egui/src/tests_compare.rs |
select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget compare:rendering:<pane>) or “This photo can't be opened” (compare:renderFailed:<pane>) as the loupe does |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | cmd:view.survey, crates/ui-egui/src/panels/compare.rs |
selection tiled (≤ 48), keys act on the active photo, hover × removes |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | cmd:view.infoOverlay |
off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | cmd:view.slideshow |
View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (interval), wrapping; Space pauses, ←/→ step, Esc ends |
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | cmd:view.secondWindow, crates/ui-egui/src/panels/second.rs |
Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) |
| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | cmd:view.clipping |
|
| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | cmd:view.histogram, crates/ui-egui/src/panels/edit.rs, crates/pipeline/src/lib.rs (hdr_histogram) |
HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, Slot::HdrHistogram) and never drops to the SDR layout mid-drag |
| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | , apps/lightcraft/src/hdr_display.rs (choose, preferred_hdr_format) |
D. Search & filter (FILT)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-FILT-SEARCH-META | Text search | P0 | ✅ | cmd:library.filter (text), crates/ui-egui/src/panels/topbar.rs, crates/catalog/src/query.rs |
fielded tokens (rating:3, iso:>800, camera:…); no suggestions dropdown |
| LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | ||
| LR-FILT-RATING | Rating filter | P0 | ✅ | cmd:library.filter (rating, ratingOp), crates/ui-egui/src/panels/filterbar.rs |
≥ / = / ≤ stars in the filter bar (cmd:view.filterBar) |
| LR-FILT-FLAG | Flag filter | P0 | ✅ | cmd:library.filter (flag), crates/ui-egui/src/panels/filterbar.rs |
picked / rejected / unflagged (one at a time) |
| LR-FILT-LABEL | Colour-label filter | P1 | ✅ | cmd:library.filter (label), crates/ui-egui/src/panels/filterbar.rs |
one label at a time; no “no label” choice |
| LR-FILT-TYPE | Type / edited filter | P1 | ✅ | cmd:library.filter (kind, merged, edited), crates/ui-egui/src/panels/filterbar.rs, crates/catalog/src/query.rs (merged_kind) |
photos / raw / videos / HDR / panoramas / HDR panoramas (merge results by name), edited / unedited; no depth kind |
| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | cmd:library.filter (keyword), crates/ui-egui/src/panels/filterbar.rs |
keyword picker |
| LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | cmd:library.filter (camera, lens), crates/ui-egui/src/panels/filterbar.rs |
camera and lens pickers |
| LR-FILT-LOCATION | Location filter | P2 | ✅ | cmd:library.filter (text, ruleSet field location) |
free text, or a rule on location / city / state / country (smart albums, library.filter) |
| LR-FILT-PEOPLE | People filter | P2 | 🟡 | cmd:library.filter, crates/catalog/src/query.rs |
filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or person:; no unnamed / suggested people |
| LR-FILT-CULL | Culling-score filters | P2 | ✅ | cmd:library.filter (ruleSet fields sharpness, bestOfGroup) |
Focus and Best of Similar Shots in the rule editor / smart albums |
| LR-FILT-SORT | Sort | P0 | ✅ | cmd:library.sort, cmd:library.shuffle, crates/ui-egui/src/panels/bottombar.rs |
Random sort (seeded, stable under edits; choosing Random or Reshuffle picks a new seed); the shuffle is part of the saved view state; no effect in Recently Added (always newest import first); no colour-label or custom (manual) order |
| LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | cmd:filter.savePreset, cmd:filter.applyPreset, cmd:filter.presets, cmd:filter.deletePreset, crates/ui-egui/src/panels/filterbar.rs |
filter bar → Presets: apply, save current filter, delete (right-click); saved with the library |
E. Metadata (META)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-META-INFO | Info panel | P0 | ✅ | cmd:panel.info, cmd:photo.setMeta, crates/ui-egui/src/panels/right.rs (info, camera_card) |
camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people |
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | cmd:library.preferences (import.copyright, import.creator), crates/ui-egui/src/panels/settings.rs |
Settings → Import → Metadata; fills only photos without their own |
| LR-META-LOCATION | Location editing | P2 | 🟡 | cmd:photo.setMeta (location, city, state, country, gps), cmd:metadata.reverseGeocode |
place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: cmd:metadata.downloadPlaceNames); no embedded map |
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | cmd:photo.copyMetadata, cmd:photo.pasteMetadata |
title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; fields picks a subset |
| LR-META-XMP | XMP read/write | P0 | ✅ | cmd:photo.saveMetadataToFile, cmd:photo.readMetadataFromFile, cmd:library.xmpPreferences, crates/engine/src/sidecar.rs, docs/xmp-interop.md |
sidecar wins for metadata; a sidecar capture time (exif:DateTimeOriginal / photoshop:DateCreated / xmp:CreateDate) fills in only when the file has none; saving merges into an existing sidecar (crates/meta/src/xmp_merge.rs): another app's crs:, xmpMM:History and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (<file>.xmp for all but the raw); a crs: custom white balance on a relative-WB raw is shifted from crs:AsShotTemperature rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (crs:RetouchAreas, legacy crs:RetouchInfo) are read into our spots (crates/engine/src/crs_masks.rs, see docs/xmp-interop.md → Spot removal); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later crs: edits are still read (issue #934) |
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | cmd:photo.allMetadata, cmd:dialog.allMetadata, crates/meta/src/tags.rs, crates/meta/src/exif.rs, crates/meta/src/iptc.rs |
read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
F. Edit panel — global adjustments (EDIT)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-EDIT-AUTO | Auto settings | P0 | ✅ | cmd:develop.auto, crates/pipeline/src/auto.rs |
a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 |
| LR-EDIT-BW | Black & white | P0 | ✅ | cmd:develop.treatment |
|
| LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | cmd:develop.hdr, ctl:hdr.maxEv, crates/ui-egui/src/panels/edit.rs (hdr_row) |
HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on); see Q. HDR |
| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | ctl:light.exposure |
|
| LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | ctl:light.contrast |
|
| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | ctl:light.highlights, crates/raw/src/highlight.rs |
sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (develop_binned_masked, highlight::reconstruct_masked), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp |
| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | ctl:light.shadows |
From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) |
| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | ctl:light.whites |
|
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | ctl:light.blacks |
|
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | ctl:curve.highlights, ctl:curve.lights, ctl:curve.darks, ctl:curve.shadows, ctl:curve.split* |
|
| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | cmd:develop.curve, cmd:curve.reset, cmd:curve.presets, cmd:curve.applyPreset, cmd:curve.savePreset, cmd:curve.deletePreset, cmd:curve.importPresets, cmd:curve.exportPresets, cmd:file.importCurvePresets, cmd:file.exportCurvePresets, crates/engine/src/cmd/curves.rs, crates/ui-egui/src/panels/edit.rs |
click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as .lccurve JSON |
| LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | cmd:develop.curve (channel) |
|
| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | ctl:curve.refineSaturation |
|
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | cmd:develop.targeted (target: curve) |
|
| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | cmd:develop.wb |
|
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | cmd:tool.wbPicker, cmd:develop.wbPick, crates/ui-egui/src/tests_wb_selector.rs |
the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking |
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | ctl:wb.temp, crates/pipeline/src/local.rs (wb_matrix_for, legacy_white), crates/raw/src/color.rs (rebalance), crates/raw/src/spectral.rs (find_camera), crates/catalog/src/model.rs (relative_wb_camera), crates/develop/src/controls.rs (ControlSpec::position), crates/raw/src/kelvin_presets.rs |
Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (lightcraft_raw::spectral, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW SR2SubIFD, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (WbScale). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (WbScale). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open |
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | ctl:wb.tint, docs/tint-direction.md |
DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP crs:Tint values render as in Lightroom (issue #188); CPU/GPU regression tests |
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | ctl:color.vibrance |
|
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | ctl:color.saturation |
|
| LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | ctl:mixer.* |
no targeted (drag-on-image) mode |
| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | ctl:bw.*, cmd:develop.autoBwMix |
eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) |
| LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | cmd:pointColor.pick, cmd:pointColor.delete |
|
| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | ctl:grading.*, crates/ui-egui/src/panels/edit.rs (grading) |
3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation, shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs, cmd_drag_keeps_the_saturation_and_changes_only_the_hue, alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel, alt_works_with_shift_and_cmd_constraints in crates/ui-egui/src/tests_grading.rs); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (RenderJob::with_hidden_grading, not saved, export unaffected) (issue #737). No Global wheel tab yet |
| LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | ctl:effects.texture |
|
| LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | ctl:effects.clarity |
|
| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | ctl:effects.dehaze |
|
| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | ctl:vignette.*, crates/pipeline/src/finish.rs, crates/ui-egui/src/panels/edit.rs |
style picker (Highlight / Color / Paint) in the Effects section |
| LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | ctl:grain.* |
|
| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | ctl:detail.sharpenAmount, ctl:detail.sharpenRadius, ctl:detail.sharpenDetail, ctl:detail.sharpenMasking |
no Alt-drag mask preview |
| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | ctl:detail.nrLuminance, ctl:detail.nrDetail, ctl:detail.nrContrast |
|
| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | ctl:detail.nrColor, ctl:detail.nrColorDetail, ctl:detail.nrColorSmoothness |
|
| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, crates/denoise/, docs/denoise.md |
non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (lightcraft-cli denoise-serve, crates/denoise/src/remote.rs); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) |
| LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | ||
| LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | ||
| LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | ||
| LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | crates/ui-egui/src/panels/edit.rs (checkbox), ctl:optics.caRed, ctl:optics.caBlue |
|
| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | ctl:optics.profileDistortion, ctl:optics.profileVignetting, crates/pipeline/src/optics.rs, crates/raw/src/vendor/rw2.rs, crates/raw/src/vendor/raf_lens.rs, crates/engine/src/crs.rs |
uses corrections embedded in the file: DNG WarpRectilinear / WarpRectilinear2 (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a WarpRectilinear after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need f64; issue #635) / FixVignetteRadial, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag 0x0119, read as the equivalent WarpRectilinear; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's LensProfileEnable=0 leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (0x7037, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see docs/sony-lens-corrections.md); Fujifilm RAF raw-IFD tables (0xf00b distortion, 0xf010 vignetting; crates/raw/src/vendor/raf_lens.rs, docs/fuji-lens-corrections.md) become a WarpRectilinear with the camera's frame-filling zoom and a FixVignetteRadial gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; 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, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (0xf00f) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in LIGHTCRAFT_LENSFUN_DIR (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (crates/lensdb, crates/engine/src/lens_profiles.rs, docs/lens-profiles.md); no settings switch or bundled database yet |
| ) | |||||
| LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | ctl:optics.defringe* |
no fringe eyedropper |
| LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | ctl:optics.distortion, ctl:optics.vignetting, ctl:optics.vignettingMidpoint |
|
| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | cmd:geometry.upright, cmd:geometry.guides |
|
| LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | ctl:geometry.* |
|
| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | crates/ui-egui/src/panels/right.rs (checkbox → cmd:develop.merge) |
|
| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | crates/ui-egui/src/panels/detail.rs |
a fine grid over the photo while a geometry slider is dragged |
| LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | settings field reserved, not rendered | |
| LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | ctl:calibration.* |
shadows tint, red/green/blue primary hue and saturation; read/written in XMP |
| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | cmd:develop.sectionEnabled, crates/develop/src/lib.rs (effective) |
the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) |
| LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | cmd:develop.reset, cmd:develop.resetSection, cmd:develop.resetControl, crates/ui-egui/src/widgets.rs (double-click) |
no "reset to open" |
| LR-EDIT-SHOWORIG | Show original | P0 | ✅ | cmd:view.showOriginal |
G. Profiles (PROF)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-PROF-DROPDOWN | Profile menu | P0 | ✅ | cmd:develop.profile, cmd:profiles.list, cmd:profiles.menu, cmd:profile.favorite, crates/ui-egui/src/panels/edit.rs |
Favorites, Recent (last 5), group submenus, favourite toggle; favourites/recent persist with the library; Amount slider under the menu for non-default profiles; Browse… opens the profile browser; resting on an entry previews it in the loupe |
| LR-PROF-BROWSER | Profile browser | P1 | ✅ | cmd:panel.profiles, crates/ui-egui/src/panels/profiles.rs, crates/engine/src/media.rs |
grid of live variant thumbnails of the current photo per group (Favorites first), star toggles favourites, amount slider, hover = loupe preview (no history), click applies |
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | crates/engine/src/presets.rs (PROFILES), crates/pipeline/src/profiles.rs |
six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | ||
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | ||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | crates/raw/src/color.rs, crates/raw/src/profile.rs, crates/raw/src/gaintable.rs, crates/engine/src/camera_preview.rs |
DNG files use the colour matrices and the camera-profile look they carry (ProfileHueSatMap, ProfileLookTable, ProfileToneCurve, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's ProfileGainTableMap / ProfileGainTableMap2 (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (profile.camera_local_tone, per photo, off by default, crates/pipeline/src/local_tone.rs) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (lightcraft-cli calibrate, ARW, NEF and RAF so far, crates/engine/src/camera_profiles.rs), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note 0xb025, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (crates/raw/src/spectral.rs, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (docs/camera-preview-colour.md); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, matrix_is_fallback) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus ColorMatrix), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | cmd:develop.profile, crates/pipeline/src/profiles.rs |
16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with ctl:profile.amount; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | ||
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | cmd:develop.profile |
same looks apply to JPEG/TIFF |
| LR-PROF-AMOUNT | Profile amount | P1 | ✅ | ctl:profile.amount |
|
| LR-PROF-IMPORT | Import profiles | P1 | ✅ | cmd:profile.import, cmd:profile.deleteImported, crates/pipeline/src/lut.rs, crates/engine/src/cmd/lut_profiles.rs, crates/ui-egui/src/panels/edit.rs, crates/ui-egui/src/panels/profiles.rs |
.cube 3D LUTs (files, folders, zips) become creative profiles with Amount, grouped in the profile browser, kept with the library; rendered on the CPU; Adobe profile formats deliberately unsupported |
H. Crop & rotate (CROP)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-CROP-RECT | Crop rectangle | P0 | ✅ | cmd:crop.set, cmd:crop.drag, crates/geom/src/crop_drag.rs, crates/ui-egui/src/panels/detail.rs |
double-click inside the box applies the crop (like Return) |
| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | cmd:crop.aspect, cmd:crop.rotateAspect, crates/ui-egui/src/panels/right.rs |
menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step |
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | cmd:crop.straighten, cmd:crop.autoStraighten, crates/ui-egui/src/panels/detail.rs (rotate_cursor, angle_readout), crates/ui-egui/src/panels/right.rs (Angle field), crates/ui-egui/src/tests_crop_rotate.rs |
Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (rotate_cursor_turn, tested by rotate_cursor_faces_away_from_the_crop_box) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (cropAngleField: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, crop.straighten, crop.set {angle}, and the generic develop.set / develop.adjust / develop.quickAdjust / develop.resetControl on crop.angle, as MCP, the control channel and lightcraft-cli render --set use them) keeps the crop if it still fits, else shrinks it to the largest crop of its aspect inside the photo (CropGeometry::with_angle, crates/engine/src/tests_crop_angle.rs; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (outside_crop_mesh), so rotating never dims wedges inside the box or doubles them outside (issue #731) |
| LR-CROP-AUTO | Auto straighten | P1 | ✅ | cmd:crop.autoStraighten |
crop-angle leveling from detected horizon/plumb lines (consensus required) |
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | ctl:crop.angle, crates/ui-egui/src/panels/right.rs |
the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting crop.angle through develop.set fits the crop inside the photo as Straighten does (issue #742) |
| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | cmd:photo.rotateLeft, cmd:photo.rotateRight |
|
| LR-CROP-FLIP | Flip | P0 | ✅ | cmd:photo.flipHorizontal, cmd:photo.flipVertical, crates/pipeline/src/geometry.rs |
the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match |
| LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | cmd:view.cropOverlay, cmd:view.cropOverlayOrientation, crates/ui-egui/src/panels/crop_overlay.rs |
thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays |
| LR-CROP-ZOOM | Zoom while cropping | P1 | ✅ | cmd:view.zoom100, cmd:view.zoomIn, crates/ui-egui/src/panels/detail.rs |
zoom levels apply with the crop tool open (verified: 100% shows the photo at native size with the crop frame) |
| LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 |
I. Remove / healing (REM)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-REM-CONTENTAWARE | Content-aware remove | P1 | 🟡 | cmd:spot.add (mode: remove / fill), crates/pipeline/src/spots.rs, crates/inpaint/src/lib.rs |
Remove heals with an automatic source; Fill synthesises the area from its surroundings (PatchMatch completion over a membrane-blended start, crates/inpaint, ported from PhotoCraft; bounded by the spot size limit); no generative fill (M8.4) |
| LR-REM-HEAL | Heal | P0 | ✅ | cmd:spot.add (mode: heal) |
|
| LR-REM-CLONE | Clone | P0 | ✅ | cmd:spot.add (mode: clone) |
|
| LR-REM-GEN | Generative remove | OOS | 🚫 | ||
| LR-REM-DETECT | Object detection for remove | P2 | ⬜ | ||
| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | cmd:spot.add (size, feather, opacity), cmd:brush.smaller, cmd:brush.larger, cmd:brush.featherLess, cmd:brush.featherMore, crates/ui-egui/src/panels/right.rs |
Size / Feather / Opacity sliders (for new spots and the selected one); [ ] size, ⇧[ ⇧] feather (also the Masking brush) |
| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | cmd:spot.select, cmd:spot.update, cmd:spot.refreshSource, cmd:spot.delete |
click a pin to select, drag target or source, ⌫ deletes the selected spot, / picks another source; automatic sources are resolved when the spot is added |
| LR-REM-VISUALIZE | Visualize spots | P1 | ✅ | cmd:view.visualizeSpots |
|
| LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | ||
| LR-REM-REFLECT | Remove reflections | P2 | ⬜ | ||
| LR-REM-DUST | Dust detection | P2 | ✅ | cmd:spot.findDust, crates/pipeline/src/dust.rs |
Remove panel ▸ Find Dust Spots: small, soft, round dark spots on smooth areas become heal spots (sensitivity; one undo step); Visualize Spots for checking by eye |
| LR-REM-BLEMISH | Find blemishes on faces (beyond Lightroom) | P2 | ✅ | cmd:spot.findBlemishes, crates/pipeline/src/blemish.rs, crates/engine/src/cmd/face_detect.rs |
Remove panel ▸ Find Blemishes: with the YuNet detector installed, small darker-or-redder round spots on each face's skin (eyes, brows, nostrils and mouth cut out around the landmarks) become heal spots (one undo step); faces under 160 px and faces with more than 12 candidates (stubble, freckles) get none, at most 6 per face; sensitivity 0–100. Classical, no learned model beyond the detector; not a Lightroom feature |
| LR-REM-SYNC | Sync spots | P1 | ✅ | cmd:develop.copy (groups), crates/develop/src/presets.rs |
J. Red eye (EYE)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-EYE-RED | Red-eye correction | P1 | ✅ | cmd:panel.redeye, cmd:redeye.add, cmd:redeye.delete, cmd:redeye.catchlight |
red eye and pet eye (with catchlight) |
| LR-EYE-PET | Pet eye | P2 | ✅ | cmd:redeye.add (pet), cmd:redeye.catchlight |
K. Masking (MASK)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-MASK-PANEL | Masks panel | P0 | ✅ | cmd:panel.masking, cmd:mask.add, cmd:mask.select, cmd:mask.rename, cmd:mask.duplicate (invert), cmd:mask.move, cmd:mask.visible, cmd:mask.delete, crates/ui-egui/src/panels/masking.rs |
list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); fits any panel width (create tiles go to three columns, mask actions wrap, long component names are cut short); no drag-to-reorder |
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | cmd:mask.add (subject), crates/pipeline/src/masks.rs |
saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) |
| LR-MASK-SKY | Select sky | P2 | 🟡 | cmd:mask.add (sky), crates/pipeline/src/masks.rs (Mattes), crates/raw/src/semantic.rs |
heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) |
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | cmd:mask.add (background) |
inverse of the subject mask (heuristic, or the photo's person mattes) |
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | cmd:mask.add (object, prompt), cmd:mask.objectPoint, cmd:mask.refineDetail, cmd:segment.prepare, cmd:segment.model.status, cmd:segment.model.download, cmd:segment.model.cancel, crates/segment, crates/engine/src/segment/mod.rs |
SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (car, road); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — but no default download location is configured yet (users set LIGHTCRAFT_SAM3_MIRRORS or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | shape exists, evaluates empty | |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | cmd:tool.brush, cmd:mask.brushStroke (autoMask), crates/pipeline/src/masks.rs, crates/ui-egui/src/brush_live.rs |
size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | cmd:tool.linear, cmd:mask.update |
|
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | cmd:tool.radial, cmd:mask.update, crates/ui-egui/src/panels/detail.rs |
drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | cmd:mask.add (colorRange), cmd:mask.sampleColor, cmd:mask.update, crates/pipeline/src/lib.rs (color_range_sample) |
Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider |
| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | cmd:mask.add (luminanceRange), cmd:mask.update, crates/ui-egui/src/panels/masking.rs (range_controls) |
range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) |
| LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | shape exists, needs depth data | |
| LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | cmd:mask.addComponent, cmd:tool.brush |
Add/Subtract > Brush creates a component with the chosen operation; Erase removes paint within that brush component |
| LR-MASK-INVERT | Invert | P0 | ✅ | cmd:mask.invert |
|
| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | cmd:mask.adjust (amount), crates/ui-egui/src/panels/masking.rs |
|
| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | cmd:mask.refine, crates/pipeline/src/masks.rs (evaluate_one) |
Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) |
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | cmd:mask.adjust, crates/pipeline/src/finish.rs |
every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets |
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | cmd:view.maskOverlay, cmd:view.maskOverlayMode, cmd:view.maskOverlayColor, crates/pipeline/src/visualize.rs |
rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; crates/ui-egui/src/tests_preview_limit.rs, crates/pipeline/src/tests_window.rs); no auto-show on hover |
| LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | cmd:view.maskOverlayAll, crates/ui-egui/src/panels/masking.rs, crates/pipeline/src/visualize.rs |
one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) |
| LR-MASK-PINS | Pins | P1 | ✅ | cmd:view.maskPins, crates/ui-egui/src/panels/detail.rs |
a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |
| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | ||
| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | cmd:develop.copy (groups) |
|
| LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ |
L. Presets (PRE)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-PRE-PANEL | Presets panel | P0 | ✅ | cmd:panel.presets, cmd:preset.apply, crates/ui-egui/src/panels/presets.rs |
grouped list with amount; resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) |
| LR-PRE-CREATE | Create preset | P0 | ✅ | cmd:dialog.createPreset, cmd:preset.create (groups) |
name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) |
| LR-PRE-MANAGE | Manage presets | P1 | ✅ | cmd:preset.delete, cmd:preset.favorite, cmd:preset.import, cmd:preset.export, cmd:preset.rename, cmd:preset.update, cmd:preset.move |
rename, update with current settings, move to a group (existing or new); no hiding of groups |
| LR-PRE-AMOUNT | Preset amount | P1 | ✅ | cmd:preset.apply (amount 0–200) |
|
| LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | ||
| LR-PRE-PREMIUM | Built-in presets (own) | P2 | ✅ | crates/engine/src/presets.rs |
41 own-authored presets in 10 groups (Color, Film, B&W incl. toners, Portrait, Landscape, Urban, Food, Seasons, Vintage, Style) |
| LR-PRE-RECOMMENDED | Community recommendations | OOS | 🚫 | ||
| LR-PRE-ONIMPORT | Apply during import | P2 | ✅ | cmd:library.import (preset) |
chosen in the import review; raw / per-camera defaults in Settings |
M. Versions & history (VER)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-VER-CREATE | Create version | P1 | ✅ | cmd:version.create |
|
| LR-VER-PANEL | Versions panel | P1 | ✅ | cmd:panel.versions, cmd:version.create, cmd:version.restore, cmd:version.delete, cmd:version.rename, cmd:version.update, crates/ui-egui/src/panels/right.rs (versions) |
Named / Auto tabs, a thumbnail of each version's look, name and date, current marker; resting on a version previews it in the loupe; click restores, double-click renames; row menu (update, rename, set as before, delete). No automatic versions are created yet (LR-VER-AUTO) |
| LR-VER-AUTO | Automatic versions | P2 | ✅ | crates/engine/src/lib.rs (auto_version) |
leaving an edited photo keeps its settings as an Auto version (when they differ from the latest version; 20 kept; not an undo step); Versions panel ▸ Auto |
| LR-VER-HISTORY | Edit history | P1 | ✅ | cmd:panel.activity, cmd:history.list, cmd:history.restore, cmd:history.previous, cmd:history.next |
going to a step moves a per-photo cursor without adding a step; later steps stay listed (greyed) until a new edit drops them; history.list returns cursor |
| LR-VER-UNDO | Undo / redo | P0 | ✅ | cmd:edit.undo, cmd:edit.redo |
N. Copy / paste / sync (SYNC)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-SYNC-COPY | Copy edit settings | P0 | ✅ | cmd:develop.copy |
|
| LR-SYNC-CHOOSE | Choose settings to copy | P0 | ✅ | cmd:dialog.copySettings |
groups are coarser than per-slider |
| LR-SYNC-PASTE | Paste to selection | P0 | ✅ | cmd:develop.paste |
no separate "paste selected" (choose at copy time instead) |
| LR-SYNC-SYNCBTN | Sync active → selected | P1 | ✅ | cmd:develop.sync |
|
| LR-SYNC-PREVIOUS | Paste from previous / auto sync [Classic] | P2 | ✅ | cmd:develop.pastePrevious, cmd:develop.autoSync |
⌥⌘V pastes the previously active photo's settings (copy groups); Auto Sync (⌥⇧⌘A) |
O. Merge (MERGE)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-MERGE-HDR | HDR merge | P2 | ✅ | cmd:merge.hdr, cmd:dialog.mergeHdr |
auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (starting_colour_tags; the look's chroma curve has no tag; panoramas too), issue #965 |
| LR-MERGE-PANO | Panorama | P2 | ✅ | cmd:merge.panorama, cmd:dialog.mergePanorama |
spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap |
| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | cmd:merge.hdrPanorama, cmd:dialog.mergeHdrPanorama |
brackets grouped by EXIF |
| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | cmd:merge.hdrLast, cmd:merge.panoramaLast, cmd:merge.hdrPanoramaLast |
no dialog; the options of the last merge of that kind (defaults the first time) |
P. Enhance (ENH)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-ENH-DIALOG | Enhance dialog | P2 | ⬜ | ||
| LR-ENH-INPLACE | In-place enhance | P2 | ⬜ |
Q. HDR (HDR)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-HDR-EDIT | HDR editing | P2 | 🟡 | cmd:develop.hdr, crates/pipeline/src/tone.rs (ToneMap::hdr), crates/pipeline/src/tests_hdr.rs |
HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); crs:HDREditMode from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) |
| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | ctl:hdr.sdr* |
SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands |
| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | cmd:view.visualizeHdr, crates/pipeline/src/visualize.rs (hdr_range) |
grey below SDR white, four colour bands by stops above it |
| LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | ctl:hdr.maxEv, cmd:develop.hdr |
0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) |
| LR-HDR-EXPORT | HDR export | P2 | 🟡 | cmd:app.export, crates/codecs/src/gainmap.rs, crates/engine/src/export.rs (encode_gain_map_jpeg) |
hdr export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 AVIF with PQ or HLG (hdrTransfer, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (cICP + cLLI; crates/codecs/src/encode.rs encode_png_pq), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) |
R. Video (VID)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-VID-PLAY | Video playback | P1 | ⬜ | crates/catalog/src/model.rs |
videos can be catalogued (kind, duration); no player |
| LR-VID-TRIM | Trim | P1 | ⬜ | ||
| LR-VID-EDIT | Edits on video | P2 | ⬜ | ||
| LR-VID-COVER | Cover frame | P2 | ⬜ | ||
| LR-VID-EXPORT | Video export | P2 | ⬜ | ||
| LR-VID-PHOTO2VIDEO | Photo to video (generative) | OOS | 🚫 |
S. Export (EXP)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-EXP-DIALOG | Export dialog | P0 | ✅ | cmd:dialog.export, cmd:app.export (preset), cmd:export.presets, cmd:export.savePreset, cmd:export.deletePreset, crates/ui-egui/src/panels/dialogs.rs, crates/engine/src/export.rs |
batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |
| LR-EXP-TYPE | File types | P0 | ✅ | cmd:app.export (format), crates/engine/src/export.rs (ExportFormat), crates/engine/src/tests_export.rs |
JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: crates/codecs/src/psd.rs), WebP, AVIF, JPEG XL (issue #247: jixel, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; crates/codecs/src/jxl_encode.rs), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build |
| LR-EXP-DIM | Output size | P0 | ✅ | cmd:app.export (longEdge, shortEdge, width, height, megapixels, percent, dontEnlarge, ppi), crates/engine/src/export.rs (Resize), crates/ui-egui/src/panels/dialogs.rs |
full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags |
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | cmd:app.export (quality, limitKb) |
|
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | cmd:app.export (bitDepth), crates/pipeline/src/output.rs (OutputDepth), crates/engine/src/export.rs |
16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | cmd:app.export (tiffCompression: none / lzw / zip) |
|
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | cmd:app.export (colorSpace), crates/pipeline/src/output.rs, crates/engine/src/export.rs |
sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile |
| LR-EXP-HDR | HDR output | P2 | 🟡 | cmd:app.export, crates/ui-egui/src/panels/dialogs.rs |
HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet |
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | cmd:app.export (sharpen, sharpenAmount) |
|
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | cmd:app.export (metadata, removeLocation) |
|
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | cmd:app.export (watermark: text, or {text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}), crates/engine/src/export.rs (Watermark, WATERMARK_PARAMS) |
text (size = height as a fraction of the short edge, 0.005..0.5, default 0.035; color sRGB [r,g,b]; shadow; Japanese vertical glyph forms and origins, tested by biz_ud_vertical_watermarks_place_punctuation_at_the_top_right; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by vertical_watermarks_keep_combining_marks_in_one_cell and upright_watermark_cell_retains_multiple_glyphs (see docs/vertical-watermarks.md)) or a graphic with transparency (image path, imageWidth = fraction of the photo's width, 0.01..1, converted to the output colour space); anchor (nine positions), insetX / insetY (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single inset still reads as both; tested by watermark_insets_are_horizontal_and_vertical), rotation (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by watermark_rotation_turns_the_text_in_its_corner and graphic_watermark_is_placed_scaled_and_blended), opacity 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (draw_watermark_preview scales the 6 px minimum text size to the preview so it matches the file), tested by watermark_preview_scales_the_minimum_text_size and export_dialog_previews_the_watermark |
| LR-EXP-NAMING | File naming | P1 | ✅ | cmd:app.export (naming, startNumber), crates/engine/src/rename.rs (expand_tokens) |
one template language for export, Rename Photos and import renaming: {name}, {num} (number at the end of the name), {seq} / {seq:N}, {date} / {date:%Y-%m-%d}, {folder}, {camera}, {lens}, {iso}, {rating}, {title}, {creator}, {ext}; free-form template rather than a list of named schemes |
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | cmd:app.export (dir, subfolder, conflict: unique / overwrite / skip), cmd:export.checkTarget, crates/ui-egui/src/panels/dialogs.rs (Choose… folder picker), crates/engine/src/originals.rs, crates/catalog/src/safe_file.rs |
folder picker on the desktop; existing files get -2, -3… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact path (export, ui.render, MCP render_photo, merge previewPath, lightcraft-cli render); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |
| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | cmd:app.exportPrevious, cmd:dialog.export |
last options persist in prefs.json; dialog prefilled; no named export presets yet |
| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | cmd:app.export (dngCompression), crates/engine/src/export.rs |
compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output |
| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | cmd:app.export (format: original), crates/engine/src/tests_export.rs |
file copied byte for byte, sidecar named after the output and subject to the conflict policy |
| LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | cmd:app.export (format: rendereddng), crates/raw/src/dngwrite.rs (write_rendered_dng), crates/raw/tests/rendered_dng.rs |
export format rendereddng (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with ColorMatrix1 / ForwardMatrix1 for that space, a neutral AsShotNeutral and an identity ProfileToneCurve so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only |
| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | cmd:app.export (addToPhotos, photosAlbum), cmd:export.addToPhotos, cmd:export.photosImports, crates/engine/src/apple_photos.rs, crates/ui-egui/src/panels/dialogs.rs, packaging/macos/entitlements.plist, docs/apple-photos.md |
macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by /usr/bin/osascript; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and lightcraft-cli all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in docs/apple-photos.md). Other platforms' photo libraries are not covered |
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | cmd:photo.editExternal, cmd:photo.editInPhotoCraft, cmd:photo.editInExternal, cmd:photo.openOriginalInExternal, crates/engine/src/cmd/convert.rs, apps/lightcraft/src/editors.rs |
an -Edit copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet |
T. Share (SHARE)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-SHARE-LINK | Web share link | OOS | 🚫 | ||
| LR-SHARE-INVITE | Invite collaborators | OOS | 🚫 | ||
| LR-SHARE-WEBGALLERY | Web galleries | OOS | 🚫 | ||
| LR-SHARE-COMMUNITY | Community edits | OOS | 🚫 |
U. Map & location (MAP)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-MAP-INFO | Location in the info panel | P1 | 🟡 | cmd:photo.setMeta (location, city, state, country, gps), crates/ui-egui/src/panels/right.rs |
location, city, state/province, country and GPS editable; Show on Map (OpenStreetMap in the browser); no map in the panel |
| LR-MAP-MODULE | Map module [Classic] | P2 | ⬜ |
V. Preferences (PREF)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-PREF-GENERAL | General settings | P0 | ✅ | cmd:app.settings, cmd:app.openLibrary, crates/ui-egui/src/panels/settings.rs, crates/ui-egui/src/panels/notices.rs |
General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as .corrupt-<time> and reported, an unreadable one is not overwritten that session (library.info → settingsWarnings); quitting with unsaved changes retries, then asks |
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | cmd:library.preferences, cmd:library.clearPreviews, cmd:library.compact |
thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | ||
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | cmd:app.settings, cmd:view.appearance, cmd:view.theme |
Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | cmd:app.displayProfile, crates/codecs/src/display.rs, crates/engine/src/display.rs, crates/ui-egui/src/panels/settings.rs, apps/lightcraft/src/screen_profile.rs, apps/lightcraft/src/colord.rs, docs/display-profiles.md |
Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: Windows' WCS profile (needs FFI, so unsafe), X11 _ICC_PROFILE, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) |
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | cmd:app.gpu, cmd:app.memoryBudget, cmd:app.settings |
GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which ui.inspect reports as sharedSourceBytes), thumbnail cache size in Settings |
| LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | cmd:faces.enable, cmd:faces.models.list, cmd:faces.models.inspect, cmd:faces.models.install, cmd:faces.models.download, cmd:faces.models.downloads, cmd:faces.models.downloadCancel, cmd:faces.models.remove, cmd:faces.models.select, cmd:dialog.faceModel, crates/faces/src/lib.rs, crates/ui-egui/src/panels/faces.rs |
Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with lightcraft-fetch), or add any .onnx by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (crates/faces/src/net.rs, crates/faces/src/runtime.rs; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces; the Recognise faces switch is off until a recognition model is chosen and installed (faces.enable {enabled: true} refuses otherwise; removing the last recogniser switches it off), issue #678 |
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | cmd:export.savePreset, crates/ui-egui/src/panels/dialogs.rs |
set in the Export dialog; kept with Export with Previous and in saved export presets |
| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | cmd:app.setShortcut, cmd:app.resetShortcuts, crates/ui-egui/src/panels/keymap.rs |
an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in ui.json; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command |
| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | ||
| LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | ||
| LR-PREF-DEVICE | Device settings | P2 | ⬜ |
W. Cloud & AI infrastructure (CLOUD / AI)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | ||
| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | cmd:library.smartPreviews, cmd:library.smartPreviewsLocation, cmd:photo.smartPreview, crates/engine/src/smart.rs, crates/ui-egui/src/panels/settings.rs |
File ▸ Previews ▸ Build / Discard Smart Previews (on a worker thread, progress and Stop like Build Previews): ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (repaired) and is never moved over a good one (issue #106). Each proxy records the version of the camera-look fit that wrote it (look_version, LOOK_VERSION in crates/engine/src/camera_preview.rs, bumped whenever the fit changes); only raws with the per-file camera look go stale (JPEG / PNG / DNG proxies are never rebuilt for it); one from an older fit is rebuilt from its original by a background scan the first time the library is opened at a new version (a look-version marker in the proxy folder keeps it to once per version) and by Build Smart Previews, and kept as it is while the original is offline (docs/camera-preview-colour.md). The thumbnail cache stays in the library |
| LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | ||
| LR-AI-CREDITS | Generative credits | OOS | 🚫 |
X. Cross-cutting behaviours (BEHAV)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | crates/catalog/src/journal.rs, crates/engine/src/library.rs |
every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved; a write that fails part-way is cut back to the last whole record so the retry never reads back as damage (logs from older builds with such a fragment load in full); quitting saves queued changes in the closing snapshot even when the log can't be appended to; versioned catalog format (journal::VERSION): older libraries are upgraded on open, a library from a newer LightCraft is refused untouched ("written by a newer version") |
| LR-BEHAV-UNDO | Global undo | P0 | ✅ | cmd:edit.undo, crates/engine/src/tests.rs (rating_flag_undo_redo), crates/engine/src/tests_filesystem_history.rs |
covers ratings, albums, deletes, edits; partial file/XMP moves keep unfinished repairs across retries and discarded history; merged renames move directly to their final catalog path |
| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | cmd:library.select (replace/add/toggle/range), cmd:library.selectAll |
|
| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | cmd:photo.rate, cmd:develop.paste, cmd:preset.apply, cmd:app.export |
|
| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | crates/ui-egui/src/panels/presets.rs, crates/ui-egui/src/panels/profiles.rs, crates/ui-egui/src/panels/right.rs (versions), crates/ui-egui/src/panels/detail.rs |
presets, profile menu, profile browser and versions preview in the loupe |
| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | crates/engine/src/media.rs, crates/preview/src/lib.rs |
|
| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | cmd:activity.list, cmd:activity.cancel, crates/engine/src/activity.rs, crates/ui-egui/src/panels/activity.rs, docs/background-tasks.md, crates/preview/src/lib.rs (JobPool) |
renders off the UI thread; exports run several photos of a batch side by side (export_parallelism, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and ui.inspect (activity) see the same list. No pause (Lightroom can pause some tasks) |
| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | local-first: everything works offline | |
| LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | cmd:file.backupLibrary, cmd:file.restoreLibrary, apps/lightcraft-web/src/safety.rs, apps/lightcraft-web/src/backup.rs, apps/lightcraft-web/src/files.rs |
web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; ?reset asks first; a panic shows a message. Missing: ?safe start, deleting originals of removed photos, zip64 (backups > 4 GB) |
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | crates/gpu/src/render.rs, cmd:app.gpu, docs/gpu-pipeline.md, crates/ui-egui/src/panels/notices.rs |
CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (perf.gpuReason, perf.gpuFallback; each render reports its own reason — lightcraft_gpu::try_render, gpuFallback per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, LIGHTCRAFT_GPU_BACKEND / WGPU_BACKEND), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (crates/gpu/src/exit.rs, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside nix develop), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (gpu_notice; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | crates/pipeline/src/lib.rs, crates/pipeline/src/tone.rs |
every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local plan/ (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, docs/process-versions.md) |
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | crates/ui-egui/src/lib.rs, crates/ui-egui/src/panels/grid.rs (drag_feedback), crates/ui-egui/src/panels/left.rs (drop_target, album_drag_over, top_level_drop_target, drag_auto_scroll) |
files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) |
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | crates/ui-egui/src/panels/mod.rs |
|
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | crates/ui-egui/src/panels/dialogs.rs (dialog_body, list_height), crates/ui-egui/src/tests_dialog_fit.rs |
a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window |
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | crates/ui-egui/src/panels/left.rs (section, section_header), crates/ui-egui/src/sidebar.rs (SidebarLayout), crates/ui-egui/src/tests_panels.rs |
click the Albums, Local, By Date, Folders or Keywords header (or drive ui.clickWidget sidebarSection:<albums|local|byDate|folders|keywords>) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (sidebar in the UI state; the collapsedSidebar list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and cmd:view.sidebarSection {section, collapsed} folds and unfolds for agents; photos dropped on a folded Albums section have no target |
| LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | crates/ui-egui/src/sidebar.rs (SidebarSection, SidebarLayout), crates/ui-egui/src/panels/left.rs (section, section_header, tree_toggle), crates/ui-egui/src/tests_panels.rs |
Albums, Local, By Date, Folders and Keywords are drawn by one section frame (the same gap above, header, fold and accessibility name) from one arrangement, sidebar in the UI state: a list of {id, hidden, collapsed} top to bottom, also settable with ui.set. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share tree_toggle: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (sidebarEmpty:<id>) instead of disappearing. ui.set reads sidebar strictly (SidebarLayout::parse): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older collapsedSidebar list still folds and unfolds. A section may have one button at the end of its header, declared in one place (header_button: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (icon:albumNew: Create Album / Smart Album / Folder…), Keywords has + (icon:keywordNew: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked; once made, Keywords is shown and opened down to it). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top |
| LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | cmd:view.sidebarSection, cmd:view.sidebarReset, crates/ui-egui/src/sidebar.rs, crates/ui-egui/src/panels/left.rs (sections_menu), crates/ui-egui/src/menubar.rs, crates/ui-egui/src/tests_panels.rs |
View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets sidebarMenu:hide, sidebarMenu:section:<id>, sidebarMenu:reset) and on the My Photos title (sidebarTitle), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. view.sidebarSection {section, show?, collapsed?} flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (sidebar). My Photos itself can't be hidden |
| LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | cmd:view.sidebarMoveSection, crates/ui-egui/src/sidebar.rs (move_before, move_by), crates/ui-egui/src/panels/left.rs (section_drag, section_drop_before, section_drag_feedback), crates/ui-egui/src/tests_panels.rs |
drag a section by its header: its name follows the pointer, a line (sidebarSectionDrop) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (sidebarMenu:moveUp / moveDown), one shown section at a time, stepping over hidden ones, disabled at the ends. view.sidebarMoveSection {section, direction: up|down} or {section, before: <section>|null} (null: last) answers {moved, order}. The order is kept with the UI state (sidebar). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another |
| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | crates/ui-egui/src/panels/left.rs (pinned_header, section_header, empty_hint), crates/ui-egui/src/tests_panels.rs |
while the rows of an open section are in view but its header has scrolled away, a copy of the header (sidebarSectionPinned:<id>, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (sidebarSectionChevron:<id>); a header highlights under the pointer; a folded section shows how many top-level rows it holds (sidebarSectionCount:<id>, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button |
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | crates/ui-egui/src/panels/mod.rs (resizable_side), crates/ui-egui/src/state.rs (LEFT_WIDTH, RIGHT_WIDTH), crates/ui-egui/src/tests_panels.rs |
drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (leftWidth / rightWidth in the UI state); the Presets column stays fixed |
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | crates/ui-egui/src/panels/mod.rs (empty_message) |
|
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | crates/ui-egui/src/panels/bottombar.rs |
|
| LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | crates/ui-egui/src/text_field.rs, crates/ui-egui/src/panels/topbar.rs, crates/ui-egui/src/widgets.rs, crates/ui-egui/src/panels/right.rs (meta_field), crates/ui-egui/src/panels/dialogs.rs, crates/ui-egui/src/panels/unnamed.rs |
one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use ui.clipboard (MCP clipboard) and ui.inspect → copied. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu |
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | apps/lightcraft-cli/src/main.rs, crates/ui-egui/src/headless.rs, docs/control-protocol.md |
snapshot rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | crates/ui-egui/src/widgets.rs, crates/ui-egui/src/text_field.rs, crates/ui-egui/src/panels/grid.rs, crates/ui-egui/tests/accessibility.rs |
AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. tests/accessibility.rs fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader (audit) |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | cmd:app.language.english, cmd:app.language.simplifiedChinese, cmd:app.language.traditionalChinese, cmd:app.language.japanese, cmd:app.language.portuguese, cmd:app.language.french, cmd:app.language.spanish, cmd:app.language.german, cmd:app.language.russian, cmd:app.language.ukrainian |
Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | crates/ui-egui/src/i18n.rs, crates/ui-egui/locales/, docs/localization.md, docs/localization-zh-hans.md, docs/localization-zh-hant.md, docs/localization-ja.md, docs/localization-pt-br.md, docs/localization-es.md, docs/localization-de.md, docs/localization-ru.md |
One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | ||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | cmd:app.whatsNew, docs/whats-new.md |
Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | ||
| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | crates/ui-egui/src/titlebar.rs, crates/ui-egui/src/panels/topbar.rs, crates/ui-egui/src/tests_titlebar.rs |
the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's Maximized: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own Maximized command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) |
Y. Menus
From 04-menu-tree.md. ✅ = a command exists and is reachable from the UI (button, panel or shortcut). There is no
visible menu bar yet: the menu model is only exposed through the control channel (ui.menu.list).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| MENU-BAR | Menu bar rendering | P1 | ✅ | crates/ui-egui/src/menubar.rs, apps/lightcraft/src/native_menu.rs |
native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux (with one menu open, the pointer on another title switches to it, as in native menu bars); ⌫ and X stay egui-handled (contextual), so they show no key in the native menu; a scrolled menu draws no scrollbar over its rows: the ▲ / ▼ strips, the wheel and dragging scroll it (issue #691: egui's floating bar hid the shortcut text's last character; crates/ui-egui/src/tests_menubar.rs) |
| MENU-APP-ABOUT | About | P2 | ✅ | cmd:app.about |
|
| MENU-APP-SETTINGS | Settings… | P0 | ✅ | cmd:app.settings |
app menu on macOS, Edit menu elsewhere |
| MENU-APP-UPDATES | Check for updates | P2 | ⬜ | ||
| MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | ||
| MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | ||
| MENU-APP-HIDE | Hide / hide others / show all | P1 | ✅ | apps/lightcraft/src/native_menu.rs |
the system's own items in the app menu (⌘H, ⌥⌘H) |
| MENU-APP-QUIT | Quit | P0 | ✅ | cmd:app.quit, apps/lightcraft/src/native_menu.rs |
macOS: app menu (native); elsewhere: File → Quit LightCraft, always the last item in its own group (after commands the File layout does not list) |
| MENU-FILE-ADDPHOTOS | Import Photos… (was Add Photos…) | P0 | ✅ | cmd:file.addPhotos, crates/ui-egui/src/menus.rs |
first item of File, ⇧⌘I; file picker → the import review |
| MENU-FILE-ADDFOLDER | Import from Folder… (was Add Folder…) | P0 | ✅ | cmd:file.addFolder, cmd:library.importPreview |
folder picker (desktop) → the import review, subfolders included; path param for agents. Worded as importing: it doesn't save a Local location (that is Local → Browse Folder…) |
| MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | ||
| MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | cmd:dialog.newAlbum |
|
| MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | cmd:dialog.newFolder |
⇧⌘N |
| MENU-FILE-NEWSMART | New Smart Album… | P1 | ✅ | cmd:dialog.newSmartAlbum |
saves the current view (source + filter) |
| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | ✅ | cmd:file.importPresets |
presets in every common format and .cube LUT profiles (see LR-IMP-PROFILES) |
| MENU-FILE-EXPORT | Export… | P0 | ✅ | cmd:dialog.export |
|
| MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | cmd:app.exportPrevious |
⌥⇧⌘E |
| MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | cmd:app.export (preset), crates/ui-egui/src/menubar.rs (Export with Preset) |
built-ins, then user presets, then Custom… (the dialog); exports to the last folder |
| MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | ||
| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | cmd:photo.editInPhotoCraft, cmd:photo.editInExternal, cmd:photo.openOriginalInExternal |
Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor / Open Original in Other Editor (RAW, no copy), and Edit In on a photo's right-click menu |
| MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | cmd:app.showInFinder |
⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web |
| MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | not applicable: local-first | |
| MENU-FILE-CLOSE | Close Window | P1 | ✅ | apps/lightcraft/src/native_menu.rs |
the system's Close Window item at the end of File (⌘W) |
| MENU-EDIT-UNDO | Undo | P0 | ✅ | cmd:edit.undo |
label does not name the step |
| MENU-EDIT-REDO | Redo | P0 | ✅ | cmd:edit.redo |
|
| MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | cmd:develop.copy, cmd:develop.paste |
|
| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | cmd:dialog.copySettings |
Return copies, Esc cancels |
| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | cmd:dialog.pasteSettings, cmd:develop.paste (groups) |
checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels |
| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | cmd:library.selectAll |
|
| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | cmd:library.selectNone |
|
| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | cmd:library.selectBy |
Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; add extends the selection |
| MENU-EDIT-FIND | Find… | P0 | ✅ | cmd:view.focusSearch, crates/ui-egui/src/panels/topbar.rs |
⌘F focuses the search field |
| MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | cmd:view.photoGrid |
|
| MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | cmd:view.squareGrid |
|
| MENU-VIEW-DETAIL | Detail | P0 | ✅ | cmd:view.detail |
|
| MENU-VIEW-EDIT | Edit | P0 | ✅ | cmd:panel.edit |
|
| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ✅ | cmd:view.fullScreenPreview |
|
| MENU-VIEW-LIGHTSOUT | Lights Out | P2 | ✅ | cmd:view.lightsOut |
everything but the photo veiled; panels stay put |
| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ✅ | cmd:view.enterFullScreen |
|
| MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | cmd:view.leftPanel |
|
| MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | cmd:view.filmstrip |
|
| MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | cmd:panel.info |
|
| MENU-VIEW-KEYWORDS | Show/Hide keywords | P0 | ✅ | cmd:panel.keywords |
|
| MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | our History panel is panel.activity |
|
| MENU-VIEW-VERSIONS | Show/Hide versions | P1 | ✅ | cmd:panel.versions |
|
| MENU-VIEW-HISTOGRAM | Show/Hide histogram | P0 | ✅ | cmd:view.histogram |
|
| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ✅ | cmd:view.infoOverlay |
cycles |
| MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | cmd:view.showOriginal |
|
| MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | cmd:view.beforeAfter, cmd:view.beforeAfterSplit, cmd:view.beforeAfterTopBottom, cmd:view.beforeAfterSplitTopBottom |
|
| MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | cmd:view.zoomIn, cmd:view.zoomOut, cmd:view.zoomToggle, cmd:view.zoomFit, cmd:view.zoom100 |
|
| MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | cmd:view.clipping |
|
| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | ✅ | cmd:view.maskOverlay, cmd:view.maskOverlayMode, cmd:view.maskOverlayColor |
colour cycles through the panel's swatches (no params) or takes color / opacity |
| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | ✅ | cmd:library.browse (subfolders) |
toggle in the folder header; imports of folders are recursive |
| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | cmd:library.sort, cmd:library.shuffle |
Random + Reshuffle; no colour-label key |
| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | cmd:stack.expandAll, cmd:stack.collapseAll |
|
| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | ✅ | cmd:view.photoCounts |
View ▸ Show Photo Counts toggles the left panel's counts |
| MENU-VIEW-SIDEBARSECTIONS | Sidebar sections | P2 | ✅ | cmd:view.sidebarSection, cmd:view.sidebarReset |
View ▸ Sidebar Sections ▸ a checkable item per left-sidebar section, then Reset Sidebar Sections (see LR-BEHAV-SIDEBAR-HIDE) |
| MENU-VIEW-HDR | HDR display options | P2 | ⬜ | ||
| MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | cmd:album.addPhotos |
off for a Local-only selection, with an Add to My Photos First hint item (issue #684) |
| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | cmd:album.removePhotos |
|
| MENU-PHOTO-RATE | Rate submenu | P0 | ✅ | cmd:photo.rate |
|
| MENU-PHOTO-FLAG | Flag submenu | P0 | ✅ | cmd:photo.flag |
|
| MENU-PHOTO-LABEL | Colour label submenu | P1 | ✅ | cmd:photo.label, cmd:dialog.labelNames |
shows custom names; Edit Label Names… |
| MENU-PHOTO-ROTATE | Rotate left / right | P0 | ✅ | cmd:photo.rotateLeft, cmd:photo.rotateRight |
|
| MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | cmd:photo.flipHorizontal, cmd:photo.flipVertical |
|
| MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | cmd:version.create |
no name prompt |
| MENU-PHOTO-STACK | Stack submenu | P1 | ✅ | cmd:stack.group, cmd:stack.ungroup, cmd:dialog.autoStack |
|
| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | cmd:dialog.mergeHdr, cmd:dialog.mergePanorama, cmd:dialog.mergeHdrPanorama, cmd:merge.hdrLast |
|
| MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | ||
| MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | cmd:develop.auto |
|
| MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | cmd:develop.treatment |
|
| MENU-PHOTO-RESET | Reset edits / crop | P0 | ✅ | cmd:develop.reset, cmd:crop.reset |
|
| MENU-PHOTO-UPDATEAI | Update AI settings | P2 | ⬜ | ||
| MENU-PHOTO-RENAME | Rename N photos… | P1 | ✅ | cmd:dialog.rename |
live “Rename N Photos…” label |
| MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ✅ | cmd:dialog.captureTime |
|
| MENU-PHOTO-COVER | Set as album cover | P2 | ✅ | cmd:album.setCover |
|
| MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | cmd:photo.delete |
asks first (Settings ▸ General, on by default, #1239); static label |
| MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | ||
| MENU-WINDOW-MINIMIZE | Minimize / zoom | P1 | ✅ | apps/lightcraft/src/native_menu.rs |
the system's Minimize (⌘M) and Zoom items at the top of Window |
| MENU-WINDOW-PANELS | Panel switches | P0 | ✅ | cmd:panel.edit, cmd:panel.crop, cmd:panel.remove, cmd:panel.masking, cmd:panel.presets, cmd:panel.versions |
|
| MENU-WINDOW-BRINGFRONT | Bring all to front | P2 | ✅ | apps/lightcraft/src/native_menu.rs |
the system's item at the end of Window |
| MENU-HELP-HELP | Help | P2 | ✅ | cmd:app.help |
opens the documentation |
| MENU-HELP-TUTORIALS | Tutorials | OOS | 🚫 | ||
| MENU-HELP-WHATSNEW | What's new | P2 | ✅ | cmd:app.whatsNew |
|
| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | cmd:app.shortcuts |
|
| MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | cmd:app.feedback |
opens a new issue on the project's GitHub |
| MENU-HELP-LOGFOLDER | Open log folder | P2 | ✅ | cmd:app.openLogFolder |
an extra (the reference app has no such item): Help ▸ Open Log Folder reveals logs/lightcraft.log in the system file manager (Finder, Explorer, or the folder on Linux); off on the web and in --memory sessions, which keep no log (issue #260) |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | cmd:app.systemInfo, cmd:library.info |
Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (open: false) |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | crates/ui-egui/src/panels/grid.rs (context_menu) |
rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) |
| MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | crates/ui-egui/src/panels/detail.rs |
Zoom submenu (fit, 100%, in, out), then the photo menu |
| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | crates/ui-egui/src/panels/left.rs (folder_menu), cmd:album.move, cmd:dialog.export, cmd:file.addPhotos, cmd:file.addFolder, cmd:file.addFromDevice |
add selected, Import ▸ Import Photos… / Import from Folder… / Import from Device ▸ (one entry per camera or card, as in File ▸ Import from Device; the import review opens with that album chosen: {album} on those commands; button:albumImport), export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules |
| MENU-CTX-MASK | Mask / component menu | P0 | ✅ | crates/ui-egui/src/panels/masking.rs (mask_menu, component_row_menu), cmd:mask.component |
mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) |
| MENU-CTX-PRESET | Preset menu | P1 | ✅ | crates/ui-egui/src/panels/presets.rs |
favourite, update with current settings, rename, move to group, delete; group: export |
| MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | cmd:profile.favorite, crates/ui-egui/src/panels/profiles.rs |
star in the profile browser; Favorites group first |
| MENU-CTX-VERSION | Version menu | P1 | ✅ | crates/ui-egui/src/panels/right.rs (versions) |
restore, update with current settings, rename, set as before, delete |
| MENU-CTX-KEYWORD | Keyword chip menu | P1 | ✅ | crates/ui-egui/src/panels/keywording.rs (menu) |
acts on the selected photos: add to all selected photos (a keyword only some have), remove from selected photos, show photos with keyword, Edit Keyword Tag…; deleting a keyword from the library is the Keyword List's |
Z. Keyboard shortcuts (desktop)
From 06-shortcuts.md part 1. Evidence is our binding; conflicts are explained in
UI parity → Shortcuts.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| KEY-CROP | Crop & rotate — C | P0 | ✅ | cmd:panel.crop |
|
| KEY-DETAIL | Detail — D | P0 | ✅ | cmd:view.detail |
|
| KEY-EDIT | Edit — E | P0 | ✅ | cmd:panel.edit |
|
| KEY-FULLSCREEN | Full-screen preview — F | P1 | ✅ | cmd:view.fullScreenPreview |
|
| KEY-LIGHTSOUT | Lights Out — Shift+L (Classic's L is Linear Gradient here) | P2 | ✅ | cmd:view.lightsOut |
On → Dim (80 %) → Off; Esc turns the lights on |
| KEY-GRID | Grid — G | P0 | ✅ | cmd:view.gridToggle, cmd:view.photoGrid |
G toggles Photo Grid ↔ Square Grid |
| KEY-INFO | Info — I | P0 | ✅ | cmd:panel.info |
|
| KEY-KEYWORDS | Keywords — K | P0 | ✅ | cmd:panel.keywords |
|
| KEY-CLIPBOARD | Copy / paste edit settings — ⌘C / ⌘V | P0 | ✅ | cmd:develop.copy, cmd:develop.paste, crates/ui-egui/src/shortcuts.rs (matches) |
⌘X has nothing to cut outside text fields; on Windows and Linux, where the windowing layer sends Ctrl+C / Ctrl+V as clipboard events rather than key presses, those run the shortcuts too (⇧Insert pastes plainly); there Ctrl+V only arrives while the system clipboard holds text (egui-winit drops it otherwise), so with an empty clipboard Paste Settings needs the menu |
| KEY-UNDOREDO | Undo / redo — ⌘Z / ⇧⌘Z | P0 | ✅ | cmd:edit.undo, cmd:edit.redo |
|
| KEY-MINIMIZE | Minimize — ⌘M | P1 | ✅ | apps/lightcraft/src/native_menu.rs |
native Window ▸ Minimize |
| KEY-AUTO | Auto — ⇧A | P0 | ✅ | cmd:develop.auto |
|
| KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ✅ | cmd:photo.editInPhotoCraft, cmd:photo.editInExternal |
⇧⌘E as observed (Edit in PhotoCraft, LightCraft's Photoshop); ⌥⌘E the other editor; the export dialog is ⇧E |
| KEY-ROTATE | Rotate — ⌘[ / ⌘] | P0 | ✅ | cmd:photo.rotateLeft, cmd:photo.rotateRight |
|
| KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | cmd:view.zoomIn, cmd:view.zoomOut, crates/ui-egui/src/tests_zoom_keys.rs |
the keys zoom the photo only: egui's own interface zoom on ⌘= / ⌘− / ⌘0 (Ctrl on Windows and Linux, where no native menu bar took the keys first) is off (issue #566) |
| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | cmd:library.selectAll |
|
| KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | cmd:library.selectNone |
secondary binding (primary ⌘⇧A) |
| KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ✅ | cmd:dialog.pasteSettings |
|
| KEY-PREFS | Settings — ⌘, | P0 | ✅ | cmd:app.settings |
|
| KEY-SEARCH | Search — ⌘F | P0 | ✅ | cmd:view.focusSearch |
|
| KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ✅ | cmd:view.visualizeSpots |
|
| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | ✅ | cmd:view.maskOverlay, cmd:view.maskOverlayMode, cmd:view.cropOverlay |
O toggles the mask overlay; ⇧O cycles mask overlay modes while masking, crop overlays elsewhere |
| KEY-PHOTOSPANEL | Photos panel — P | P0 | 🟡 | cmd:view.leftPanel |
bound to ⌘⇧L; P = pick |
| KEY-LINEAR | Linear gradient — L | P0 | ✅ | cmd:tool.linear |
|
| KEY-RADIAL | Radial gradient — R | P0 | ✅ | cmd:tool.radial |
|
| KEY-CLIPPING | Clipping — J | P0 | ✅ | cmd:view.clipping |
|
| KEY-WB | White-balance selector — W | P0 | ✅ | cmd:tool.wbPicker |
|
| KEY-FILMSTRIP | Filmstrip — / | P0 | ✅ | cmd:view.filmstrip |
|
| KEY-SHOWORIGINAL | Show original — \ | P0 | ✅ | cmd:view.showOriginal |
|
| KEY-TOGGLEZOOM | Toggle zoom — Space | P0 | ✅ | cmd:view.zoomToggle |
Space is a secondary binding (primary Z) |
| KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ✅ | cmd:view.maskOverlayColor, crates/ui-egui/src/shortcuts.rs |
⇧O while masking cycles the overlay colour (while cropping: the guides' orientation); the overlay mode is in View ▸ Cycle Mask Overlay Mode |
| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | ✅ | cmd:app.exportPrevious |
⌘E (alias) and ⌥⇧⌘E (Classic) |
| KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | cmd:dialog.export |
secondary binding (primary ⌘⇧E) |
| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ✅ | cmd:view.enterFullScreen |
|
| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | cmd:stack.group, cmd:stack.ungroup |
also S expand/collapse, ⇧S top of stack |
| KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | ✅ | cmd:tool.guidedUpright, cmd:geometry.upright |
opens Crop & Geometry with Guided Upright on and the guide tool active; G toggles Photo Grid ↔ Square Grid (cmd:view.gridToggle) as observed |
| KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | ✅ | apps/lightcraft/src/native_menu.rs |
native app-menu items |
| KEY-QUIT | Quit — ⌘Q | P0 | ✅ | cmd:app.quit |
⌘Q (Ctrl+Q off macOS) |
| KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | cmd:version.create |
secondary binding (primary ⌘⇧S) |
| KEY-CLOSEWINDOW | Close window — ⌘W | P1 | ✅ | apps/lightcraft/src/native_menu.rs |
native File ▸ Close Window |
| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | cmd:photo.delete |
|
| KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | cmd:file.addPhotos |
|
| KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | cmd:panel.versions |
|
| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | cmd:section.light, cmd:section.color, cmd:section.effects, cmd:section.detail, cmd:section.optics |
⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 |
| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | cmd:panel.presets |
Shift+P opens Presets outside the two library grids; in grids it picks and advances (Classic convention). Menu hides the Presets key there; shortcut help follows the view |
| KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | cmd:view.histogram |
bound to ⌘⇧H; ⌘0 = zoom to fit |
| KEY-BRUSHSIZE | Brush size — [ / ] |
P0 | ✅ | cmd:brush.smaller, cmd:brush.larger |
Masking brush and Remove tool (and its selected spot) |
| KEY-BRUSHFEATHER | Brush feather — ⇧[ / ⇧] |
P0 | ✅ | cmd:brush.featherLess, cmd:brush.featherMore |
|
| KEY-BRUSH | Brush — B | P0 | ✅ | cmd:tool.brush |
|
| KEY-HEAL | Remove / heal — H | P0 | ✅ | cmd:panel.remove |
|
| KEY-MERGE | HDR / panorama merges — ⌃H ⇧⌃H ⌃M ⇧⌃M | P2 | ✅ | cmd:dialog.mergeHdr, cmd:dialog.mergePanorama, cmd:merge.hdrLast, cmd:merge.panoramaLast |
|
| KEY-PICK | Pick — Z | P0 | 🟡 | cmd:photo.pick |
bound to P; Z toggles zoom |
| KEY-UNFLAG | Unflag — U | P0 | ✅ | cmd:photo.unflag |
|
| KEY-REJECT | Reject — X | P0 | ✅ | cmd:photo.reject |
swaps crop aspect while cropping |
| KEY-RATING | Ratings — 0…5 | P0 | ✅ | crates/ui-egui/src/shortcuts.rs → cmd:photo.rate |
0 clears, 1–5 set; Shift applies and advances; native macOS plain keys stay with egui; grid multi-selection and undo/redo tested |
| KEY-LABELS | Labels — 6…9 | P1 | ✅ | crates/ui-egui/src/shortcuts.rs → cmd:photo.label |
6 red, 7 yellow, 8 green, 9 blue; Shift+6–9 labels and advances; purple/none available from menus |
| KEY-MASKING | Masking — M | P0 | ✅ | cmd:panel.masking |
|
| KEY-ERASE | Erase while held — ⌥ | P0 | ✅ | crates/ui-egui/src/panels/detail.rs, crates/ui-egui/src/panels/masking.rs |
⌥ while painting flips Add ↔ Erase for that stroke; Add/Erase buttons too |
| KEY-RATEADVANCE | Rate and advance — ⇧0…5 | P1 | ✅ | crates/ui-egui/src/shortcuts.rs → cmd:photo.rate (advance) |
|
| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | ✅ | cmd:photo.flag (advance), crates/ui-egui/src/shortcuts.rs |
Shift+Z pick, Shift+X reject, Shift+U clear; Shift+P also picks and advances in the library grids; exactly one step with Auto Advance enabled |
| KEY-NEXTPREV | Next / previous — → / ←; above / below — ↑ / ↓ | P0 | ✅ | cmd:library.next, cmd:library.previous, cmd:library.up, cmd:library.down |
↑ / ↓ go to the cell above / below in the grids (nearest by position in justified rows; the top / bottom row stays put), one photo in the filmstrip (issue #1171); no vim keys by default (H, J, K, L are taken), but they can be assigned in the keyboard shortcuts editor |
| KEY-BA-CYCLE | Before/after — Y | P0 | ✅ | cmd:view.beforeAfter |
toggles side by side (no cycling) |
| KEY-BA-TOPBOTTOM | Before/after top/bottom — ⌥Y | P1 | ✅ | cmd:view.beforeAfterTopBottom, cmd:view.beforeAfterSplitTopBottom |
⌥Y, ⇧⌥Y |
| KEY-BA-SPLIT | Split before/after — ⇧Y | P0 | ✅ | cmd:view.beforeAfterSplit |
|
| KEY-CHOOSECOPY | Choose settings to copy — ⇧⌘C | P0 | ✅ | cmd:dialog.copySettings |
|
| KEY-RESETALL | Reset all — ⇧⌘R | P0 | ✅ | cmd:develop.reset |
|
| KEY-GENAI | Generative toggles / variations — ⌥⇧G, ⌥←/→ | OOS | 🚫 | ||
| KEY-DETECT | Detect objects — ⌥⇧O | P2 | ⬜ | ||
| KEY-CROP-CONSTRAIN | Lock crop aspect — A | P1 | ✅ | cmd:crop.aspect (toggle, current) |
A while cropping locks the current shape / unlocks |
| KEY-CROP-SWAP | Swap crop orientation — X | P0 | ✅ | cmd:crop.rotateAspect |
|
| KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ✅ | cmd:view.cropOverlayOrientation |
while cropping: O cycles the overlay, ⇧O its orientation |
| KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | cmd:crop.reset |
|
| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | cmd:crop.straighten, crates/ui-egui/src/panels/detail.rs (straighten_overlay) |
⌘-drag in Crop draws a level line; the crop tool stays active |
| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | crates/ui-egui/src/widgets.rs |
|
| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | crates/ui-egui/src/widgets.rs (nudged), cmd:develop.adjust |
↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each |
| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | crates/ui-egui/src/widgets.rs (typed_value), crates/ui-egui/src/text_field.rs, crates/ui-egui/src/widgets/tests_numeric_entry.rs (opening_release_honors_return_and_escape_immediately), crates/ui-egui/src/tests_keymap.rs (opening_numeric_release_keeps_keys_in_the_editor) |
click the value next to any slider's name and type one (1.5, +0,25, -20, 5600 K): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts |
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | cmd:app.shortcuts |
|
| KEY-HELP | Help — F1 | P2 | ✅ | cmd:app.help |
|
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | ||
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | cmd:view.back, crates/ui-egui/src/icons.rs (navigation_arrowheads_have_both_halves), crates/ui-egui/src/tests_crop_tool.rs |
top-bar navigation arrowheads render both halves at 1× and 2× scale; in the crop tool Esc closes the tool first (the crop stays), the next Esc goes back to the grid; Esc that ends an edit in a text field does nothing else |
| KEY-COMMIT | Commit tool — Return | P1 | ✅ | cmd:tool.done |
Return closes Crop / Remove / Red Eye / Masking back to Edit (edits apply live, as in Lightroom) |
| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | cmd:mask.delete, cmd:spot.delete |
⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching |
| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | H = Remove panel |
Lightroom Classic extras
From 08-lightroom-classic-extras.md (Classic-only features) and part 2 of 06-shortcuts.md (Classic keys, grouped).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | cmd:library.import (mode, destination, organize, rename, metadataPreset, preset, keywords, album), crates/ui-egui/src/import.rs |
review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as {date:%Y}/{date:%Y%m%d} → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import |
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | cmd:library.autoImport |
see LR-IMP-AUTO |
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | ||
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | cmd:view.photoGrid, cmd:view.squareGrid, cmd:view.detail, cmd:view.compare, cmd:view.survey, cmd:view.gridInfo |
grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) |
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | cmd:view.compare |
⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) |
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | cmd:view.survey |
N |
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | cmd:view.reference, cmd:photo.setReference, crates/ui-egui/src/panels/compare.rs (show_reference) |
View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget compare:rendering:<pane>) or “This photo can't be opened” (compare:renderFailed:<pane>) as the loupe does |
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | cmd:library.source (missing) |
all, recently added, picks, recently deleted, missing photos (when any) |
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | cmd:library.inspectLightroom, cmd:library.importLightroom, crates/engine/src/lightroom_catalog.rs, docs/lightroom-catalog-import.md |
read-only .lrcat plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's relativePathFromCatalog, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (RAW in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an .lrdata / .lrcat-data folder or the .lrcat makes no library there and offers this import with the catalog found (cmd:app.openLibrary, crates/engine/src/lightroom_folder.rs; issue #891) |
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | cmd:app.openLibrary, crates/catalog/src/lock.rs, crates/engine/src/library.rs (open_library) |
exclusive OS lock on catalog.lock for the session (app and lightcraft-cli --library); a second opener is refused with who has it (catalog.lock.owner: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, library.info → settingsWarnings, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (crates/ui-egui/src/panels/library_problem.rs); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) |
| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | cmd:app.newLibrary, cmd:app.openLibrary, cmd:app.recentLibraries, cmd:app.clearRecentLibraries, crates/ui-egui/src/panels/settings.rs, crates/ui-egui/src/panels/left.rs |
File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open |
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | cmd:library.browse, cmd:folder.rename, cmd:folder.move, crates/ui-egui/src/panels/left.rs (local_section, folder_tree) |
standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | cmd:library.folders, cmd:library.source (libraryFolder), cmd:library.filter (libraryFolder), cmd:library.removeFolder, cmd:folder.rename, cmd:folder.move, cmd:folder.label, cmd:folder.create, cmd:photo.moveToFolder, cmd:folder.scanChanges, cmd:folder.synchronize, crates/catalog/src/folders.rs, crates/engine/src/sync.rs, crates/ui-egui/src/sync.rs, crates/ui-egui/src/panels/left.rs (folders_section, folder_menu_for_library, row_menu), crates/ui-egui/src/tests_panels.rs |
sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (\\server\share, \\?\UNC\…, \\wsl$\…), a macOS share or disk (/Volumes/tokyo), a Linux mount (/media, /run/media, /mnt, gvfs smb-share:…, /net/<host>), NFS (/Network/Servers/<host>), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (Users → me → Pictures), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; library.source libraryFolder + path; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not /Volumes-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, /Volumes, /mnt…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (folder.create; one undo step, undo removes it while empty), Move Selected Photos Here (photo.moveToFolder, also Move to Folder… in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; folder.label, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; folder.scanChanges / folder.synchronize, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs disk: true; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs disk: true for a whole disk or a folder spanning several). Limits: names are compared as written (macOS /Volumes/tokyo vs /Volumes/TOKYO are two disks; /Volumes/Macintosh HD/… and /… two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged |
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | cmd:album.create, cmd:album.createSmart, cmd:album.toggleTarget, cmd:album.setTarget, cmd:album.clearQuick |
albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | cmd:album.createSmart, cmd:album.setRules, cmd:album.ruleFields, cmd:dialog.smartAlbum, crates/catalog/src/rules.rs, crates/ui-egui/src/panels/rules_editor.rs |
rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (FIELD_GROUPS; album.ruleFields reports each field's group): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (ruleSet). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; album.ruleFields gives choiceLabels), while rules store the ids; yes/no fields read Yes / No and take true/false, "yes"/"no", "true"/"false" or 1/0. Every value is checked against its field (RuleSet::check): numbers (rating 0–5), shutter speeds, dates (2026, 2026-08, 2026-08-14, then T10, T10:00 or T10:00:00, a space working as the T, and after whole seconds a fraction and a time zone as imported capture times carry them (.250+02:00); no 30 February; rules::rule_date is the one reading for the check and the matcher; each date field has a calendar picker, crates/ui-egui/src/date_picker.rs, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (rule 2.1: no rating 0–5 is 9), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (crates/ui-egui/src/album_picker.rs: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (RuleSet::upgrade). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; albums.list problems, Catalog::smart_album_problems); the name and rules of an album are saved as one undo step (album.setRules name). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; Problem::issue / field, Issue::text, translated in every locale), while commands keep the English rule 2.1: … message for agents |
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | ||
| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | cmd:view.filterBar, cmd:filter.applyPreset, cmd:library.filter (labels, ruleSet), crates/ui-egui/src/panels/filterbar.rs |
rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor |
| LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | crates/catalog/src/stacks.rs, cmd:stack.group, cmd:stack.split, cmd:stack.moveUp, cmd:stack.moveDown |
group / ungroup / toggle / set top / remove / auto by time / split / move up and down |
| LRC-LIB-VC | Virtual copies | P1 | ✅ | cmd:photo.virtualCopy |
⌘' |
| LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | cmd:label.sets, cmd:label.applySet, cmd:label.saveSet, cmd:label.deleteSet, cmd:label.setNames, crates/engine/src/cmd/manage.rs |
built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from xmp:Label |
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | crates/catalog/src/keywords.rs, crates/ui-egui/src/panels/keyword_list.rs, cmd:keyword.list, cmd:keyword.create, cmd:keyword.edit, cmd:keyword.move, cmd:keyword.info, cmd:keyword.purgeUnused, cmd:keyword.setDefaultParent, cmd:keyword.sets, cmd:keyword.toggleFromSet, cmd:keyword.saveSet, cmd:tool.keywordPainter |
hierarchical a|b|c keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (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 for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in dc:subject, paths in lr:hierarchicalSubject), and files' lr:hierarchicalSubject paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (crates/ui-egui/src/panels/keywording.rs): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (cmd:keyword.saveSet replace) or saved as a new set (new, a taken name refused). File ▸ Import Keywords… / Export Keywords… (cmd:keyword.import, cmd:keyword.export): keyword list files in Lightroom Classic's format (tab-indented, {synonym}, [left out of export]), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields |
| LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | cmd:photo.setMeta, cmd:metadata.savePreset, cmd:metadata.applyPreset, cmd:metadata.presets, cmd:metadata.deletePreset, cmd:dialog.captureTime |
IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (photo.setMeta copyrightStatus, usageTerms, copyrightUrl; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) |
| LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | cmd:develop.quickAdjust, crates/ui-egui/src/panels/edit.rs (quick_develop) |
grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step |
| LRC-LIB-PEOPLE | People view | P2 | 🟡 | cmd:view.people, cmd:view.person, cmd:faces.person, cmd:faces.unnamed, cmd:faces.nameFaces, crates/ui-egui/src/panels/people.rs, crates/ui-egui/src/panels/person.rs, crates/ui-egui/src/panels/unnamed.rs |
Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the Unnamed faces: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then More (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people |
| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | ||
| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | cmd:library.findSimilar, crates/pipeline/src/cull.rs (signature) |
photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (only) |
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | cmd:library.missing, cmd:photo.relink, cmd:library.findMissing, cmd:file.findMissing, cmd:photo.locate, crates/engine/src/cmd/missing.rs (checked_path), crates/ui-egui/src/panels/left.rs (locate_missing_item) |
File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, library.missing and Find Missing alike |
| LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | cmd:photo.convertToDng, crates/engine/src/cmd/convert.rs |
Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) |
| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | cmd:library.buildPreviews, cmd:library.previewProgress, cmd:library.cancelPreviews, cmd:library.clearPreviews, crates/engine/src/cmd/previews.rs, crates/preview/src/lib.rs |
File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) |
| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | cmd:view.slideshow |
see LR-VIEW-SLIDESHOW |
| LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | cmd:version.create, cmd:version.restore |
= versions |
| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | cmd:history.list, cmd:history.restore, cmd:history.previous, cmd:history.next, cmd:history.clear |
row menu: copy step to before, create version from step, clear history; clicking a step loads it without adding one (current step highlighted, later steps greyed and returnable until a new edit truncates them; one undo step; cursor saved in the catalog, old catalogs open at the latest step) |
| LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | cmd:view.softProof, crates/pipeline/src/output.rs (Proof), crates/ui-egui/src/panels/edit.rs (soft_proofing) |
S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings (the display's gamut is the monitor profile's, cmd:app.displayProfile, else sRGB), Create Proof Copy (a named virtual copy). printer / paper ICC profiles can be loaded as the proof profile (size-capped, the last few kept), with rendering intent (Perceptual / Relative Colorimetric), Simulate Paper & Ink and a device-gamut warning (crates/codecs/src/proof.rs, crates/engine/src/proof.rs) |
| LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | cmd:develop.sync, cmd:develop.autoSync, cmd:develop.pastePrevious |
Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner |
| LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | cmd:develop.matchExposure |
Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's |
| LRC-DEV-CALIB | Calibration panel | P1 | ✅ | ctl:calibration.* |
|
| LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | cmd:develop.targeted (target: curve / hue / sat / lum) |
|
| LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | cmd:library.preferences (camera, import.perCamera), crates/ui-egui/src/panels/settings.rs |
Settings → Import: raw default and per-camera presets |
| LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | cmd:develop.updateProcess, cmd:develop.reset, cmd:develop.get, crates/develop/src/settings.rs, crates/pipeline/src/finish.rs, docs/process-versions.md |
each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits |
| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | ||
| LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | ||
| LRC-MAP-VIEW | Map view | P2 | ⬜ | ||
| LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | ||
| LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | ||
| LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | cmd:photo.autoTagTracklog, cmd:photo.tagFromTracklog, crates/meta/src/gpx.rs |
Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within maxGap (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless replace; dryRun previews; one undo step. No track drawn on a map (no Map module) |
| LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | ||
| LRC-MAP-REVGEO | Reverse geocoding | P2 | 🟡 | cmd:metadata.reverseGeocode |
offline from GeoNames (City / State / Country, no sublocation); no online provider |
| LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | ||
| LRC-BOOK-AUTOLAYOUT | Book auto layout | P2 | ⬜ | ||
| LRC-BOOK-PAGE | Book pages & templates | P2 | ⬜ | ||
| LRC-BOOK-GUIDES | Book guides | P2 | ⬜ | ||
| LRC-BOOK-CELL | Book cell padding | P2 | ⬜ | ||
| LRC-BOOK-TEXT | Book photo/page text | P2 | ⬜ | ||
| LRC-BOOK-TYPE | Book typography | P2 | ⬜ | ||
| LRC-BOOK-BG | Book backgrounds | P2 | ⬜ | ||
| LRC-BOOK-VIEWS | Book views | P2 | ⬜ | ||
| LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | ||
| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | ||
| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | ||
| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | ||
| LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | ||
| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | ||
| LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | ||
| LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | ||
| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | ||
| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | ||
| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | cmd:dialog.contactSheet, cmd:export.contactSheet, crates/engine/src/contact_sheet.rs |
paginated contact sheets; single-image/custom package layouts missing |
| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | ||
| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | cmd:export.contactSheet, crates/ui-egui/src/panels/dialogs.rs |
A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing |
| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | ||
| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | ||
| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | cmd:export.contactSheet, crates/engine/src/contact_sheet.rs |
optional filename captions; no page backgrounds/identity plates |
| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | cmd:export.contactSheet, cmd:app.contactSheet, crates/ui-egui/src/export_task.rs |
PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution |
| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | ||
| LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | ||
| LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | ||
| LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | ||
| LRC-WEB-APPEARANCE | Web gallery appearance | OOS | 🚫 | ||
| LRC-WEB-IMAGEINFO | Web gallery image info | OOS | 🚫 | ||
| LRC-WEB-OUTPUT | Web gallery output | OOS | 🚫 | ||
| LRC-WEB-UPLOAD | Web gallery upload | OOS | 🚫 | ||
| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | ||
| KEYC-MODULES | Classic module switching (⌘⌥1–7) | P2 | 🚫 | no modules in LightCraft | |
| KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | cmd:view.photoGrid, cmd:view.zoom100 |
G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes |
| KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | ||
| KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | cmd:library.import, cmd:photo.delete, cmd:photo.virtualCopy, cmd:app.showInFinder, cmd:dialog.rename, cmd:photo.editInExternal |
⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor |
| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | cmd:view.zoomToggle |
Z toggles zoom; no compare, stacks, thumbnail-size keys |
| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, [ ], `) |
P2 | 🟡 | cmd:photo.rate, cmd:photo.pick, cmd:photo.flag |
0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating [ / ], flag cycle and filter-bar keys |
| KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | cmd:dialog.newAlbum |
⌘N new album; no quick collection |
| KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | cmd:photo.saveMetadataToFile |
⌘S saves metadata; no keyword sets, metadata copy/paste |
| KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | cmd:develop.treatment, cmd:develop.reset, cmd:crop.reset |
V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer |
| KEYC-MODULE-OUTPUT | Book / slideshow / print / map / web keys | P2 | ⬜ | modules not implemented | |
| KEYC-HELP | Classic help keys (⌘/, F1) | P2 | 🟡 | cmd:app.shortcuts |
⌘/ only |
Japanese text watermarks
app.export watermark objects accept vertical (boolean, defaults to false). The export
dialog offers Horizontal/Vertical and multiline text. Japanese glyphs fall back to the craft-fonts
Mincho faces (BIZ UDMincho; on the web build, BIZ UDPGothic) when built with CRAFT_FONTS_DIR. Vertical lettering uses upright em cells and right-to-left newline columns;
Latin stays upright. Advanced Japanese composition remains open. Regression coverage:
export::tests::japanese_watermarks_support_vertical_columns, japanese_watermark_options_and_legacy_defaults, watermarks_work_without_craft_fonts.
Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added lc:settings and hid another app's later crs: edits (#934); status unchanged (✅) |
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |
| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Pentax PEFs without area tags lose their masked border lines (issue #1271); status unchanged (🟡) |
| 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial |
| 2026-10-11 | minor | LR-IMP-PREVIEW-READ: a preview reads the raw's header and its embedded JPEG instead of the whole file (a 56 MB RAF: 56.5 MB → 5.9 MB, ~10 s → 0.96 s over SMB, byte-identical pixels), and an unedited raw's grid thumbnail is that camera JPEG itself, so no sensor decode is queued behind it. A CR3's JPEG track and a TIFF raw whose preview tag sits past the prefix are read through lightcraft_raw::sparse::preview_plan, which names the ranges embedded_preview reads; the formats it cannot describe (CRW/MRW/X3F, NEF/ORF/PEF) keep the whole-file path |
| 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 |
| 2026-10-12 | minor | LR-PREF-DISPLAY: on Linux, previews follow the display profile the desktop assigns in colord |
| 2026-10-12 | minor | LR-IMP-FORMATS: transparent areas are flattened onto white in linear light instead of rendering black (issue #1212); status unchanged (🟡) |
| 2026-10-12 | minor | LRC-LIB-MISSING: folder and disk context menus locate a moved tree recursively without relinking missing photos outside it (issue #1194) |
| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Canon PowerShot G9 as-shot white balance from WBInfo (issue #1273); status unchanged (🟡) |
| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Olympus E-10 as-shot white balance and black level from the main maker note (issue #1273); status unchanged (🟡) |
| 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) |
| 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) |
| 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) |
| 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (hdrTransfer); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision |
| 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged |
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
| 2026-10-11 | minor | LR-IMP-FORMATS: EOS R6 Mark III CR3 opens with its as-shot white balance and black level (ColorData version 66); status unchanged (🟡) |
| 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) |
| 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. |
| 2026-10-10 | minor | Renamed from parity.md (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a cargo xtask parity --write |
| 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see git log -- docs/parity.md docs/parity-checklist.md) |