From d3e56df54a12c3dff9e00cbddded42b1da451592 Mon Sep 17 00:00:00 2001 From: null 00000111 <287216888+s17sh70x2jfl031ag@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:50:09 -0700 Subject: [PATCH 01/20] Add a security policy --- SECURITY.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..6d7340dd5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,54 @@ +# Security Policy + +LightCraft reads camera raw files, image formats, metadata and sidecars, imports other photo catalogs, downloads and runs machine-learning models, and exposes CLI, JSON control, and MCP automation. Treat files, paths, metadata, catalogs, model files, automation requests, and command parameters as potentially malicious. + +## Reporting a vulnerability + +Please give the repository maintainers a reasonable opportunity to investigate and coordinate a fix before publishing exploitable details, proof-of-concept code, or weaponized files. + +This repository does not currently document a dedicated private security email or private reporting channel. Contact the repository maintainers through the repository owner/maintainer channels already listed in the project, provide only a minimal non-exploitable summary in public, and ask for an appropriate private transfer method before sharing sensitive details. Do not open a public issue containing a working exploit, secret, sensitive crash dump, or malicious attachment. + +If no private channel can be established, withhold weaponized material and report the smallest safe description that allows maintainers to make contact. + +## What to include + +A useful report includes: + +- affected LightCraft version, commit, or release artifact; +- operating system, architecture, build profile (desktop or web), and relevant feature flags; +- the affected component or path; +- security impact and required attacker access; +- exact reproduction steps; +- expected behavior and actual behavior; +- crash logs, backtraces, sanitizer output, or resource measurements when applicable; +- whether the problem reproduces from a clean checkout; +- any known workaround or containment. + +Remove credentials, personal paths, private photos, and unrelated system information from logs. + +## Malicious test files + +Do not attach a potentially harmful or non-redistributable file to a public issue. Initially provide its format, size, cryptographic hash, observed effect, provenance category (for example, synthetic or fuzz-generated), and the command needed to reproduce the problem. Coordinate a private transfer method with maintainers before sending the sample. + +Test files accepted into the repository must be minimized, free of personal or proprietary data, legally redistributable, and documented with the expected failure behavior. A fixed parser defect should receive a permanent regression test whenever safe and practical. + +## Scope + +Security reports may cover: + +- camera raw, DNG, TIFF, HEIF, JPEG and other image decoding, and EXIF/XMP metadata and sidecar parsing; +- decompression bombs, oversized dimensions, integer overflow, memory exhaustion, recursion, or hangs; +- the LightCraft catalog, and importing other catalogs and libraries (such as Lightroom catalogs); +- import/export and filesystem path handling, including traversal, symlinks, and unsafe overwrite or move behavior; +- downloading, verifying, unpacking and running machine-learning models (denoise, faces, masks); +- engine command validation and serialization/deserialization; +- the desktop control channel, the MCP server, and the MCP-to-app connection; +- the WebAssembly build; +- GPU resource validation or driver-facing input handling; +- dependencies, CI, packaging, signing, checksums, and release artifact integrity. + +General bugs without a security impact should use the project's normal issue and contribution process. + +## Current limitations + +The control channel (`lightcraft --control PORT`) listens on loopback only and has no authentication, so any local process can drive the app while it is enabled; only enable it when you need it ([control-protocol.md](docs/control-protocol.md)). From 40050b7bd5f9b5bd22ec92fbfb03f4a196816afe Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Wed, 14 Oct 2026 10:32:35 -0500 Subject: [PATCH 02/20] LR-IMP-LOCAL: Record folders browsed from the sidebar as browsed The sidebar reads a folder on a worker thread and never stamped it like library.browse does, so folders in daily use were forgotten after the set number of days. --- crates/engine/src/cmd/browse.rs | 10 ++++++++++ crates/ui-egui/src/headless.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 6 ++++++ docs/parity-checklist.md | 3 ++- docs/whats-new.md | 8 ++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a681ec14b..29b7d65e6 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -49,6 +49,16 @@ fn browse(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()})) } +impl Session { + /// Record that the folder `root` was browsed now, with the folders of `files` (the photos it showed): what + /// `library.browse` does after reading a folder, for a UI that reads it on a worker thread instead. Untouched + /// Local photos are forgotten only from folders not browsed for the set number of days, so a browse that + /// isn't recorded lets a folder in daily use be forgotten. + pub fn note_browsed(&mut self, root: &str, files: &[String]) { + stamp_browsed(self, root, files); + } +} + /// Folders whose browse time is refreshed at most this often (keeps repeated browsing from /// growing the log; far finer than the days the eviction counts in). const RESTAMP_SECS: i64 = 3600; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..1fa154b18 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2983,6 +2983,30 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Browsing a folder from the UI (read on a worker thread) records it as browsed, as `library.browse` does: + /// untouched Local photos are only forgotten from folders not browsed for the set number of days. + #[test] + fn browsing_from_the_ui_records_the_folder() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-stamp-{}", std::process::id())); + let day = dir.join("2026/10/14"); + std::fs::create_dir_all(&day).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(day.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let root = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let day_s = day.to_string_lossy().to_string(); + assert!(h.app.session.catalog.last_browsed(&root).is_none()); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": root, "subfolders": true}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 1); + assert!(h.app.session.catalog.last_browsed(&root).is_some(), "the browsed folder"); + assert!(h.app.session.catalog.last_browsed(&day_s).is_some(), "the folder its photo is in"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn add_folder_scans_in_the_background() { let mut h = demo([1200.0, 900.0]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a1a720a8e..64e90acb8 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -559,6 +559,12 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { } app.session.import_probes = out.probes; if task.browse { + // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so + // their untouched Local photos aren't forgotten while the folder is in use + if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { + let shown: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.note_browsed(&root, &shown); + } // what the library doesn't know yet joins the Local view let queue: Vec = out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..5626345c7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-IMP-LOCAL: browsing from the sidebar records the folder; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **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 @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | patch | LR-IMP-LOCAL: browsing a folder from the sidebar records it as browsed, as `library.browse` does, so its untouched photos aren't forgotten while in use; status unchanged (✅) | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..2a184da48 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,14 @@ ## 2026-10-14 +### Local folders you browse are kept +- Browsing a folder under Local now records it as browsed, so the photos in folders you use aren't forgotten after + the number of days set in Settings → Performance. Only the `library.browse` command recorded it before, so a + folder browsed every day from the sidebar was forgotten anyway and read again from scratch on the next visit, + which is slow on a network share. + +## 2026-10-14 + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 484215f009fadd69b5c7ac439ffa8e6715db48d7 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 02:48:34 +0200 Subject: [PATCH 03/20] LR-IMP-FORMATS: discover Sony SR2 and SRF files in folders Reuse the engine's import extensions in MCP and CLI discovery. Add recursive, case-insensitive discovery tests and record that DSC-F828 SRF import remains preview-only. Co-Authored-By: Codex --- crates/engine/src/import.rs | 7 ++++--- crates/engine/src/tests_import.rs | 19 +++++++++++++++++++ crates/mcp/src/headless.rs | 22 ++++++++++++++++++++++ docs/gaps.md | 3 ++- docs/parity-checklist.md | 3 ++- docs/raw-parity.md | 17 +++++++++++++---- docs/raw/coverage.py | 2 ++ docs/raw/coverage.tsv | 4 ++-- docs/raw/evidence.tsv | 6 +++--- 9 files changed, 69 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..6023540fb 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -32,10 +32,11 @@ use serde::{Deserialize, Serialize}; use crate::Session; use crate::media::ProbeInfo; -/// File extensions LightCraft imports (lower case). +/// File extensions offered by import discovery and file pickers (lower case). +/// This includes preview-only raws; the file probe decides whether sensor decoding is available. pub const EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", - "jxl", "gif", "bmp", + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", + "srw", "psd", "jxl", "gif", "bmp", // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 3d6469deb..dce7e17c6 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -30,6 +30,25 @@ fn ids(v: &Value, key: &str) -> usize { v[key].as_array().map(Vec::len).unwrap_or(0) } +#[test] +fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() { + let src = temp_dir("sony-discovery"); + std::fs::create_dir_all(src.join("trip/day2")).unwrap(); + let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"]; + let mut expected: Vec = names.iter().map(|name| src.join(name).to_string_lossy().into_owned()).collect(); + for path in &expected { + std::fs::write(path, b"discovery only, not a decoded photo").unwrap(); + } + std::fs::write(src.join("trip/notes.txt"), b"not a photo").unwrap(); + + let mut found = crate::import::expand(&[src.to_string_lossy().into_owned()], None); + found.sort(); + expected.sort(); + assert_eq!(found, expected); + assert_eq!(crate::import::expand(&expected, None), expected, "folder and direct discovery agree"); + std::fs::remove_dir_all(src).unwrap(); +} + #[test] fn recursive_import_with_duplicates() { let src = temp_dir("src"); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 7c9af704d..4c985c7bf 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -206,6 +206,28 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + #[test] + fn folder_discovery_matches_engine_extensions() { + let dir = std::env::temp_dir().join(format!("lc-mcp-discovery-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let nested = dir.join("trip/day2"); + std::fs::create_dir_all(&nested).unwrap(); + let mut expected = Vec::new(); + for ext in lightcraft_engine::import::EXTENSIONS { + for (folder, name) in [(&dir, format!("lower.{ext}")), (&nested, format!("upper.{}", ext.to_ascii_uppercase()))] { + let path = folder.join(name); + std::fs::write(&path, b"discovery only, not a decoded photo").unwrap(); + expected.push(path.to_string_lossy().into_owned()); + } + } + std::fs::write(nested.join("notes.txt"), b"not a photo").unwrap(); + let mut found = expand_paths(&[dir.to_string_lossy().into_owned()]); + expected.sort(); + found.sort(); + assert_eq!(found, expected); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Headless sessions look for the SAM 3 model where the desktop app does, and `segment.model.status` reports it (issue #619). #[test] fn headless_sessions_get_the_sam3_model_folder() { diff --git a/docs/gaps.md b/docs/gaps.md index f11a74f7a..01a658dd4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -77,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged | | 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | | 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..030daa9c9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -82,7 +82,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 (local adjustment templates become mask effects, LR-MASK-EFFECTS), 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); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | 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); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | 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/RP, incl. RP 1.6× crop mode (`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, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), 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-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/RP, incl. RP 1.6× crop mode (`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, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), 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); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; 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`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `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). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `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. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | 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 | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: folder discovery includes SR2/SRF; MCP and CLI reuse the engine extension list. DSC-F828 remains preview-only; decoder support and row status unchanged | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d9cc06a93..c408b35d3 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -31,6 +31,13 @@ model from the format rules in the script (which mirror `crates/raw/src`) plus t rewrites [raw/coverage.tsv](raw/coverage.tsv) and prints the tables below; `--check` fails when the committed table is stale. +Folder discovery, desktop file pickers, CLI and MCP share +`lightcraft_engine::import::EXTENSIONS`. The list includes formats that may open only from an +embedded preview or report a decoder error; an extension is not a promise of sensor decoding. +The 2026-10-12 release CLI check of the public DSC-R1 SR2 and DSC-F828 SRF samples found the +same photos and decode status through recursive and direct import. DSC-F828 moves from +unsupported to preview-only in the counts; sensor-decoding readiness is unchanged. + ## How it was measured - **Adobe's list**: helpx "Camera Raw supported cameras" (the live page refuses scripted requests, so @@ -78,7 +85,7 @@ committed table is stale. | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| -| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported | +| Sony | 149 | 86 | 51 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 remains classified unsupported in the recorded evidence; DSC-F828 SRF imports as preview-only; DSC-V3 preview not verified | | Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | | Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | | Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) | @@ -142,9 +149,10 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured | | ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` | | ARW | 12-bit compressed of DSLR-A200…A390 | ⬜ | preview (#535) | -| ARW | A100 ARW; SRF (F828, V3) | ⬜ | refused (#702); SRF not imported | +| ARW | A100 ARW | ⬜ | recorded as refused (#702); not rechecked here | +| SRF | DSC-F828; DSC-V3 | ⬜ | discovered during folder import; F828 imports as preview-only, V3 has not been checked on a real file | | ARW | ARQ pixel shift | ⬜ | no code | -| SR2 | DSC-R1 | ✅ | CC0 3221 sample | +| SR2 | DSC-R1 | ✅ | CC0 3221 sample; folder and direct import both decode sensor data | | RAF | uncompressed, lossless and lossy compressed; Bayer and X-Trans; GFX 14/16-bit | ✅ | 24 files from 13 bodies exact | | RAF | pre-X FinePix without a raw IFD | ⬜ | `raf.rs` refuses (26 Adobe models, estimated split) | | ORF | uncompressed 16-bit, 12-bit packed, two-field 12-bit, 12.8 bpp | ✅ | E-1, E-400, XZ-2 corpus; #651 | @@ -269,7 +277,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). | 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | | 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | | 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples | -| 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | +| 7 | Sony A100 regression (#702) and DSC-F828 sensor decoding (preview-only) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | | 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples | | 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder | | 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | @@ -287,6 +295,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | SR2/SRF folder discovery and shared CLI/MCP extensions; public DSC-R1 sensor import and DSC-F828 preview-only import agree with direct import. F828 reclassified as preview-only (219 preview, 37 unsupported in the current generated coverage); sensor-decoding scores unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index c4552feee..cbd0769ff 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -66,6 +66,8 @@ def classify(maker, camera, ext_field): return "unverified", "ARW", "ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr)" if e == "SR2": return "unverified", "SR2", "SR2 decoder" + if e == "SRF": + return "unsupported", "SRF", "SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file" if e == "RAF": if RAF_OLD.search(camera): return "preview", "RAF", "pre-X FinePix RAF without a raw IFD" diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index 8bf7c67a8..63e8917c1 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -1306,10 +1306,10 @@ Sony A6400 (ILCE-6400) ARW 2018 verified issue #535: raw.pixls.us bulk check of Sony A6500 (ILCE-6500) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A6600 (ILCE-6600) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A6700 (ILCE-6700) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-F828 SRF 2003 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-F828 SRF 2003 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md) Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-V3 SRF 2005 unsupported SRF not in the import list +Sony DSC-V3 SRF 2005 unsupported SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file Sony DSC-RX100M7A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3 (ILME-FX3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony FX30 (ILME-FX30) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 6e60a6828..08a1e6cfb 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -465,10 +465,10 @@ Sony A900 (DSLR-A900) verified issue #535: raw.pixls.us bulk check of all 100 So Sony A99 (SLT-A99V) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A99 II (ILCA-99M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony Alpha 1 (ILCE-1)* verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-F828 unsupported SRF not in the import list (the probe accepts it again since #774, issue #702); raw not decoded +Sony DSC-F828 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md) Sony DSC-HX95 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-V3 unsupported SRF not in the import list +Sony DSC-V3 unsupported SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file Sony FX3 (ILME-FX3) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony FX30 (ILME-FX30) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony NEX-3 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -528,7 +528,7 @@ Samsung TL350 (WB2000) verified corpus: crates/raw/tests/olympus_samsung_corpus. Samsung NX1000 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung NX1100 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo -Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-F828 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md) Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) From b5c7eddf8fc1447df8b7fa315ef4152ddfc1a6dc Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Thu, 15 Oct 2026 17:24:05 -0500 Subject: [PATCH 04/20] Photo Merge: Merge a photo named twice only once (#1947) --- crates/engine/src/merge.rs | 6 ++++-- crates/engine/src/tests_merge.rs | 24 ++++++++++++++++++++++++ docs/whats-new.md | 7 +++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 26c69c63d..76bcfd7f2 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -404,10 +404,12 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { } impl Session { - /// Plan a merge of `ids` (catalog order of the given ids is kept). + /// Plan a merge of `ids` (catalog order of the given ids is kept). A photo named more than once is merged once + /// (issue #1947): it adds nothing to the merge, and each copy would be decoded and held in memory. pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result { let mut sources = Vec::new(); - for id in ids { + let mut seen = std::collections::HashSet::new(); + for id in ids.iter().filter(|id| seen.insert(**id)) { let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?; match &p.source { Source::File { path } => sources.push((*id, path.clone())), diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index aaa59acb1..ed524ae3d 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -226,3 +226,27 @@ fn panorama_size_is_held_to_memory() { assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits"); assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB"); } + +/// A photo named twice is merged once (issue #1947): `[a, a, a]` is one photo, too few to merge, and `[a, b, a, b]` +/// merges two frames rather than holding four in memory. +#[test] +fn a_photo_named_twice_is_merged_once() { + let dir = temp_dir("dup-ids"); + let mut paths = Vec::new(); + for (i, b) in lightcraft_merge::synth::bracket_dngs(96, 64, &[-1.0, 1.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, b).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + let (a, b) = (ids[0], ids[1]); + for cmd in ["merge.hdr", "merge.panorama"] { + let e = s.execute(cmd, &json!({"ids": [a, a, a], "preview": true})).unwrap_err(); + assert!(e.to_string().contains("at least 2"), "{cmd}: {e}"); + } + let (kind, finish) = crate::merge::parse("merge.hdr", &json!({})).unwrap(); + let job = s.plan_merge(kind, finish, &[a, b, a, b, a].map(lightcraft_catalog::PhotoId), true).unwrap(); + assert_eq!(job.sources.iter().map(|(p, _)| p.0).collect::>(), vec![a, b]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 97e1367a8..9819ae670 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-15 + +### Photo Merge takes each photo once +- A merge that names the same photo more than once (through `ids` on the control channel, MCP or the CLI) now merges + it once (issue #1947). Naming one photo three times no longer "merges" it with itself, and a long repeated list no + longer decodes the photo again for every entry. + ## 2026-10-14 ### Lightroom radial masks on the right side From 16f5a9f0cad4980ba9b156c4ad23f444c372ebaa Mon Sep 17 00:00:00 2001 From: LochLogic <252105752+LochLogic@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:16:51 -0600 Subject: [PATCH 05/20] Control port: don't clone the connection's socket (Windows: launched programs held it open) serve() read through stream.try_clone(). On Windows a cloned socket handle is inheritable (std's accept/connect sockets are not), so every program the app started while a control client was connected (the browser for a link, Explorer for Show in Explorer, an external editor) got a copy of the connection. When the server then closed it, the client saw nothing until that program exited, and then a reset instead of a clean close. Read and write through the one stream (&TcpStream is Read and Write) instead. Regression test (Windows): a program started while a client is connected no longer delays or resets the close; it failed with ConnectionReset before. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/control_server.rs | 39 +++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index 537c75bd1..feb9ba028 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -66,9 +66,13 @@ const NO_FRAMES_AFTER: Duration = Duration::from_secs(5); pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \ (on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again"; +/// Reads and writes go through the one `stream` (`&TcpStream` is `Read` and `Write`): no +/// `try_clone`. On Windows a cloned socket handle is inheritable, so every program the app starts +/// while a client is connected (a link in the browser, Show in Explorer, an external editor) kept a +/// copy of the connection: closing it then reached the client only when that program exited, as a +/// reset instead of a clean close. fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { - let Ok(read) = stream.try_clone() else { return }; - serve_lines(read, stream, |method, params| { + serve_lines(&stream, &stream, |method, params| { let (req, rrx) = ControlRequest::new(method, params); let state = Arc::clone(&req.state); tx.send(req).ok()?; @@ -337,6 +341,37 @@ mod tests { assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err()); } + /// A program the app starts while a client is connected doesn't keep the connection open: + /// when the server closes it, the client sees that at once, not when the program exits. + #[cfg(windows)] + #[test] + fn a_program_started_meanwhile_does_not_hold_the_connection_open() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, _rx) = channel::(); + let server = std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + serve(s, &tx, &egui::Context::default()); + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + // the connection is being served: start a program that lives about 4 s + std::thread::sleep(Duration::from_millis(200)); + let mut child = std::process::Command::new("cmd").args(["/c", "ping -n 5 127.0.0.1 >nul"]).spawn().unwrap(); + std::thread::sleep(Duration::from_millis(200)); + // junk makes the server answer once and close + c.write_all(b"hello\n").unwrap(); + let t0 = std::time::Instant::now(); + let mut text = String::new(); + let read = c.read_to_string(&mut text); + let waited = t0.elapsed(); + server.join().unwrap(); + let _ = child.wait(); + assert!(read.is_ok(), "the close must be clean, not a reset: {read:?}"); + assert!(text.contains("closing the connection"), "{text}"); + assert!(waited < Duration::from_secs(2), "the client saw the close only after {waited:?}, when the program exited"); + } + #[test] fn tcp_connection_is_closed_after_junk() { // End to end over a real loopback socket (the transport the app uses). From bf82b57a0a8eee7bbfa2c380a8ed8ff675525571 Mon Sep 17 00:00:00 2001 From: Alexander Bystrow <2510233+alexbystrow@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:46:30 +0200 Subject: [PATCH 06/20] LR-IMP-LOCAL: Opening on Missing Photos no longer checks every original Forgetting untouched Local records on open kept the visible photos, and on Missing Photos working them out stats every original on the UI thread before the availability worker runs. A 37k-photo library on a sleeping SMB share froze the window for minutes. Missing Photos lists library photos only, never Local records, so it can keep nothing: skip it. --- crates/engine/src/cmd/browse.rs | 6 ++++- crates/engine/src/tests_forget_local.rs | 35 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 655965688..4f83c77fc 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -148,7 +148,11 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) { /// referred to by an undo / redo step. fn in_use(s: &mut Session) -> std::collections::HashSet { let mut keep = held(s); - keep.extend(s.visible().iter().copied()); + // Missing Photos lists library photos only, never Local records, so it keeps nothing here; and + // working it out checks every original (minutes on a sleeping NAS when the library opens on it) + if s.source != crate::LibrarySource::Missing { + keep.extend(s.visible().iter().copied()); + } keep } diff --git a/crates/engine/src/tests_forget_local.rs b/crates/engine/src/tests_forget_local.rs index 229b262a0..439e2a285 100644 --- a/crates/engine/src/tests_forget_local.rs +++ b/crates/engine/src/tests_forget_local.rs @@ -259,3 +259,38 @@ fn browsing_again_forgets_untouched_records_of_deleted_files() { assert_eq!(s.catalog.to_snapshot(), expect); let _ = std::fs::remove_dir_all(&root); } + +/// A library last shown on Missing Photos opens without checking its originals: that view holds +/// no Local records, so forgetting them needn't work it out (each check can wait for a sleeping +/// NAS; a big library on one froze the window for minutes on start-up). +#[test] +fn opening_on_missing_photos_checks_no_originals() { + let root = temp_dir("missing"); + let (a, lib) = (root.join("a"), root.join("lib")); + for i in 0..5 { + png(&a.join(format!("m{i}.png")), i); + } + let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string())); + let mut s = open(&lib, &clock); + s.execute("library.import", &json!({"paths": [a.to_string_lossy()], "mode": "add"})).unwrap(); + std::fs::remove_dir_all(&a).unwrap(); + s.execute("library.source", &json!({"kind": "missing"})).unwrap(); + assert_eq!(s.visible().len(), 5, "all five are missing"); + s.close_library().unwrap(); + drop(s); + + let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut s = Session::new().with_fs(); + let c = clock.clone(); + s.clock = Box::new(move || c.lock().unwrap().clone()); + let n = checks.clone(); + s.media.availability.set_probe(Arc::new(move |p| { + n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Path::new(p).exists() + })); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.source, LibrarySource::Missing, "the view is restored"); + assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening checked originals"); + assert!(s.library.as_ref().unwrap().forgot_local.is_some(), "forgetting still ran"); + let _ = std::fs::remove_dir_all(&root); +} From 085d18d69248fff65aa9045593299976a65c29e0 Mon Sep 17 00:00:00 2001 From: Alexander Bystrow <2510233+alexbystrow@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:54:33 +0200 Subject: [PATCH 07/20] LR-IMP-LOCAL: Opening on Missing Photos picks no photo from it either Review round 1: with no active photo saved (an empty Missing view leaves none), open_stores worked the view out to pick its first photo, with the same synchronous check of every original. Skip that on Missing Photos; the test now opens both an empty and a full Missing view. --- crates/engine/src/library.rs | 3 ++ crates/engine/src/tests_forget_local.rs | 52 +++++++++++++++++-------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 51655aea5..4d37fa6a2 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -552,7 +552,10 @@ impl Session { self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some()); } + // not on Missing Photos: working that view out checks every original (minutes on a + // sleeping NAS), and the app checks them in the background once it runs if self.selection.active.is_none() + && self.source != LibrarySource::Missing && let Some(first) = self.visible_cloned().first() { self.selection = Selection::single(*first); diff --git a/crates/engine/src/tests_forget_local.rs b/crates/engine/src/tests_forget_local.rs index 439e2a285..9f5d1f572 100644 --- a/crates/engine/src/tests_forget_local.rs +++ b/crates/engine/src/tests_forget_local.rs @@ -260,9 +260,24 @@ fn browsing_again_forgets_untouched_records_of_deleted_files() { let _ = std::fs::remove_dir_all(&root); } +/// Opens `lib` with a probe that counts the originals it checks. +fn open_counting(lib: &Path, clock: &Arc>) -> (Session, Arc) { + let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut s = Session::new().with_fs(); + let c = clock.clone(); + s.clock = Box::new(move || c.lock().unwrap().clone()); + let n = checks.clone(); + s.media.availability.set_probe(Arc::new(move |p| { + n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Path::new(p).exists() + })); + s.open_library(lib, false).unwrap(); + (s, checks) +} + /// A library last shown on Missing Photos opens without checking its originals: that view holds -/// no Local records, so forgetting them needn't work it out (each check can wait for a sleeping -/// NAS; a big library on one froze the window for minutes on start-up). +/// no Local records, so forgetting them needn't work it out, and no photo is picked from it (each +/// check can wait for a sleeping NAS; a big library on one froze the window for minutes). #[test] fn opening_on_missing_photos_checks_no_originals() { let root = temp_dir("missing"); @@ -273,24 +288,29 @@ fn opening_on_missing_photos_checks_no_originals() { let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string())); let mut s = open(&lib, &clock); s.execute("library.import", &json!({"paths": [a.to_string_lossy()], "mode": "add"})).unwrap(); - std::fs::remove_dir_all(&a).unwrap(); s.execute("library.source", &json!({"kind": "missing"})).unwrap(); - assert_eq!(s.visible().len(), 5, "all five are missing"); + // every original is there: the view is empty and nothing is selected + assert!(s.visible().is_empty()); + assert!(s.selection.active.is_none()); s.close_library().unwrap(); drop(s); - - let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mut s = Session::new().with_fs(); - let c = clock.clone(); - s.clock = Box::new(move || c.lock().unwrap().clone()); - let n = checks.clone(); - s.media.availability.set_probe(Arc::new(move |p| { - n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Path::new(p).exists() - })); - s.open_library(&lib, false).unwrap(); + let (s, checks) = open_counting(&lib, &clock); assert_eq!(s.source, LibrarySource::Missing, "the view is restored"); - assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening checked originals"); + assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening an empty Missing view checked originals"); assert!(s.library.as_ref().unwrap().forgot_local.is_some(), "forgetting still ran"); + drop(s); + + // the originals go: the view lists them, one is active + std::fs::remove_dir_all(&a).unwrap(); + let mut s = open(&lib, &clock); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + s.execute("library.source", &json!({"kind": "missing"})).unwrap(); + assert_eq!(s.visible().len(), 5, "all five are missing"); + assert!(s.selection.active.is_some()); + s.close_library().unwrap(); + drop(s); + let (s, checks) = open_counting(&lib, &clock); + assert_eq!(s.source, LibrarySource::Missing); + assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening a full Missing view checked originals"); let _ = std::fs::remove_dir_all(&root); } From a43c3c2a895870d22a07e27bf1c5770572886ba0 Mon Sep 17 00:00:00 2001 From: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:15:48 +0100 Subject: [PATCH 08/20] #1952: expire idle control connections --- apps/lightcraft/src/control_server.rs | 137 +++++++++++++++++++++++--- contributors/people.toml | 3 + crates/mcp/src/backend.rs | 47 +++++++++ docs/control-protocol.md | 12 +++ 4 files changed, 184 insertions(+), 15 deletions(-) diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index 537c75bd1..baad2dbe9 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -39,22 +39,33 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { let open = Arc::new(AtomicUsize::new(0)); - for mut stream in listener.incoming().flatten() { - if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { - open.fetch_sub(1, Ordering::SeqCst); - let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); - continue; - } - let tx = tx.clone(); - let ctx = ctx.clone(); - let open = Arc::clone(&open); - std::thread::spawn(move || { - serve(stream, &tx, &ctx); - open.fetch_sub(1, Ordering::SeqCst); - }); + for stream in listener.incoming().flatten() { + let _ = spawn_connection(stream, tx.clone(), ctx.clone(), Arc::clone(&open), IDLE_TIMEOUT); } } +/// Admit and serve one connection, releasing its slot when serving ends. +fn spawn_connection( + mut stream: TcpStream, + tx: Sender, + ctx: egui::Context, + open: Arc, + idle_timeout: Duration, +) -> Option> { + if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + open.fetch_sub(1, Ordering::SeqCst); + let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); + return None; + } + Some(std::thread::spawn(move || { + serve(stream, &tx, &ctx, idle_timeout); + open.fetch_sub(1, Ordering::SeqCst); + })) +} + +/// Five minutes allows pauses between automation commands without retaining abandoned slots forever. +const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + /// How long a request waits for its reply. const REPLY_TIMEOUT: Duration = Duration::from_secs(60); /// A request the app has not picked up while it drew no frame for this long is given up: the @@ -66,7 +77,11 @@ const NO_FRAMES_AFTER: Duration = Duration::from_secs(5); pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \ (on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again"; -fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { +fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context, idle_timeout: Duration) { + if let Err(e) = stream.set_read_timeout(Some(idle_timeout)) { + log::warn!("control connection closed: could not set idle read timeout: {e}"); + return; + } let Ok(read) = stream.try_clone() else { return }; serve_lines(read, stream, |method, params| { let (req, rrx) = ControlRequest::new(method, params); @@ -157,7 +172,10 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) - buf.clear(); // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); - let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() { + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "control connection idle read timed out; closing the connection".to_string(), + _ => e.to_string(), + })?; if n == 0 { return Ok(false); } @@ -209,6 +227,95 @@ fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(St mod tests { use super::*; + /// Own the client and worker together: even a failing timeout assertion closes the socket + /// before joining, so the regression cannot leave a worker stuck in a read. + struct Connection { + client: BufReader, + requests: Receiver, + worker: Option>, + } + + impl Connection { + fn new(open: &Arc, idle: Duration) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let (stream, _) = listener.accept().unwrap(); + let (tx, requests) = channel(); + let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap(); + Self { client: BufReader::new(client), requests, worker: Some(worker) } + } + + fn join(&mut self) { + if let Some(worker) = self.worker.take() { + worker.join().unwrap(); + } + } + } + + impl Drop for Connection { + fn drop(&mut self) { + let _ = self.client.get_ref().shutdown(std::net::Shutdown::Both); + while let Ok(req) = self.requests.try_recv() { + let _ = req.reply.send(json!({"ok": false, "error": "test closing"})); + } + self.join(); + } + } + + #[test] + fn idle_connections_release_their_slots_without_dispatching() { + for input in ["", "{\"method\":\"engine.execute\""] { + let open = Arc::new(AtomicUsize::new(0)); + let mut connection = Connection::new(&open, Duration::from_millis(100)); + assert_eq!(open.load(Ordering::SeqCst), 1); + connection.client.get_mut().write_all(input.as_bytes()).unwrap(); + let mut reply = String::new(); + let closed = connection.client.read_to_string(&mut reply); + assert!(closed.is_ok(), "idle connection stayed open: {closed:?}"); + connection.join(); + assert_eq!(open.load(Ordering::SeqCst), 0, "the production worker releases its slot"); + assert!(connection.requests.try_recv().is_err(), "incomplete input never runs"); + let reply: Value = serde_json::from_str(&reply).unwrap(); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().contains("idle read timed out")); + + // A subsequent connection is admitted through the same accounting path. + let mut next = Connection::new(&open, Duration::from_secs(1)); + next.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap(); + let mut eof = String::new(); + next.client.read_to_string(&mut eof).unwrap(); + next.join(); + assert!(eof.is_empty()); + assert_eq!(open.load(Ordering::SeqCst), 0); + } + } + + #[test] + fn persistent_requests_and_slow_replies_outlive_the_idle_read_timeout() { + let open = Arc::new(AtomicUsize::new(0)); + let mut connection = Connection::new(&open, Duration::from_millis(100)); + for id in [1, 2] { + writeln!(connection.client.get_mut(), "{}", json!({"id": id, "method": "engine.commands"})).unwrap(); + let req = connection.requests.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!(req.method, "engine.commands"); + assert!(req.take()); + // A dispatched command waits for its reply, not for socket input. + std::thread::sleep(Duration::from_millis(250)); + req.reply.send(json!({"ok": true, "result": id})).unwrap(); + let mut reply = String::new(); + connection.client.read_line(&mut reply).unwrap(); + let reply: Value = serde_json::from_str(&reply).unwrap(); + assert_eq!(reply, json!({"id": id, "ok": true, "result": id})); + } + connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap(); + let mut eof = String::new(); + connection.client.read_to_string(&mut eof).unwrap(); + connection.join(); + assert!(eof.is_empty()); + assert_eq!(open.load(Ordering::SeqCst), 0); + } + /// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given /// up with a reason, quickly, and the app then skips it instead of running it late. #[test] diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..7f00a670b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.ronibalthazar] +sync_github_name = true diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 0c59afa6a..5eca65636 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -376,6 +376,53 @@ mod tests { assert_eq!(seen.load(Ordering::SeqCst), 2); } + #[test] + fn idle_close_reconnects_without_replaying_a_command() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let (closed, expired) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut ids = Vec::new(); + for _ in 0..2 { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(e) => panic!("accept: {e}"), + } + }; + stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + ids.push(request["id"].as_u64().unwrap()); + writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": ids.len()})).unwrap(); + writer.set_read_timeout(Some(Duration::from_millis(50))).unwrap(); + line.clear(); + let error = reader.read_line(&mut line).unwrap_err(); + assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut)); + assert!(line.is_empty(), "no command is resent while idle"); + writeln!(writer, "{}", json!({"ok": false, "error": "control connection idle read timed out; closing the connection"})).unwrap(); + drop(reader); + drop(writer); + closed.send(()).unwrap(); + } + ids + }); + let mut remote = Remote::lazy(&addr); + assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(1)); + expired.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(2)); + expired.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(server.join().unwrap(), [1, 2], "each command ran once on a separate connection"); + } + #[test] fn unreachable_app_is_reported() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 31ecbf8c2..bc69d8bd8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,6 +17,18 @@ callbacks, so the app's loop is paused. Keep the window on screen while you driv headless snapshots or `lightcraft-cli mcp` without `--connect`. A request that has already started (a long command blocks frames) still gets the full 60 s. +Connections close after five minutes without incoming bytes while the server is waiting for +input, including in the middle of an unfinished request line. This allows pauses between +automation commands while releasing abandoned connection slots. An idle read timeout gets an +`ok: false` error saying the connection is closing; incomplete input is never dispatched. +This is a socket read inactivity limit, not a total connection lifetime: a peer that keeps +sending bytes can keep it open. Waiting for an already-dispatched command's reply does not +count as idle reading and retains the request timeout above. + +Reconnect before sending another request after an idle close. MCP connect mode detects closed +connections before sending and reconnects; if a connection is lost after sending a request, +its outcome remains unknown and the client must not blindly replay it. + **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without `method`, invalid UTF-8, or a line longer than 4 MiB — gets one error reply From 089172137b490ad92b8d929caf7ca893e4765a99 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:12:31 +1000 Subject: [PATCH 09/20] test(ui): wait for menu registration and visible sync rows Wait for the registered Edit title before the existing one-click test. Reveal the Sync folder row only when needed, click its visible intersection, and keep geometry in failing assertions. Preserve the original scenarios and pointer interactions. Co-authored-by: Codex --- crates/ui-egui/src/tests_menubar.rs | 1 + crates/ui-egui/src/tests_sync.rs | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tests_menubar.rs b/crates/ui-egui/src/tests_menubar.rs index 8fbc13608..758ac595a 100644 --- a/crates/ui-egui/src/tests_menubar.rs +++ b/crates/ui-egui/src/tests_menubar.rs @@ -116,6 +116,7 @@ fn hovering_another_title_switches_the_open_menu() { #[test] fn clicking_another_title_opens_it_in_one_click() { let mut h = app([1280.0, 703.0]); + assert!(h.step_until(SETTLE, |h| widget(h, "menu:Edit").is_some()), "Edit menu title was not registered"); go(&mut h, "ui.clickWidget", "menu:Edit"); assert_eq!(open_menu(&h).as_deref(), Some("menu:Edit")); // move away from the titles first so only the click can be what switches diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 9e0db5297..379f176c0 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -84,8 +84,24 @@ fn open_and_scan(h: &mut Headless, folder: &str) { // the rows above it hold one folder each and open by themselves let row = format!("source:libfolder:{folder}"); assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}"); + let before = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}")); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel"); + // Off-screen rows are registered too. Preserve the default layout when this row is already visible. + let before_visible = before.intersect(panel); + let before_drawn = h.app.widgets.iter().any(|(w, _)| *w == format!("label:libfolder:{folder}")); + if !before_drawn || !before_visible.is_positive() || before_visible.height() < before.height() - 0.5 { + crate::tests_panels::scroll_sidebar(h, panel.center().y - before.center().y); + } let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}")); - let c = rect.center(); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:left_panel").map(|(_, r)| *r).expect("left panel"); + let visible = rect.intersect(panel); + let c = visible.center(); + let drawn = h.app.widgets.iter().any(|(w, _)| *w == format!("label:libfolder:{folder}")); + let clips: Vec<_> = h.view.shapes.iter().filter(|s| s.shape.visual_bounding_rect().intersects(rect)).map(|s| s.clip_rect).collect(); + assert!( + drawn && visible.is_positive() && visible.height() >= rect.height() - 0.5, + "folder row must be drawn and vertically in view: before={before:?} row={rect:?} panel={panel:?} visible={visible:?} click={c:?} drawn={drawn} paint_clips={clips:?}" + ); let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); From 8c90c3547245fb1b15d2781e390da411fbc96aba Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:27:29 +0200 Subject: [PATCH 10/20] Treat Unicode minus and dash characters as negative in parse_gps Found while testing crates/engine/src/cmd/library.rs:1723 (parse_gps) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/library.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 860cf089f..9249ed588 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1709,6 +1709,8 @@ fn has_library(s: &Session) -> std::result::Result<(), String> { /// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W", /// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs). pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { + // typographic minus signs and dashes mean a negative value, like ASCII '-' + let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-"); let t = t.trim(); // split into the two coordinates: at a comma, else after the first N/S hemisphere letter let (a, b) = match t.split_once(',') { @@ -1746,6 +1748,7 @@ mod gps_tests { assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); + assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093))); assert_eq!(p("95, 10"), None); assert_eq!(p("hello"), None); } From 80a342e9e2a7f20583e140a73f369dcea40088fd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:45:23 +0200 Subject: [PATCH 11/20] Fix parse_gps for hemisphere-letter-first coordinates without comma Found while testing crates/engine/src/cmd/library.rs:1723 (parse_gps) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/library.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 860cf089f..4a7bcde70 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1714,7 +1714,14 @@ pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { let (a, b) = match t.split_once(',') { Some((a, b)) => (a.trim().to_string(), b.trim().to_string()), None => { - let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?; + let lead = t.starts_with(['N', 'S', 'n', 's']); + let i = if lead { + // hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter + t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i) + } else { + t.find(['N', 'S', 'n', 's']).map(|i| i + 1) + } + .or_else(|| t.find(char::is_whitespace))?; (t[..i].trim().to_string(), t[i..].trim().to_string()) } }; @@ -1746,6 +1753,9 @@ mod gps_tests { assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); + assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1))); + assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276))); + assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093))); assert_eq!(p("95, 10"), None); assert_eq!(p("hello"), None); } From 78f0e6e650444886069f4d4ae4e6593f221b98d4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:03:00 +0200 Subject: [PATCH 12/20] Keep file name when a redirect Location is query-only Found while testing crates/fetch/src/http.rs:139 (Url::join) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/fetch/src/http.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 0b1b0ae98..f99454ede 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -137,6 +137,10 @@ impl Url { } // relative to the current directory let dir = self.path.split('?').next().unwrap_or("/"); + // a query-only reference keeps the whole path, file name included (RFC 3986 §5.2.2) + if l.starts_with('?') { + return Url::parse(&format!("{base}{dir}{l}")); + } let dir = dir.rsplit_once('/').map_or("", |(d, _)| d); Url::parse(&format!("{base}{dir}/{l}")) } @@ -618,6 +622,12 @@ mod tests { assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err()); } + #[test] + fn join_query_only_keeps_file_name() { + let u = Url::parse("https://a.example/x/y/file?q=1").unwrap(); + assert_eq!(u.join("?page=2").unwrap().path, "/x/y/file?page=2"); + } + #[test] fn parses_urls() { let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap(); From cee93496587d31c514aaeced3e6b8027cfdb41a0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:21:33 +0000 Subject: [PATCH 13/20] Remove dot segments when resolving redirect Locations Found while testing crates/fetch/src/http.rs:136 (Url::join) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/fetch/src/http.rs | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 0b1b0ae98..d5be997ca 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -58,6 +58,37 @@ pub struct Url { pub path: String, } +/// Remove `.` and `..` segments from the path part of `path` (RFC 3986 section 5.2.4); the query is kept as is. +fn remove_dot_segments(path: &str) -> String { + let (p, query) = match path.find('?') { + Some(i) => (path.get(..i).unwrap_or_default(), path.get(i..).unwrap_or_default()), + None => (path, ""), + }; + let mut out: Vec<&str> = Vec::new(); + let mut segments = p.split('/').skip(1).peekable(); + let mut trailing_slash = false; + while let Some(seg) = segments.next() { + let last = segments.peek().is_none(); + match seg { + "." => trailing_slash = last, + ".." => { + out.pop(); + trailing_slash = last; + } + _ => { + out.push(seg); + trailing_slash = false; + } + } + } + let mut result = format!("/{}", out.join("/")); + if trailing_slash && !result.ends_with('/') { + result.push('/'); + } + result.push_str(query); + result +} + impl Url { pub fn parse(s: &str) -> Result { let bad = |why: &str| HttpError::BadUrl(why.to_string()); @@ -81,6 +112,7 @@ impl Url { None => (rest, ""), }; let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") }; + let path = remove_dot_segments(&path); if authority.contains('@') { return Err(bad("user names and passwords in URLs are not supported")); } @@ -618,6 +650,16 @@ mod tests { assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err()); } + #[test] + fn join_removes_dot_segments() { + let base = Url::parse("https://a.example/x/y/file").unwrap(); + for (loc, want) in + [("../z", "/x/z"), ("./z", "/x/y/z"), ("../../../z", "/z"), ("/a/./b/../c", "/a/c"), ("../z?q=1", "/x/z?q=1"), ("..", "/x/")] + { + assert_eq!(base.join(loc).unwrap().path, want, "{loc}"); + } + } + #[test] fn parses_urls() { let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap(); From dee5f7910ad8c24cf28bab528f8d007550af2b23 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:17:55 +0200 Subject: [PATCH 14/20] Match URL schemes case-insensitively in Url::parse and Url::join Found while testing crates/fetch/src/http.rs:71 (Url::parse), also Url::join at :115 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/fetch/src/http.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 0b1b0ae98..bad39163c 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -58,6 +58,12 @@ pub struct Url { pub path: String, } +/// `s` without the leading `prefix`, matched ASCII case-insensitively (RFC 3986 §3.1). +fn strip_scheme<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..).unwrap_or_default()) +} + impl Url { pub fn parse(s: &str) -> Result { let bad = |why: &str| HttpError::BadUrl(why.to_string()); @@ -68,9 +74,9 @@ impl Url { if s.chars().any(|c| c.is_control() || c == ' ') { return Err(bad("contains spaces or control characters")); } - let (tls, rest) = if let Some(r) = s.strip_prefix("https://") { + let (tls, rest) = if let Some(r) = strip_scheme(s, "https://") { (true, r) - } else if let Some(r) = s.strip_prefix("http://") { + } else if let Some(r) = strip_scheme(s, "http://") { (false, r) } else { return Err(bad("only http:// and https:// are supported")); @@ -112,7 +118,7 @@ impl Url { /// `Location` of a redirect, resolved against this URL. pub fn join(&self, location: &str) -> Result { let l = location.trim(); - if l.starts_with("http://") || l.starts_with("https://") { + if strip_scheme(l, "http://").is_some() || strip_scheme(l, "https://").is_some() { let url = Url::parse(l)?; if self.tls && !url.tls { return Err(HttpError::BadUrl("redirect from https to http refused".into())); @@ -618,6 +624,15 @@ mod tests { assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err()); } + #[test] + fn url_scheme_is_matched_case_insensitively() { + let u = Url::parse("HTTPS://A.example/z").unwrap(); + assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/z")); + assert!(Url::parse("Http://a.example/").is_ok()); + let j = Url::parse("https://a.example/start").unwrap().join("HTTPS://cdn.example/file").unwrap(); + assert_eq!((j.tls, j.host.as_str(), j.path.as_str()), (true, "cdn.example", "/file")); + } + #[test] fn parses_urls() { let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap(); From 286171e2e598c7a9558fcbbac9e7f756ccfbf37a Mon Sep 17 00:00:00 2001 From: tylerphotos <93777540+tylerphotos@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:41:40 -0500 Subject: [PATCH 15/20] LR-IMP-ADD-DIALOG: preserve import destination album hierarchy Reuse the shared album picker in Import Photos, retaining None/New album, editable names, album-context preselection and existing import semantics. Keep long paths within the viewport and cover hierarchy, localization, keyboard choices, real imports, undo and layout regressions. Co-Authored-By: Codex --- crates/ui-egui/src/album_picker.rs | 54 +++- crates/ui-egui/src/import.rs | 57 ++-- crates/ui-egui/src/tests_album_picker.rs | 321 ++++++++++++++++++++++- docs/control-protocol.md | 14 + docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- 6 files changed, 419 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 914827e5d..05e4d6dd3 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -172,13 +172,14 @@ pub struct AlbumPicker<'a> { value: &'a mut Option, entries: &'a [AlbumEntry], width: f32, + empty_text: Option, } impl<'a> AlbumPicker<'a> { /// `id` must be unique among the pickers on screen (it names their widgets and keeps their /// state apart); `value` is the album picked (`None`: none yet); `entries` in tree order. pub fn new(id: impl Into, value: &'a mut Option, entries: &'a [AlbumEntry]) -> Self { - AlbumPicker { id: id.into(), value, entries, width: 180.0 } + AlbumPicker { id: id.into(), value, entries, width: 180.0, empty_text: None } } /// The button's width (180 by default). @@ -187,13 +188,27 @@ impl<'a> AlbumPicker<'a> { self } + /// Text for an empty choice (already localized by the caller); the default is "Choose an album…". + pub fn empty_text(mut self, text: impl Into) -> Self { + self.empty_text = Some(text.into()); + self + } + /// The button and, while open, the popup. The response is `changed()` when an album was picked. pub fn show(self, ui: &mut Ui) -> Response { - let AlbumPicker { id, value, entries, width } = self; + self.show_with_choices(ui, |_| false) + } + + /// Draw caller-owned choices before the search (e.g. None / New album in the import review). + /// Return true when an extra choice was picked to close the popup. The caller handles those + /// actions; `changed()` still reports only an album picked from the tree or search. + pub fn show_with_choices(self, ui: &mut Ui, choices: impl FnOnce(&mut Ui) -> bool) -> Response { + let AlbumPicker { id, value, entries, width, empty_text } = self; let chosen = value.filter(|v| entries.iter().any(|e| e.id == *v && !e.folder)); - let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |c| path_label(entries, c)); + let text = chosen.map_or_else(|| empty_text.unwrap_or_else(|| crate::i18n::tr("Choose an album…").to_string()), |c| path_label(entries, c)); // the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph) - let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" ")); + let mut button = + ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text.as_str()).truncate().right_text(" ")).on_hover_text(text); let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); register(ui.ctx(), format!("albumPicker:{id}"), button.rect); @@ -206,8 +221,15 @@ impl<'a> AlbumPicker<'a> { let mut state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default(); let mut picked: Option = None; egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.set_min_width(width.max(260.0)); - ui.set_max_width(width.max(260.0) + 120.0); + // A wide popup remembered before a window resize must still fit the current viewport. + let margin = egui::Frame::popup(ui.style()).total_margin().sum().x; + let max_width = (ui.ctx().content_rect().width() - margin).max(1.0); + ui.set_min_width(width.max(260.0).min(max_width)); + ui.set_max_width((width.max(260.0) + 120.0).min(max_width)); + if choices(ui) { + ui.close(); + return; + } let search_box = ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY)); register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect); @@ -227,7 +249,9 @@ impl<'a> AlbumPicker<'a> { let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album }; crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon }); - let resp = ui.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label)); + let resp = ui + .add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label).truncate()) + .on_hover_text(label); register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect); let resp = match &e.blocked { Some(why) => resp.on_disabled_hover_text(why.as_str()), @@ -390,6 +414,22 @@ mod tests { assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]); } + #[test] + fn damaged_parent_chains_are_bounded() { + let mut entries = vec![e(1, "Loop A", Some(2), true, false), e(2, "Loop B", Some(1), true, false), e(3, "Photo", Some(1), false, false)]; + set_depths(&mut entries); + assert!(ancestors(&entries, 3).len() <= 3); + assert_eq!(search(&entries, "photo"), vec![2]); + assert_eq!(visible_tree(&entries, &[1, 2]), vec![0, 1, 2]); + let mut deep: Vec<_> = (1..1000).map(|id| e(id, "Deep", Some(id + 1), true, false)).collect(); + deep.push(e(1000, "End", None, true, false)); + set_depths(&mut deep); + assert!(deep.iter().all(|entry| entry.depth <= 65)); + assert!(ancestors(&deep, 1).len() <= 65); + assert!(path_label(&deep, 1).split(" / ").count() <= 66); + assert_eq!(path_label(&[e(1, "Orphan", Some(99), false, false)], 1), "Orphan"); + } + /// Big libraries stay quick: 20,000 albums in nested folders are laid out and searched in well /// under a second (folder paths are worked out once, not per row by scanning every album). #[test] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index e851463d1..e7c80925a 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1238,40 +1238,47 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { ); }); } - let albums: Vec<(u64, String)> = { - let mut v: Vec<(u64, String)> = - app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); - v.sort_by_key(|(_, n)| n.to_lowercase()); - v - }; + let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); + albums.retain(|a| a.folder || !a.smart); field(ui, "Album", |ui| { - let cur = match d.album { - Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(), - None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(), - None => crate::i18n::tr("None").into(), - }; - crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| { - if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() { - d.album = None; + ui.vertical(|ui| { + let mut chosen = d.album; + let mut extra = None; + let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); + let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) + .width(ui.available_width()) + .empty_text(empty) + .show_with_choices(ui, |ui| { + let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); + register(ui.ctx(), "importAlbum:none", none.rect); + if none.clicked() { + extra = Some(false); + } + let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); + register(ui.ctx(), "importAlbum:new", new.rect); + if new.clicked() { + extra = Some(true); + } + ui.separator(); + extra.is_some() + }); + if response.changed() { + d.album = chosen; d.new_album.clear(); } - if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() { + if let Some(new) = extra { d.album = None; - if d.new_album.is_empty() { + if !new { + d.new_album.clear(); + } else if d.new_album.is_empty() { d.new_album = crate::i18n::tr("Imported Photos").into(); } } - for (id, name) in &albums { - if ui.selectable_label(d.album == Some(*id), name).clicked() { - d.album = Some(*id); - d.new_album.clear(); - } + if d.album.is_none() && !d.new_album.is_empty() { + let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); + register(ui.ctx(), "field:importAlbumName", r.rect); } }); - if d.album.is_none() && !d.new_album.is_empty() { - let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); - register(ui.ctx(), "field:importAlbumName", r.rect); - } }); field(ui, "Preset", |ui| { let cur = app diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs index f48e150b9..fe09afd2f 100644 --- a/crates/ui-egui/src/tests_album_picker.rs +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -1,5 +1,5 @@ -//! The album picker in the smart-album rule editor: a tree like the sidebar's, a search that -//! narrows it as you type, keys to pick with, and albums that can't be picked shown greyed. +//! The shared album picker in smart rules and the import review: sidebar hierarchy, path search, +//! keyboard choices, and only valid destinations. Import also retains None and New album. use std::time::Duration; @@ -126,3 +126,320 @@ fn typing_after_a_folder_click_still_searches() { h.settle(SETTLE); assert!(has(&h, &format!("albumPickerItem:{trip}:rules-0")) && !has(&h, &format!("albumPickerItem:{excluded}:rules-0")), "searching for trip"); } + +/// The import review reuses the same tree, while accepting only regular album destinations. +mod import_review { + use super::*; + + struct Review { + h: Headless, + folder: u64, + nested: u64, + album: u64, + other: u64, + smart: u64, + } + + fn review() -> Review { + review_at([1300.0, 950.0], 1.0) + } + + fn review_at(size: [f32; 2], density: f32) -> Review { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, size, density); + let folder = id(exec(&mut h, "album.create", json!({"name": "Travel", "folder": true}))); + let nested = id(exec(&mut h, "album.create", json!({"name": "2026", "folder": true, "parent": folder}))); + let album = id(exec(&mut h, "album.create", json!({"name": "Favorites", "parent": nested, "addSelected": false}))); + let other = id(exec(&mut h, "album.create", json!({"name": "Favorites", "addSelected": false}))); + let smart = id(exec(&mut h, "album.createSmart", json!({"name": "Smart travel", "parent": folder, "rules": {"rating": 4}}))); + h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) }); + h.settle(SETTLE); + Review { h, folder, nested, album, other, smart } + } + + fn destination(h: &Headless) -> &crate::import::ImportDialog { + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("import review remains open") }; + opts + } + + /// #2362: the actual import dialog browses nested folders and remembers the chosen full path. + #[test] + fn import_album_picker_preserves_the_sidebar_hierarchy() { + let Review { mut h, folder, nested, album, other, smart } = review(); + click(&mut h, "albumPicker:import-album"); + assert!(has(&h, &format!("albumPickerFolder:{folder}:import-album"))); + assert!(has(&h, &format!("albumPickerItem:{other}:import-album"))); + assert!(!has(&h, &format!("albumPickerItem:{album}:import-album")), "nested album starts folded"); + click(&mut h, &format!("albumPickerFolder:{folder}:import-album")); + assert_eq!(destination(&h).album, None, "a folder is for browsing, not a destination"); + assert!(!has(&h, &format!("albumPickerItem:{smart}:import-album")), "smart albums cannot receive imports"); + click(&mut h, &format!("albumPickerFolder:{nested}:import-album")); + click(&mut h, &format!("albumPickerItem:{album}:import-album")); + assert_eq!(destination(&h).album, Some(album)); + assert!(destination(&h).new_album.is_empty()); + assert!(h.painted_text().iter().any(|t| t == "Travel / 2026 / Favorites"), "duplicate album names have their full path"); + click(&mut h, "albumPicker:import-album"); + assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "selected album's ancestors reopen"); + } + + fn text(h: &mut Headless, value: &str) { + let r = h.request("ui.text", json!({"text": value}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + } + + fn key(h: &mut Headless, key: &str) { + let r = h.request("ui.key", json!({"key": key}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + } + + fn replace_name(h: &mut Headless, name: &str) { + click(h, "field:importAlbumName"); + let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + text(h, name); + } + + #[test] + fn import_album_picker_retains_none_new_and_existing_transitions() { + let Review { mut h, album, .. } = review(); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + assert_eq!(destination(&h).album, None); + assert_eq!(destination(&h).new_album, "Imported Photos"); + replace_name(&mut h, "Été 東京"); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + assert_eq!(destination(&h).new_album, "Été 東京", "choosing New again keeps the typed name"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "travel 2026 fav"); + key(&mut h, "Enter"); + assert_eq!(destination(&h).album, Some(album)); + assert!(destination(&h).new_album.is_empty(), "existing album replaces a pending new album"); + assert!(!has(&h, "field:importAlbumName")); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + assert_eq!(destination(&h).album, None); + assert!(destination(&h).new_album.is_empty()); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + replace_name(&mut h, "Leave no orphan album"); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + assert!(destination(&h).new_album.is_empty(), "None clears a pending name too"); + } + + /// The picker must leave room for a usable name field; a full-width button followed by an + /// inline name field made the resizable dialog grow on every frame, off the native window. + #[test] + fn import_album_picker_keeps_the_new_name_inside_the_dialog() { + let Review { mut h, .. } = review(); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + for _ in 0..20 { + h.step(); + } + let name = h.app.widgets.iter().find(|(id, _)| id == "field:importAlbumName").unwrap().1; + assert!(name.width() >= 160.0, "the name field is usable: {name:?}"); + assert!(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 950.0)).contains_rect(name), "name stays on screen: {name:?}"); + let picker = |h: &Headless| h.app.widgets.iter().find(|(id, _)| id == "albumPicker:import-album").unwrap().1; + let before = picker(&h); + for _ in 0..10 { + h.step(); + } + let after = picker(&h); + assert!( + (before.width() - after.width()).abs() < 0.5 && (before.left() - after.left()).abs() < 0.5, + "the dialog stops growing: {before:?} -> {after:?}" + ); + } + + #[test] + fn import_album_picker_search_escape_and_unicode_names() { + let Review { mut h, folder, album, .. } = review(); + let unicode = id(exec(&mut h, "album.create", json!({"name": "La fête 東京", "parent": folder, "addSelected": false}))); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + replace_name(&mut h, "Keep this name"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "no such album"); + assert!(h.painted_text().iter().any(|t| t == "No albums match")); + key(&mut h, "Enter"); + assert!(has(&h, "albumPickerSearch:import-album"), "Enter with no result keeps the picker open"); + key(&mut h, "Escape"); + assert!(!has(&h, "albumPickerSearch:import-album")); + assert_eq!(destination(&h).new_album, "Keep this name", "Escape leaves the review's edits intact"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "FÊTE"); + assert!(has(&h, &format!("albumPickerItem:{unicode}:import-album"))); + assert!(!has(&h, &format!("albumPickerItem:{album}:import-album"))); + key(&mut h, "Enter"); + assert_eq!(destination(&h).album, Some(unicode)); + assert!(h.painted_text().iter().any(|t| t == "Travel / La fête 東京"), "user names are kept verbatim"); + } + + #[test] + fn import_album_picker_follows_custom_sidebar_order() { + let Review { mut h, other, .. } = review(); + let last_by_name = id(exec(&mut h, "album.create", json!({"name": "Zebra", "addSelected": false}))); + exec(&mut h, "album.reorder", json!({"id": last_by_name, "before": other})); + click(&mut h, "albumPicker:import-album"); + let top = |id| h.app.widgets.iter().find(|(w, _)| w == &format!("albumPickerItem:{id}:import-album")).unwrap().1.top(); + assert!(top(last_by_name) < top(other), "sidebar order takes priority over alphabetical order"); + } + + fn button_text(h: &Headless, id: &str, expected: &str) -> bool { + let rect = h.app.widgets.iter().find(|(w, _)| w == id).unwrap().1; + h.view.painted_shapes().iter().any(|shape| matches!(shape, egui::Shape::Text(t) if t.galley.job.text == expected && rect.contains(t.pos))) + } + + #[test] + fn import_album_picker_fits_long_names_across_themes_sizes_and_densities() { + for (size, density) in [([960.0, 760.0], 1.0), ([1366.0, 900.0], 2.0), ([960.0, 760.0], 2.25)] { + let Review { mut h, folder, .. } = review_at(size, density); + let long = "La fête des photographies – 東京 – ".repeat(12); + let album = id(exec(&mut h, "album.create", json!({"name": long, "parent": folder, "addSelected": false}))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("review") }; + opts.album = Some(album); + for theme in ["charcoal", "midnight", "paper", "silver"] { + exec(&mut h, "view.theme", json!({"theme": theme})); + let r = h.request("ui.set", json!({"language": "de"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let rect = |h: &Headless, name: &str| h.app.widgets.iter().find(|(id, _)| id == name).unwrap().1; + assert!(screen.contains_rect(rect(&h, "albumPicker:import-album")), "{theme} {size:?}: selected long path"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "fête"); + let row = rect(&h, &format!("albumPickerItem:{album}:import-album")); + assert!(screen.contains_rect(row), "{theme} {size:?}: search result {row:?} inside {screen:?}"); + key(&mut h, "Escape"); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:new"); + let name = rect(&h, "field:importAlbumName"); + assert!(name.width() >= 160.0 && screen.contains_rect(name), "{theme} {size:?}: name {name:?}"); + click(&mut h, "albumPicker:import-album"); + text(&mut h, "fête"); + key(&mut h, "Enter"); + assert_eq!(destination(&h).album, Some(album)); + } + } + } + + #[test] + fn import_album_picker_localizes_choices_in_every_shipped_language() { + use crate::i18n::Locale; + let Review { mut h, .. } = review(); + for locale in Locale::ALL { + for source in + ["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"] + { + assert!(!locale.tr(source).is_empty()); + if *locale != Locale::En { + assert_ne!(locale.tr(source), source, "{locale:?}: {source}"); + } + } + let r = h.request("ui.set", json!({"language": locale.code()}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + assert!(button_text(&h, "albumPicker:import-album", locale.tr("None")), "{locale:?}: localized empty destination"); + click(&mut h, "albumPicker:import-album"); + assert!(button_text(&h, "importAlbum:new", locale.tr("New album…")), "{locale:?}: localized new-album option"); + click(&mut h, "importAlbum:new"); + assert_eq!(destination(&h).new_album, locale.tr("Imported Photos")); + assert!(button_text(&h, "albumPicker:import-album", locale.tr("New album"))); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + } + } + + #[test] + fn import_album_picker_handles_an_empty_catalog() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1000.0, 800.0], 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) }); + assert!(h.settle(SETTLE)); + click(&mut h, "albumPicker:import-album"); + assert!(h.painted_text().iter().any(|t| t == "No albums yet")); + click(&mut h, "importAlbum:new"); + assert!(has(&h, "field:importAlbumName")); + click(&mut h, "albumPicker:import-album"); + click(&mut h, "importAlbum:none"); + assert!(destination(&h).new_album.is_empty()); + } + + struct Fixture(std::path::PathBuf); + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn fixture() -> Fixture { + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("lc-import-tree-{}-{unique}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + let pixels = + lightcraft_raster::Rgba8 { width: 40, height: 30, data: (0..1200).map(|i| [(i % 40 * 6) as u8, 31, (i / 40 * 8) as u8, 255]).collect() }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&pixels), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(path.join("hierarchy.png"), png).unwrap(); + Fixture(path) + } + + /// A real file scan, the real picker, and the real import worker: the intended album receives + /// the photo, duplicate names never redirect it, and the import is still one undo step. + #[test] + fn import_album_picker_delivers_to_the_chosen_destination_and_undoes() { + use lightcraft_catalog::PhotoId; + for choice in ["existing", "new", "none"] { + let fixture = fixture(); + let Review { mut h, album, other, .. } = review(); + let before: std::collections::HashSet = h.app.session.catalog.photos().map(|p| p.id).collect(); + h.app.ui.dialog = None; + exec(&mut h, "file.addPhotos", json!({"paths": [fixture.0.to_string_lossy()], "album": album})); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); + assert!(h.settle(SETTLE)); + assert_eq!(destination(&h).album, Some(album), "album-context import keeps its preselection"); + click(&mut h, "albumPicker:import-album"); + assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "preselection opens the right ancestors"); + match choice { + "existing" => { + text(&mut h, "travel 2026 fav"); + key(&mut h, "Enter"); + } + "new" => { + click(&mut h, "importAlbum:new"); + replace_name(&mut h, "From card"); + } + _ => click(&mut h, "importAlbum:none"), + } + let undo = h.app.session.undo.len(); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.import.is_none())); + assert!(h.settle(SETTLE)); + let added: Vec = h.app.session.catalog.photos().filter(|p| !before.contains(&p.id)).map(|p| p.id).collect(); + assert_eq!(added.len(), 1, "{choice}: exactly the scanned photo is imported"); + let receives = |id| h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().photos.contains(&added[0]); + assert!(!receives(other), "same-named album outside the chosen folder is untouched"); + match choice { + "existing" => assert!(receives(album)), + "new" => { + let created = h.app.session.catalog.albums().find(|a| a.name == "From card").unwrap(); + assert!(created.photos.contains(&added[0])); + assert!(!receives(album)); + } + _ => assert!(h.app.session.catalog.albums().all(|a| !a.photos.contains(&added[0]))), + } + assert_eq!(h.app.session.undo.len(), undo + 1, "{choice}: one undo step"); + exec(&mut h, "edit.undo", json!({})); + assert!(h.settle(SETTLE)); + assert_eq!(h.app.session.catalog.len(), before.len()); + if choice == "new" { + assert!(h.app.session.catalog.albums().all(|a| a.name != "From card"), "undo removes the new album too"); + } + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 31ecbf8c2..98ef401e7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -281,6 +281,20 @@ one pixel. (clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets` (e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build). +## Import album picker + +In the Import Photos review, `albumPicker:import-album` opens the destination picker. +`albumPickerFolder::import-album` expands or folds a folder; it never chooses a destination. +`albumPickerItem::import-album` selects a regular album. The picker follows sidebar order, +opens the current choice's ancestors and shows its full path on the closed button. Smart albums +are not import destinations. Type into `albumPickerSearch:import-album` to search folder/album paths; +↑ / ↓ and Enter pick a search result, and Escape closes the picker while keeping the review open. + +`importAlbum:none` clears the destination and any pending new album name. `importAlbum:new` keeps +or initializes that name, edited through `field:importAlbumName`. Choosing an existing album clears +the pending name. `ui.dialog.confirm` starts the same import into the selected album ID; engine +`library.import` parameters `album` and `albumName` are unchanged. + ## Panel docking Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. diff --git a/docs/gaps.md b/docs/gaps.md index 1b4a385fa..2b36b012a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (import review album hierarchy, #2362; no ranked gap closed) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes. + - 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export. - 2026-10-11: Sony ILCA-99M2 profile. @@ -77,6 +79,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | | 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | | 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | | 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..fce186e2e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-IMP-ADD-DIALOG: hierarchical import album destination, #2362) · **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 @@ -71,7 +71,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | | 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | -| 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-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): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); 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); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | From c3a5d9b7f1f837dc14c01942e3d0293ecbf36345 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:13:10 +0000 Subject: [PATCH 16/20] Keep file name and query for fragment-only redirect Location Found while testing crates/fetch/src/http.rs:139 (Url::join) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/fetch/src/http.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 0b1b0ae98..4566b3d99 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -135,6 +135,10 @@ impl Url { if l.starts_with('/') { return Url::parse(&format!("{base}{l}")); } + // empty or fragment-only: the same document + if l.is_empty() || l.starts_with('#') { + return Ok(self.clone()); + } // relative to the current directory let dir = self.path.split('?').next().unwrap_or("/"); let dir = dir.rsplit_once('/').map_or("", |(d, _)| d); @@ -612,6 +616,15 @@ mod tests_connect { mod tests { use super::*; + #[test] + fn fragment_only_or_empty_location_keeps_file_name_and_query() { + let base = Url::parse("https://a.example/x/y/file?q").unwrap(); + for l in ["#top", "", " "] { + let u = base.join(l).unwrap(); + assert_eq!((u.tls, u.host.as_str(), u.port, u.path.as_str()), (true, "a.example", 443, "/x/y/file?q"), "{l:?}"); + } + } + #[test] fn rejects_oversized_and_control_character_urls() { assert!(Url::parse(&format!("https://example.com/{}", "x".repeat(16 * 1024))).is_err()); From 4cb90d4b7ee515917f48539f5fa10b892fcbb3b0 Mon Sep 17 00:00:00 2001 From: tylerphotos <93777540+tylerphotos@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:34:52 -0500 Subject: [PATCH 17/20] LR-IMP-ADD-DIALOG: clear deleted import destinations Keep the stored destination consistent with the picker's None fallback after catalog changes. Extend the real-file import regression to delete the chosen album before confirmation, then verify import membership and undo. Document the helper contracts and destination transitions for review coverage. Co-Authored-By: Codex --- crates/ui-egui/src/album_picker.rs | 1 + crates/ui-egui/src/import.rs | 2 ++ crates/ui-egui/src/tests_album_picker.rs | 30 +++++++++++++++++++++++- docs/control-protocol.md | 2 ++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 05e4d6dd3..835dbcce8 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -414,6 +414,7 @@ mod tests { assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]); } + /// Cycles, dangling parents and excessively deep chains finish with bounded paths and depths. #[test] fn damaged_parent_chains_are_bounded() { let mut entries = vec![e(1, "Loop A", Some(2), true, false), e(2, "Loop B", Some(1), true, false), e(3, "Photo", Some(1), false, false)]; diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index e7c80925a..188efca44 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1240,6 +1240,8 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); albums.retain(|a| a.folder || !a.smart); + // Catalog changes while the review is open must not leave a hidden, unselectable destination. + d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); field(ui, "Album", |ui| { ui.vertical(|ui| { let mut chosen = d.album; diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs index fe09afd2f..32cc906fe 100644 --- a/crates/ui-egui/src/tests_album_picker.rs +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -12,26 +12,31 @@ use crate::{LightcraftApp, Services}; const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); +/// Dispatch a public command and return its successful engine result. fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { let r = h.request("engine.execute", json!({"command": command, "params": params}), T); assert_eq!(r["ok"], true, "{command}: {r}"); r["result"].clone() } +/// Read the album ID returned by a successful creation command. fn id(v: serde_json::Value) -> u64 { v["id"].as_u64().expect("an id") } +/// Click a registered widget through the public control channel and let frames settle. fn click(h: &mut Headless, id: &str) { let r = h.request("ui.clickWidget", json!({"id": id}), T); assert_eq!(r["ok"], true, "{id}: {r}"); h.settle(SETTLE); } +/// Check the current frame registry for an exact widget ID. fn has(h: &Headless, id: &str) -> bool { h.app.widgets.iter().any(|(w, _)| w == id) } +/// Read the Album rule value from the active smart-rule editor. fn value(h: &Headless) -> serde_json::Value { let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; let Some(Rule::Field { value, .. }) = rules.rules.first() else { panic!("no rule") }; @@ -140,10 +145,12 @@ mod import_review { smart: u64, } + /// Open the standard import review fixture with nested and same-named albums. fn review() -> Review { review_at([1300.0, 950.0], 1.0) } + /// Build the import review fixture at the requested logical size and display density. fn review_at(size: [f32; 2], density: f32) -> Review { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, size, density); @@ -157,6 +164,7 @@ mod import_review { Review { h, folder, nested, album, other, smart } } + /// Read the active import review without accepting a silently closed dialog. fn destination(h: &Headless) -> &crate::import::ImportDialog { let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("import review remains open") }; opts @@ -182,18 +190,21 @@ mod import_review { assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "selected album's ancestors reopen"); } + /// Type through the public control channel and wait for the search or field to update. fn text(h: &mut Headless, value: &str) { let r = h.request("ui.text", json!({"text": value}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.settle(SETTLE)); } + /// Send a public keyboard event and wait for its resulting UI state. fn key(h: &mut Headless, key: &str) { let r = h.request("ui.key", json!({"key": key}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.settle(SETTLE)); } + /// Replace the pending new album name using normal field selection and typing. fn replace_name(h: &mut Headless, name: &str) { click(h, "field:importAlbumName"); let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); @@ -201,6 +212,7 @@ mod import_review { text(h, name); } + /// None, New and an existing album replace each other without retaining a stale pending name. #[test] fn import_album_picker_retains_none_new_and_existing_transitions() { let Review { mut h, album, .. } = review(); @@ -255,6 +267,7 @@ mod import_review { ); } + /// Path search is case-insensitive and Escape preserves review edits and user-provided names. #[test] fn import_album_picker_search_escape_and_unicode_names() { let Review { mut h, folder, album, .. } = review(); @@ -279,6 +292,7 @@ mod import_review { assert!(h.painted_text().iter().any(|t| t == "Travel / La fête 東京"), "user names are kept verbatim"); } + /// The catalog custom order wins over alphabetical order in the import picker. #[test] fn import_album_picker_follows_custom_sidebar_order() { let Review { mut h, other, .. } = review(); @@ -289,11 +303,13 @@ mod import_review { assert!(top(last_by_name) < top(other), "sidebar order takes priority over alphabetical order"); } + /// Match painted text inside the specified control instead of anywhere in the frame. fn button_text(h: &Headless, id: &str, expected: &str) -> bool { let rect = h.app.widgets.iter().find(|(w, _)| w == id).unwrap().1; h.view.painted_shapes().iter().any(|shape| matches!(shape, egui::Shape::Text(t) if t.galley.job.text == expected && rect.contains(t.pos))) } + /// Long paths and editable names remain usable across themes, viewport sizes and display densities. #[test] fn import_album_picker_fits_long_names_across_themes_sizes_and_densities() { for (size, density) in [([960.0, 760.0], 1.0), ([1366.0, 900.0], 2.0), ([960.0, 760.0], 2.25)] { @@ -327,6 +343,7 @@ mod import_review { } } + /// Every shipped locale paints translated destination choices and initializes a translated new name. #[test] fn import_album_picker_localizes_choices_in_every_shipped_language() { use crate::i18n::Locale; @@ -354,6 +371,7 @@ mod import_review { } } + /// An empty catalog still offers None and New without inventing an existing destination. #[test] fn import_album_picker_handles_an_empty_catalog() { let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }); @@ -372,11 +390,13 @@ mod import_review { struct Fixture(std::path::PathBuf); impl Drop for Fixture { + /// Remove only the uniquely owned fixture directory after the test session releases it. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } + /// Write an original procedural PNG in a unique temporary directory for a real import. fn fixture() -> Fixture { let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let path = std::env::temp_dir().join(format!("lc-import-tree-{}-{unique}", std::process::id())); @@ -390,10 +410,11 @@ mod import_review { /// A real file scan, the real picker, and the real import worker: the intended album receives /// the photo, duplicate names never redirect it, and the import is still one undo step. + /// A destination deleted while the review is open falls back to no album before confirmation. #[test] fn import_album_picker_delivers_to_the_chosen_destination_and_undoes() { use lightcraft_catalog::PhotoId; - for choice in ["existing", "new", "none"] { + for choice in ["existing", "new", "none", "deleted"] { let fixture = fixture(); let Review { mut h, album, other, .. } = review(); let before: std::collections::HashSet = h.app.session.catalog.photos().map(|p| p.id).collect(); @@ -413,6 +434,13 @@ mod import_review { click(&mut h, "importAlbum:new"); replace_name(&mut h, "From card"); } + "deleted" => { + key(&mut h, "Escape"); + exec(&mut h, "album.delete", json!({"id": album})); + assert!(h.settle(SETTLE)); + assert_eq!(destination(&h).album, None, "a deleted destination matches the displayed None choice"); + assert!(button_text(&h, "albumPicker:import-album", "None")); + } _ => click(&mut h, "importAlbum:none"), } let undo = h.app.session.undo.len(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 98ef401e7..4c4c686ac 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -289,6 +289,8 @@ In the Import Photos review, `albumPicker:import-album` opens the destination pi opens the current choice's ancestors and shows its full path on the closed button. Smart albums are not import destinations. Type into `albumPickerSearch:import-album` to search folder/album paths; ↑ / ↓ and Enter pick a search result, and Escape closes the picker while keeping the review open. +If the chosen album is deleted while the review is open, the destination returns to None before +confirmation instead of submitting the deleted ID. `importAlbum:none` clears the destination and any pending new album name. `importAlbum:new` keeps or initializes that name, edited through `field:importAlbumName`. Choosing an existing album clears From d87a8714adf33d4963dc08b2aef692815ccdb52b Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Wed, 21 Oct 2026 18:52:24 +0200 Subject: [PATCH 18/20] LR-MERGE-HDR: merge warps sample Lanczos-3 instead of bilinear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #1887 (part 1 of 3). Every warp in the merge crate (HDR alignment, panorama projection, Boundary Warp) sampled bilinearly: at a half-pixel offset that is a [½ ½] filter, which keeps cos²(πf) of a pattern's fine detail (a third at 0.3 cycles per pixel). - lightcraft_raster::sample: Rgb32f::sample_lanczos3, a 6 × 6 Lanczos-3 point sampler with tabulated taps, each sample held to the range of the 4 × 4 pixels around it so hard edges (clipped highlights, stars) don't ring. - align::warp, the panorama projection and Boundary Warp use it. - align.rs: the merge luminance (`luma`) and the simplest-model choice among RANSAC fits (`choose_model`) are shared functions, for the full-size refinement that builds on them. Co-Authored-By: Claude Opus 5.5 --- crates/merge/src/align.rs | 62 +++++++++---- crates/merge/src/pano/finish.rs | 2 +- crates/merge/src/pano/mod.rs | 2 +- crates/raster/src/lib.rs | 4 +- crates/raster/src/resample.rs | 3 +- crates/raster/src/sample.rs | 154 ++++++++++++++++++++++++++++++++ docs/merge.md | 4 +- docs/parity-checklist.md | 7 +- docs/whats-new.md | 7 ++ 9 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 crates/raster/src/sample.rs diff --git a/crates/merge/src/align.rs b/crates/merge/src/align.rs index c977c8b53..34a606155 100644 --- a/crates/merge/src/align.rs +++ b/crates/merge/src/align.rs @@ -13,13 +13,49 @@ use crate::ransac::{self, Model}; /// Long edge of the images features are detected on. pub const WORK_EDGE: usize = 1200; +/// The luminance merges align and weigh by (camera or linear RGB; not a colorimetric one). +#[inline] +pub(crate) fn luma(c: [f32; 3]) -> f32 { + 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2] +} + +/// The motion models a fit chooses among, simplest first: (model, name, fewest inliers). +pub type Models = [(Model, &'static str, usize)]; + +/// Of `models` fitted by RANSAC to `a` → `b` (`thresh` px; at least `share` of the points and each +/// model's minimum as inliers; plausible for a `w × h` frame), the simplest that explains (almost) as +/// many points as the one explaining the most: extra degrees of freedom fitted to clustered points +/// extrapolate badly. +pub(crate) fn choose_model( + models: &Models, + a: &[Point], + b: &[Point], + thresh: f64, + share: f64, + (w, h): (f64, f64), + seed: u64, +) -> Option<(&'static str, ransac::Fit)> { + let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new(); + for &(model, name, min) in models { + let need = ((a.len() as f64 * share).ceil() as usize).max(min); + if let Some(f) = ransac::ransac(model, a, b, thresh, need, seed) + && plausible(&f.h, w, h, 0.12) + { + fits.push((name, f)); + } + } + let most = fits.iter().map(|(_, f)| f.inliers.len()).max()?; + // the fit with `most` inliers always qualifies + fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) +} + /// A grey, perceptually encoded work image: `(luminance · gain)^(1/2.2)`, clamped to 0..1, and /// the scale from full-resolution pixels to work pixels. pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) { let small = if img.width.max(img.height) > max_edge { fit(img, max_edge, max_edge, Filter::Box) } else { img.clone() }; let scale = small.width as f64 / img.width as f64; let p = small.map(|c| { - let y = (0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]) * gain; + let y = luma(c) * gain; y.clamp(0.0, 1.0).powf(1.0 / 2.2) }); (p, scale) @@ -29,8 +65,7 @@ pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) { /// without EXIF. pub fn median_luma(img: &Rgb32f, clip: f32) -> f32 { let step = (img.data.len() / 100_000).max(1); - let mut v: Vec = - img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]).collect(); + let mut v: Vec = img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| luma(*c)).collect(); if v.is_empty() { return clip; } @@ -86,24 +121,13 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz let a: Vec = m.iter().map(|(i, _)| Point::new(ref_feat.points[*i].x as f64, ref_feat.points[*i].y as f64)).collect(); let b: Vec = m.iter().map(|(_, j)| Point::new(feat.points[*j].x as f64, feat.points[*j].y as f64)).collect(); let (ww, wh) = (full_w as f64 * scale, full_h as f64 * scale); - // Fit every model; keep the simplest one that explains (almost) as many matches as the most - // general one — extra degrees of freedom fitted to clustered points extrapolate badly. - let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new(); - for (model, name, min) in [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)] { - if let Some(f) = ransac::ransac(model, &a, &b, 1.5, min, seed) - && plausible(&f.h, ww, wh, 0.12) - { - fits.push((name, f)); - } - } - let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() }; - // the fit with `most` inliers always qualifies - let Some((name, f)) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97) else { return Alignment::identity() }; + let models = [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)]; + let Some((name, f)) = choose_model(&models, &a, &b, 1.5, 0.0, (ww, wh), seed) else { return Alignment::identity() }; Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale } } -/// Resample `img` into the reference geometry: `out(p) = img(h(p))`. Returns the warped image and -/// a validity mask (false where `h(p)` falls outside `img`). +/// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped +/// image and a validity mask (false where `h(p)` falls outside `img`). pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec) { let (w, ht) = (img.width, img.height); if *h == Homography::IDENTITY { @@ -116,7 +140,7 @@ pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec) { let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); let (sx, sy) = (p.x as f32, p.y as f32); if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 { - row[x] = img.sample_bilinear(sx, sy); + row[x] = img.sample_lanczos3(sx, sy); vrow[x] = true; } } diff --git a/crates/merge/src/pano/finish.rs b/crates/merge/src/pano/finish.rs index ac57a18d0..8dc89e536 100644 --- a/crates/merge/src/pano/finish.rs +++ b/crates/merge/src/pano/finish.rs @@ -76,7 +76,7 @@ fn stretch(img: &Rgb32f, alpha: &Plane, amount: f64, vertical: bool) -> (Rgb32f, let src = lo[i] + t * (hi[i] - lo[i]); let s = (1.0 - amount) * (k as f64 + 0.5) + amount * src; let (sx, sy) = if vertical { (x as f32 + 0.5, s as f32) } else { (s as f32, y as f32 + 0.5) }; - row[x] = img.sample_bilinear(sx, sy); + row[x] = img.sample_lanczos3(sx, sy); arow[x] = crate::features::bilinear(alpha, sx, sy); } }); diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 4211f6503..5ce439681 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -298,7 +298,7 @@ pub fn stitch(mut frames: Vec, opts: &PanoOptions, progress: &Progress) - let wx = 1.0 - (2.0 * px / fw - 1.0).abs(); let wy = 1.0 - (2.0 * py / fh - 1.0).abs(); let w = (wx * wy).max(1e-6) as f32; - row[tx] = fr.image.sample_bilinear(px as f32, py as f32); + row[tx] = fr.image.sample_lanczos3(px as f32, py as f32); wrow[tx] = w; } }); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 0347b8fcc..76d48872d 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -2,7 +2,8 @@ //! //! - [`Image`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`, //! `Plane = Image`). -//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel. +//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel; point sampling for warps +//! ([`sample`]: Lanczos-3 held to its neighbours' range). //! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius). //! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius). //! - [`Histogram`] of display-encoded RGB + luminance. @@ -13,6 +14,7 @@ pub mod blur; pub mod histogram; pub mod morph; pub mod resample; +pub mod sample; pub use histogram::{HdrBins, Histogram}; diff --git a/crates/raster/src/resample.rs b/crates/raster/src/resample.rs index a57595838..afe0c10d7 100644 --- a/crates/raster/src/resample.rs +++ b/crates/raster/src/resample.rs @@ -19,7 +19,8 @@ impl Filter { Filter::Lanczos3 => 3.0, } } - fn eval(self, x: f32) -> f32 { + /// The filter's weight at distance `x` (pixels of the source). + pub(crate) fn eval(self, x: f32) -> f32 { let x = x.abs(); match self { Filter::Box => { diff --git a/crates/raster/src/sample.rs b/crates/raster/src/sample.rs new file mode 100644 index 000000000..99ebc0abe --- /dev/null +++ b/crates/raster/src/sample.rs @@ -0,0 +1,154 @@ +//! Point sampling at continuous coordinates for geometric warps (alignment, projections). +//! +//! [`Rgb32f::sample_lanczos3`] interpolates with a 6 × 6 Lanczos-3 kernel. Bilinear sampling (the +//! [`Rgb32f::sample_bilinear`] next to it) averages two pixels at a half-pixel offset: a [½ ½] +//! filter that passes nothing at the Nyquist frequency, so a frame warped by a fraction of a pixel +//! loses its finest detail. Lanczos-3 keeps most of it. Its negative lobes would overshoot next to +//! a hard edge (a clipped highlight, a star), so each sample is held to the range of the 4 × 4 +//! pixels around it, channel by channel: in smooth areas and fine texture that range holds the +//! interpolated value anyway (a 2 × 2 range would cut the peaks of texture near Nyquist that fall +//! between pixels), and at an edge the overshoot goes. + +use std::sync::OnceLock; + +use crate::Rgb32f; +use crate::resample::Filter; + +/// Phases of a pixel the kernel's taps are tabulated for. +const PHASES: usize = 1024; + +/// The six Lanczos-3 weights (summing to 1) for a sample `t` (0..1) past the third of the six +/// pixels it reads: pixel `k` (0..6) is at distance `t + 2 − k`. +pub fn lanczos3_taps(t: f32) -> [f32; 6] { + static TABLE: OnceLock> = OnceLock::new(); + let table = TABLE.get_or_init(|| { + (0..=PHASES) + .map(|i| { + let t = i as f32 / PHASES as f32; + let w: [f32; 6] = std::array::from_fn(|k| Filter::Lanczos3.eval(t + 2.0 - k as f32)); + let s: f32 = w.iter().sum(); + w.map(|v| v / s) + }) + .collect() + }); + let i = if t.is_finite() { (t.clamp(0.0, 1.0) * PHASES as f32).round() as usize } else { 0 }; + table.get(i).copied().unwrap_or([0.0, 0.0, 1.0, 0.0, 0.0, 0.0]) +} + +impl Rgb32f { + /// Lanczos-3 sample at continuous pixel coordinates (pixel centres at +0.5), held to the range + /// of the 4 × 4 pixels around it (see the module docs). Past the edges the edge pixels repeat; + /// a coordinate that isn't finite reads the nearest edge, as [`Rgb32f::sample_bilinear`] does. + pub fn sample_lanczos3(&self, x: f32, y: f32) -> [f32; 3] { + let (w, h) = (self.width, self.height); + if w == 0 || h == 0 { + return [0.0; 3]; + } + let fx = x.clamp(-1.0, w as f32 + 1.0) - 0.5; + let fy = y.clamp(-1.0, h as f32 + 1.0) - 0.5; + let (fx, fy) = (if fx.is_finite() { fx } else { 0.0 }, if fy.is_finite() { fy } else { 0.0 }); + let (x0, y0) = (fx.floor(), fy.floor()); + let (wx, wy) = (lanczos3_taps(fx - x0), lanczos3_taps(fy - y0)); + let (x0, y0) = (x0 as isize, y0 as isize); + let col: [usize; 6] = std::array::from_fn(|k| (x0 + k as isize - 2).clamp(0, w as isize - 1) as usize); + let row: [usize; 6] = std::array::from_fn(|k| (y0 + k as isize - 2).clamp(0, h as isize - 1) as usize * w); + let px = |r: usize, c: usize| self.data.get(r + c).copied().unwrap_or_default(); + let mut acc = [0f32; 3]; + // and the range of the 4 × 4 pixels around the sample (taps 1 to 4 on each axis) + let mut lo = [f32::INFINITY; 3]; + let mut hi = [f32::NEG_INFINITY; 3]; + for (j, (r, &wr)) in row.iter().zip(&wy).enumerate() { + let mut line = [0f32; 3]; + for (i, (&c, &wc)) in col.iter().zip(&wx).enumerate() { + let p = px(*r, c); + for ch in 0..3 { + line[ch] += wc * p[ch]; + } + if (1..5).contains(&i) && (1..5).contains(&j) { + for ch in 0..3 { + lo[ch] = lo[ch].min(p[ch]); + hi[ch] = hi[ch].max(p[ch]); + } + } + } + for ch in 0..3 { + acc[ch] += wr * line[ch]; + } + } + // no finite pixel in the 4 × 4 range (NaN all around): nothing to hold the sample to + std::array::from_fn(|ch| if lo[ch] <= hi[ch] { acc[ch].clamp(lo[ch], hi[ch]) } else { acc[ch] }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// High-pass energy of a row-wise pattern: the mean squared second difference. + fn hp(img: &Rgb32f) -> f64 { + let w = img.width; + let mut s = 0.0; + let mut n = 0; + for y in 4..img.height - 4 { + for x in 4..w - 4 { + let v = |dx: isize| img.get((x as isize + dx) as usize, y)[1] as f64; + s += (2.0 * v(0) - v(-1) - v(1)).powi(2); + n += 1; + } + } + s / n as f64 + } + + #[test] + fn taps_sum_to_one_and_hit_pixels_exactly() { + for t in [0.0, 0.1, 0.5, 0.77, 1.0] { + let w = lanczos3_taps(t); + assert!((w.iter().sum::() - 1.0).abs() < 1e-5); + } + let w = lanczos3_taps(0.0); + assert!((w[2] - 1.0).abs() < 1e-5 && w.iter().enumerate().all(|(k, v)| k == 2 || v.abs() < 1e-5)); + assert_eq!(lanczos3_taps(f32::NAN), lanczos3_taps(0.0)); + } + + /// A fine pattern moved by half a pixel: bilinear keeps cos²(π f) of its high-pass energy (a + /// third at 0.3 cycles per pixel), Lanczos-3 twice that, and nearly all of it at 0.2. + #[test] + fn a_half_pixel_shift_keeps_fine_detail() { + for (f, lanczos_min, over_bilinear) in [(0.2f32, 0.9, 1.3), (0.3, 0.65, 1.9)] { + let img = Rgb32f::from_fn(64, 16, |x, _| [0.5 + 0.4 * (x as f32 * f * std::f32::consts::TAU).sin(); 3]); + let shifted = |s: &dyn Fn(f32, f32) -> [f32; 3]| Rgb32f::from_fn(64, 16, |x, y| s(x as f32 + 1.0, y as f32 + 0.5)); + let bil = shifted(&|x, y| img.sample_bilinear(x, y)); + let lan = shifted(&|x, y| img.sample_lanczos3(x, y)); + let (e0, eb, el) = (hp(&img), hp(&bil), hp(&lan)); + assert!(el > lanczos_min * e0, "{f} c/px: lanczos {el} vs {e0}"); + assert!(el > over_bilinear * eb, "{f} c/px: lanczos {el} vs bilinear {eb}"); + } + } + + /// Next to a hard edge Lanczos-3 would overshoot; the sample stays within its neighbours. + #[test] + fn no_overshoot_at_a_hard_edge() { + let img = Rgb32f::from_fn(16, 4, |x, _| if x < 8 { [0.0; 3] } else { [1.0; 3] }); + for i in 0..160 { + let v = img.sample_lanczos3(i as f32 * 0.1, 2.0); + assert!((0.0..=1.0).contains(&v[0]), "{v:?} at {}", i as f32 * 0.1); + } + // pixel centres come back exactly + assert_eq!(img.sample_lanczos3(8.5, 1.5), [1.0; 3]); + assert_eq!(img.sample_lanczos3(7.5, 1.5), [0.0; 3]); + } + + #[test] + fn edges_and_odd_coordinates_never_panic() { + let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]); + for (x, y) in [(f32::INFINITY, f32::INFINITY), (f32::NEG_INFINITY, 0.0), (f32::NAN, f32::NAN), (f32::MAX, -1e30), (-5.0, 9.0)] { + let v = img.sample_lanczos3(x, y); + assert!(v.iter().all(|c| c.is_finite()), "{v:?}"); + } + assert_eq!(Rgb32f::new(0, 0).sample_lanczos3(1.0, 1.0), [0.0; 3]); + // pixels that are all NaN (a damaged float file) give a NaN sample, never a panic + let nan = Rgb32f::from_fn(8, 8, |_, _| [f32::NAN; 3]); + assert!(nan.sample_lanczos3(4.3, 4.6).iter().all(|v| v.is_nan())); + assert_eq!(img.sample_lanczos3(0.5, 0.5), [0.0, 0.0, 1.0]); + } +} diff --git a/docs/merge.md b/docs/merge.md index e3f3e71ad..ce9c71beb 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -27,7 +27,9 @@ All algorithms are our own implementations from published papers (cited in the m **HDR** (`hdr.rs`): frames ordered by EXIF exposure (or median luminance); the middle one is the reference. Auto Align: features on exposure-normalised luminance, RANSAC translation / similarity / -homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured +homography (the simplest model that explains ≥ 97 % of the matches). Frames are resampled with Lanczos-3, held to +the range of their 4 × 4 neighbours so edges don't ring (`lightcraft_raster::sample`); bilinear lost a third of the +finest detail. Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..e3e2ec143 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LRC-PRINT-PAGE: Print Page Options) · **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 @@ -352,8 +352,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); 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; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); 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; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto (projected with Lanczos-3), boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; 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`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | @@ -891,3 +891,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-21 | minor | LR-MERGE-HDR / LR-MERGE-PANO: HDR alignment warps, panorama projection and Boundary Warp sample Lanczos-3 instead of bilinear (`lightcraft_raster::sample`); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f02ffb91..827a02d69 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-21 + +### Sharper resampling in HDR and panorama merges +- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a + photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around + hard edges such as clipped highlights. + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve From a104372c9210c9af59fce7c15ddeda75b6ea05a0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:39:09 +0000 Subject: [PATCH 19/20] Integration: #1165 Sony maker row counts match coverage.tsv (94 / 43 / 10 / 2) --- docs/raw-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 7e8dd740d..5320ce0c1 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -85,7 +85,7 @@ unsupported to preview-only in the counts; sensor-decoding readiness is unchange | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| -| Sony | 149 | 86 | 51 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 remains classified unsupported in the recorded evidence; DSC-F828 SRF imports as preview-only; DSC-V3 preview not verified | +| Sony | 149 | 94 | 43 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 remains classified unsupported in the recorded evidence; DSC-F828 SRF imports as preview-only; DSC-V3 preview not verified | | Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | | Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | | Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) | From 0e59398375440d83c97b72453176efa8bbd5259d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:39:25 +0000 Subject: [PATCH 20/20] Integration: #1746 whats-new entry under the existing 2026-10-14 heading, without a duplicate heading --- docs/whats-new.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 7e8ea2524..37b415b56 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -207,7 +207,7 @@ the number of days set in Settings → Performance. Only the `library.browse` command recorded it before, so a folder browsed every day from the sidebar was forgotten anyway and read again from scratch on the next visit, which is slow on a network share. -## 2026-10-14 + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.