# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas committed 2026-10-13 11:52:07 -04:00
commit 93f8ee7c0d
7 files changed
+214 -9

No files matched your search

+8
View File
@@ -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)),
+8
View File
@@ -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<dyn FnMut(&str) -> Result<(), String>>;
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
/// Open several files with one start of an application (`app` as for [`OpenWithFn`]).
pub type OpenAllWithFn = Box<dyn FnMut(&[String], &str) -> 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<dyn Fn(&str) -> 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<dyn FnMut() -> Option<Vec<u8>>>;
@@ -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<OpenAllWithFn>,
/// Optional worker-safe model-folder opener alongside `open_with` (Linux desktop only).
#[cfg(target_os = "linux")]
pub open_model_folder: Option<OpenModelFolderFn>,
/// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none =
/// assume it can).
pub check_editor: Option<CheckEditorFn>,
+3
View File
@@ -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<Re
}
"dialog.mergePanorama" => 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,
+28 -7
View File
@@ -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<serde_json::Value, String> {
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);
+2 -1
View File
@@ -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::<String>::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(())
}));
+160
View File
@@ -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::<Result<(), String>>();
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");
}
+5 -1
View File
@@ -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.
<!-- /parity:summary -->
## 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 |