diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index e7c33e8fd..1c70e19ea 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -462,6 +462,14 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> pick_folder: Some(Box::new(|| { file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), + #[cfg(target_os = "linux")] + open_model_folder: Some(std::sync::Arc::new(|path: &str| { + std::process::Command::new("xdg-open") + .arg(path) + .status() + .map_err(|e| e.to_string()) + .and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) }) + })), open_with: Some(Box::new(editors::open_with)), open_all_with: Some(Box::new(editors::open_all_with)), check_editor: Some(Box::new(editors::check)), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..eb33cdb30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -108,6 +108,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_sam_setup; +#[cfg(test)] mod tests_scroll; #[cfg(test)] mod tests_switch_library; @@ -155,6 +157,9 @@ pub type OpenUrlFn = Box Result<(), String>>; pub type OpenWithFn = Box Result<(), String>>; /// Open several files with one start of an application (`app` as for [`OpenWithFn`]). pub type OpenAllWithFn = Box Result<(), String>>; +/// Linux model-folder work: wait for the default opener on a background task. +#[cfg(target_os = "linux")] +pub type OpenModelFolderFn = std::sync::Arc Result<(), String> + Send + Sync>; /// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: /// not known). pub type ScreenProfileFn = Box Option>>; @@ -223,6 +228,9 @@ pub struct Services { /// Open several files with one start of the editor (Open Original; desktop only): an editor /// that keeps one window takes them all. None = [`Services::open_with`] for each. pub open_all_with: Option, + /// Optional worker-safe model-folder opener alongside `open_with` (Linux desktop only). + #[cfg(target_os = "linux")] + pub open_model_folder: Option, /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = /// assume it can). pub check_editor: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a13f25446..9a0867253 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -218,6 +218,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), ("app.export", "Export Now", None, ""), ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("app.openSamModelFolder", "Show in File Manager", None, ""), ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), ("dialog.labelNames", "Edit Color Label Names…", None, ""), ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), @@ -1252,6 +1253,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::merge::open(app, "merge.panorama"), "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), + "app.openSamModelFolder" => crate::panels::dialogs::show_model_folder(app), "app.about" => { app.ui.dialog = Some(Dialog::About); Ok(Value::Null) @@ -1878,6 +1880,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .and_then(|id| app.session.catalog.photo(id)) .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } + "app.openSamModelFolder" => app.session.segmenter.dir.is_some() && app.services.open_with.is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 08e3bd670..a79261fdc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1426,11 +1426,32 @@ fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool { /// The model installation guide. const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model"; -/// Show the SAM 3 model folder in the file manager (created first, so there is something to show). -fn show_model_folder(app: &mut LightcraftApp, dir: &std::path::Path) -> Result<(), String> { +/// Open the configured SAM 3 folder itself; revealing a file opens its parent instead. +pub(crate) fn show_model_folder(app: &mut LightcraftApp) -> Result { + let dir = app.session.segmenter.dir.as_ref().ok_or("no folder is set for the SAM 3 model")?; + let path = dir.to_str().ok_or("the model folder path is not valid UTF-8")?; + let open = app.services.open_with.as_mut().ok_or("opening model folders is not available here")?; + // Create only after the host action is available. Existing model files are left alone. std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let reveal = app.services.reveal.as_mut().ok_or("not available here")?; - reveal(&dir.to_string_lossy()) + #[cfg(target_os = "linux")] + if let Some(open_folder) = app.services.open_model_folder.clone() { + let path = path.to_string(); + let folder = path.clone(); + crate::tasks::spawn( + app, + "Show in File Manager", + Some("openModelFolder"), + move || open_folder(&folder), + |app, ctx, result| { + if let Err(e) = result { + app.toast_error(ctx, e); + } + }, + )?; + return Ok(json!({"path": path, "background": true})); + } + open(path, "")?; + Ok(json!({"path": path})) } /// The SAM 3 dialog: what the model is, its size and licence, and the download's progress. @@ -1488,13 +1509,13 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str .color(t.text_dim), ); ui.horizontal_wrapped(|ui| { - if let Some(dir) = dir.filter(|_| app.services.reveal.is_some()) { + if dir.is_some() && crate::menus::ui_enabled(app, "app.openSamModelFolder") { let r = ui.button(crate::i18n::tr(crate::menus::reveal_label())); crate::widgets::register(ui.ctx(), "button:samFolder", r.rect); if r.clicked() - && let Err(e) = show_model_folder(app, &dir) + && let Err(e) = app.run("app.openSamModelFolder", json!({})) { - app.toast(ui.ctx(), e); + app.toast_error(ui.ctx(), e); } } let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bf808b532..43e53932d 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1373,7 +1373,8 @@ fn ai_masks_without_the_model_offer_the_download() { assert!(h.app.widgets.iter().any(|(id, _)| id == "link:samHelp"), "no installation guide link"); let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); let log = shown.clone(); - h.app.services.reveal = Some(Box::new(move |p: &str| { + h.app.services.open_with = Some(Box::new(move |p: &str, editor: &str| { + assert_eq!(editor, "", "open the model directory with the default application"); log.lock().unwrap().push(p.to_string()); Ok(()) })); diff --git a/crates/ui-egui/src/tests_sam_setup.rs b/crates/ui-egui/src/tests_sam_setup.rs new file mode 100644 index 000000000..0f8026050 --- /dev/null +++ b/crates/ui-egui/src/tests_sam_setup.rs @@ -0,0 +1,160 @@ +//! The manual-install folder action uses the engine-configured directory and the normal host +//! opener. These tests never download or load a model, or start a native file manager. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use serde_json::json; + +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +struct Fixture { + app: LightcraftApp, + root: PathBuf, +} + +impl Fixture { + fn new(name: &str) -> Self { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let root = std::env::temp_dir().join(format!("lc-sam-folder-{name}-{}-{stamp}", std::process::id())); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + app.session.segmenter.dir = Some(root.join("custom/models/sam3")); + Self { app, root } + } + + fn dir(&self) -> PathBuf { + self.app.session.segmenter.dir.clone().unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[test] +fn folder_command_opens_the_configured_directory_and_preserves_files() { + let mut f = Fixture::new("configured"); + let dir = f.dir(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("local-note"), b"keep this file").unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let log = calls.clone(); + f.app.services.open_with = Some(Box::new(move |path, editor| { + assert!(std::path::Path::new(path).is_dir()); + log.lock().unwrap().push((path.to_string(), editor.to_string())); + Ok(()) + })); + f.app.services.reveal = Some(Box::new(|_| panic!("revealing a file would open its parent"))); + assert!(crate::menus::ui_commands().any(|c| c.0 == "app.openSamModelFolder")); + assert!(crate::menus::ui_enabled(&f.app, "app.openSamModelFolder")); + let reply = f.app.run("app.openSamModelFolder", json!({})).unwrap(); + assert_eq!(reply["path"], dir.to_str().unwrap()); + assert_eq!(*calls.lock().unwrap(), vec![(dir.to_str().unwrap().to_string(), String::new())]); + assert_eq!(std::fs::read(dir.join("local-note")).unwrap(), b"keep this file"); + assert!(!f.app.session.segmenter.download_status().running); +} + +#[test] +fn missing_folder_or_opener_is_rejected_before_creating_any_directory() { + let mut f = Fixture::new("unavailable"); + assert!(!crate::menus::ui_enabled(&f.app, "app.openSamModelFolder")); + assert!(f.app.run("app.openSamModelFolder", json!({})).unwrap_err().contains("not available")); + assert!(!f.root.exists()); + f.app.services.open_with = Some(Box::new(|_, _| panic!("no directory is configured"))); + f.app.session.segmenter.dir = None; + assert!(!crate::menus::ui_enabled(&f.app, "app.openSamModelFolder")); + assert!(f.app.run("app.openSamModelFolder", json!({})).unwrap_err().contains("no folder")); + assert!(!f.root.exists()); +} + +#[test] +fn folder_creation_failure_preserves_the_existing_file_and_skips_the_opener() { + let mut f = Fixture::new("blocked"); + std::fs::create_dir_all(&f.root).unwrap(); + let blocked = f.root.join("not-a-directory"); + std::fs::write(&blocked, b"keep").unwrap(); + f.app.session.segmenter.dir = Some(blocked.join("sam3")); + f.app.services.open_with = Some(Box::new(|_, _| panic!("creation failed before opening"))); + assert!(f.app.run("app.openSamModelFolder", json!({})).is_err()); + assert_eq!(std::fs::read(blocked).unwrap(), b"keep"); +} + +#[test] +fn folder_opener_failure_can_be_retried_without_dismissing_setup() { + let mut f = Fixture::new("retry"); + f.app.ui.dialog = Some(Dialog::SamModel { then: Some(("object".into(), "new".into())), error: None }); + let pending = f.app.ui.dialog.clone(); + f.app.services.open_with = Some(Box::new(|_, _| Err("test opener failure".into()))); + assert_eq!(f.app.run("app.openSamModelFolder", json!({})).unwrap_err(), "test opener failure"); + assert_eq!(f.app.ui.dialog, pending); + assert!(f.dir().is_dir()); + f.app.services.open_with = Some(Box::new(|_, _| Ok(()))); + assert!(f.app.run("app.openSamModelFolder", json!({})).is_ok()); + assert_eq!(f.app.ui.dialog, pending); + assert!(!f.app.session.segmenter.download_status().running); +} + +#[cfg(target_os = "linux")] +#[test] +fn folder_worker_keeps_frames_running_and_delivers_failure_before_retry() { + use crate::headless::Headless; + use std::sync::mpsc::channel; + use std::time::Duration; + + const T: Duration = Duration::from_secs(20); + let mut f = Fixture::new("background"); + let dir = f.dir(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("local-note"), b"keep this file").unwrap(); + f.app.ui.dialog = Some(Dialog::SamModel { then: Some(("object".into(), "new".into())), error: None }); + let pending = f.app.ui.dialog.clone(); + f.app.services.open_with = Some(Box::new(|_, _| panic!("the UI must use the worker-safe folder opener"))); + let (started, entered) = channel(); + let (release, held) = channel::>(); + let held = Mutex::new(held); + f.app.services.open_model_folder = Some(Arc::new(move |path| { + started.send(path.to_string()).map_err(|e| e.to_string())?; + held.lock().unwrap().recv_timeout(T).map_err(|e| e.to_string())? + })); + let app = std::mem::replace(&mut f.app, LightcraftApp::new(lightcraft_engine::Session::new(), Services::default())); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.step(); + let reply = h.app.run("app.openSamModelFolder", json!({})).unwrap(); + assert_eq!(reply, json!({"path": dir.to_str().unwrap(), "background": true})); + assert_eq!(entered.recv_timeout(T).unwrap(), dir.to_str().unwrap()); + let frames = h.frames(); + h.step(); + assert_eq!(h.frames(), frames + 1, "the worker is still held while the UI advances"); + assert_eq!(h.app.tasks.labels(), vec!["Show in File Manager"]); + assert!(h.busy(), "pending work stays visible to headless settling"); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].cancellable), ("openModelFolder", false)); + assert!(h.app.ui.toast.is_none(), "no result before releasing the worker"); + assert_eq!(h.app.ui.dialog, pending); + assert!(!h.app.session.segmenter.download_status().running); + + release.send(Err("opening folder failed: exit status: 23".into())).unwrap(); + assert!(h.step_until(T, |h| h.app.tasks.is_empty())); + assert!(h.app.session.activity.list().is_empty(), "completion retires the task before retry"); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("opening folder failed: exit status: 23")); + assert_eq!(h.app.ui.dialog, pending); + assert!(!h.app.session.segmenter.download_status().running); + + let reply = h.app.run("app.openSamModelFolder", json!({})).unwrap(); + assert_eq!(reply, json!({"path": dir.to_str().unwrap(), "background": true})); + assert_eq!(entered.recv_timeout(T).unwrap(), dir.to_str().unwrap()); + assert_eq!(h.app.tasks.labels(), vec!["Show in File Manager"]); + let prior_error = h.app.ui.toast.clone(); + release.send(Ok(())).unwrap(); + assert!(h.step_until(T, |h| h.app.tasks.is_empty())); + assert!(h.app.session.activity.list().is_empty()); + assert!(h.app.ui.toast.is_none() || h.app.ui.toast == prior_error, "successful retry adds no error"); + assert_eq!(h.app.ui.dialog, pending); + assert!(!h.app.session.segmenter.download_status().running); + assert_eq!(h.app.session.segmenter.dir.as_ref(), Some(&dir)); + assert_eq!(std::fs::read(dir.join("local-note")).unwrap(), b"keep this file"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 35becd2b0..93d24a9bb 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,6 +3,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEYC-SELECTONLYACTIVE: Select Only Active Photo, ⌘⇧D; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-BEHAV-AUTOSAVE: damaged snapshots are repaired on open; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z f in-file distortion correction; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; Model folder/error/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 @@ -56,8 +57,10 @@ cited here still exists. Formerly docs/parity.md. | Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | | **Total** | 408 | 53 | 72 | 35 | 193/201 (96%) | 145/158 (92%) | +| **Total** | 408 | 51 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 533 in-scope rows — P0 98.0% of 201 · P1 94.9% of 158 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 172. ## Top gaps and shortcut notes @@ -291,7 +294,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) | -| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | +| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | @@ -813,6 +816,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | +| 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |