From 520d0917c048f47ceb0a1becfbf6db49b0f1feb1 Mon Sep 17 00:00:00 2001 From: Kaan Date: Tue, 20 Oct 2026 23:45:57 +0300 Subject: [PATCH 01/44] Add opt-in Windows VRR flicker workaround Add a restart-required setting that opts Windows DX12 into DirectComposition to help prevent G-SYNC/FreeSync flicker. Keep other renderers unchanged, and note that hardware verification is pending. --- apps/lightcraft/src/main.rs | 6 +++- apps/lightcraft/src/window_gpu.rs | 43 +++++++++++++++++++++++---- crates/catalog/src/store.rs | 2 +- crates/ui-egui/locales/cs.json | 3 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/it.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/ka.json | 3 +- crates/ui-egui/locales/pl.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/panels/settings.rs | 2 ++ crates/ui-egui/src/state.rs | 3 ++ docs/gaps.md | 6 ++-- docs/parity-checklist.md | 6 ++-- 20 files changed, 82 insertions(+), 25 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 95739b3e9..772b52654 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -916,8 +916,11 @@ fn main() -> eframe::Result { lightcraft_engine::gpu::set_enabled(gpu_on); // the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't // build egui's pipeline never kills the start-up + let compose_window = + prefs.as_ref().map_or_else(|| lightcraft_ui_egui::state::AppSettings::default().compose_window, |ui| ui.settings.compose_window) + && std::env::var_os("WGPU_BACKEND").is_none(); let win_files = std::sync::Arc::new(window_files(in_memory)); - let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files); + let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files, compose_window); win_files.arm(window_renderer); let win_files_app = win_files.clone(); // the window came up: a failure after this is LightCraft stopping, not a start that failed @@ -930,6 +933,7 @@ fn main() -> eframe::Result { viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as) .with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE) .with_drag_and_drop(true) + .with_transparent(window_gpu::composes_window(compose_window, window_renderer)) .with_fullsize_content_view(true) .with_titlebar_shown(false) .with_title_shown(false) diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 1f84ec477..8d4ad5bbc 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -85,7 +85,7 @@ impl Files { } /// The renderer for this start: see the module docs. -pub fn choose(files: &Files) -> Chosen { +pub fn choose(files: &Files, compose_window: bool) -> Chosen { let forced = backend::forced_window_renderer(std::env::var("LIGHTCRAFT_GPU_BACKEND").ok().as_deref(), std::env::var("WGPU_BACKEND").ok().as_deref()); let failed = files.take_failed(); @@ -96,7 +96,7 @@ pub fn choose(files: &Files) -> Chosen { let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name())); let mut problems = Vec::new(); let chosen = if cfg!(windows) { - pick_probed(&order, &mut problems) + pick_probed(&order, &mut problems, compose_window) } else if forced == Some(WindowRenderer::Glow) || (forced.is_none() && failed.is_some_and(|f| f != WindowRenderer::Glow)) { // elsewhere eframe's own wgpu set-up stays the default; glow after a failed start Some((WindowRenderer::Glow, None)) @@ -147,12 +147,12 @@ pub fn base_options() -> WgpuConfiguration { } /// The first renderer of `order` that probes cleanly (glow is taken without a probe). -fn pick_probed(order: &[WindowRenderer], problems: &mut Vec) -> Option<(WindowRenderer, Option)> { +fn pick_probed(order: &[WindowRenderer], problems: &mut Vec, compose_window: bool) -> Option<(WindowRenderer, Option)> { for &r in order { match r { WindowRenderer::Glow => return Some((r, None)), WindowRenderer::Wgpu(b) => { - let probed = std::panic::catch_unwind(|| probe(b)).unwrap_or_else(|_| Err("panicked".into())); + let probed = std::panic::catch_unwind(|| probe(b, compose_window)).unwrap_or_else(|_| Err("panicked".into())); match probed { Ok(e) => return Some((r, Some(e))), Err(e) => { @@ -167,11 +167,11 @@ fn pick_probed(order: &[WindowRenderer], problems: &mut Vec) -> Option<( } /// Create a device on `backends` and build egui's pipeline on it; any wgpu error is returned. -fn probe(backends: Backends) -> Result { +fn probe(backends: Backends, compose_window: bool) -> Result { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, flags: backend::instance_flags_from_env(wgpu::InstanceFlags::from_build_config().with_env()), - backend_options: backend::backend_options(), + backend_options: presentation_options(backends, compose_window), memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), display: None, }); @@ -314,3 +314,34 @@ mod tests { let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) }); } } + +/// Only DX12 windows use the compositor; fallback renderers retain their normal presentation. +/// Adapted from kmnkv1's PhotoCraft PR: https://github.com/storytold/photocraft/pull/7035. +pub fn composes_window(enabled: bool, renderer: WindowRenderer) -> bool { + cfg!(target_os = "windows") && enabled && renderer == WindowRenderer::Wgpu(Backends::DX12) +} + +fn presentation_options(backends: Backends, enabled: bool) -> wgpu::BackendOptions { + let mut options = backend::backend_options(); + if composes_window(enabled, WindowRenderer::Wgpu(backends)) { + options.dx12.presentation_system = wgpu::Dx12SwapchainKind::DxgiFromVisual; + } + options +} + +#[test] +fn composition_only_changes_windows_dx12_presentation() { + let legacy: lightcraft_ui_egui::state::AppSettings = serde_json::from_str("{}").unwrap(); + assert!(!legacy.compose_window, "existing settings keep the workaround off until opted in"); + assert!(!lightcraft_ui_egui::state::AppSettings::default().compose_window); + let enabled: lightcraft_ui_egui::state::AppSettings = serde_json::from_str(r#"{"composeWindow":true}"#).unwrap(); + let saved = serde_json::to_value(&enabled).unwrap(); + assert_eq!(saved["composeWindow"], true); + for backend in [Backends::DX12, Backends::VULKAN, Backends::GL] { + let wanted = cfg!(target_os = "windows") && backend == Backends::DX12; + assert_eq!(composes_window(true, WindowRenderer::Wgpu(backend)), wanted); + assert_eq!(presentation_options(backend, true).dx12.presentation_system == wgpu::Dx12SwapchainKind::DxgiFromVisual, wanted); + assert!(!composes_window(false, WindowRenderer::Wgpu(backend))); + } + assert!(!composes_window(true, WindowRenderer::Glow)); +} diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index 2f3dc4912..553eb241f 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -242,7 +242,7 @@ impl Store for MemStore { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests_staging { use super::*; diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 1e84313ae..e4b51438d 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -2063,5 +2063,6 @@ "Open Settings": "Otevřít nastavení", "Select none": "Zrušit celý výběr", "Select all": "Vybrat vše", - "Visualize HDR": "Zobrazit rozsah HDR" + "Visualize HDR": "Zobrazit rozsah HDR", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zabránit blikání G-SYNC/FreeSync (platí po dalším spuštění)" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 000aea2d4..f0a947376 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2341,5 +2341,6 @@ "Checking the GPU…": "GPU wird geprüft …", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.", - "Checking the GPU for faces": "GPU für Gesichter wird geprüft" + "Checking the GPU for faces": "GPU für Gesichter wird geprüft", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "G-SYNC-/FreeSync-Flimmern verhindern (gilt ab dem nächsten Start)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 77ba4c08e..56e8fe3fd 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2340,5 +2340,6 @@ "Checking the GPU…": "Comprobando la GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.", - "Checking the GPU for faces": "Comprobando la GPU para las caras" + "Checking the GPU for faces": "Comprobando la GPU para las caras", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evitar el parpadeo de G-SYNC/FreeSync (se aplica al reiniciar)" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index fa57e373e..758d4b5b4 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -2338,5 +2338,6 @@ "Checking the GPU…": "Vérification du GPU…", "CPU": "Processeur", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.", - "Checking the GPU for faces": "Vérification du GPU pour les visages" + "Checking the GPU for faces": "Vérification du GPU pour les visages", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Empêcher le scintillement G-SYNC/FreeSync (au prochain démarrage)" } diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 382fb80c9..8fc8a82dd 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -1990,5 +1990,6 @@ "Test Face Model": "Testa il modello dei volti", "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", - "Unnamed Faces": "Volti senza nome" + "Unnamed Faces": "Volti senza nome", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evita lo sfarfallio G-SYNC/FreeSync (dal prossimo avvio)" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2dc80934f..5a73f6b02 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -2340,5 +2340,6 @@ "Checking the GPU…": "GPU を確認中…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。", - "Checking the GPU for faces": "顔用の GPU を確認中" + "Checking the GPU for faces": "顔用の GPU を確認中", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "G-SYNC/FreeSyncのちらつきを防ぐ(次回起動時に適用)" } diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index f6530929d..c3a96533e 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -2718,5 +2718,6 @@ "≥ at least": "≥ არანაკლებ", "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "G-SYNC/FreeSync-ის ციმციმის თავიდან აცილება (ამოქმედდება შემდეგი გაშვებისას)" } diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index 7416ce241..51d00a758 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -2157,5 +2157,6 @@ "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "To jest katalog Lightrooma, a nie biblioteka LightCraft. Zaimportować go zamiast tego do biblioteki LightCraft?", "Unnamed Faces": "Nienazwane twarze", "Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)", - "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)" + "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zapobiegaj migotaniu G-SYNC/FreeSync (po ponownym uruchomieniu)" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2c9e9da05..a0ae8225c 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2186,5 +2186,6 @@ "Color Overlay on B&W": "Sobreposição de cor em P&B", "Image on Black": "Imagem sobre preto", "Image on White": "Imagem sobre branco", - "White on Black": "Branco sobre preto" + "White on Black": "Branco sobre preto", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evitar cintilação de G-SYNC/FreeSync (no próximo início)" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5ead5101f..aee930cbd 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2333,5 +2333,6 @@ "Checking the GPU…": "Проверка ГП…", "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.", - "Checking the GPU for faces": "Проверка ГП для лиц" + "Checking the GPU for faces": "Проверка ГП для лиц", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Предотвращать мерцание G-SYNC/FreeSync (после перезапуска)" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index fbdef1c5f..01c1f4ccc 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2338,5 +2338,6 @@ "Checking the GPU…": "Перевірка ГП…", "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.", - "Checking the GPU for faces": "Перевірка ГП для облич" + "Checking the GPU for faces": "Перевірка ГП для облич", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Запобігати мерехтінню G-SYNC/FreeSync (після перезапуску)" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2750dd4c8..0c4460c74 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2179,5 +2179,6 @@ "Checking the GPU…": "正在检查 GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。", - "Checking the GPU for faces": "正在检查用于人脸的 GPU" + "Checking the GPU for faces": "正在检查用于人脸的 GPU", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "防止 G-SYNC/FreeSync 闪烁(下次启动时生效)" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index a1723fc22..713825c6b 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -2141,5 +2141,6 @@ "Checking the GPU…": "正在檢查 GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。", - "Checking the GPU for faces": "正在檢查用於臉孔的 GPU" + "Checking the GPU for faces": "正在檢查用於臉孔的 GPU", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "防止 G-SYNC/FreeSync 閃爍(下次啟動時生效)" } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 682e2acb2..b01cc9a75 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -416,6 +416,8 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::gpu; heading(ui, t, crate::i18n::tr("Rendering")); + #[cfg(target_os = "windows")] + check(ui, "settings.composeWindow", &mut app.ui.settings.compose_window, "Prevent G-SYNC/FreeSync flicker (applies at next launch)"); check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); let status = if !gpu::available() { match gpu::unavailable_reason() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f8c8cc0d4..d59ce2735 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -348,6 +348,8 @@ pub struct AppSettings { pub ask_before_move: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, + /// Use Windows DirectComposition to prevent VRR flicker; applies at next launch. + pub compose_window: bool, /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). @@ -402,6 +404,7 @@ impl Default for AppSettings { confirm_delete: true, ask_before_move: true, gpu: true, + compose_window: false, preview_limit: 0, external_editor: String::new(), external_file: EditFile::default(), diff --git a/docs/gaps.md b/docs/gaps.md index 1b4a385fa..725bf8318 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 (gap 15: Print Page Options — page numbers, page info, crop marks) 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 @@ -41,7 +41,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | -| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | +| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; Windows DirectComposition VRR workaround implemented, monitor verification pending; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | @@ -126,3 +126,5 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-14 | minor | Gap 10 shrinks: 97 Canon CR2, Pentax and Ricoh models verified on one CC0 file each (334 models, ~50% on a verified body) | | 2026-10-14 | minor | Gap 10: 515 of 1,446 models verified (36%), ~84% of photographers on a verified body (integration batch 36) | + +| 2026-10-20 | minor | Opt-in Windows DirectComposition preference added; VRR hardware verification pending; parity counts unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..6db9b7b09 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 (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 @@ -434,7 +434,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | | LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: Windows' WCS profile (needs FFI, so `unsafe`), X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings; Opt-in Windows DX12 DirectComposition preference to prevent VRR flicker (restart required; hardware verification pending) | | LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.download`, `cmd:faces.models.downloads`, `cmd:faces.models.downloadCancel`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/panels/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with `lightcraft-fetch`), or add any `.onnx` by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (`crates/faces/src/net.rs`, `crates/faces/src/runtime.rs`; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces; the Recognise faces switch is off until a recognition model is chosen and installed (`faces.enable {enabled: true}` refuses otherwise; removing the last recogniser switches it off), issue #678 | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | | LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | @@ -891,3 +891,5 @@ 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-20 | minor | Opt-in Windows DirectComposition preference added; VRR hardware verification pending; parity counts unchanged | From 0ead41b7876a75943f9741b6a3bbbabbebf2a9d8 Mon Sep 17 00:00:00 2001 From: Kaan Date: Tue, 20 Oct 2026 23:51:38 +0300 Subject: [PATCH 02/44] Fixed the partly transparent main window with settings open --- apps/lightcraft/src/main.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 772b52654..7f06aa308 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -58,6 +58,11 @@ fn app_icon() -> egui::IconData { struct App(LightcraftApp, PrefsWriter, #[cfg(target_os = "macos")] Option); impl eframe::App for App { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + // The transparency-capable DirectComposition window is a presentation workaround; + // translucent UI layers (disabled panels, modal dimming) must not reveal the desktop. + egui::Color32::from_rgb(12, 12, 12).to_normalized_gamma_f32() + } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] if let Some(m) = self.2.as_mut() @@ -1120,6 +1125,21 @@ fn startup_failed(error: &str, log_file: Option<&std::path::Path>) { mod tests { use super::*; + #[test] + fn desktop_clear_color_stays_opaque_with_a_modal_open() { + let mut app = App( + LightcraftApp::new(Session::new(), Services::default()), + PrefsWriter::default(), + #[cfg(target_os = "macos")] + None, + ); + app.0.run("app.settings", serde_json::json!({})).unwrap(); + assert!(app.0.ui.dialog.is_some()); + for visuals in [egui::Visuals::dark(), egui::Visuals::light()] { + assert_eq!(eframe::App::clear_color(&app, &visuals)[3], 1.0); + } + } + fn dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("lc-app-prefs-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); From f340519901c7b04ed774671d7c0c74179384ca2c Mon Sep 17 00:00:00 2001 From: Kaan Date: Wed, 21 Oct 2026 00:25:16 +0300 Subject: [PATCH 03/44] Group compositor test with GPU window tests Keep the presentation helpers in production scope and move the composition behavior test into the existing test module. --- apps/lightcraft/src/window_gpu.rs | 61 ++++++++++++++++--------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 8d4ad5bbc..05d937f0b 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -235,6 +235,22 @@ pub fn relaunch_after_failure(files: &Files) -> bool { .is_ok() } +/// Only DX12 windows use the compositor; fallback renderers retain their normal presentation. +/// Adapted from kmnkv1's PhotoCraft PR: https://github.com/storytold/photocraft/pull/7035. +pub fn composes_window(enabled: bool, renderer: WindowRenderer) -> bool { + cfg!(target_os = "windows") && enabled && renderer == WindowRenderer::Wgpu(Backends::DX12) +} + +/// Select DirectComposition for opted-in Windows DX12 windows, preserving +/// the default presentation options for other renderers. +fn presentation_options(backends: Backends, enabled: bool) -> wgpu::BackendOptions { + let mut options = backend::backend_options(); + if composes_window(enabled, WindowRenderer::Wgpu(backends)) { + options.dx12.presentation_system = wgpu::Dx12SwapchainKind::DxgiFromVisual; + } + options +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -313,35 +329,22 @@ mod tests { // outside a scope: logged by our handler, not a panic let _ = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl("not wgsl".into()) }); } -} -/// Only DX12 windows use the compositor; fallback renderers retain their normal presentation. -/// Adapted from kmnkv1's PhotoCraft PR: https://github.com/storytold/photocraft/pull/7035. -pub fn composes_window(enabled: bool, renderer: WindowRenderer) -> bool { - cfg!(target_os = "windows") && enabled && renderer == WindowRenderer::Wgpu(Backends::DX12) -} - -fn presentation_options(backends: Backends, enabled: bool) -> wgpu::BackendOptions { - let mut options = backend::backend_options(); - if composes_window(enabled, WindowRenderer::Wgpu(backends)) { - options.dx12.presentation_system = wgpu::Dx12SwapchainKind::DxgiFromVisual; + /// The workaround is opt-in and changes presentation only for Windows DX12. + #[test] + fn composition_only_changes_windows_dx12_presentation() { + let legacy: lightcraft_ui_egui::state::AppSettings = serde_json::from_str("{}").unwrap(); + assert!(!legacy.compose_window, "existing settings keep the workaround off until opted in"); + assert!(!lightcraft_ui_egui::state::AppSettings::default().compose_window); + let enabled: lightcraft_ui_egui::state::AppSettings = serde_json::from_str(r#"{"composeWindow":true}"#).unwrap(); + let saved = serde_json::to_value(&enabled).unwrap(); + assert_eq!(saved["composeWindow"], true); + for backend in [Backends::DX12, Backends::VULKAN, Backends::GL] { + let wanted = cfg!(target_os = "windows") && backend == Backends::DX12; + assert_eq!(composes_window(true, WindowRenderer::Wgpu(backend)), wanted); + assert_eq!(presentation_options(backend, true).dx12.presentation_system == wgpu::Dx12SwapchainKind::DxgiFromVisual, wanted); + assert!(!composes_window(false, WindowRenderer::Wgpu(backend))); + } + assert!(!composes_window(true, WindowRenderer::Glow)); } - options -} - -#[test] -fn composition_only_changes_windows_dx12_presentation() { - let legacy: lightcraft_ui_egui::state::AppSettings = serde_json::from_str("{}").unwrap(); - assert!(!legacy.compose_window, "existing settings keep the workaround off until opted in"); - assert!(!lightcraft_ui_egui::state::AppSettings::default().compose_window); - let enabled: lightcraft_ui_egui::state::AppSettings = serde_json::from_str(r#"{"composeWindow":true}"#).unwrap(); - let saved = serde_json::to_value(&enabled).unwrap(); - assert_eq!(saved["composeWindow"], true); - for backend in [Backends::DX12, Backends::VULKAN, Backends::GL] { - let wanted = cfg!(target_os = "windows") && backend == Backends::DX12; - assert_eq!(composes_window(true, WindowRenderer::Wgpu(backend)), wanted); - assert_eq!(presentation_options(backend, true).dx12.presentation_system == wgpu::Dx12SwapchainKind::DxgiFromVisual, wanted); - assert!(!composes_window(false, WindowRenderer::Wgpu(backend))); - } - assert!(!composes_window(true, WindowRenderer::Glow)); } From 9e2a2de7feab1cb39547894025c6e021a38aaec3 Mon Sep 17 00:00:00 2001 From: Kaan Date: Thu, 22 Oct 2026 00:52:56 +0300 Subject: [PATCH 04/44] Fix locale JSON separators --- crates/ui-egui/locales/cs.json | 2 +- crates/ui-egui/locales/it.json | 2 +- crates/ui-egui/locales/pl.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index bb46350a0..af7d7446c 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -2240,7 +2240,7 @@ "Select none": "Zrušit celý výběr", "Select all": "Vybrat vše", "Visualize HDR": "Zobrazit rozsah HDR", - "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zabránit blikání G-SYNC/FreeSync (platí po dalším spuštění)" + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zabránit blikání G-SYNC/FreeSync (platí po dalším spuštění)", "Add a Face Recognition Model": "Přidat model rozpoznávání obličejů", "Choose Monitor Profile": "Vybrat profil monitoru", "Display profile not used": "Profil displeje nebyl použit", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 4454cc325..80a37daca 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2262,7 +2262,7 @@ "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", "Unnamed Faces": "Volti senza nome", - "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evita lo sfarfallio G-SYNC/FreeSync (dal prossimo avvio)" + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Evita lo sfarfallio G-SYNC/FreeSync (dal prossimo avvio)", "smart album value|No": "No", "smart album value|Yes": "Sì", "Add Print Cell": "Aggiungi cella di stampa", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index 4dfcfe1c9..31fd64fba 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -2158,7 +2158,7 @@ "Unnamed Faces": "Nienazwane twarze", "Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)", "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)", - "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zapobiegaj migotaniu G-SYNC/FreeSync (po ponownym uruchomieniu)" + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "Zapobiegaj migotaniu G-SYNC/FreeSync (po ponownym uruchomieniu)", "Set Folder Color Label": "Ustaw etykietę kolorystyczną folderu", "Refresh View": "Odśwież widok", "Hold View Until Refreshed": "Wstrzymaj widok do odświeżenia", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 4c31758ca..27a40abef 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2180,7 +2180,7 @@ "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。", "Checking the GPU for faces": "正在检查用于人脸的 GPU", - "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "防止 G-SYNC/FreeSync 闪烁(下次启动时生效)" + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "防止 G-SYNC/FreeSync 闪烁(下次启动时生效)", "Include selected photos": "包含所选照片", "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。", "This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。", From 6376b1cebf3437aced6449f2f59fb721bda0d48e Mon Sep 17 00:00:00 2001 From: OneSlips Date: Thu, 22 Oct 2026 12:30:30 +1000 Subject: [PATCH 05/44] Issue #136: applying the GPU setting never waits for the device on the UI thread The first frame ran `app.gpu {enabled}` and its reply called `gpu::available()`, which waits for the device that `warm_up` is creating off the UI thread: the window's first frame stalled for the whole device and kernel setup (8-11 s under load). `app.gpu` takes `wait: false`, which answers at once while the device is still being created (available/adapter/reason null), and the UI uses it. `a_slow_photos_import_never_blocks_the_window` caught it (8.8 s -> 0.9 s). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/query.rs | 13 ++++++++++++- crates/ui-egui/src/lib.rs | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index dfbf4bfc5..fd81047cd 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -226,10 +226,21 @@ pub fn specs() -> Vec { "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), - cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| { + cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool, wait?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}. wait: false never waits for a device still being created (available, adapter and reason are then null)", always, |_, p| { if let Some(on) = p.get("enabled").and_then(Value::as_bool) { lightcraft_gpu::set_enabled(on); } + // the UI applies the setting on its first frame: it must not wait seconds for the + // device there (issue #136; `warm_up` creates it off the UI thread) + if p.get("wait").and_then(Value::as_bool) == Some(false) && !lightcraft_gpu::ready() { + return Ok(json!({ + "enabled": lightcraft_gpu::enabled(), + "available": null, + "adapter": null, + "reason": null, + "lastFallback": lightcraft_gpu::last_fallback(), + })); + } Ok(json!({ "enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b2517907..052aa12c2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1263,7 +1263,7 @@ impl LightcraftApp { fn apply_settings(&mut self, ctx: &egui::Context) { if self.gpu_applied != Some(self.ui.settings.gpu) { self.gpu_applied = Some(self.ui.settings.gpu); - let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu, "wait": false})); // GPU device + kernels off the UI thread, once the window is up and only when GPU // rendering is on: a broken driver must not keep the window from appearing (issue #136) if self.ui.settings.gpu { From ff4dc74e5dcc592e3da6bd6de51322f22dcca20c Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:21:30 +0200 Subject: [PATCH 06/44] LR-EDIT-DETAIL-DENOISE: GPU denoise doesn't hang when its tiles in flight fail (#2577) The GPU runner's workspace pool kept its free list and its count of made workspaces behind two mutexes, and forget() lowered the count without the free list's lock that a waiting caller holds. If the tiles in flight failed between a caller finding every workspace in use and that caller starting to wait, the wakeup was lost and the denoise thread blocked forever, out of reach of cancel. The free list and the count now share one lock in a small Workspaces type, so a give or a forget can't slip in between the check and the wait. A caller woken that way after the device was lost returns the GPU's error instead of making a new workspace on it. --- crates/gpu/src/nn.rs | 167 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 132 insertions(+), 35 deletions(-) diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 55420598b..10e074cb7 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -649,15 +649,71 @@ impl Shared { } } +/// Workspaces not in use, and how many exist (free or with a tile in flight). +struct Slots { + free: Vec, + made: usize, +} + +/// Up to `cap` workspaces shared by the threads that run tiles. The free list and the count share one lock, so a caller +/// that finds none free and waits cannot miss one being given back or forgotten in between (issue #2577). +struct Workspaces { + slots: Mutex>, + wake: Condvar, + cap: usize, +} + +impl Workspaces { + fn new(first: T, cap: usize) -> Self { + Workspaces { slots: Mutex::new(Slots { free: vec![first], made: 1 }), wake: Condvar::new(), cap } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Slots> { + self.slots.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// A free workspace, a new one from `make` when fewer than `cap` exist, else wait for one to come back or be + /// forgotten. + fn take(&self, make: impl FnOnce() -> Result) -> Result { + let mut s = self.lock(); + loop { + if let Some(w) = s.free.pop() { + return Ok(w); + } + if s.made < self.cap { + s.made += 1; + drop(s); + let w = make(); + if w.is_err() { + self.forget(); + } + return w; + } + s = self.wake.wait(s).unwrap_or_else(|e| e.into_inner()); + } + } + + fn give(&self, w: T) { + self.lock().free.push(w); + self.wake.notify_one(); + } + + /// One workspace fewer exists: a waiting caller may make another. + fn forget(&self) { + let mut s = self.lock(); + s.made = s.made.saturating_sub(1); + drop(s); + self.wake.notify_one(); + } +} + /// A network on the GPU for one tile size. Cheap to share between threads: each call takes a free workspace. pub struct NetRunner { shared: Shared, tile: usize, in_channels: usize, - free: Mutex>, - /// Workspaces made so far (they are made when a call finds none free, up to [`WORKSPACES`]). - made: Mutex, - wake: Condvar, + /// Workspaces are made when a call finds none free, up to [`WORKSPACES`]. + workspaces: Workspaces, bytes: u64, } @@ -708,7 +764,7 @@ pub fn runner(net: &Net, tile: usize) -> Result { if let Some(e) = errors.into_iter().flatten().next() { return Err(format!("{}: the denoise kernels could not be set up ({e})", d.info.name)); } - Ok(NetRunner { shared, tile, in_channels: net.in_channels, free: Mutex::new(vec![first]), made: Mutex::new(1), wake: Condvar::new(), bytes }) + Ok(NetRunner { shared, tile, in_channels: net.in_channels, workspaces: Workspaces::new(first, WORKSPACES), bytes }) } impl NetRunner { @@ -729,45 +785,30 @@ impl NetRunner { /// A free workspace, a new one when fewer than [`WORKSPACES`] exist, else wait for one to come back. fn take(&self) -> Result { - let mut free = self.free.lock().unwrap_or_else(|e| e.into_inner()); - loop { - if let Some(w) = free.pop() { - return Ok(w); + self.workspaces.take(|| { + // a caller woken by the last tiles failing on a lost device makes nothing on it + if let Some(why) = broken_reason() { + return Err(Error::Runtime(format!("the GPU is not used any more: {why}"))); } - { - let mut made = self.made.lock().unwrap_or_else(|e| e.into_inner()); - if *made < WORKSPACES { - *made += 1; - drop(made); - drop(free); - let d = self.shared.dev; - let scopes = - [d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)]; - let w = self.shared.workspace(); - let [oom, validation] = scopes; - let (validation, oom) = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); - if let Some(e) = oom.or(validation) { - self.forget(); - return Err(Error::Runtime(format!("the GPU could not make room for another tile: {e}"))); - } - return Ok(w); - } + let d = self.shared.dev; + let scopes = [d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)]; + let w = self.shared.workspace(); + let [oom, validation] = scopes; + let (validation, oom) = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + if let Some(e) = oom.or(validation) { + return Err(Error::Runtime(format!("the GPU could not make room for another tile: {e}"))); } - free = self.wake.wait(free).unwrap_or_else(|e| e.into_inner()); - } + Ok(w) + }) } fn give(&self, w: Workspace) { - self.free.lock().unwrap_or_else(|e| e.into_inner()).push(w); - self.wake.notify_one(); + self.workspaces.give(w); } /// A workspace is gone (dropped after a failure): room for a new one, and a waiting caller may make it. fn forget(&self) { - let mut made = self.made.lock().unwrap_or_else(|e| e.into_inner()); - *made = made.saturating_sub(1); - drop(made); - self.wake.notify_one(); + self.workspaces.forget(); } fn execute(&self, w: &Workspace, input: &[f32]) -> Result, Error> { @@ -1114,4 +1155,60 @@ mod tests { // a buffer limit smaller than the activations assert!(plan(&n, 16, 64).is_err()); } + + /// Issue #2577: a tile failing between a caller finding every workspace in use and that caller waiting still wakes + /// it. The caller holds the lock from its check to its wait, as `Workspaces::take` does, so `forget` waits for it. A + /// wait that runs out fails, whatever the count is by then: the wakeup itself has to arrive. + #[test] + fn a_caller_that_finds_every_workspace_in_use_hears_one_being_forgotten() { + let ws = Workspaces::new(0u32, 1); + assert_eq!(ws.lock().free.pop(), Some(0)); + std::thread::scope(|scope| { + // where take() is between finding every workspace in use and waiting + let mut s = ws.lock(); + assert!(s.free.is_empty() && s.made == ws.cap); + scope.spawn(|| ws.forget()); + // the tile in flight fails now + std::thread::sleep(Duration::from_millis(50)); + // wait as take() does, and again only after a spurious wakeup that changed nothing + loop { + let (next, r) = ws.wake.wait_timeout(s, Duration::from_secs(10)).unwrap_or_else(|e| e.into_inner()); + assert!(!r.timed_out(), "the caller missed the workspace being forgotten"); + s = next; + if s.made != ws.cap { + break; + } + } + assert_eq!(s.made, 0); + }); + } + + #[test] + fn take_waits_for_a_workspace_to_come_back_or_be_forgotten() { + let ws = std::sync::Arc::new(Workspaces::new(0u32, 1)); + let fail = || Err(Error::Runtime("no room".into())); + assert_eq!(ws.take(fail).unwrap(), 0); + // a caller on a thread nobody joins: if its wakeup never comes, the test fails on its timeout instead of hanging + let caller = |made: u32| { + let (ws, (tx, rx)) = (ws.clone(), std::sync::mpsc::channel()); + std::thread::spawn(move || tx.send(ws.take(|| Ok(made)))); + rx + }; + // the only workspace is in flight: a caller waits until it is forgotten, then makes a new one + let rx = caller(1); + assert!(rx.recv_timeout(Duration::from_millis(50)).is_err(), "took a workspace while none was free"); + ws.forget(); + assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap().unwrap(), 1); + // or until it comes back, and takes that one + let rx = caller(2); + assert!(rx.recv_timeout(Duration::from_millis(50)).is_err(), "took a workspace while none was free"); + ws.give(1); + assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap().unwrap(), 1); + // a workspace that could not be made leaves room for the next try + ws.forget(); + assert!(ws.take(fail).is_err()); + assert_eq!(ws.lock().made, 0); + assert_eq!(ws.take(|| Ok(3)).unwrap(), 3); + assert_eq!(ws.lock().made, 1); + } } From 42709684d25e656f1ab0d29e89ea96a4cfc5764e Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:42:53 +0200 Subject: [PATCH 07/44] LR-MASK-BRUSH: with the mask overlay hidden, the stroke being painted no longer tints the photo (#2549) While the brush button was held, the live stroke was always drawn in the overlay colour, falling back to a fixed 43% tint when the mask overlay was hidden. Hiding the overlay with O or the panel eye, or having a mask slider hide it before dodging and burning, still left each stroke red until release, so the photo could not be judged while painting. The live stroke now follows the same visibility as the committed overlay: it is tinted only when Show Mask Overlay or the all-masks overlay is on, and not for a colour overlay at opacity 0. A headless test paints after a mask slider hid the overlay, after O, at opacity 0 and with the all-masks overlay, and checks that only the all-masks case tints the stroke being painted. --- crates/ui-egui/src/panels/detail.rs | 13 +++- crates/ui-egui/src/tests_masking.rs | 100 ++++++++++++++++++++++++++++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 8 +++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index b95d7b056..84531132b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2384,10 +2384,17 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } // the stroke being painted (the render shows it once it's committed), with the committed - // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + // stroke's dab spacing, feather and flow (issue #517), tinted only when the overlay will + // show it: with the overlay hidden (O, or a mask slider hid it) or a colour overlay at + // opacity 0, the photo stays untinted while painting, so dodging and burning is judged on + // the photo (issue #2549) + use lightcraft_pipeline::MaskView; + let colored = matches!(MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), MaskView::Color | MaskView::ColorOnBw); + let shown = (app.ui.mask_overlay || app.ui.mask_overlay_all) && !(colored && app.ui.mask_overlay_opacity <= 0.0); + if shown && let Some(Gesture::Brush { points, pressure }) = &app.gesture { let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); - let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + // opacity 0 only gets here in the image views, which ignore it: a fixed tint there + let opacity = if app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; let params = crate::brush_live::LiveParams { clip, scale: ui.ctx().pixels_per_point(), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index a1e5846e4..f6841129f 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1782,6 +1782,106 @@ fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() { } } +/// What the photo shows along the centreline of [`hold_fast_stroke`]'s stroke at height `y`, away +/// from the cursor ring at its end. +fn stroke_centreline(h: &Headless, y: f32) -> Vec<[f32; 3]> { + let im = h.paint(); + let img = h.app.image_rect.expect("the photo is on screen"); + let ppp = h.pixels_per_point; + (1..=74) + .map(|i| { + let x = 0.17 + (0.85 - 0.17) * i as f32 / 80.0; + let (sx, sy) = (((img.left() + x * img.width()) * ppp) as usize, ((img.top() + y * img.height()) * ppp) as usize); + let c = im.pixels[sy * im.size[0] + sx]; + [c.r() as f32, c.g() as f32, c.b() as f32] + }) + .collect() +} + +/// The fast stroke of the #517 test at height `y`, with the button still held: its centreline +/// before painting and while painting. +fn hold_fast_stroke(h: &mut Headless, y: f32) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { + pointer(h, json!([{"kind": "move", "x": 0.02, "y": 0.02}])); + h.settle(SETTLE); + let photo = stroke_centreline(h, y); + pointer( + h, + json!([{"kind": "down", "x": 0.15, "y": y}, {"kind": "drag", "x": 0.17, "y": y}, {"kind": "drag", "x": 0.5, "y": y}, {"kind": "drag", "x": 0.85, "y": y}]), + ); + assert!(matches!(h.app.gesture, Some(crate::panels::detail::Gesture::Brush { .. })), "still painting"); + (photo, stroke_centreline(h, y)) +} + +/// The centreline points (photo, held) where the stroke being painted changes the photo. +fn tinted(photo: &[[f32; 3]], held: &[[f32; 3]]) -> Vec<([f32; 3], [f32; 3])> { + photo.iter().zip(held).filter(|(p, l)| (0..3).any(|k| (p[k] - l[k]).abs() > 6.0)).map(|(p, l)| (*p, *l)).collect() +} + +/// Issue #2549: with the mask overlay hidden (a mask slider hid it, or O) or a colour overlay at +/// opacity 0, the stroke being painted doesn't tint the photo either, so dodging and burning is +/// judged on the photo; the effect shows once the stroke is committed. The all-masks overlay still +/// shows it, as it will the committed stroke. +#[test] +fn a_brush_stroke_is_not_tinted_while_the_mask_overlay_is_hidden() { + let mut h = detail("panel.masking"); + h.settle(SETTLE); + exec(&mut h, "tool.brush", json!({"new": true})); + let r = h.request("ui.set", json!({"brushSize": 0.03, "brushFeather": 50.0, "brushFlow": 100.0}), T); + assert_eq!(r["ok"], true, "{r}"); + // dodging: brighten the new, still empty mask first (which hides the overlay), then paint + exec(&mut h, "mask.adjust", json!({"values": {"exposure": 1.0}})); + assert!(!h.app.ui.mask_overlay, "a mask slider hides the overlay"); + let (photo, held) = hold_fast_stroke(&mut h, 0.3); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "the hidden overlay tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first()); + assert!(h.app.brush_live.is_none(), "nothing is drawn over the photo while painting"); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.3}])); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert!(strokes[0].points.len() >= 3, "{strokes:?}"); + assert!(!h.app.ui.mask_overlay, "painting leaves the overlay hidden"); + // committed: the stroke brightens the photo + h.settle(SETTLE); + let luma = |c: &[[f32; 3]]| c.iter().map(|p| p[0] + p[1] + p[2]).sum::() / c.len() as f32; + let done = stroke_centreline(&h, 0.3); + assert!(luma(&done) > luma(&photo) + 30.0, "committed {} vs before {}", luma(&done), luma(&photo)); + + // O shows the overlay again, but a colour overlay at opacity 0 shows nothing of the committed + // strokes, nor of the one being painted + let r = h.request("ui.key", json!({"key": "o"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.mask_overlay, "O shows the overlay"); + exec(&mut h, "view.maskOverlayColor", json!({"opacity": 0})); + let (photo, held) = hold_fast_stroke(&mut h, 0.45); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "an overlay at opacity 0 tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first()); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.45}])); + + // O hides it again, at its usual opacity + exec(&mut h, "view.maskOverlayColor", json!({"opacity": 50})); + let r = h.request("ui.key", json!({"key": "o"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.ui.mask_overlay, "O hides the overlay"); + let (photo, held) = hold_fast_stroke(&mut h, 0.6); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "the overlay hidden with O tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first()); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.6}])); + + // the all-masks overlay shows every mask's region, so the stroke being painted is tinted as it + // will be once committed + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(h.app.ui.mask_overlay_all && !h.app.ui.mask_overlay); + let (photo, held) = hold_fast_stroke(&mut h, 0.75); + let [r0, g0, b0] = h.app.ui.mask_overlay_color.map(|v| v as f32); + let op = h.app.ui.mask_overlay_opacity / 100.0; + for (p, got) in photo.iter().zip(&held) { + let want = [p[0] + (r0 - p[0]) * op, p[1] + (g0 - p[1]) * op, p[2] + (b0 - p[2]) * op]; + assert!((0..3).all(|k| (got[k] - want[k]).abs() <= 12.0), "the all-masks overlay tints the stroke: {got:?} vs {want:?}"); + } + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.75}])); + assert_eq!(develop(&h).masks.len(), 1, "every stroke went into the one mask"); +} + #[test] fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { let mut h = detail("panel.masking"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..13eccd99f 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-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** patch (LR-MASK-BRUSH: with the mask overlay hidden, the stroke being painted no longer tints the photo) · **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 @@ -296,7 +296,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), 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); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | +| 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); with the mask overlay hidden (O / the eye, or after a mask slider hid it) or a colour overlay at opacity 0 it is not tinted while painting either, so dodging and burning is judged on the photo, and its effect shows once the stroke is committed (#2549); 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); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider | @@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-22 | patch | LR-MASK-BRUSH: the stroke being painted follows Show Mask Overlay: hidden (or a colour overlay at opacity 0), it no longer tints the photo (#2549); status unchanged (🟡) | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | | 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-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index cffff83e2..121645f81 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-22 + +### Dodge and burn with the brush on the photo itself +- With the mask overlay hidden (O, View ▸ Show Mask Overlay or the eye beside Masks, or after a mask slider hid it), + the brush stroke you are painting is no longer drawn in the overlay colour while you hold the button, so the photo + stays visible as you dodge and burn (issue #2549). The stroke's effect still appears when you let go. With the + overlay shown, the stroke is tinted as before; a colour overlay at opacity 0 leaves it untinted too. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges From 66ccce422b8df32d39e5e5b64e1a30d94687dfe6 Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:22:58 +0200 Subject: [PATCH 08/44] Review: under the all-masks overlay, a stroke into a hidden mask isn't tinted while painting (#2549) The all-masks overlay shows only visible masks, so the live tint now follows the same rule: a stroke into a hidden mask is untinted there, and when no visible mask has anything in it the selected-mask overlay setting decides, as the rendered overlay does. --- crates/ui-egui/src/panels/detail.rs | 6 +++++- crates/ui-egui/src/tests_masking.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 84531132b..7fd50d00f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2390,7 +2390,11 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons // the photo (issue #2549) use lightcraft_pipeline::MaskView; let colored = matches!(MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), MaskView::Color | MaskView::ColorOnBw); - let shown = (app.ui.mask_overlay || app.ui.mask_overlay_all) && !(colored && app.ui.mask_overlay_opacity <= 0.0); + // the all-masks overlay ([`view_overlay`]) shows only visible masks, and gives way to the + // selected mask's when none has anything in it (a stroke goes into the selected mask, or a new one) + let visible = active.and_then(|id| d.masks.iter().find(|m| m.id == id)).is_none_or(|m| m.visible); + let all = app.ui.mask_overlay_all && (visible || d.masks.iter().any(|m| m.visible && !m.components.is_empty())); + let shown = if all { visible } else { app.ui.mask_overlay } && !(colored && app.ui.mask_overlay_opacity <= 0.0); if shown && let Some(Gesture::Brush { points, pressure }) = &app.gesture { let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); // opacity 0 only gets here in the image views, which ignore it: a fixed tint there diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index f6841129f..7c221bef5 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1880,6 +1880,14 @@ fn a_brush_stroke_is_not_tinted_while_the_mask_overlay_is_hidden() { } pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.75}])); assert_eq!(develop(&h).masks.len(), 1, "every stroke went into the one mask"); + + // a hidden mask isn't in the all-masks overlay: nor is the stroke being painted into it + let id = develop(&h).masks[0].id; + exec(&mut h, "mask.visible", json!({"id": id, "visible": false})); + let (photo, held) = hold_fast_stroke(&mut h, 0.9); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "the all-masks overlay tints a stroke into a hidden mask: {} of 74 points, first {:?}", t.len(), t.first()); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.9}])); } #[test] From a3dbe2ab914dc818f79d22fd6faf0a998144c2c5 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Fri, 23 Oct 2026 23:49:37 +0400 Subject: [PATCH 09/44] Brush dabs: build only as far as the caller will look (#1520) MAX_STROKE_DABS bounds one stroke at two million, but the two callers that will not look at nearly that many check the length after the vector exists, which bounds nothing: it only discards what was already built. gpu/render.rs rejects a brush mask past MAX_DABS, 4096 across the whole mask, after collecting every stroke's dabs. Each stroke may construct two million first, so the sum that rejects them is reached only once they all exist. spots.rs skips an Auto Mask stroke past MAX_DABS_CONSIDERED, 200,000, after brush_dabs has built the whole thing. brush_dabs_within takes the caller's own budget, and push_dabs_within charges against it instead of MAX_STROKE_DABS. Both callers now ask for one more than they accept, so a stroke past the threshold is still recognised as past it without the rest being built: 4097 rather than two million per stroke on the GPU path, 200,001 rather than two million for the spot window. Behaviour is unchanged for anything either caller keeps. A result short of its budget was never truncated, so it is the whole stroke, and a truncated one only ever reaches a caller that was going to reject it. A truncated stroke is not a prefix: running out of room makes a segment lay its remaining dabs further apart rather than stop mid-segment, which is how push_dabs_counted already behaved at MAX_STROKE_DABS. --- crates/gpu/src/render.rs | 14 ++++++- crates/pipeline/src/masks.rs | 76 +++++++++++++++++++++++++++++++++++- crates/pipeline/src/spots.rs | 4 +- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0c5bd19f0..9fc2b26bc 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -1012,7 +1012,19 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho Some(3) } MaskShape::Brush { strokes } => { - let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect(); + // The kernel takes at most MAX_DABS across the whole mask, so no more than + // that (plus one, to tell "at it" from "past it") is ever built: each stroke + // may otherwise construct two million dabs before the sum below rejects them + // all (#1520). + let mut room = MAX_DABS + 1; + let dabs: Vec<_> = strokes + .iter() + .map(|st| { + let d = lightcraft_pipeline::masks::brush_dabs_within(st, frame, w, h, room); + room -= d.dabs.len().min(room); + d + }) + .collect(); // pen pressure varies radius and flow per dab, which the kernel doesn't take: the CPU rasterizes it if dabs.iter().map(|d| d.dabs.len()).sum::() > MAX_DABS || dabs.iter().any(|d| !d.pressure.is_empty()) { None diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 36cc29192..a2497a2bd 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -524,6 +524,19 @@ pub const MAX_STROKE_DABS: usize = 2_000_000; /// The dabs of stroke `s` in a `w × h` output. pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs { + brush_dabs_within(s, frame, w, h, MAX_STROKE_DABS) +} + +/// [`brush_dabs`] stopping at `max` dabs instead of [`MAX_STROKE_DABS`]. +/// +/// For a caller that discards a stroke past a threshold of its own: it builds to that threshold +/// rather than to the stroke budget and throws the difference away. The GPU path rejects a brush +/// mask past 4096 dabs in total and the Auto Mask spot window past 200,000, both far below the +/// two million a single stroke may otherwise construct first (#1520). +/// +/// Ask for one more than the threshold to tell "at it" from "past it": a result shorter than +/// `max` is the whole stroke. +pub fn brush_dabs_within(s: &BrushStroke, frame: &Frame, w: usize, h: usize, max: usize) -> BrushDabs { let to_out = frame.norm_to_out(w, h); let ppl = frame.px_per_long(w); let r = (s.size * ppl).max(0.5); @@ -536,10 +549,14 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa let with_pressure = !s.pressure.is_empty(); let mut prev = None; let mut prev_p = 1.0; + let mut emitted = 0usize; for (i, p) in s.points.iter().enumerate() { + if emitted >= max { + break; + } let q = to_out.apply(*p); let before = dabs.len(); - if push_dabs(&mut dabs, prev, q, r) { + if push_dabs_within(&mut dabs, &mut emitted, prev, q, r, max) { let pq = s.pressure_at(i); if with_pressure { // the first dab of the stroke has nothing to interpolate from @@ -585,7 +602,16 @@ pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) - /// dabs no longer held in `dabs`. Incremental callers retain `emitted` when clearing their buffer; /// it advances only by the number actually appended. Acceptance and spacing match [`push_dabs`]. pub fn push_dabs_counted(dabs: &mut Vec, emitted: &mut usize, prev: Option, q: Point, r: f64) -> bool { - let room = MAX_STROKE_DABS.saturating_sub(*emitted); + push_dabs_within(dabs, emitted, prev, q, r, MAX_STROKE_DABS) +} + +/// [`push_dabs_counted`] against a caller's own budget rather than [`MAX_STROKE_DABS`]. +/// +/// A caller that will not look at more than `max` dabs should build no more than `max`: a length +/// check after the vector exists bounds nothing, it only throws away what was already paid for +/// (#1520). +pub fn push_dabs_within(dabs: &mut Vec, emitted: &mut usize, prev: Option, q: Point, r: f64, max: usize) -> bool { + let room = max.min(MAX_STROKE_DABS).saturating_sub(*emitted); if room == 0 || !(q.x.is_finite() && q.y.is_finite()) { return false; } @@ -856,6 +882,52 @@ mod tests { assert!(e[0].alpha.get(50, 50) < 0.01); } + /// #1520: a caller that will not look past its own threshold builds to that threshold, not to + /// the stroke budget. Each segment here is admissible on its own and together they want far + /// more than any threshold the callers apply, which is the aggregate the issue describes + /// rather than one hostile segment. + #[test] + fn a_stroke_is_built_only_as_far_as_the_caller_will_look() { + let f = frame(4000, 4000); + // Twenty long sweeps across the output: tens of thousands of dabs between them. + let points: Vec = + (0..20).map(|i| if i % 2 == 0 { Point::new(0.02, 0.02 + i as f64 * 0.04) } else { Point::new(0.98, 0.02 + i as f64 * 0.04) }).collect(); + let s = BrushStroke { points, size: 0.002, feather: 50.0, flow: 100.0, density: 100.0, ..Default::default() }; + + let whole = brush_dabs(&s, &f, 4000, 4000); + assert!(whole.dabs.len() > 20_000, "the stroke really is large: {}", whole.dabs.len()); + assert!(whole.dabs.len() <= MAX_STROKE_DABS); + + // Each caller gets what it asked for and no more, however large the stroke is. 4097 and + // 200_001 are what the GPU path and the Auto Mask spot window now ask for. + for max in [1usize, 2, 4097, 200_001] { + let got = brush_dabs_within(&s, &f, 4000, 4000, max); + assert!(got.dabs.len() <= max, "asked for at most {max}, built {}", got.dabs.len()); + assert_eq!(got.r, whole.r, "the brush itself is unchanged"); + } + + // A budget the stroke never reaches changes nothing: it only ever truncates. + let generous = brush_dabs_within(&s, &f, 4000, 4000, whole.dabs.len() + 1000); + assert_eq!(generous.dabs, whole.dabs, "an unreached budget leaves the stroke alone"); + + // The guarantee the callers rely on: a result short of the budget was never truncated, + // so it is the stroke itself. Both callers ask for one more than they will accept, so + // anything they keep is identical to what they drew before this change, and anything + // truncated is something they were going to reject anyway. + // + // A truncated one is not a prefix: running out of room makes a segment lay its remaining + // dabs further apart rather than stop mid-segment, which is how `push_dabs_counted` + // already behaved at MAX_STROKE_DABS. That only ever reaches a caller that rejects it. + let cut = brush_dabs_within(&s, &f, 4000, 4000, 500); + assert_eq!(cut.dabs.len(), 500, "a stroke past the budget fills it exactly"); + for max in [700usize, 5_000, whole.dabs.len()] { + let got = brush_dabs_within(&s, &f, 4000, 4000, max); + if got.dabs.len() < max { + assert_eq!(got.dabs, whole.dabs, "a result short of its budget is the whole stroke"); + } + } + } + /// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at /// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once /// (the committed one). diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 315f7f956..a875ffb7f 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -206,7 +206,9 @@ pub fn window_for_reads_checked( // refine the stroke over a couple of radii: each dab matters to pixels within 2.5 radii let mut dab_boxes: Vec<[f64; 4]> = Vec::new(); for st in autos { - let dabs = crate::masks::brush_dabs(st, frame, full_w, full_h); + // One more than the window takes, so a stroke past it is still recognised as past it + // without the other 1.8 million dabs being built first (#1520). + let dabs = crate::masks::brush_dabs_within(st, frame, full_w, full_h, MAX_DABS_CONSIDERED + 1); let r = dabs.r; if dabs.dabs.len() > MAX_DABS_CONSIDERED { continue; From 32e55afee8d82c8cc5e95e12cd0ba75509a10832 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 25 Oct 2026 05:15:13 +1000 Subject: [PATCH 10/44] =?UTF-8?q?Issue=20#136:=20app.gpu=20{wait:false}=20?= =?UTF-8?q?still=20says=20why=20the=20GPU=20is=20off;=20Settings=20shows?= =?UTF-8?q?=20"GPU:=20starting=E2=80=A6"=20while=20the=20device=20is=20cre?= =?UTF-8?q?ated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: with GPU rendering off (preference, environment, earlier failure) the no-wait reply now reports available=false and the reason at once, without creating the device. The Performance tab no longer calls gpu::available() while the device is still being created, which could block the UI thread. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/tests/cli.rs | 19 +++++++++++++------ crates/engine/src/cmd/query.rs | 11 +++++++---- crates/ui-egui/src/panels/settings.rs | 5 ++++- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 8f56bafe6..6bdf9e7b9 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -404,7 +404,12 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() { let script = tmp("default-cpu.jsonl"); std::fs::write( &script, - format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.screenshot"})), + format!( + "{}\n{}\n{}\n", + json!({"method": "engine.execute", "params": {"command": "app.gpu", "params": {"wait": false}}}), + json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), + json!({"method": "ui.screenshot"}) + ), ) .unwrap(); let image = tmp("default-cpu.png"); @@ -418,11 +423,13 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() { assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr)); let replies: Vec = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect(); assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}"); - let gpu = &replies[0]["result"]; - assert_eq!(gpu["enabled"], false, "{gpu}"); - assert_eq!(gpu["available"], false, "{gpu}"); - assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}"); - assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}"); + // `wait: false` (what the UI sends) still says why the GPU is off: no device is being created + for gpu in [&replies[0]["result"], &replies[1]["result"]] { + assert_eq!(gpu["enabled"], false, "{gpu}"); + assert_eq!(gpu["available"], false, "{gpu}"); + assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}"); + assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}"); + } let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap(); assert_eq!((decoded.width, decoded.height), (480, 320)); } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index fd81047cd..52dca09ed 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -226,18 +226,21 @@ pub fn specs() -> Vec { "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), - cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool, wait?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}. wait: false never waits for a device still being created (available, adapter and reason are then null)", always, |_, p| { + cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool, wait?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}. wait: false never waits for a device still being created (available, adapter and reason are then null; with GPU rendering off they say so at once)", always, |_, p| { if let Some(on) = p.get("enabled").and_then(Value::as_bool) { lightcraft_gpu::set_enabled(on); } // the UI applies the setting on its first frame: it must not wait seconds for the // device there (issue #136; `warm_up` creates it off the UI thread) if p.get("wait").and_then(Value::as_bool) == Some(false) && !lightcraft_gpu::ready() { + // off (preference, environment): known at once, and asking the adapter would + // create the device; on: still being created, so nothing is known yet + let on = lightcraft_gpu::enabled(); return Ok(json!({ - "enabled": lightcraft_gpu::enabled(), - "available": null, + "enabled": on, + "available": if on { Value::Null } else { Value::Bool(false) }, "adapter": null, - "reason": null, + "reason": if on { None } else { lightcraft_gpu::unavailable_reason() }, "lastFallback": lightcraft_gpu::last_fallback(), })); } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f844f4ae..8debaeeaf 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -417,7 +417,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::gpu; heading(ui, t, crate::i18n::tr("Rendering")); check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); - let status = if !gpu::available() { + // `available` would wait for a device still being created (issue #136) + let status = if gpu::enabled() && !gpu::ready() { + "GPU: starting…".to_string() + } else if !gpu::available() { match gpu::unavailable_reason() { Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why), None => "No usable GPU found: rendering on the CPU".to_string(), From 9a6ac9aea7ead542f63173ef894fc824099f91d7 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 25 Oct 2026 13:48:25 +1000 Subject: [PATCH 11/44] Issue #136: a non-waiting GPU query starts the device; Settings repaints while it starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `app.gpu {wait: false}` with GPU rendering on starts creating the device in the background (`warm_up`), so a caller that never waits still gets an answer later. `warm_up` starts one thread at most, and may start again if GPU rendering was turned off before it ran. - Settings > Performance asks for a repaint every 250 ms while it shows "GPU: starting…": the device is created on another thread, which doesn't wake the window. - CLI test: `{enabled: true, wait: false}` answers enabled with available, adapter and reason null (skips with LIGHTCRAFT_GPU=0, since it starts creating a device). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/lightcraft-cli/Cargo.toml | 3 +++ apps/lightcraft-cli/tests/it/cli.rs | 35 +++++++++++++++++++++++++++ crates/engine/src/cmd/query.rs | 7 +++++- crates/gpu/src/lib.rs | 15 ++++++++++-- crates/ui-egui/src/panels/settings.rs | 2 ++ 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78de058c3..60b893afe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3141,6 +3141,7 @@ dependencies = [ "lightcraft-merge", "lightcraft-raster", "lightcraft-sysmem", + "lightcraft-testkit", "lightcraft-ui-egui", "serde_json", ] diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml index b82aca1d5..e55d458b3 100644 --- a/apps/lightcraft-cli/Cargo.toml +++ b/apps/lightcraft-cli/Cargo.toml @@ -25,6 +25,9 @@ lightcraft-merge = { workspace = true } serde_json = { workspace = true } dhat = { workspace = true, optional = true } +[dev-dependencies] +lightcraft-testkit = { workspace = true } + [features] default = ["denoise"] denoise = ["lightcraft-engine/denoise"] diff --git a/apps/lightcraft-cli/tests/it/cli.rs b/apps/lightcraft-cli/tests/it/cli.rs index 78ee42561..4ea499cf1 100644 --- a/apps/lightcraft-cli/tests/it/cli.rs +++ b/apps/lightcraft-cli/tests/it/cli.rs @@ -466,6 +466,41 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() { assert_eq!((decoded.width, decoded.height), (480, 320)); } +/// Issue #136: `app.gpu {enabled: true, wait: false}` (what the UI sends on its first frame) answers +/// at once while the device is still being created: enabled, and nothing known yet (`null`), instead +/// of waiting for the driver. It starts creating the device (off the calling thread), so this test +/// touches the GPU driver and skips when the environment turns the GPU off. +#[test] +fn enabling_the_gpu_without_waiting_answers_before_the_device_exists() { + if std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")) { + lightcraft_testkit::skip!(Gpu, "LIGHTCRAFT_GPU=0: enabling the GPU starts creating a device"); + return; + } + let script = tmp("gpu-starting.jsonl"); + std::fs::write( + &script, + format!("{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu", "params": {"enabled": true, "wait": false}}})), + ) + .unwrap(); + let image = tmp("gpu-starting.png"); + let output = Command::new(BIN) + .env_remove("LIGHTCRAFT_GPU") + .env_remove("LIGHTCRAFT_GPU_BACKEND") + .env_remove("WGPU_BACKEND") + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"]) + .output() + .unwrap(); + assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr)); + let replies: Vec = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], true, "{replies:?}"); + let gpu = &replies[0]["result"]; + assert_eq!(gpu["enabled"], true, "{gpu}"); + for field in ["available", "adapter", "reason"] { + assert_eq!(gpu[field], Value::Null, "{field} is not known while the device is created: {gpu}"); + } +} + /// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU, /// without creating a GPU device (no driver is loaded), and says why. #[test] diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index b50bc72a1..469253805 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -230,7 +230,7 @@ pub fn specs() -> Vec { "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), - cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool, wait?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}. wait: false never waits for a device still being created (available, adapter and reason are then null; with GPU rendering off they say so at once)", always, |_, p| { + cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool, wait?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}. wait: false never waits for a device still being created (available, adapter and reason are then null and the device is created in the background; with GPU rendering off they say so at once)", always, |_, p| { if let Some(on) = p.get("enabled").and_then(Value::as_bool) { lightcraft_gpu::set_enabled(on); } @@ -240,6 +240,11 @@ pub fn specs() -> Vec { // off (preference, environment): known at once, and asking the adapter would // create the device; on: still being created, so nothing is known yet let on = lightcraft_gpu::enabled(); + if on { + // a caller that never waits still gets an answer later (off the UI thread; + // starts at most once) + lightcraft_gpu::warm_up(); + } return Ok(json!({ "enabled": on, "available": if on { Value::Null } else { Value::Bool(false) }, diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 452e2dd67..58aa1d967 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -308,13 +308,24 @@ pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> { /// Create the device and compile the kernels on a background thread now (once the window is up), /// so the first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. Does nothing -/// while GPU rendering is turned off. +/// while GPU rendering is turned off. Repeated calls start one thread at most. pub fn warm_up() { #[cfg(not(target_arch = "wasm32"))] if !env_disabled() && ENABLED.load(Ordering::Relaxed) && GPU.get().is_none() { - let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { + static STARTED: AtomicBool = AtomicBool::new(false); + if STARTED.swap(true, Ordering::AcqRel) { + return; + } + let spawned = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { let _ = device(); + // turned off before the thread ran (nothing created): a later call may start again + if GPU.get().is_none() { + STARTED.store(false, Ordering::Release); + } }); + if spawned.is_err() { + STARTED.store(false, Ordering::Release); + } } } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 95d09c306..555aa349c 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -588,6 +588,8 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); // `available` would wait for a device still being created (issue #136) let status = if gpu::enabled() && !gpu::ready() { + // the device is created on another thread, which doesn't wake the UI when it's done + ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); "GPU: starting…".to_string() } else if !gpu::available() { match gpu::unavailable_reason() { From 8541131cd8464d1948144c44718f8c824f04d56c Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 25 Oct 2026 18:58:20 +1000 Subject: [PATCH 12/44] Issue #136: a GPU start lost to an off/on switch is retried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by radialmonster: turned off before `lc-gpu-init` created the device and back on, `warm_up` saw the start still held and returned, then the thread released it with no device, leaving Settings > Performance at "GPU: starting…". The thread now releases the flag first, then reads the preference again and tries again itself when GPU rendering is back on (it stops when another start took over, at shutdown or with the environment's off switch). Settings > Performance also calls the idempotent `gpu::warm_up()` in its "starting" branch (their suggestion), on the repaint it already asks for. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 17 +++++++++++++---- crates/ui-egui/src/panels/settings.rs | 5 ++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 58aa1d967..84aad52ea 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -317,10 +317,19 @@ pub fn warm_up() { return; } let spawned = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { - let _ = device(); - // turned off before the thread ran (nothing created): a later call may start again - if GPU.get().is_none() { - STARTED.store(false, Ordering::Release); + loop { + let _ = device(); + if GPU.get().is_some() || shutting_down() || env_disabled() { + break; + } + // turned off before the thread ran (nothing created): a later call may start + // again. Released first, then the preference read again: a call made while this + // thread still held the flag (turned back on meanwhile) returned without starting + // one, so this thread tries again itself rather than leave "starting" for good. + STARTED.store(false, Ordering::SeqCst); + if !ENABLED.load(Ordering::SeqCst) || STARTED.swap(true, Ordering::SeqCst) { + break; + } } }); if spawned.is_err() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 555aa349c..111b37a28 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -588,7 +588,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); // `available` would wait for a device still being created (issue #136) let status = if gpu::enabled() && !gpu::ready() { - // the device is created on another thread, which doesn't wake the UI when it's done + // the device is created on another thread, which doesn't wake the UI when it's done; + // make sure that thread runs (cheap and idempotent: a start lost to an off/on switch + // can't leave "starting" for good) + gpu::warm_up(); ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); "GPU: starting…".to_string() } else if !gpu::available() { From 2dd02f6f97f4d91e680254979b2fbb309ed9a043 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:08:35 -0700 Subject: [PATCH 13/44] PERF-WINDOW: the window queues one frame, not two (eframe's low-latency surface) window_gpu::base_options() built the window's wgpu options from egui_wgpu::WgpuConfiguration::default(), which is egui-wgpu's HIGH_THROUGHPUT surface (desired_maximum_frame_latency 2; three drawables on Metal), not eframe's default. eframe's NativeOptions::default() uses LOW_LATENCY (latency 1). The window had eframe's default until #136 started building the options by hand to choose the backends. Start from eframe::NativeOptions::default().wgpu_options and change only the backends, the DX12 compiler and the instance flags, as before. The probed Windows configurations are built on the same options. Measured on an M2 Max at 120 Hz (Metal System Trace, heavy develop drag, presents joined to WindowServer's reads of the same IOSurface): the window cycles 2 drawables instead of 3, and a frame reaches the compositor about 8.3 ms (one frame) sooner, 9.5-10.6 ms instead of 17.9-18.9 ms after it is presented, at the same ~119 frames a second. Test: window_gpu::tests::window_surface_keeps_one_frame_in_flight (fails on the old options: latency Some(2) instead of Some(1)). Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/window_gpu.rs | 24 +++++++++++++++++++++++- docs/gpu-pipeline.md | 9 +++++++++ docs/whats-new.md | 10 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index fefa65a51..69418028a 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -142,8 +142,13 @@ fn user_notice(failed: Option, forced: Option, p } /// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471). +/// +/// Taken from `NativeOptions::default()`, whose surface is egui-wgpu's `LOW_LATENCY` (one frame in +/// flight; two drawables on Metal). `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two +/// frames queued, three drawables): every drag, slider and crop handle reached the screen a frame +/// later than eframe's default. Live window resizing still gets three drawables from egui-wgpu. pub fn base_options() -> WgpuConfiguration { - let mut c = WgpuConfiguration::default(); + let mut c = eframe::NativeOptions::default().wgpu_options; if let WgpuSetup::CreateNew(n) = &mut c.wgpu_setup { n.instance_descriptor.backends = backend::window_backends(); n.instance_descriptor.backend_options = backend::backend_options(); @@ -290,6 +295,23 @@ mod tests { assert!(died.contains("last time") && !died.contains("Validation Error"), "{died}"); } + /// The window keeps eframe's low-latency surface: one frame queued (two Metal drawables), not + /// the high-throughput `WgpuConfiguration::default()`, which held input a frame longer. The + /// probed (Windows) and forced-backend configurations are built on the same options. + #[test] + fn window_surface_keeps_one_frame_in_flight() { + let surface = base_options().surface; + assert_eq!(surface, eframe::NativeOptions::default().wgpu_options.surface); + assert_eq!(surface, eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY); + assert_eq!(surface.desired_maximum_frame_latency, Some(1)); + assert_eq!(surface.present_mode, wgpu::PresentMode::AutoVsync); + assert_ne!(surface, WgpuConfiguration::default().surface, "egui-wgpu's own default is the high-throughput one"); + // what `main` hands eframe (on Windows `choose` probes the adapters first; not here) + if !cfg!(windows) { + assert_eq!(choose(&Files::new(None)).wgpu_options.surface, surface); + } + } + /// The crash report: a pipeline that fails validation must come back as an error, never as /// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU). #[test] diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index f88f81c39..0370e6407 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -113,6 +113,15 @@ renderer last. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow` (or `WGPU_BACK first because a broken Vulkan driver can crash the process uncatchably (#136). Compute keeps its own device and falls back to the CPU as before. +**The window's surface: one frame in flight.** The window's wgpu options start from eframe's defaults +(`NativeOptions::default().wgpu_options`, `window_gpu::base_options`), whose surface is egui-wgpu's +`SurfaceConfig::LOW_LATENCY`: vsync, at most one frame queued ahead of the display (two drawables on +Metal). egui-wgpu's bare `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` (two frames queued, three +drawables), and the window used it from #136 until this change, so a drag reached the screen one frame +(8.3 ms at 120 Hz, 16.7 ms at 60 Hz) later. The window's own GPU work is small (egui's meshes and +texture uploads; renders run on the compute device), which is what `LOW_LATENCY` is meant for. +During a live window resize egui-wgpu still uses three drawables on macOS. + **Troubleshooting a crash at startup (Windows).** Start LightCraft from a `.cmd` file or a terminal with `set LIGHTCRAFT_GPU_BACKEND=dx12` (the default since #136), or `=off` to keep the GPU out of rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a specific Vulkan driver diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..3c5a0b187 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,15 @@ # What's new in LightCraft +## 2026-10-25 + +### Drags reach the screen a frame sooner +- The window queues one frame ahead of the display again instead of two, as eframe does by default (since #136 it + used egui-wgpu's high-throughput setting). Sliders, crop handles, brushes and every other drag show up one display + frame sooner: 8.3 ms at 120 Hz, 16.7 ms at 60 Hz. On a MacBook Pro at 120 Hz, a heavy develop drag went from + about 18 ms to about 10 ms between a frame being handed to macOS and macOS compositing it, with the same 120 frames + a second. On a Mac the window also holds one drawable less: its width × height × 4 bytes, 15–22 MB for a + 1200–1500-point-wide window on a Retina display. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From caba780e1e97c189dd8750712a6891847760d7c6 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 25 Oct 2026 21:00:50 +1000 Subject: [PATCH 14/44] Issue #136: the GPU preference and the warm-up flag use one ordering set_enabled now stores the preference with SeqCst, as the warm-up thread reads it after releasing its start, so a switch back on made meanwhile is always seen there (CodeRabbit). The start flag uses SeqCst throughout. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 84aad52ea..e66a8fadd 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -193,7 +193,9 @@ pub fn reset_failures() { /// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the /// whole process regardless. pub fn set_enabled(on: bool) { - ENABLED.store(on, Ordering::Relaxed); + // (SeqCst, as `warm_up`'s thread reads it after releasing its start: a switch back on is + // never missed there) + ENABLED.store(on, Ordering::SeqCst); } /// Whether GPU rendering is allowed (environment, preference, no earlier failure — real, or @@ -311,9 +313,9 @@ pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> { /// while GPU rendering is turned off. Repeated calls start one thread at most. pub fn warm_up() { #[cfg(not(target_arch = "wasm32"))] - if !env_disabled() && ENABLED.load(Ordering::Relaxed) && GPU.get().is_none() { + if !env_disabled() && ENABLED.load(Ordering::SeqCst) && GPU.get().is_none() { static STARTED: AtomicBool = AtomicBool::new(false); - if STARTED.swap(true, Ordering::AcqRel) { + if STARTED.swap(true, Ordering::SeqCst) { return; } let spawned = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { @@ -333,7 +335,7 @@ pub fn warm_up() { } }); if spawned.is_err() { - STARTED.store(false, Ordering::Release); + STARTED.store(false, Ordering::SeqCst); } } } From fa4bccff64ef7674346ca5ae77c0b2a056cbcf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ingmar=20L=C3=BCbbe?= Date: Sun, 25 Oct 2026 16:38:12 +0100 Subject: [PATCH 15/44] Issue #2936: build the GPU kernels on parallel threads Gpu::new built the 27 compute pipelines one after another. On DX12 each one is compiled by FXC (issue #471), single-threaded, inside create_compute_pipeline, so every process waited ~2.2 s for them on an Intel Core Ultra 7 165U iGPU (app.gpu 2.4 s median) while the other cores idled. build_kernels creates the shader modules and layouts on the calling thread and shares the pipelines out to worker threads (std::thread::scope, an atomic job counter; the calling thread works too, so every kernel is built even when no thread can be started). wgpu's error scopes are per thread, so each worker pushes its own validation and internal scopes: a refused kernel or a panicked worker still means no GPU, never a half-built one. app.gpu: 2.4 s -> 1.2 s median on that machine, exports pixel-identical. The gpu: info line now reports the kernel count and build time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QWU3BMJMENFqFqxDHzV3b --- crates/gpu/src/ctx.rs | 153 ++++++++++++++++++++++++++++++++---------- docs/gpu-pipeline.md | 8 +++ 2 files changed, 127 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 33e02fbe4..2c523539f 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -1,7 +1,7 @@ //! The wgpu device, the compiled kernels, buffers, dispatch and readback. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use wgpu::util::DeviceExt; @@ -77,6 +77,101 @@ struct Kernel { nbuf: usize, } +/// A module's shader and layouts, shared by the kernels of its entry points. +struct Prepared { + module: wgpu::ShaderModule, + layout: wgpu::BindGroupLayout, + pipeline_layout: wgpu::PipelineLayout, + nbuf: usize, +} + +/// Validation and internal error scopes. They belong to the thread that pushed them. +fn push_scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 2] { + [device.push_error_scope(wgpu::ErrorFilter::Validation), device.push_error_scope(wgpu::ErrorFilter::Internal)] +} + +/// The error the scopes caught, if any (popped innermost first). +fn pop_scopes(scopes: [wgpu::ErrorScopeGuard; 2]) -> Option { + let [validation, internal] = scopes; + let (internal, validation) = (pollster::block_on(internal.pop()), pollster::block_on(validation.pop())); + internal.or(validation).map(|e| e.to_string()) +} + +/// Build every kernel. A kernel the driver's compiler refuses is a validation or an internal error, +/// and then there is no GPU, not a half-built one. +/// +/// Issue #2936: built one after another, the 27 pipelines took 1.7–7 s on an Intel iGPU under DX12, +/// where FXC (issue #471) compiles each one, single-threaded, inside `create_compute_pipeline`. The +/// device takes pipeline creation from any thread, so the entry points are shared out to worker +/// threads. This thread works too: every kernel is built even when no thread can be started. +fn build_kernels(device: &wgpu::Device) -> Result, String> { + let scopes = push_scopes(device); + let consts = constants(); + let prepared: Vec = MODULES + .iter() + .map(|m| { + let src = module_source(m, &consts); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) }); + let mut entries = vec![storage_entry(0, true)]; + entries.extend(m.bindings.iter().enumerate().map(|(i, (_, w, _))| storage_entry(i as u32 + 1, !w))); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &entries }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + Prepared { module, layout, pipeline_layout, nbuf: m.bindings.len() } + }) + .collect(); + if let Some(e) = pop_scopes(scopes) { + return Err(e); + } + let jobs: Vec<(&Prepared, &'static str)> = MODULES.iter().zip(&prepared).flat_map(|(m, p)| m.entries.iter().map(move |e| (p, *e))).collect(); + let next = AtomicUsize::new(0); + let built = std::sync::Mutex::new(HashMap::with_capacity(jobs.len())); + let workers = std::thread::available_parallelism().map_or(1, |n| n.get()).min(jobs.len()).saturating_sub(1); + let errors: Vec = std::thread::scope(|s| { + let work = || build_jobs(device, &jobs, &next, &built); + let threads: Vec<_> = + (0..workers).filter_map(|_| std::thread::Builder::new().name("lc-gpu-kernels".into()).spawn_scoped(s, work).ok()).collect(); + let mut errors: Vec = work().into_iter().collect(); + for t in threads { + match t.join() { + Ok(e) => errors.extend(e), + Err(_) => errors.push("a kernel build thread panicked".into()), + } + } + errors + }); + if let Some(e) = errors.into_iter().next() { + return Err(e); + } + Ok(built.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner)) +} + +/// One worker of [`build_kernels`]: build kernels until none is left; the first error its scopes caught. +fn build_jobs( + device: &wgpu::Device, + jobs: &[(&Prepared, &'static str)], + next: &AtomicUsize, + built: &std::sync::Mutex>, +) -> Option { + let scopes = push_scopes(device); + while let Some(&(p, entry)) = jobs.get(next.fetch_add(1, Ordering::Relaxed)) { + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&p.pipeline_layout), + module: &p.module, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }); + let kernel = Kernel { pipeline, layout: p.layout.clone(), nbuf: p.nbuf }; + built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(entry, kernel); + } + pop_scopes(scopes) +} + /// A device buffer of `len` 32-bit values. /// /// Dropped buffers are recycled: they first wait in a per-thread list (commands recorded on this @@ -339,39 +434,15 @@ impl Gpu { log::error!("gpu: device lost ({reason:?}): {msg}"); crate::mark_broken(&format!("device lost ({reason:?}): {msg}")); }); - // a kernel the driver's compiler refuses is a validation or an internal error: no GPU, not a half-built one - let scopes = [device.push_error_scope(wgpu::ErrorFilter::Validation), device.push_error_scope(wgpu::ErrorFilter::Internal)]; - let consts = constants(); - let mut kernels = HashMap::new(); - for m in MODULES { - let src = module_source(m, &consts); - let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) }); - let mut entries = vec![storage_entry(0, true)]; - entries.extend(m.bindings.iter().enumerate().map(|(i, (_, w, _))| storage_entry(i as u32 + 1, !w))); - let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &entries }); - let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: None, - bind_group_layouts: &[Some(&layout)], - immediate_size: 0, - }); - for e in m.entries { - let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(e), - layout: Some(&pl), - module: &module, - entry_point: Some(e), - compilation_options: Default::default(), - cache: None, - }); - kernels.insert(*e, Kernel { pipeline, layout: layout.clone(), nbuf: m.bindings.len() }); + let started = std::time::Instant::now(); + let kernels = match build_kernels(&device) { + Ok(kernels) => kernels, + Err(e) => { + log::error!("gpu: kernels failed to build on {}: {e}", info.name); + return Err(format!("{}: kernels failed to build ({e})", info.name)); } - } - let [validation, internal] = scopes; - let (internal, validation) = (pollster::block_on(internal.pop()), pollster::block_on(validation.pop())); - if let Some(e) = internal.or(validation) { - log::error!("gpu: kernels failed to build on {}: {e}", info.name); - return Err(format!("{}: kernels failed to build ({e})", info.name)); - } + }; + let build_ms = started.elapsed().as_millis(); let dummy = device.create_buffer(&wgpu::BufferDescriptor { label: Some("dummy"), size: 16, @@ -379,7 +450,7 @@ impl Gpu { mapped_at_creation: false, }); let max_buffer = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size); - log::info!("gpu: {} ({:?}), buffers ≤ {} MiB", info.name, info.backend, max_buffer >> 20); + log::info!("gpu: {} ({:?}), {} kernels in {build_ms} ms, buffers ≤ {} MiB", info.name, info.backend, kernels.len(), max_buffer >> 20); Ok(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer }) } @@ -773,6 +844,20 @@ mod tests { assert_eq!(super::parse_limits("lots"), None); } + /// Issue #2936: the kernels are built on several threads; every entry point of every module has one. + #[test] + fn every_entry_point_has_a_kernel() { + let Some(g) = crate::device() else { + lightcraft_testkit::skip!(Gpu, "no GPU device"); + return; + }; + let entries: Vec<&str> = super::MODULES.iter().flat_map(|m| m.entries.iter().copied()).collect(); + assert_eq!(g.kernels.len(), entries.len()); + for e in entries { + assert!(g.kernels.contains_key(e), "no kernel for {e}"); + } + } + /// Run `child` (an ignored test in this module) in its own process: it loses the device, which /// no other test running beside it may see. Returns its stdout, or `None` without a GPU. fn run_child(child: &str) -> Option { diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index f88f81c39..ecf52603f 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -302,6 +302,14 @@ compilation: ~0.4 s once per process — the desktop app starts it on a backgrou (`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create the device on their first GPU render. `lightcraft_gpu::ready()` asks without blocking. +On Windows / DX12 the kernels cost more: FXC compiles each pipeline single-threaded inside +`create_compute_pipeline` (issue #471). Built one after another, the 27 took ~2.2 s on an Intel Core +Ultra 7 165U iGPU (`app.gpu` 2.4 s median, up to 7 s on the first run after an install), so +`ctx::build_kernels` shares them out to worker threads (issue #2936): ~1.05 s there (`app.gpu` 1.2 s), +now bounded by the largest kernel (`finish.wgsl`). wgpu's error scopes are per thread, so every worker +opens its own; a refused kernel still means no GPU. The `gpu:` info log line gives the kernel count +and build time. + ## Opening a photo (M5.4) - The loupe shows a stand-in at once (`media::QuickJob`): the photo's cached view render for its current settings, else (raws with their import look) the embedded camera JPEG, else a From 1ada7c2af04a705c66c688b1ff45c412acb5a766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ingmar=20L=C3=BCbbe?= Date: Sun, 25 Oct 2026 16:39:14 +0100 Subject: [PATCH 16/44] Issue #2936: gpu-pipeline.md says what is measured about the remaining kernel time Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QWU3BMJMENFqFqxDHzV3b --- docs/gpu-pipeline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index ecf52603f..a6d78a4e1 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -306,7 +306,7 @@ On Windows / DX12 the kernels cost more: FXC compiles each pipeline single-threa `create_compute_pipeline` (issue #471). Built one after another, the 27 took ~2.2 s on an Intel Core Ultra 7 165U iGPU (`app.gpu` 2.4 s median, up to 7 s on the first run after an install), so `ctx::build_kernels` shares them out to worker threads (issue #2936): ~1.05 s there (`app.gpu` 1.2 s), -now bounded by the largest kernel (`finish.wgsl`). wgpu's error scopes are per thread, so every worker +most likely the largest kernel (`finish.wgsl`) alone (not timed per kernel). wgpu's error scopes are per thread, so every worker opens its own; a refused kernel still means no GPU. The `gpu:` info log line gives the kernel count and build time. From c9b123ab9eae31b90ed5150427156c6db8681bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ingmar=20L=C3=BCbbe?= Date: Sun, 25 Oct 2026 16:53:34 +0100 Subject: [PATCH 17/44] Issue #2936: the doc comment gives the kernel time, not the whole start-up Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QWU3BMJMENFqFqxDHzV3b --- crates/gpu/src/ctx.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 2c523539f..bf2b27920 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -100,8 +100,9 @@ fn pop_scopes(scopes: [wgpu::ErrorScopeGuard; 2]) -> Option { /// Build every kernel. A kernel the driver's compiler refuses is a validation or an internal error, /// and then there is no GPU, not a half-built one. /// -/// Issue #2936: built one after another, the 27 pipelines took 1.7–7 s on an Intel iGPU under DX12, -/// where FXC (issue #471) compiles each one, single-threaded, inside `create_compute_pipeline`. The +/// Issue #2936: built one after another, the 27 pipelines took ~2.2 s on an Intel iGPU under DX12 +/// (device start-up 1.7–4.4 s in all, 7 s on the first run after an install), where FXC (issue #471) +/// compiles each one, single-threaded, inside `create_compute_pipeline`. The /// device takes pipeline creation from any thread, so the entry points are shared out to worker /// threads. This thread works too: every kernel is built even when no thread can be started. fn build_kernels(device: &wgpu::Device) -> Result, String> { From 7c9b6d88e52e2bfe6e08ebf33f170d66a7d4cbc5 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 25 Oct 2026 19:41:17 +1000 Subject: [PATCH 18/44] GPU-TESTS: every WGSL kernel parses and validates in the test run, without a GPU The kernels were only compiled when a device was created, so a module that doesn't build (a function defined twice after an automatic merge, a type error) passed CI on machines without a GPU and failed on users' machines. naga (re-exported by wgpu) now parses and validates every module as it is handed to the device: the finish, blur, resize, map, mask and geometry modules with their generated constants and bindings, the denoise convolution for every block size the planner picks, the pooling kernel and the face detector's two. A test checks that a duplicate definition is caught. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/ctx.rs | 11 +++++++++++ crates/gpu/src/faces.rs | 7 +++++++ crates/gpu/src/lib.rs | 23 +++++++++++++++++++++++ crates/gpu/src/nn.rs | 19 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 33e02fbe4..a6f702c12 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -763,6 +763,17 @@ pub fn groups1(n: usize) -> [u32; 3] { #[cfg(test)] mod tests { + /// Every kernel module, as it is handed to the device (the generated constants, the bindings, `common.wgsl`, the + /// module), parses and validates without a GPU. + #[test] + fn every_kernel_module_validates_without_a_gpu() { + let consts = super::constants(); + for m in super::MODULES { + let name = m.entries.first().copied().unwrap_or("?"); + assert_eq!(crate::validate_wgsl(name, &super::module_source(m, &consts)), Ok(())); + } + } + #[test] fn limits_override_parses() { assert_eq!(super::parse_limits("webgpu"), Some((128 << 20, 256 << 20))); diff --git a/crates/gpu/src/faces.rs b/crates/gpu/src/faces.rs index bee9929ef..919267786 100644 --- a/crates/gpu/src/faces.rs +++ b/crates/gpu/src/faces.rs @@ -817,6 +817,13 @@ static PER_CONV: AtomicBool = AtomicBool::new(false); mod tests { use super::*; + /// The face detector's kernels validate without a GPU. + #[test] + fn the_face_kernels_validate_without_a_gpu() { + assert_eq!(crate::validate_wgsl("faces_conv", include_str!("wgsl/faces_conv.wgsl")), Ok(())); + assert_eq!(crate::validate_wgsl("faces_ops", include_str!("wgsl/faces_ops.wgsl")), Ok(())); + } + fn gpu() -> Option> { match accelerator() { Ok(a) => Some(a), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 452e2dd67..ff8c8dbd8 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -550,10 +550,33 @@ pub fn try_render( } } +/// Tests: parse and validate a WGSL module with naga, as wgpu does before it hands a shader to the driver. Needs no +/// GPU, so kernels that don't build (a duplicate definition after a merge, a type error) fail every test run, not only +/// the GPU ones. +#[cfg(test)] +pub(crate) fn validate_wgsl(name: &str, src: &str) -> Result<(), String> { + use wgpu::naga; + let module = naga::front::wgsl::parse_str(src).map_err(|e| format!("{name}: {}", e.emit_to_string(src)))?; + naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all()) + .validate(&module) + .map(|_| ()) + .map_err(|e| format!("{name}: {}", e.emit_to_string(src))) +} + #[cfg(test)] mod tests { use lightcraft_pipeline::{OutputDepth, Overlay}; + /// The helper refuses what a driver would: a function defined twice (what an automatic merge of two branches + /// touching the same kernel can leave). + #[test] + fn wgsl_validation_catches_a_duplicate_definition() { + let ok = "fn f() -> f32 { return 1.0; }\n@compute @workgroup_size(1) fn main() { _ = f(); }\n"; + assert_eq!(super::validate_wgsl("ok", ok), Ok(())); + let twice = format!("fn f() -> f32 {{ return 2.0; }}\n{ok}"); + assert!(super::validate_wgsl("twice", &twice).is_err()); + } + // Given a request the 8-bit kernel can't draw (high bit depth, a soft proof, Visualize HDR), // the GPU doesn't take it; the loupe and diagnostic overlays it can draw stay on the GPU #[test] diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 55420598b..c1db6d2ae 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -881,6 +881,25 @@ impl TileRunner for NetRunner { #[cfg(test)] mod tests { use super::*; + + /// The denoise kernels validate without a GPU: the pooling kernel, and the convolution for every block size the + /// planner picks for these layer widths and channel counts. + #[test] + fn the_network_kernels_validate_without_a_gpu() { + assert_eq!(crate::validate_wgsl("nn_pool", include_str!("wgsl/nn_pool.wgsl")), Ok(())); + let mut sizes: Vec<(u32, u32, u32)> = Vec::new(); + for n in [1usize, 3, 8, 16, 24, 32, 48, 64, 96, 128, 256, 512] { + for cin in [1usize, 3, 4, 8, 16, 32, 48, 64, 128, 256, 512] { + let b = blocks(n, cin); + if !sizes.contains(&b) { + sizes.push(b); + } + } + } + for (bm, bn, bk) in sizes { + assert_eq!(crate::validate_wgsl(&format!("nn_conv {bm}x{bn}x{bk}"), &conv_source(bm, bn, bk)), Ok(())); + } + } use lightcraft_denoise::{onnx, reference, synthetic}; fn net(tile: u64, ch: u64, depth: usize, seed: u64) -> Net { From 878d3dee4c143d839d51cbc71528ab9ffcedb39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:39:16 +0000 Subject: [PATCH 19/44] Disarm color picking when adding mask components Fixes #3178 Co-Authored-By: GPT-6-Luna --- crates/ui-egui/src/app.rs | 4 ++++ crates/ui-egui/src/tests/masking.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 98f8458c9..75765ebc7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -221,6 +221,10 @@ impl LightcraftApp { // menu), it must not keep a search that no longer applies (issue #1648) self.ui.search.clear(); } + if r.is_ok() && id == "mask.addComponent" && params.get("kind").and_then(Value::as_str) != Some("colorRange") && self.ui.tool == "colorRange" + { + self.ui.tool.clear(); + } if r.is_ok() && id == "mask.adjust" { // Judge local adjustments on the photo, without the selection overlay obscuring them. // Keep it hidden after release; O / the overlay eye can show it again. diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index c8d6d6c2e..a0a44273c 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -1700,6 +1700,28 @@ fn assert_close(a: &serde_json::Value, b: &serde_json::Value) { /// Regression: the Colour Range "Pick" button armed the eyedropper, but with the Masking panel /// open the loupe's clicks went to the mask overlay, so no sample was ever taken. +#[test] +fn adding_radial_component_disarms_color_range_picker() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "colorRange"})); + h.app.ui.tool.clear(); + click(&mut h, "button:colorPick0"); + brush_at(&mut h, 0.25, 0.5); + assert_eq!(color_samples(&h).as_array().map(Vec::len), Some(1)); + + exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "intersect", "center": [0.5, 0.5], "rx": 0.22, "ry": 0.22})); + assert!(h.app.ui.tool.is_empty(), "adding a radial component disarms colour picking"); + + let widget = "maskHandle:1:1:1"; + let rect = h.app.widgets.iter().find(|(id, _)| *id == widget).unwrap().1; + let image = h.app.image_rect.unwrap(); + let q = rect.center(); + let (x, y) = ((q.x - image.left()) / image.width(), (q.y - image.top()) / image.height()); + pointer(&mut h, json!([{"kind": "down", "x": x, "y": y}, {"kind": "drag", "x": x + 0.05, "y": y}, {"kind": "up", "x": x + 0.05, "y": y}])); + let MaskShape::Radial { rx, .. } = develop(&h).masks[0].components[1].shape else { panic!("radial") }; + assert!(rx > 0.22, "dragging a radial handle resizes the component: {rx}"); +} + #[test] fn color_range_pick_button_samples_the_photo() { let mut h = detail("panel.masking"); From 1bfa617a5a84083d79e948f8a4d46425a02eeb03 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 24 Oct 2026 16:38:20 -0400 Subject: [PATCH 20/44] GPU: black pixels take the colour tools and a light vignette as on the CPU The GPU colour tools took the hue of every pixel with atan2(a, b). For a black pixel both are 0, and WGSL leaves atan2(0, 0) undefined: on an RTX 3090 (Vulkan) it is NaN, so with any colour tool on (saturation, vibrance, the mixer) every exactly black pixel became NaN. NaN is written as 0, so it stayed black and nothing showed, until a later step lightened it: with a positive vignette the CPU lifts the black corners and the GPU left them black (up to 127 levels apart in 8-bit). A neutral pixel now gets hue 0, as Rust's atan2 gives on the CPU. Regression test: black bars under a light vignette with saturation, vibrance or the mixer (fails on main: mean 12.8, max 127 levels). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/gpu/src/wgsl/finish.wgsl | 4 +++- crates/gpu/tests/it/equivalence.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 04115b945..767ff44c9 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -106,7 +106,9 @@ fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { let lab0 = oklab(rgb); var l = lab0.x; var c = sqrt(lab0.y * lab0.y + lab0.z * lab0.z); - var h = atan2(lab0.z, lab0.y); + // a neutral (black or grey) pixel has no hue: atan2(0, 0) is undefined in WGSL (NaN on some + // GPUs, which a later lightening such as the vignette then shows); the CPU's atan2 gives 0 + var h = select(atan2(lab0.z, lab0.y), 0.0, c == 0.0); if (pu(F_MIXER) != 0u) { let w = band_weights(h); var dh = 0.0; diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index 6a8169b30..d3c369454 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -494,6 +494,34 @@ fn clipped_highlights_stay_neutral_on_both() { } } +/// Black pixels have no hue. The GPU's `atan2(0, 0)` is undefined (NaN on some GPUs), which made +/// every exactly black pixel NaN once a colour tool ran, and a lightening vignette then showed it: +/// the CPU lifted the black corners, the GPU left them black. +#[test] +fn black_pixels_match_under_colour_tools_and_a_light_vignette() { + if !gpu() { + return; + } + let base = scene(0, 320, 240); + // black bars across the frame, reaching into the corners the vignette lightens + let src = Arc::new(Rgb32f::from_fn(320, 240, |x, y| if y % 40 < 8 || x % 64 < 6 { [0.0; 3] } else { base.get(x, y) })); + let info = SourceInfo { raw: true, ..Default::default() }; + let req = RenderRequest::fit(320, 240); + for (name, f) in [ + ("black, light vignette + saturation", (|s: &mut DevelopSettings| s.color.saturation = 40.0) as fn(&mut DevelopSettings)), + ("black, light vignette + vibrance", |s| s.color.vibrance = 30.0), + ("black, light vignette + mixer", |s| s.mixer.yellow.hue = 30.0), + ] { + let mut s = DevelopSettings::default(); + s.vignette.amount = 25.0; + s.vignette.style = VignetteStyle::HighlightPriority; + f(&mut s); + check(name, &src, &info, &s, &req); + let gpu = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu render").image; + assert!(gpu.get(0, 0)[1] > 0, "{name}: the black corner is not lightened on the GPU"); + } +} + #[test] fn rendered_sources_and_other_scenes() { if !gpu() { From f14d166fdfb09ce810c590ed1f7c294de4e35412 Mon Sep 17 00:00:00 2001 From: Brain Wang Date: Thu, 29 Oct 2026 11:13:04 +0800 Subject: [PATCH 21/44] Issue #3272: AI masks are enlarged along the photo's edges, no outline under a raised Exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Object / Describe mask is stored as a model's logit grid (288² over the whole photo, ~20 px cells on a 24 MP export) and was enlarged by bilinear sampling alone, so its edge spilled 1–2 px onto the background; raising the mask's Exposure turned that strip into a bright outline in previews and exports (CPU and GPU: the GPU renderer evaluates these shapes with the same CPU `shape_alpha`). `sample_seg` now guided-filters the sampled logits by the photo's log luminance where the grid is coarser than the output: the fast (subsampled) guided filter, a window of 1.5 grid cells, so a preview and an export put the edge in the same place. Filtering logits, not coverage, lets the confident side win (the spill goes to ≈ 0, not to a mean of ≈ 0.1). Pixels whose window is all inside or all outside, and edges the photo has no contrast across, keep the plain sample exactly. Refine Edges still runs afterwards on the combined mask and finds the edges in place: no second halo, no tightening. One implementation: `guided_cross` (Refine Edges, Auto Mask) and the new `guided_cross_fast` share `guided_coeffs`; the subsampled form uses `local::guided_fast_step` and a box `resize`, like `local::guided_fast`. `guided_cross`'s output is unchanged. Existing AI masks become 1–2 px tighter at a subject's edges, on every process; their inside, outside and contrast-less edges are unchanged. This lands in place under the bug-fix rule of docs/process-versions.md (output that is in neither the photo nor the edit), noted there. Cost per AI selection (M2 Pro, 12 cores; one shape_alpha, before → after): 24 MP 25.7 → 48.2 ms, heap peak 96.5 → 108.8 MB; 60 MP 64.1 → 109.9 ms, 239.9 → 256.7 MB; 2.7 MP preview 3.1 → 10.6 ms, 11.2 → 26.5 MB. Tests (a dark disc on a light background, its mask on a 32² grid): ≈ 0 one and two px outside and ≈ 1 inside, all around (1 px outside was 0.66), bit-identical away from the edge; with Refine Edges 50 / 100 (was 0.29 outside) and the edge within 1 px of the unrefined one; the same edge position at 256, 512 and 1024 px; a Prompt mask's window matches the whole render. Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/masks.rs | 111 +++++++++++++++++++++++++--- crates/pipeline/src/masks/tests.rs | 109 +++++++++++++++++++++++++++ crates/pipeline/src/tests_window.rs | 14 ++++ docs/ai-masks.md | 10 +++ docs/gaps.md | 3 +- docs/parity-checklist.md | 5 +- docs/process-versions.md | 7 ++ 7 files changed, 247 insertions(+), 12 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 73383d1d4..80faa5af5 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -281,7 +281,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R // 2 % of the long edge, so previews and exports match) let e = (*edge / 100.0).clamp(-1.0, 1.0) as f32; let gain = if e < 0.0 { 1.0 - 7.0 * e } else { 1.0 }; - sample_seg(seg.as_ref(), detail, gain, frame, w, h, &mut out); + sample_seg(seg.as_ref(), detail, gain, frame, w, h, log_l, &mut out); if e > 0.0 { smooth_plane(&mut out, e * 0.02 * frame_px(frame, w)); } @@ -343,13 +343,40 @@ impl SegGrid { let bot = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx; top * (1.0 - ty) + bot * ty } + + /// Output pixels per grid cell (the longer side of a cell: the grid is square over a + /// rectangle). + fn cell_px(&self, frame: &Frame, w: usize) -> f64 { + let px = frame.px_per_long(w) / frame.ow.max(frame.oh).max(1e-9); + ((self.r[2] - self.r[0]) * frame.ow).max((self.r[3] - self.r[1]) * frame.oh) * px / self.side.max(1) as f64 + } } +/// [`sample_seg`]'s window, in grid cells (Gaussian sigma): the width of the band bilinear +/// enlargement leaves uncertain, about a cell on either side of the contour. +const SEG_WINDOW: f64 = 1.5; +/// [`sample_seg`]'s guided-filter regularization (EV² of log luminance): an edge of ~0.1 EV +/// contrast counts half. +const SEG_EPS: f32 = 0.01; + /// Stored model segmentations at each output pixel: the zoomed-in `detail` passes inside /// their rectangles (the highest of them where they overlap), the whole-photo `seg` /// elsewhere; the logit (times `gain`: > 1 is a harder edge) through a sigmoid. Damaged /// data selects nothing. -fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, out: &mut Plane) { +/// +/// Issue #3272: a model's grid is much coarser than an export (a 288² grid has ~20 px cells on +/// 24 MP), and its bilinear contour passes up to a cell from the photo's edge, so the mask spilled +/// onto the background (a bright outline under a raised Exposure). Where the grid is coarser than +/// the output, the logits are guided-filtered by the photo's log luminance ([`guided_cross_fast`]; a +/// window of [`SEG_WINDOW`] cells, so previews and exports draw the same edge): near the contour +/// the logit follows the photo's tones and its edge lands on the photo's. Filtering logits, not +/// coverage, makes the confident side win (a strip at 0.3 between 1 and 0 goes to 0, not 0.1), +/// and leaves a linear ramp of logits, where the photo has no edge, as it was. Pixels whose +/// window is all inside or all outside (|mean logit| ≥ 7, times `gain`) keep their sample exactly, +/// as do those where the photo has no edge to follow. Refine Edges +/// then works on the combined mask ([`evaluate_one`]) and finds these edges already in place. +#[allow(clippy::too_many_arguments)] +fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, log_l: &Plane, out: &mut Plane) { let coarse = seg.and_then(SegGrid::new); // (capped: a hostile document can hold many) let patches: Vec = detail.iter().take(lightcraft_develop::segmask::MAX_DETAIL).filter_map(SegGrid::new).collect(); @@ -361,10 +388,15 @@ fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Fram for (x, v) in row.iter_mut().enumerate() { let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); let fine = patches.iter().filter(|p| p.contains(n)).map(|p| p.logit(n)).reduce(f32::max); - let l = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0); - *v = 1.0 / (1.0 + (-l * gain).exp()); + *v = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0) * gain; } }); + // (the coarsest grid sets the window: it leaves the widest uncertain band) + let cell = coarse.iter().chain(&patches).map(|g| g.cell_px(frame, w)).fold(0.0, f64::max); + if cell >= 1.0 { + guided_cross_fast(log_l, out, (SEG_WINDOW * cell) as f32, SEG_EPS, |mean| 1.0 - smooth(3.0 * gain, 7.0 * gain, mean.abs())); + } + out.map_in_place(|l| 1.0 / (1.0 + (-l).exp())); } fn frame_px(frame: &Frame, w: usize) -> f32 { @@ -665,9 +697,20 @@ pub fn auto_refine(r: f64) -> (f32, f32) { ((0.25 * r).max(1.0) as f32, 0.02) } -/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the -/// guide's. -pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { +/// A guided filter's linear model at each pixel (`q = a · guide + b`, coefficients averaged over +/// the windows that hold the pixel), the window mean of the filtered plane and, when asked for, +/// how much of an edge the guide has there (`var / (var + eps)`, averaged like the coefficients: +/// 0 where the guide is flat and the model is only a blur). +struct Guided { + a: Plane, + b: Plane, + mean: Plane, + edge: Option, +} + +/// The guided filter (He, Sun and Tang) of `p` steered by `guide`, both the same size: Gaussian +/// windows of `sigma` px, `eps` the regularization (in guide units squared). +fn guided_coeffs(guide: &Plane, p: &Plane, sigma: f32, eps: f32, with_edge: bool) -> Guided { use lightcraft_raster::blur::gaussian; let mi = gaussian(guide, sigma); let mp = gaussian(p, sigma); @@ -675,20 +718,70 @@ pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { let cii = gaussian(&guide.map(|a| a * a), sigma); let mut a = Plane::new(p.width, p.height); let mut b = Plane::new(p.width, p.height); + let mut edge = Plane::new(if with_edge { p.width } else { 0 }, if with_edge { p.height } else { 0 }); for i in 0..p.data.len() { let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0); let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps); a.data[i] = k; b.data[i] = mp.data[i] - k * mi.data[i]; + if let Some(e) = edge.data.get_mut(i) { + *e = var / (var + eps); + } } - let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma)); + Guided { a: gaussian(&a, sigma), b: gaussian(&b, sigma), mean: mp, edge: with_edge.then(|| gaussian(&edge, sigma)) } +} + +/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the +/// guide's. +pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { + let g = guided_coeffs(guide, p, sigma, eps, false); let mut q = Plane::new(p.width, p.height); for i in 0..q.data.len() { - q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0); + q.data[i] = (g.a.data[i] * guide.data[i] + g.b.data[i]).clamp(0.0, 1.0); } q } +/// [`guided_cross`] in its subsampled form (He and Sun, "Fast Guided Filter", 2015, as +/// [`crate::local::guided_fast`] is of the self-guided filter, at its step), in place and +/// unclamped, for a `p` that is smooth at the window's scale (a mask enlarged from a model's grid): +/// the linear model is fitted on both planes box-reduced to about 3 px of window, sampled +/// bilinearly and applied to the full-resolution guide, which is where the edge comes from. A +/// pixel moves to the filtered value by `weight` of its window mean of `p` times how much of an +/// edge the guide has there (0 keeps it exactly: where the photo is flat the filter could only +/// blur `p`). Extra memory: the reduced planes only (the coefficients are sampled per pixel, not +/// enlarged into planes). +fn guided_cross_fast(guide: &Plane, p: &mut Plane, sigma: f32, eps: f32, weight: impl Fn(f32) -> f32 + Sync + Send) { + use lightcraft_raster::resample::{Filter, resize}; + let (w, h) = (p.width, p.height); + if guide.width != w || guide.height != h || w == 0 || h == 0 || !sigma.is_finite() { + return; + } + // (a window past the whole frame fits one model to it all) + let sigma = sigma.clamp(0.5, w.max(h) as f32); + let s = crate::local::guided_fast_step(sigma); + let (lw, lh) = (w.div_ceil(s).max(1), h.div_ceil(s).max(1)); + let g = guided_coeffs(&resize(guide, lw, lh, Filter::Box), &resize(p, lw, lh, Filter::Box), sigma / s as f32, eps, true); + let Some(edge) = &g.edge else { + return; + }; + // (the reduced planes span the same frame: their pixel per full pixel) + let (kx, ky) = (lw as f32 / w as f32, lh as f32 / h as f32); + for_rows(&mut p.data, w, |y, row| { + let fy = (y as f32 + 0.5) * ky; + for (x, (v, i)) in row.iter_mut().zip(guide.row(y)).enumerate() { + let fx = (x as f32 + 0.5) * kx; + let k = weight(bilinear(&g.mean, fx, fy)); + // (no edge in the photo: nothing to follow, keep the sample) + let k = if k > 0.0 { k * smooth(0.05, 1.0, bilinear(edge, fx, fy)) } else { 0.0 }; + if k > 0.0 { + let q = bilinear(&g.a, fx, fy) * i + bilinear(&g.b, fx, fy); + *v += (q - *v) * k; + } + } + }); +} + /// Stamp brush strokes into `out` (max-combine; erase strokes subtract). Auto Mask strokes weight /// each dab by [`auto_similarity`] to the pixel under its centre and are refined with /// [`guided_cross`] on log luminance. diff --git a/crates/pipeline/src/masks/tests.rs b/crates/pipeline/src/masks/tests.rs index a3f776d0d..14cb5dbac 100644 --- a/crates/pipeline/src/masks/tests.rs +++ b/crates/pipeline/src/masks/tests.rs @@ -89,6 +89,115 @@ fn the_edge_setting_hardens_or_feathers_the_transition() { assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); } +/// A dark disc (radius 0.3 of the frame) on a light background, `n × n`: the photo and its log +/// luminance, and a model's view of it on a `side²` grid (each cell's logit from how much of the +/// cell the disc covers, as a low-resolution segmenter sees it). +fn disc_scene(n: usize, side: usize) -> (Rgb32f, Plane, SegMask) { + const R: f64 = 0.3; + let dark = |x: f64, y: f64| (x - 0.5).hypot(y - 0.5) < R; + let lum = |x: usize, y: usize| if dark((x as f64 + 0.5) / n as f64, (y as f64 + 0.5) / n as f64) { 0.05f32 } else { 0.6 }; + let img = Rgb32f::from_fn(n, n, |x, y| [lum(x, y); 3]); + let log_l = img.map(crate::local::log_lum); + let logits: Vec = (0..side * side) + .map(|i| { + let (cx, cy) = (i % side, i / side); + let sub = 8; + let cover = (0..sub * sub) + .filter(|k| { + dark((cx as f64 + (k % sub) as f64 / sub as f64) / side as f64, (cy as f64 + (k / sub) as f64 / sub as f64) / side as f64) + }) + .count() as f32 + / (sub * sub) as f32; + 12.0 * (2.0 * cover - 1.0) + }) + .collect(); + (img, log_l, SegMask::from_logits(side, &logits)) +} + +/// Alpha along the disc's horizontal radius, `d` px outside (> 0) or inside (< 0) its edge. +fn across_disc_edge(a: &Plane, d: i32) -> f32 { + let n = a.width; + let edge = (0.8 * n as f64).round() as i32; // first background pixel on the right + let x = if d > 0 { edge + d - 1 } else { edge + d }; + a.get(x as usize, n / 2) +} + +/// Issue #3272: a mask from a low-resolution model grid spilled 1–2 px onto the background (a bright +/// outline once its Exposure was raised). Enlarged along the photo's edges, it is ≈ 0 one and two +/// pixels outside the subject, ≈ 1 inside, and exactly what it was away from the edge. +#[test] +fn ai_masks_follow_the_photos_edge_when_enlarged() { + let (n, side) = (512, 32); // 16 px cells, as a 288² grid on a ~4600 px export + let (img, log_l, seg) = disc_scene(n, side); + let shape = MaskShape::Prompt { text: "disc".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + let f = frame(n, n); + let a = shape_alpha(&shape, &f, n, n, &img, &log_l, 0.0, None); + for d in [1, 2] { + assert!(across_disc_edge(&a, d) < 0.05, "{d} px outside: {}", across_disc_edge(&a, d)); + assert!(across_disc_edge(&a, -d) > 0.95, "{d} px inside: {}", across_disc_edge(&a, -d)); + } + // all around the disc, not only along one row + let r_px = 0.3 * n as f64; + for k in 0..64 { + let t = k as f64 / 64.0 * std::f64::consts::TAU; + let at = |r: f64| a.get((256.0 + r * t.cos()) as usize, (256.0 + r * t.sin()) as usize); + assert!(at(r_px + 2.5) < 0.05 && at(r_px - 2.5) > 0.95, "at {t:.2} rad: {} / {}", at(r_px + 2.5), at(r_px - 2.5)); + } + // away from the edge the selection is the plain sample of the grid, to the bit + let flat = Plane::new(n, n); + let plain = shape_alpha(&shape, &f, n, n, &img, &flat, 0.0, None); + for (x, y) in [(256, 256), (200, 300), (10, 10), (500, 256), (256, 505)] { + assert_eq!(a.get(x, y), plain.get(x, y), "({x}, {y})"); + } +} + +/// Refine Edges on top of an enlarged AI mask: the second filter finds the edge already in place, so +/// there is no second halo and the edge doesn't move inwards. +#[test] +fn refine_edges_on_an_enlarged_ai_mask_neither_halos_nor_tightens() { + let (n, side) = (512, 32); + let (img, log_l, seg) = disc_scene(n, side); + let f = frame(n, n); + let mask = |refine: f64| Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Prompt { text: "disc".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + }], + refine, + ..Default::default() + }; + let crossing = |a: &Plane| (0..n).rev().find(|x| a.get(*x, n / 2) > 0.5).unwrap_or(0); + let plain = evaluate_one(&mask(0.0), &f, n, n, &img, &log_l, 0.0, None); + for refine in [50.0, 100.0] { + let a = evaluate_one(&mask(refine), &f, n, n, &img, &log_l, 0.0, None); + for d in [1, 2] { + assert!(across_disc_edge(&a, d) < 0.05, "refine {refine}, {d} px outside: {}", across_disc_edge(&a, d)); + assert!(across_disc_edge(&a, -d) > 0.95, "refine {refine}, {d} px inside: {}", across_disc_edge(&a, -d)); + } + assert!(crossing(&a).abs_diff(crossing(&plain)) <= 1, "refine {refine}: edge at {} vs {}", crossing(&a), crossing(&plain)); + } +} + +/// The window follows the grid, not the output: a preview and an export put the edge in the same +/// place of the photo (and on its edge). +#[test] +fn enlarged_ai_masks_put_the_edge_in_the_same_place_at_any_size() { + let side = 32; + let mut edges = Vec::new(); + for n in [256, 512, 1024] { + let (img, log_l, seg) = disc_scene(n, side); + let shape = MaskShape::Prompt { text: "disc".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + let a = shape_alpha(&shape, &frame(n, n), n, n, &img, &log_l, 0.0, None); + // the edge's sub-pixel position along the row: the integral of alpha past the centre + let covered: f32 = (n / 2..n).map(|x| a.get(x, n / 2)).sum(); + edges.push((n / 2) as f64 + covered as f64); + let x = edges.last().copied().unwrap_or(0.0) / n as f64; + assert!((x - 0.8).abs() < 1.0 / n as f64, "{n} px: edge at {x:.4} of the width, the disc's at 0.8"); + } +} + #[test] fn ai_shapes_without_a_segmentation_select_nothing() { let f = frame(30, 30); diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index b82a19f45..bf8b8690e 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -157,6 +157,18 @@ fn windows_outside_or_degenerate_are_clamped() { } /// A textured, noisy source: what noise reduction and edge refinement act on. +/// A model's segmentation on a `side²` grid: an ellipse in the middle of the photo. +fn middle_seg(side: usize) -> lightcraft_develop::SegMask { + let logits: Vec = (0..side * side) + .map(|i| { + let (x, y) = (((i % side) as f32 + 0.5) / side as f32, ((i / side) as f32 + 0.5) / side as f32); + let r = (((x - 0.5) / 0.3).powi(2) + ((y - 0.5) / 0.25).powi(2)).sqrt(); + (12.0 * (1.0 - r) * 4.0).clamp(-12.0, 12.0) + }) + .collect(); + lightcraft_develop::SegMask::from_logits(side, &logits) +} + fn noisy_scene() -> Rgb32f { let base = scene(); Rgb32f::from_fn(base.width, base.height, |x, y| { @@ -216,6 +228,8 @@ fn a_mask_overlay_window_matches_the_whole_render() { ("radial", MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0), ("linear refined", MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, 100.0), ("sky", MaskShape::Sky, 0.0), + // a model's grid, enlarged along the photo's edges (issue #3272) + ("prompt", MaskShape::Prompt { text: "middle".into(), seg: Some(middle_seg(32)), detail: vec![], edge: 0.0 }, 0.0), ]; for (name, shape, refine) in shapes { let mut s = DevelopSettings::default(); diff --git a/docs/ai-masks.md b/docs/ai-masks.md index 845dc0e23..14737ef52 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -27,6 +27,16 @@ In the Masking panel (M): the people out of a sky mask; − ▸ Object then clicks remove one object. - **Edge** (per selection, −100…100): below 0 a crisper border, above 0 a feathered one (up to 2 % of the long edge, so previews and exports match). +- **Edges follow the photo** (issue #3272): the model's grid (288² over the whole photo) has + ~20 px cells on a 24 MP export, so a plain enlargement put the mask's edge up to a cell off the + subject's and a raised Exposure drew a bright outline around it. Where the grid is coarser than + the output, the mask's logits are guided-filtered by the photo's log luminance (the fast guided + filter, a window of 1.5 grid cells, so a preview and an export put the edge in the same place): + near the edge the selection follows the photo's tones, ≈ 0 a pixel or two outside a subject that + stands out from its background and ≈ 1 inside. Pixels whose surroundings are all in or all out, + and edges the photo has no contrast across, keep the plain enlargement. Refine Edges then works + on the combined mask and finds these edges in place. Cost on an M2 Pro, per AI selection: +22 ms + and +12 MB at 24 MP, +46 ms and +17 MB at 60 MP, +7.5 ms on a 2.7 MP preview. - **Detail:** about a second after the last click (or right after a description), the photo around the selection is analyzed again, zoomed in, from a sharper source (~5 s on an M4 Pro, in the background); the result is kept as a high-resolution patch (at most 4 per selection), diff --git a/docs/gaps.md b/docs/gaps.md index 42d9d437b..98d1abe36 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-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (gap 5: AI mask edges follow the photo's, no outline under a raised Exposure, #3272; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) 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 @@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Gap 5: Object / Describe masks are enlarged along the photo's edges, so a raised Exposure no longer outlines the subject (#3272); the gap (heuristic Subject / Sky / People, no model mirror) and its hours are unchanged | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..b85f931e9 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-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-MASK-OBJECTS: AI mask edges follow the photo's when enlarged, no outline under a raised Exposure, #3272; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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 @@ -293,7 +293,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`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.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; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), 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-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.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 stored grid is enlarged along the photo's edges (a guided filter of its logits by log luminance, so no outline spills onto the background under a raised Exposure, #3272). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), 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); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-29 | minor | LR-MASK-OBJECTS: an Object / Describe selection's stored grid is enlarged along the photo's edges (fast guided filter of its logits by log luminance, a 1.5-cell window), so a raised Exposure no longer draws an outline around the subject (#3272); existing AI masks are 1–2 px tighter at edges; status unchanged (🟡) | diff --git a/docs/process-versions.md b/docs/process-versions.md index 1da9367a9..7e6788f11 100644 --- a/docs/process-versions.md +++ b/docs/process-versions.md @@ -27,6 +27,13 @@ Blacks at 0) is the identity up to white: the SDR shoulder no longer pulls 1.0 d 255 to 254, issue #1945). It landed in place as a bug fix, since the tone map promised identity at neutral settings; edited curves keep the shoulder and their V1 look. +**AI mask edges** (Object and Describe selections stored as a model's grid) follow the photo's edges +when they are enlarged to the output (issue #3272): the plain enlargement spilled 1–2 px onto the +background, which a raised Exposure turned into a bright outline around the subject in previews and +exports. Existing AI masks become 1–2 px tighter at a subject's edges, on every process; their +inside, their outside and edges without contrast in the photo are unchanged. It landed in place as +a bug fix: the outline is output that isn't in the photo nor in the edit, not a look anyone chose. + ## Adding a process 1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in From bf8b5e358412b4d065374cc33c7b65deb1a5531a Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:38:32 +0300 Subject: [PATCH 22/44] LR-MASK-BRUSH: skip the interpolation of extreme brush segments push_dabs clamped the number of intermediate dabs of one brush segment to 1,000,000, so a finite but extreme segment (a point 10,000,000 px away) still allocated a million dabs. A segment that would need more than that many now keeps only its endpoints; ordinary spacing and the segment's end point are unchanged. Adds oversized_brush_segments_skip_intermediate_dabs, which checks both the extreme case (2 dabs) and an ordinary feathered-brush segment (21 dabs, end point kept, spacing kept). The docs record the fix under gap 24 and LR-MASK-BRUSH; the parity status stays partial. --- crates/pipeline/src/masks.rs | 2 +- crates/pipeline/src/masks/tests.rs | 17 +++++++++++++++++ docs/gaps.md | 5 +++-- docs/parity-checklist.md | 5 +++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 73383d1d4..4790177e6 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -598,7 +598,7 @@ pub fn push_dabs_counted(dabs: &mut Vec, emitted: &mut usize, prev: Optio let n = (dist / dab_spacing(r)).ceil(); // a hostile or degenerate segment (huge) gets no in-between dabs rather than a huge // allocation: 1 M dabs is far more than any on-screen stroke needs - let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 }; + let n = if n.is_finite() && n <= 1_000_000.0 { n.max(0.0) as usize } else { 0 }; // the in-between dabs and `q` together fit what is left of the stroke's budget let n = n.min(room); for k in 1..n { diff --git a/crates/pipeline/src/masks/tests.rs b/crates/pipeline/src/masks/tests.rs index a3f776d0d..66e451a75 100644 --- a/crates/pipeline/src/masks/tests.rs +++ b/crates/pipeline/src/masks/tests.rs @@ -167,6 +167,23 @@ fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { assert!(v.len() <= 1_000_002, "{}", v.len()); } +#[test] +fn oversized_brush_segments_skip_intermediate_dabs() { + let mut dabs = vec![Point::new(10.0, 10.0)]; + let far = Point::new(10_000_000.0, 10.0); + assert!(push_dabs(&mut dabs, Some(Point::new(10.0, 10.0)), far, 2.0)); + assert_eq!(dabs.len(), 2, "an extreme segment must not allocate a million intermediate dabs"); + assert_eq!(dabs.last(), Some(&far)); + + // Ordinary feathered-brush paths still get overlapping dabs and their endpoint. + let mut regular = vec![Point::new(10.0, 10.0)]; + let end = Point::new(30.0, 10.0); + assert!(push_dabs(&mut regular, Some(Point::new(10.0, 10.0)), end, 4.0)); + assert_eq!(regular.len(), 21); + assert_eq!(regular.last(), Some(&end)); + assert!(regular.windows(2).all(|pair| pair[0].dist(pair[1]) <= dab_spacing(4.0))); +} + /// A temporary output buffer must spend the same stroke budget as a retained one. #[test] fn incremental_dab_buffers_share_the_stroke_budget() { diff --git a/docs/gaps.md b/docs/gaps.md index 42d9d437b..a626a05c1 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-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; gap 24: bound oversized brush interpolation) 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 @@ -38,7 +38,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping; exports add Thai glyphs only (#2052), with horizontal mark positioning still incomplete and Arabic fallback deferred until joining and bidi exist | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) | | 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | | 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | -| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | +| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | live preview and edge refinement exist; oversized brush segments skip excessive interpolation; broader masking feel still needs validation | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | @@ -117,6 +117,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-12 | minor | Gap 20 shrinks: Mamiya MEF decodes (`vendor/mef.rs`) and is imported; gap 10 count 240 → 241 | | 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 237 → 240 | | 2026-10-13 | minor | Gap 28: F6 (filmstrip) and F7 (left panel) are bound, so the entry no longer lists them as missing; hours unchanged | +| 2026-10-20 | patch | Gap 24: extreme brush segments skip intermediate dabs; broader masking quality remains open; 18 mask tests pass | | 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-12 | minor | Gap 23 (accessibility): crop handles, spots, gradient handles and mask pins by keyboard; 4–8 h → 2–4 h; gap not closed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..25766dbf2 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-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-MASK-BRUSH: oversized brush segments skip intermediate dabs) · **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 @@ -296,7 +296,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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.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; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), 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); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | +| 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); oversized path segments skip intermediate dabs instead of allocating a million samples; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider | @@ -878,6 +878,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-IMP-DEVICE: Settings → Import → Cameras and Cards can open the import review when a camera or card is connected; status unchanged (✅) | | 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) | | 2026-10-13 | minor | KEY-SLIDER-RESET: hovering a slider's name or thumb says a double-click resets it; a double-click on the thumb is one undo step, not two; a disabled slider's name doesn't reset it (issue #1436); status unchanged (✅) | +| 2026-10-20 | patch | LR-MASK-BRUSH: extreme segments skip intermediate dabs; ordinary spacing and endpoints preserved; status unchanged (🟡) | | 2026-10-12 | minor | LR-META-XMP: bounded complete-APP1 manual JPEG reread, fail-closed sidecar precedence and scoped regression coverage; LR-META-EXIF-FULL: reuse loaded bytes; statuses unchanged | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | From 6a7a6fa3cfc549427dc1245dc96e692b02952df4 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:18:35 +0300 Subject: [PATCH 23/44] docs: trim the brush note to what the change does The gap 24 row, the change-log line and the two document headers now say only that an extreme brush segment skips its intermediate dabs. They no longer claim live preview, edge refinement or a mask test count, and the headers no longer carry the unrelated gap 15 and print-page entries. --- docs/gaps.md | 6 +++--- docs/parity-checklist.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index a626a05c1..c6c3fb74b 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-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; gap 24: bound oversized brush interpolation) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF; gap 24: extreme brush segments skip the intermediate dabs) 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 @@ -38,7 +38,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping; exports add Thai glyphs only (#2052), with horizontal mark positioning still incomplete and Arabic fallback deferred until joining and bidi exist | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) | | 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | | 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | -| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | live preview and edge refinement exist; oversized brush segments skip excessive interpolation; broader masking feel still needs validation | 8–12 | [ui-parity](ui-parity.md) | +| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom; extreme brush segments now skip the intermediate dabs | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | @@ -117,7 +117,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-12 | minor | Gap 20 shrinks: Mamiya MEF decodes (`vendor/mef.rs`) and is imported; gap 10 count 240 → 241 | | 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 237 → 240 | | 2026-10-13 | minor | Gap 28: F6 (filmstrip) and F7 (left panel) are bound, so the entry no longer lists them as missing; hours unchanged | -| 2026-10-20 | patch | Gap 24: extreme brush segments skip intermediate dabs; broader masking quality remains open; 18 mask tests pass | +| 2026-10-20 | patch | Gap 24: extreme brush segments skip the intermediate dabs; the gap stays open | | 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-12 | minor | Gap 23 (accessibility): crop handles, spots, gradient handles and mask pins by keyboard; 4–8 h → 2–4 h; gap not closed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 25766dbf2..2444a71fb 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-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-MASK-BRUSH: oversized brush segments skip intermediate dabs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-MASK-BRUSH: extreme brush segments skip the intermediate dabs) · **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 From 67f53390fe4d4d2b268491f80048facb7c8d9dd8 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:18:39 +0200 Subject: [PATCH 24/44] masks: adapt the long-segment test to endpoint-only hostile segments A hostile segment now lays only its end point, so five of them no longer fill the stroke budget. The test asserts the endpoint-only count directly and proves that a stroke which has spent its count takes no more through push_dabs_counted, which tracks the budget across buffers. --- crates/pipeline/src/masks/tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/pipeline/src/masks/tests.rs b/crates/pipeline/src/masks/tests.rs index 66e451a75..59f258726 100644 --- a/crates/pipeline/src/masks/tests.rs +++ b/crates/pipeline/src/masks/tests.rs @@ -148,7 +148,8 @@ fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { } assert_eq!(committed.dabs, again); assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); - // many huge segments stop at the stroke budget instead of adding up without bound + // a huge segment lays only its end point, so many of them add one dab each instead of + // filling the stroke budget with intermediate dabs let mut big = Vec::new(); let mut at = Point::new(0.0, 0.0); for i in 0..5 { @@ -157,8 +158,9 @@ fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { at = q; } } - assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); - assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); + assert_eq!(big.len(), 5, "an extreme segment lays no intermediate dabs"); + let mut spent = MAX_STROKE_DABS; + assert!(!push_dabs_counted(&mut big, &mut spent, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); // a hostile segment places no in-between dabs let mut v = Vec::new(); assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); From 63b7d0766e18726ba35a2f4be645280acd478984 Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:04:47 +0100 Subject: [PATCH 25/44] LR-MASK-PINS: Object and Describe masks show a pin that selects them (#3049) With Show Pins on, Object and Describe masks had no pin on the photo, so they could only be picked in the Masking panel. An Object component's pin now sits at its first click and a Describe component's in the middle of what its segmentation selected. Clicking the pin selects the mask; dragging it changes nothing and adds no History step. Subject, Sky, Background, People, Landscape and the range masks still have no pin. --- crates/ui-egui/src/panels/detail/masks.rs | 22 ++++++++++- crates/ui-egui/src/tests/masking.rs | 45 +++++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 8 ++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 365ae2311..0ab7bb5ee 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -187,16 +187,36 @@ pub(super) fn region_overlay( // ------------------------------------------------------------------------ masks -/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. +/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo: an +/// Object at its first click, a Describe mask on what its segmentation found (issue #3049). pub(crate) fn component_pin(shape: &MaskShape) -> Option { match shape { MaskShape::Radial { center, .. } => Some(*center), MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), + MaskShape::Object { hint, seg, .. } => hint.first().copied().or_else(|| seg.as_ref().and_then(seg_center)), + MaskShape::Prompt { seg, .. } => seg.as_ref().and_then(seg_center), _ => None, } } +/// The middle of what a segmentation selects (the mean of its cells inside the mask), else of the +/// part of the image it covers; `None` when it is damaged. +fn seg_center(seg: &lightcraft_develop::SegMask) -> Option { + let [x0, y0, x1, y1] = seg.bounds()?; + let logits = seg.logits()?; + let side = seg.side as usize; + let (mut sx, mut sy, mut n) = (0.0, 0.0, 0usize); + for (i, _) in logits.iter().enumerate().filter(|(_, l)| **l > 0.0) { + sx += (i % side) as f64 + 0.5; + sy += (i / side) as f64 + 0.5; + n += 1; + } + let cells = n as f64 * side as f64; + let (u, v) = if n == 0 { (0.5, 0.5) } else { (sx / cells, sy / cells) }; + Some(Point::new(x0 + u * (x1 - x0), y0 + v * (y1 - y0))) +} + /// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index c8d6d6c2e..444096d15 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -363,6 +363,51 @@ fn mask_overlay_keys_and_pins() { h.settle(SETTLE); } +/// Issue #3049: Show Pins draws a pin for an Object mask (at its first click) and a Describe +/// mask (on what its segmentation found). Clicking one selects its mask; dragging it leaves the +/// mask and the History alone, since neither can be moved. +#[test] +fn object_and_describe_masks_show_pins_that_select_but_do_not_move() { + use lightcraft_develop::SegMask; + use lightcraft_geom::Point; + let mut h = detail("panel.masking"); + // a 16 × 16 segmentation found in columns 2..=5, rows 10..=13: centred on (0.25, 0.75) + let blob: Vec = (0..16 * 16).map(|i| if (2..=5).contains(&(i % 16)) && (10..=13).contains(&(i / 16)) { 8.0 } else { -8.0 }).collect(); + let seg = SegMask::from_logits(16, &blob); + let object = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }; + let prompt = MaskShape::Prompt { text: "the boat".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.update", json!({"id": 1, "shape": object})); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.update", json!({"id": 2, "shape": prompt})); + assert_eq!(h.app.session.active_mask, Some(2)); + // pins shown without the pointer over the photo + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + h.settle(SETTLE); + let pin_at = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| r.center()); + let (Some(object_pin), Some(prompt_pin)) = (pin_at(&h, "maskPin:1:0"), pin_at(&h, "maskPin:2:0")) else { + panic!("both masks have a pin: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("maskPin:")).collect::>()) + }; + // the Describe pin sits on what was found, right of and below the Object's + assert!(prompt_pin.x < object_pin.x && prompt_pin.y > object_pin.y, "{object_pin:?} {prompt_pin:?}"); + // clicking a pin selects its mask + pointer(&mut h, json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "up", "x": 0.6, "y": 0.5}])); + assert_eq!(h.app.session.active_mask, Some(1)); + pointer(&mut h, json!([{"kind": "down", "x": 0.25, "y": 0.75}, {"kind": "up", "x": 0.25, "y": 0.75}])); + assert_eq!(h.app.session.active_mask, Some(2)); + // dragging one changes nothing and adds no History step + let id = h.app.session.active().expect("active photo"); + let before = h.app.session.catalog.photo(id).expect("photo").clone(); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "drag", "x": 0.65, "y": 0.55}, {"kind": "drag", "x": 0.8, "y": 0.7}, {"kind": "up", "x": 0.8, "y": 0.7}]), + ); + assert_eq!(h.app.session.active_mask, Some(1), "the press still selects the mask"); + let after = h.app.session.catalog.photo(id).expect("photo").clone(); + assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len())); + h.settle(SETTLE); +} + /// #2210: Ctrl+Shift+O (the all-masks overlay) draws the white outline of every visible /// Radial/Linear mask, not just the selected one — but only the selected mask registers /// `maskHandle:` elements, so nothing else becomes draggable. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..1c3abd736 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -310,7 +310,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests/preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | -| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests/tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests/tool_overlay.rs`, `crates/ui-egui/src/tests/masking.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Object (at its first click) and Describe (on what it found) components have pins too: click selects, drag changes nothing (issue #3049); Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range components or Subject, Sky, Background, People and Landscape | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..9583209df 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-29 + +### Object and Describe masks show their pins +- With Show Pins on, an Object mask now has a pin where you first clicked the object, and a Describe mask has one in + the middle of what its description found. Before, these masks had no pin on the photo, so they couldn't be picked + there. Clicking a pin selects its mask; dragging it changes nothing, as these masks follow what they selected. + Subject, Sky, Background and the range masks still have no pin. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From 71748919396d7fb0adb8b96d02eb31dd6fc6bcad Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:05:20 +0100 Subject: [PATCH 26/44] LR-MASK-PINS: with the Object tool on, a click on another mask's pin selects it (#3049) --- crates/ui-egui/src/panels/detail/masks.rs | 34 +++++++++++++++++-- crates/ui-egui/src/tests/masking.rs | 41 +++++++++++++++++++++++ docs/whats-new.md | 3 +- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 0ab7bb5ee..c67823db0 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -356,7 +356,7 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg return; } if app.ui.tool == "object" { - object_tool(app, ui, resp, map); + object_tool(app, ui, resp, map, d, &grips); return; } if app.ui.tool == "brush" { @@ -457,14 +457,42 @@ fn object_marks(app: &LightcraftApp, p: &egui::Painter, map: &CanvasMap, d: &Dev } } -/// Object tool: a click includes what's under it, ⌥-click leaves it out. -fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) { +/// The mask whose pin is under `q` (within the 10 px [`grip_gestures`] grabs at), not counting +/// the `active` mask's own pins. +fn other_pin_at(grips: &[(u32, usize, u8, Pos2)], active: Option, q: Pos2) -> Option { + grips + .iter() + .filter(|g| Some(g.0) != active && g.2 == 0 && g.3.distance(q) < 10.0) + .min_by(|a, b| a.3.distance(q).total_cmp(&b.3.distance(q))) + .map(|g| g.0) +} + +/// Object tool: a click includes what's under it, ⌥-click leaves it out. A click on another +/// mask's pin selects that mask instead of adding a click to the selected one (issue #3049); +/// the tool then stays on only if the newly selected mask has an Object selection to refine. +fn object_tool( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + d: &DevelopSettings, + grips: &[(u32, usize, u8, Pos2)], +) { if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } if resp.clicked() && let Some(q) = resp.interact_pointer_pos() { + if let Some(mid) = other_pin_at(grips, app.session.active_mask, q) { + let _ = app.run("mask.select", json!({"id": mid})); + let has_object = + d.masks.iter().find(|m| m.id == mid).is_some_and(|m| m.components.iter().any(|c| matches!(c.shape, MaskShape::Object { .. }))); + if !has_object { + app.ui.tool.clear(); + } + return; + } let n = map.norm(q); let exclude = ui.input(|i| i.modifiers.alt); match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index 444096d15..4e7358d3f 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -408,6 +408,47 @@ fn object_and_describe_masks_show_pins_that_select_but_do_not_move() { h.settle(SETTLE); } +/// Issue #3049: with the Object tool on, a click on another mask's pin selects that mask instead +/// of adding an Object click to the selected one. The tool stays on for an Object mask (its +/// clicks refine it) and goes off for a mask with no Object selection to refine. +#[test] +fn the_object_tool_selects_the_mask_whose_pin_is_clicked() { + use lightcraft_geom::Point; + let mut h = detail("panel.masking"); + // no SAM 3 model here: a stray Object click would offer the download or show an error + let dir = std::env::temp_dir().join(format!("lc-ui-pin-object-{}", std::process::id())); + h.app.session.segmenter.dir = Some(dir.clone()); + h.app.session.segmenter.mirrors_file = Some(dir.join("none.txt")); + let object = |at: Point| MaskShape::Object { hint: vec![at], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }; + let radial = json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1}); + exec(&mut h, "mask.add", radial.clone()); + exec(&mut h, "mask.update", json!({"id": 1, "shape": object(Point::new(0.6, 0.5))})); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.2, 0.8], "rx": 0.05, "ry": 0.05})); + exec(&mut h, "mask.add", radial); + exec(&mut h, "mask.update", json!({"id": 3, "shape": object(Point::new(0.3, 0.3))})); + assert_eq!(h.app.session.active_mask, Some(3)); + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + h.app.ui.tool = "object".into(); + h.settle(SETTLE); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()); + let before = develop(&h).masks; + // the Object mask's pin: it is selected, its clicks and the selected mask's stay as they were + pointer(&mut h, json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "up", "x": 0.6, "y": 0.5}])); + h.settle(SETTLE); + assert_eq!(h.app.session.active_mask, Some(1), "the pinned mask is selected"); + assert_eq!(develop(&h).masks, before, "no Object click was added to any mask"); + assert_eq!(h.app.ui.dialog, None, "no Object click was tried"); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.clone()).filter(|t| Some(t) != toast.as_ref()), None, "no error"); + assert_eq!(h.app.ui.tool, "object", "the tool stays on to refine the Object mask"); + // the Radial mask's pin: selected, and the Object tool goes off (nothing for it to refine) + pointer(&mut h, json!([{"kind": "down", "x": 0.2, "y": 0.8}, {"kind": "up", "x": 0.2, "y": 0.8}])); + h.settle(SETTLE); + assert_eq!(h.app.session.active_mask, Some(2)); + assert_eq!(develop(&h).masks, before); + assert_eq!(h.app.ui.tool, "", "the Object tool is off"); + assert_eq!(h.app.ui.dialog, None); +} + /// #2210: Ctrl+Shift+O (the all-masks overlay) draws the white outline of every visible /// Radial/Linear mask, not just the selected one — but only the selected mask registers /// `maskHandle:` elements, so nothing else becomes draggable. diff --git a/docs/whats-new.md b/docs/whats-new.md index 9583209df..525115bf5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -6,7 +6,8 @@ - With Show Pins on, an Object mask now has a pin where you first clicked the object, and a Describe mask has one in the middle of what its description found. Before, these masks had no pin on the photo, so they couldn't be picked there. Clicking a pin selects its mask; dragging it changes nothing, as these masks follow what they selected. - Subject, Sky, Background and the range masks still have no pin. + With the Object tool on, a click on another mask's pin selects that mask too, instead of adding a click to the + selected Object mask. Subject, Sky, Background and the range masks still have no pin. ## 2026-10-24 From b3f569a824f43435195bf48981ecf75aa82bef15 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 17:29:14 +0200 Subject: [PATCH 27/44] Lightroom catalog import: turn masks into the photo's upright frame (part of #1806) --- crates/engine/src/lightroom_catalog.rs | 43 +++++++++++++++++ crates/engine/src/lightroom_catalog/tests.rs | 50 ++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 3066187ce..4cc6c6c1e 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -705,6 +705,7 @@ fn mapped_settings( } let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect); catalog_crop(&mut partial, orientation, stored_aspect); + catalog_masks(&mut partial, orientation); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); } @@ -764,6 +765,48 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6 geometry.insert("angle".into(), json!(angle)); } +/// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the +/// stored image (before orientation), normalized to its size; LightCraft's are on the oriented +/// image. So the points go through the orientation, a radial's radii (in long-edge units) swap when +/// the axes do, and a mirror turns its angle the other way. A quarter turn keeps the angle: the +/// ellipse turned 90° is the same ellipse with its radii swapped. +fn catalog_masks(partial: &mut Value, orientation: Orientation) { + if orientation == Orientation::Normal { + return; + } + let point = |p: Option<&mut Value>| { + let Some(p) = p.and_then(Value::as_object_mut) else { return }; + let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else { return }; + let (u, v) = orientation.map(x, y, 1.0, 1.0); + p.insert("x".into(), json!(u)); + p.insert("y".into(), json!(v)); + }; + let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return }; + let components = masks.iter_mut().filter_map(|m| m.get_mut("components").and_then(Value::as_array_mut)).flatten(); + for shape in components.filter_map(|c| c.get_mut("shape").and_then(Value::as_object_mut)) { + point(shape.get_mut("start")); + point(shape.get_mut("end")); + point(shape.get_mut("center")); + let strokes = shape.get_mut("strokes").and_then(Value::as_array_mut).into_iter().flatten(); + for points in strokes.filter_map(|s| s.get_mut("points").and_then(Value::as_array_mut)) { + points.iter_mut().for_each(|p| point(Some(p))); + } + if shape.get("kind").and_then(Value::as_str) == Some("radial") { + if orientation.swaps_axes() + && let (Some(rx), Some(ry)) = (shape.get("rx").cloned(), shape.get("ry").cloned()) + { + shape.insert("rx".into(), ry); + shape.insert("ry".into(), rx); + } + if orientation.to_parts().0 + && let Some(angle) = shape.get("angle").and_then(Value::as_f64) + { + shape.insert("angle".into(), json!(-angle)); + } + } + } +} + fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { use crate::preset_import::Lua; match value { diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index 654b40458..e420a995e 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -284,6 +284,56 @@ fn catalog_crops_follow_the_photo_orientation() { assert!(partial.get("crop").is_none()); } +#[test] +fn catalog_masks_follow_the_photo_orientation() { + // #1806, EMY_9996 (EXIF 8, shown a quarter turn counter-clockwise): a horizon gradient along + // the stored image's x axis runs along the shown photo's y axis + let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.1, CorrectionMasks = { + { What = "Mask/Gradient", MaskValue = 1, ZeroX = 0.362544, ZeroY = 0.481093, FullX = 0.451639, FullY = 0.480645 } } } } }"#; + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5).unwrap(); + let g = &partial["masks"][0]["components"][0]["shape"]; + let close = |v: &Value, want: f64| (v.as_f64().unwrap() - want).abs() < 1e-9; + assert!(close(&g["start"]["x"], 0.480645) && close(&g["start"]["y"], 1.0 - 0.451639), "{g}"); + assert!(close(&g["end"]["x"], 0.481093) && close(&g["end"]["y"], 1.0 - 0.362544), "{g}"); + + let masks = |o: Orientation| { + let s = r#"s = { + MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { + { What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 }, + { What = "Mask/CircularGradient", Left = 0, Right = 0.2, Top = 0.1, Bottom = 0.3, Angle = 10, Feather = 50, Version = 2, Flipped = true }, + { What = "Mask/Paint", Radius = 0.05, MaskValue = 1, Dabs = { "d 0.1 0.2", "d 0.3 0.6" } } } } } }"#; + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let c = &partial["masks"][0]["components"]; + let (linear, radial, brush) = (&c[0]["shape"], &c[1]["shape"], &c[2]["shape"]["strokes"][0]["points"]); + [ + &linear["start"]["x"], + &linear["start"]["y"], + &linear["end"]["x"], + &linear["end"]["y"], + &radial["center"]["x"], + &radial["center"]["y"], + &radial["rx"], + &radial["ry"], + &radial["angle"], + &brush[0]["x"], + &brush[0]["y"], + &brush[1]["x"], + &brush[1]["y"], + ] + .map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9) + }; + // (x, y) of the stored image → (1 − y, x) a quarter turn clockwise, (y, 1 − x) counter-clockwise; + // a radial's radii (in long-edge units) swap with the axes, and a mirror turns its angle the + // other way + let r = (0.1, (0.1_f64 / 1.5 * 1e9).round() / 1e9); + assert_eq!(masks(Orientation::Normal), [0.1, 0.2, 0.3, 0.6, 0.1, 0.2, r.0, r.1, 10.0, 0.1, 0.2, 0.3, 0.6]); + assert_eq!(masks(Orientation::Rotate90), [0.8, 0.1, 0.4, 0.3, 0.8, 0.1, r.1, r.0, 10.0, 0.8, 0.1, 0.4, 0.3]); + assert_eq!(masks(Orientation::Rotate270), [0.2, 0.9, 0.6, 0.7, 0.2, 0.9, r.1, r.0, 10.0, 0.2, 0.9, 0.6, 0.7]); + assert_eq!(masks(Orientation::Rotate180), [0.9, 0.8, 0.7, 0.4, 0.9, 0.8, r.0, r.1, 10.0, 0.9, 0.8, 0.7, 0.4]); + assert_eq!(masks(Orientation::FlipH), [0.9, 0.2, 0.7, 0.6, 0.9, 0.2, r.0, r.1, -10.0, 0.9, 0.2, 0.7, 0.6]); + assert_eq!(masks(Orientation::Transpose), [0.2, 0.1, 0.6, 0.3, 0.2, 0.1, r.1, r.0, -10.0, 0.2, 0.1, 0.6, 0.3]); +} + /// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image /// of aspect `w` (height 1) shown with orientation `o`. fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { From 307ec1a053dea397537d41e110d66c2ddfea539c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 17:41:38 +0200 Subject: [PATCH 28/44] Lightroom catalog import: size radial masks with the stored image's aspect (part of #1806) --- crates/engine/src/lightroom_catalog.rs | 12 ++++++++---- crates/engine/src/lightroom_catalog/tests.rs | 7 +++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 4cc6c6c1e..12934c0e0 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -703,7 +703,11 @@ fn mapped_settings( if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) { props.insert("crs:HasCrop".into(), vec!["True".into()]); } - let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect); + // Mask edges are fractions of the stored image, like the crop's, so radii go to long-edge units + // with its aspect: `aspect` is the photo's in LightCraft, which is the shown one for a photo + // already in the library and only stands in when the catalog has no file size. + let mask_aspect = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { aspect }; + let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, mask_aspect); catalog_crop(&mut partial, orientation, stored_aspect); catalog_masks(&mut partial, orientation); if deferred > 0 { @@ -767,9 +771,9 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6 /// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the /// stored image (before orientation), normalized to its size; LightCraft's are on the oriented -/// image. So the points go through the orientation, a radial's radii (in long-edge units) swap when -/// the axes do, and a mirror turns its angle the other way. A quarter turn keeps the angle: the -/// ellipse turned 90° is the same ellipse with its radii swapped. +/// image. So the points go through the orientation, a radial's radii (in long-edge units, from the +/// stored image's aspect) swap when the axes do, and a mirror turns its angle the other way. A +/// quarter turn keeps the angle: the ellipse turned 90° is the same ellipse with its radii swapped. fn catalog_masks(partial: &mut Value, orientation: Orientation) { if orientation == Orientation::Normal { return; diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index e420a995e..857de2f5c 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -332,6 +332,13 @@ fn catalog_masks_follow_the_photo_orientation() { assert_eq!(masks(Orientation::Rotate180), [0.9, 0.8, 0.7, 0.4, 0.9, 0.8, r.0, r.1, 10.0, 0.9, 0.8, 0.7, 0.4]); assert_eq!(masks(Orientation::FlipH), [0.9, 0.2, 0.7, 0.6, 0.9, 0.2, r.0, r.1, -10.0, 0.9, 0.2, 0.7, 0.6]); assert_eq!(masks(Orientation::Transpose), [0.2, 0.1, 0.6, 0.3, 0.2, 0.1, r.1, r.0, -10.0, 0.2, 0.1, 0.6, 0.3]); + // a photo already in the library has its shown size (portrait here), but Lightroom's edges are + // fractions of the stored image: a circle on a 3:2 stored image stays a circle + let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { + { What = "Mask/CircularGradient", Left = 0.3, Right = 0.5, Top = 0.3, Bottom = 0.6, Version = 2, Flipped = true } } } } }"#; + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5).unwrap(); + let radial = &partial["masks"][0]["components"][0]["shape"]; + assert!(close(&radial["rx"], 0.1) && close(&radial["ry"], 0.1), "{radial}"); } /// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image From e5667d4e6febacfc2bd1e4a1a00693cb00eb386d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 29 Oct 2026 23:30:45 +0100 Subject: [PATCH 29/44] Lightroom catalog import: turn masks with the rotation a photo already in the library keeps (part of #1806) Updating a photo already in the library keeps its Rotate Left / Right and Flip in LightCraft, which LightCraft draws masks after, but the masks went through the catalog's orientation only: an upright photo turned right in LightCraft got an unturned gradient. They now go through the catalog's orientation and then the photo's own. --- crates/engine/src/lightroom_catalog.rs | 13 ++- crates/engine/src/lightroom_catalog/tests.rs | 90 ++++++++++++++++---- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 12934c0e0..94fe34a95 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -693,6 +693,7 @@ fn mapped_settings( aspect: f64, orientation: Orientation, stored_aspect: f64, + user: Orientation, ) -> Result<(Value, Vec), String> { let mut root = crate::preset_import::parse_lua(text)?; let deferred = strip_lua_sentinels(&mut root); @@ -709,7 +710,7 @@ fn mapped_settings( let mask_aspect = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { aspect }; let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, mask_aspect); catalog_crop(&mut partial, orientation, stored_aspect); - catalog_masks(&mut partial, orientation); + catalog_masks(&mut partial, in_photo_frame(orientation, user)); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); } @@ -769,6 +770,15 @@ fn catalog_crop(partial: &mut Value, orientation: Orientation, stored_aspect: f6 geometry.insert("angle".into(), json!(angle)); } +/// The catalog's orientation (the stored image to the photo as Lightroom shows it), then the +/// photo's own Rotate Left / Right and Flip in LightCraft (`user`), which an update of a photo +/// already in the library keeps: the frame LightCraft draws masks in. +fn in_photo_frame(catalog: Orientation, user: Orientation) -> Orientation { + let (flip, turns) = user.to_parts(); + let o = if flip { catalog.flipped_h() } else { catalog }; + (0..turns).fold(o, |o, _| o.rotated(true)) +} + /// Lightroom's masks in LightCraft's frame. Like the crop's corners, their positions are on the /// stored image (before orientation), normalized to its size; LightCraft's are on the oriented /// image. So the points go through the orientation, a radial's radii (in long-edge units, from the @@ -1103,6 +1113,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co p.width.max(1) as f64 / p.height.max(1) as f64, orientation, stored_aspect, + p.develop.orientation, ) { Ok((partial, unknown)) => { p.develop = Arc::new(lightcraft_develop::apply_partial(&p.develop, &partial, 1.0)); diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index 857de2f5c..17ec44cfb 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -173,8 +173,15 @@ fn preserves_existing_edits_and_rejects_invalid_structure_before_mutation() { assert!(apply(&mut s, cyclic, false).is_err()); assert_eq!(s.catalog.to_snapshot(), before); assert!( - mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) - .is_err() + mapped_settings( + &format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), + crate::crs::Target::RawAbsolute, + 1.0, + Orientation::Normal, + 1.5, + Orientation::Normal + ) + .is_err() ); } #[test] @@ -191,6 +198,7 @@ fn compressed_xmp_is_bounded_and_settings_are_data_only() { 1.5, Orientation::Normal, 1.5, + Orientation::Normal, ) .unwrap(); assert_eq!(partial["light"]["exposure"], 0.75); @@ -202,11 +210,11 @@ fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin let settings = "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); assert_eq!(partial["light"]["exposure"], 0.5); assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); - let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert_eq!( (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), (Some("custom"), Some(3578.0), Some(-5.0)) @@ -223,6 +231,7 @@ fn catalog_crops_without_has_crop_are_imported() { 1.5, Orientation::Normal, 1.5, + Orientation::Normal, ) .unwrap(); let crop = &partial["crop"]["geometry"]; @@ -233,11 +242,13 @@ fn catalog_crops_without_has_crop_are_imported() { assert_eq!(crop["angle"], 0.0); assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) - let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = + mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); // no crop fields: the crop stays untouched - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = + mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, Orientation::Normal).unwrap(); assert!(partial.get("crop").is_none()); } @@ -261,7 +272,7 @@ fn lightroom_orientation_codes() { fn catalog_crops_follow_the_photo_orientation() { let crop = |o: Orientation| { let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); let g = &partial["crop"]["geometry"]; let r = &g["rect"]; [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) @@ -274,13 +285,14 @@ fn catalog_crops_follow_the_photo_orientation() { assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); // a mirror turns the straighten the other way let angle = |o: Orientation| { - let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); partial["crop"]["geometry"]["angle"].as_f64().unwrap() }; assert_eq!(angle(Orientation::Rotate90), -2.0); assert_eq!(angle(Orientation::FlipH), 2.0); // no crop: nothing to orient - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); + let (partial, _) = + mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap(); assert!(partial.get("crop").is_none()); } @@ -290,7 +302,7 @@ fn catalog_masks_follow_the_photo_orientation() { // the stored image's x axis runs along the shown photo's y axis let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.1, CorrectionMasks = { { What = "Mask/Gradient", MaskValue = 1, ZeroX = 0.362544, ZeroY = 0.481093, FullX = 0.451639, FullY = 0.480645 } } } } }"#; - let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5).unwrap(); + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate270, 1.5, Orientation::Normal).unwrap(); let g = &partial["masks"][0]["components"][0]["shape"]; let close = |v: &Value, want: f64| (v.as_f64().unwrap() - want).abs() < 1e-9; assert!(close(&g["start"]["x"], 0.480645) && close(&g["start"]["y"], 1.0 - 0.451639), "{g}"); @@ -302,7 +314,7 @@ fn catalog_masks_follow_the_photo_orientation() { { What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 }, { What = "Mask/CircularGradient", Left = 0, Right = 0.2, Top = 0.1, Bottom = 0.3, Angle = 10, Feather = 50, Version = 2, Flipped = true }, { What = "Mask/Paint", Radius = 0.05, MaskValue = 1, Dabs = { "d 0.1 0.2", "d 0.3 0.6" } } } } } }"#; - let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); let c = &partial["masks"][0]["components"]; let (linear, radial, brush) = (&c[0]["shape"], &c[1]["shape"], &c[2]["shape"]["strokes"][0]["points"]); [ @@ -336,7 +348,7 @@ fn catalog_masks_follow_the_photo_orientation() { // fractions of the stored image: a circle on a 3:2 stored image stays a circle let s = r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { { What = "Mask/CircularGradient", Left = 0.3, Right = 0.5, Top = 0.3, Bottom = 0.6, Version = 2, Flipped = true } } } } }"#; - let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5).unwrap(); + let (partial, _) = mapped_settings(s, crate::crs::Target::RawAbsolute, 1.0 / 1.5, Orientation::Rotate90, 1.5, Orientation::Normal).unwrap(); let radial = &partial["masks"][0]["components"][0]["shape"]; assert!(close(&radial["rx"], 0.1) && close(&radial["ry"], 0.1), "{radial}"); } @@ -363,7 +375,8 @@ fn straightened_catalog_crops_round_trip() { for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { for angle in [6.5, -6.5] { let g = CropGeometry { angle, ..g }; - let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); + let (partial, _) = + mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5, Orientation::Normal).unwrap(); let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); let close = |a: f64, b: f64| (a - b).abs() < 1e-9; assert!( @@ -393,7 +406,7 @@ fn straightened_catalog_crops_match_lightroom_exports() { (6100.0, 9150.0), ), ] { - let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0, Orientation::Normal).unwrap(); let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; let (w, h) = (r.width() * ow, r.height() * oh); @@ -416,6 +429,44 @@ fn imported_crops_use_the_catalog_orientation() { assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); } +/// A photo already in the library keeps its Rotate Left / Right on update, so the catalog's masks +/// go through it too: an upright (`AB`) photo turned right in LightCraft gets its gradient turned. +#[test] +fn updated_masks_follow_the_rotation_kept_from_the_library() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].image.insert("orientation".into(), json!("AB")); + data.photos[0].settings = + r#"s = { MaskGroupBasedCorrections = { { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.1, CorrectionMasks = { + { What = "Mask/Gradient", FullX = 0.1, FullY = 0.2, ZeroX = 0.3, ZeroY = 0.6 } } } } }"# + .into(); + data.photos[0].xmp = String::new(); + let mut s = crate::Session::new(); + apply(&mut s, data.clone(), false).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let turned = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; + s.set_develop(id, turned, "Rotate Right").unwrap(); + apply(&mut s, data, true).unwrap(); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.develop.orientation, Orientation::Rotate90, "the update keeps the photo's rotation"); + let g = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"][0]["shape"]; + let r = [&g["start"]["x"], &g["start"]["y"], &g["end"]["x"], &g["end"]["y"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9); + // (x, y) → (1 − y, x), a quarter turn clockwise + assert_eq!(r, [0.8, 0.1, 0.4, 0.3], "{g}"); + // the catalog's orientation first, then the photo's own, for every pair + for a in 1..=8 { + for b in 1..=8 { + let (a, b) = (Orientation::from_exif(a), Orientation::from_exif(b)); + for (x, y) in [(0.1, 0.2), (0.7, 0.4)] { + let (u, v) = a.map(x, y, 1.0, 1.0); + let ((p, q), (want_p, want_q)) = (in_photo_frame(a, b).map(x, y, 1.0, 1.0), b.map(u, v, 1.0, 1.0)); + assert!((p - want_p).abs() < 1e-9 && (q - want_q).abs() < 1e-9, "{a:?} then {b:?}: ({p}, {q}) for ({want_p}, {want_q})"); + } + } + } +} + #[test] fn deferred_auto_tone_sentinels_never_become_real_slider_values() { let mut data = sample(); @@ -431,8 +482,15 @@ fn deferred_auto_tone_sentinels_never_become_real_slider_values() { assert_eq!(p.develop.light.shadows, 65.0); assert_eq!(p.develop.light.blacks, -22.0); assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); - let (partial, _) = - mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = mapped_settings( + "s = { Exposure2012 = -5, Contrast2012 = -100 }", + crate::crs::Target::RawAbsolute, + 1.0, + Orientation::Normal, + 1.5, + Orientation::Normal, + ) + .unwrap(); assert_eq!(partial["light"]["exposure"], -5.0); assert_eq!(partial["light"]["contrast"], -100.0); } From 491fe2869a6b4aeb88d76e15b2cb7c856e12f787 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 29 Oct 2026 23:36:35 +0100 Subject: [PATCH 30/44] Lightroom catalog import: turn the catalog XMP's masks into the photo's frame too (part of #1806) The catalog's XMP is merged before the develop row, and its masks were taken as they are, on the stored image: when the row has no masks of its own to replace them, they stayed a quarter turn off on a vertical shot. They now go through the same frame as the row's. A radial is fitted to the stored image before it is turned, so its radii swap with the stored aspect and not the sidecar default. --- crates/engine/src/lightroom_catalog.rs | 19 +++++++++- crates/engine/src/lightroom_catalog/tests.rs | 39 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 94fe34a95..9a1359c4d 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -821,6 +821,20 @@ fn catalog_masks(partial: &mut Value, orientation: Orientation) { } } +/// The catalog's XMP masks in LightCraft's frame, as [`catalog_masks`] puts the develop row's: they +/// are on the same stored image, and they stay when the row has no masks of its own. The packet's +/// radials come fitted to the sidecar default aspect and `sidecar::merge_into` re-fits them to the +/// photo's size, so they are fitted to the stored image to be turned (a quarter turn swaps the +/// radii) and handed back in the default aspect, for that re-fit to undo. +fn catalog_xmp_masks(partial: &mut Value, orientation: Orientation, stored_aspect: f64, photo: &Photo) { + use crate::crs_masks::{DEFAULT_ASPECT, refit_radials}; + let merged = if photo.width > 0 && photo.height > 0 { photo.width as f64 / photo.height as f64 } else { DEFAULT_ASPECT }; + let fit = if stored_aspect.is_finite() && stored_aspect > 0.0 { stored_aspect } else { merged }; + refit_radials(partial, DEFAULT_ASPECT, fit); + catalog_masks(partial, orientation); + refit_radials(partial, merged, DEFAULT_ASPECT); +} + fn strip_lua_sentinels(value: &mut crate::preset_import::Lua) -> usize { use crate::preset_import::Lua; match value { @@ -1080,11 +1094,14 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co if keep_develop { preserved += 1; } + let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default(); + let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64; if !src.xmp.is_empty() { match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) { Ok(mut sc) => { if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { strip_xmp_sentinels(partial); + catalog_xmp_masks(partial, in_photo_frame(orientation, p.develop.orientation), stored_aspect, &p); } crate::sidecar::merge_into(&mut p, &sc, &now); } @@ -1105,8 +1122,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co p.meta.keywords = src.keywords.clone(); } if !src.settings.is_empty() && !keep_develop { - let orientation = lightroom_orientation(text(&src.image, "orientation")).unwrap_or_default(); - let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64; match mapped_settings( &src.settings, crate::crs::Target::for_photo(&p), diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index 17ec44cfb..a48ed9451 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -429,6 +429,45 @@ fn imported_crops_use_the_catalog_orientation() { assert_eq!(r, [0.1, 0.1, 0.8, 0.6]); } +/// Masks only the catalog's XMP has (the develop row has none to replace them) are on the stored +/// image too: on a vertical shot (`DA`) they turn like the row's, and a radial keeps its shape on +/// the 2:1 stored image. +#[test] +fn catalog_xmp_masks_follow_the_photo_orientation() { + let mut data = sample(); + data.photos.truncate(1); + data.photos[0].image.remove("masterImage"); + data.photos[0].image.insert("orientation".into(), json!("DA")); + data.photos[0].image.insert("fileWidth".into(), json!(20)); + data.photos[0].image.insert("fileHeight".into(), json!(10)); + data.photos[0].settings = "s = { Exposure2012 = 0.5 }".into(); + data.photos[0].xmp = r#" + + + + + + + + + +"# + .into(); + let mut s = crate::Session::new(); + apply(&mut s, data, false).unwrap(); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.develop.light.exposure, 0.5, "the develop row is applied too"); + let c = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"]; + let (linear, radial) = (&c[0]["shape"], &c[1]["shape"]); + let r = [&linear["start"]["x"], &linear["start"]["y"], &linear["end"]["x"], &linear["end"]["y"], &radial["center"]["x"], &radial["center"]["y"]] + .map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9); + // (x, y) → (y, 1 − x), a quarter turn counter-clockwise + assert_eq!(r, [0.2, 0.9, 0.6, 0.7, 0.45, 0.6], "{c}"); + // 4 × 3 px of a 20 × 10 stored image, in long-edge units, with the axes swapped + let radii = [&radial["rx"], &radial["ry"]].map(|v| (v.as_f64().unwrap() * 1e9).round() / 1e9); + assert_eq!(radii, [0.075, 0.1], "{radial}"); +} + /// A photo already in the library keeps its Rotate Left / Right on update, so the catalog's masks /// go through it too: an upright (`AB`) photo turned right in LightCraft gets its gradient turned. #[test] From cf35cd16944e8ea2608f24598579915ab8ee7355 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 29 Oct 2026 23:56:13 +0100 Subject: [PATCH 31/44] Lightroom catalog import: check the catalog XMP's radials on a photo already in the library (part of #1806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test's stored image and LightCraft's size of the photo were both 20 × 10, so fitting radials to the photo's size instead of the stored image went unnoticed. The photo is now already in the library at its shown 10 × 20, as a vertical shot is. --- crates/engine/src/lightroom_catalog/tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs index a48ed9451..16ea9e4eb 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -431,7 +431,7 @@ fn imported_crops_use_the_catalog_orientation() { /// Masks only the catalog's XMP has (the develop row has none to replace them) are on the stored /// image too: on a vertical shot (`DA`) they turn like the row's, and a radial keeps its shape on -/// the 2:1 stored image. +/// the 2:1 stored image, also for a photo already in the library at its shown 10 × 20. #[test] fn catalog_xmp_masks_follow_the_photo_orientation() { let mut data = sample(); @@ -454,8 +454,12 @@ fn catalog_xmp_masks_follow_the_photo_orientation() { "# .into(); let mut s = crate::Session::new(); - apply(&mut s, data, false).unwrap(); - let p = s.catalog.photos().next().unwrap(); + let id = s.catalog.alloc_photo_id(); + let shown = Photo::new(id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 10, 20, "before"); + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(shown) }).unwrap(); + apply(&mut s, data, true).unwrap(); + assert_eq!(s.catalog.len(), 1, "the catalog's photo is the one already in the library"); + let p = s.catalog.photo(id).unwrap(); assert_eq!(p.develop.light.exposure, 0.5, "the develop row is applied too"); let c = &serde_json::to_value(&p.develop.masks).unwrap()[0]["components"]; let (linear, radial) = (&c[0]["shape"], &c[1]["shape"]); From cb841a3fe1d00d5ee52bc003ea21d9108f736d8b Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:13:07 +0100 Subject: [PATCH 32/44] LR-MASK-PINS: with the Object tool on, a click on the selected mask's own pin adds no Object click (#3049) --- crates/ui-egui/src/panels/detail/masks.rs | 12 ++++++++- crates/ui-egui/src/tests/masking.rs | 30 +++++++++++++++++++++++ docs/whats-new.md | 3 ++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index c67823db0..1c649c7c5 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -467,9 +467,16 @@ fn other_pin_at(grips: &[(u32, usize, u8, Pos2)], active: Option, q: Pos2) .map(|g| g.0) } +/// Whether `q` is on one of the `active` mask's own pins (within the same 10 px). +fn own_pin_at(grips: &[(u32, usize, u8, Pos2)], active: Option, q: Pos2) -> bool { + active.is_some_and(|a| grips.iter().any(|g| g.0 == a && g.2 == 0 && g.3.distance(q) < 10.0)) +} + /// Object tool: a click includes what's under it, ⌥-click leaves it out. A click on another /// mask's pin selects that mask instead of adding a click to the selected one (issue #3049); -/// the tool then stays on only if the newly selected mask has an Object selection to refine. +/// the tool then stays on only if the newly selected mask has an Object selection to refine. A +/// click on the selected mask's own pin does nothing: its first click is already there, and a +/// near-copy of it would use up one of the mask's limited Object clicks. fn object_tool( app: &mut LightcraftApp, ui: &mut egui::Ui, @@ -493,6 +500,9 @@ fn object_tool( } return; } + if own_pin_at(grips, app.session.active_mask, q) { + return; + } let n = map.norm(q); let exclude = ui.input(|i| i.modifiers.alt); match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index 4e7358d3f..027d89a33 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -449,6 +449,36 @@ fn the_object_tool_selects_the_mask_whose_pin_is_clicked() { assert_eq!(h.app.ui.dialog, None); } +/// Issue #3049: with the Object tool on, a click on the selected Object mask's own pin adds no +/// near-copy of its first click (which would use up one of its limited Object clicks): its +/// clicks stay as they were, it stays selected and no error is shown. +#[test] +fn the_object_tool_adds_no_click_on_the_selected_masks_own_pin() { + use lightcraft_geom::Point; + let mut h = detail("panel.masking"); + // no SAM 3 model here: a stray Object click would offer the download or show an error + let dir = std::env::temp_dir().join(format!("lc-ui-own-pin-object-{}", std::process::id())); + h.app.session.segmenter.dir = Some(dir.clone()); + h.app.session.segmenter.mirrors_file = Some(dir.join("none.txt")); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1})); + let object = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }; + exec(&mut h, "mask.update", json!({"id": 1, "shape": object})); + assert_eq!(h.app.session.active_mask, Some(1)); + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + h.app.ui.tool = "object".into(); + h.settle(SETTLE); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()); + let before = develop(&h).masks; + // a little off the pin's centre, as a hand clicks it + pointer(&mut h, json!([{"kind": "down", "x": 0.601, "y": 0.501}, {"kind": "up", "x": 0.601, "y": 0.501}])); + h.settle(SETTLE); + assert_eq!(develop(&h).masks, before, "no Object click was added"); + assert_eq!(h.app.session.active_mask, Some(1), "the mask stays selected"); + assert_eq!(h.app.ui.dialog, None, "no Object click was tried"); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.clone()).filter(|t| Some(t) != toast.as_ref()), None, "no error"); + assert_eq!(h.app.ui.tool, "object", "the tool stays on"); +} + /// #2210: Ctrl+Shift+O (the all-masks overlay) draws the white outline of every visible /// Radial/Linear mask, not just the selected one — but only the selected mask registers /// `maskHandle:` elements, so nothing else becomes draggable. diff --git a/docs/whats-new.md b/docs/whats-new.md index 525115bf5..7fff88ea1 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,7 +7,8 @@ the middle of what its description found. Before, these masks had no pin on the photo, so they couldn't be picked there. Clicking a pin selects its mask; dragging it changes nothing, as these masks follow what they selected. With the Object tool on, a click on another mask's pin selects that mask too, instead of adding a click to the - selected Object mask. Subject, Sky, Background and the range masks still have no pin. + selected Object mask, and a click on the selected mask's own pin adds nothing. Subject, Sky, Background and the + range masks still have no pin. ## 2026-10-24 From 06e2ec43bac52effdf1c90abb97f87425476dc34 Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:03:03 +0100 Subject: [PATCH 33/44] LR-MASK-COLORRANGE: a colour-range mask keeps at most 64 samples however it is loaded (#2556) Render cost is pixels x samples (CPU and GPU), and only mask.add / mask.update capped the list, so a sidecar's lc:settings, a preset, develop.merge, render --settings or a catalog / history load could carry thousands and stall a render or lose the GPU device. MAX_COLOR_RANGE_SAMPLES moves to lightcraft-develop and ColorRange::samples deserializes through a capped visitor (the first 64 in order, the rest skipped as IgnoredAny); the CPU and GPU renders also take at most 64 for settings built in code. --- crates/develop/src/lib.rs | 48 ++++++++++++++++++++++++++++++++++ crates/develop/src/settings.rs | 32 ++++++++++++++++++++++- crates/engine/src/cmd/masks.rs | 7 +---- crates/engine/src/sidecar.rs | 17 ++++++++++++ crates/gpu/src/render.rs | 4 ++- crates/pipeline/src/masks.rs | 4 ++- docs/parity-checklist.md | 5 ++-- docs/whats-new.md | 9 +++++++ 8 files changed, 115 insertions(+), 11 deletions(-) diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index 4631e5e49..ea7ed1bd0 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -271,6 +271,54 @@ mod tests { assert_eq!(s.grading.blending, 50.0); } + /// Settings with one colour-range mask of `n` distinct samples (built in code, so not capped). + fn color_range_settings(n: usize) -> DevelopSettings { + let samples = (0..n).map(|i| [i as f64 / 1000.0, 0.1, -0.1]).collect(); + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 1, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::ColorRange { samples, refine: 50.0 } }], + ..Default::default() + }); + s + } + + fn color_range_samples(s: &DevelopSettings) -> &[[f64; 3]] { + match s.masks.first().and_then(|m| m.components.first()).map(|c| &c.shape) { + Some(MaskShape::ColorRange { samples, .. }) => samples, + other => panic!("expected a colour range, got {other:?}"), + } + } + + /// Issue #2556: `mask.add`/`mask.update` capped a colour range's samples, but a sidecar `lc:settings`, a + /// preset, `develop.merge`, `render --settings` or a catalog / history load took any number (render cost is + /// pixels x samples). Every load now keeps the first [`MAX_COLOR_RANGE_SAMPLES`], in order. + #[test] + fn color_range_samples_are_capped_however_settings_load() { + let big = color_range_settings(1000); + let want = &color_range_samples(&big)[..MAX_COLOR_RANGE_SAMPLES]; + let v = big.to_json(); + let loaded = DevelopSettings::from_json(&v).unwrap(); + assert_eq!(color_range_samples(&loaded).len(), MAX_COLOR_RANGE_SAMPLES); + assert_eq!(color_range_samples(&loaded), want); + let from_str: DevelopSettings = serde_json::from_str(&v.to_string()).unwrap(); + assert_eq!(color_range_samples(&from_str), want); + // `develop.merge`, `render --settings` and presets merge a partial + let merged = DevelopSettings::default().merged(&json!({"masks": v["masks"]})).unwrap(); + assert_eq!(color_range_samples(&merged), want); + let preset = + Preset { id: "p".into(), name: "P".into(), group: "G".into(), settings: json!({"masks": v["masks"]}), favorite: false, builtin: false }; + assert_eq!(color_range_samples(&preset.apply(&DevelopSettings::default(), 1.0)), want); + // a capped list round-trips, and a short one is unchanged + assert_eq!(DevelopSettings::from_json(&loaded.to_json()).unwrap(), loaded); + let few = color_range_settings(2); + assert_eq!(DevelopSettings::from_json(&few.to_json()).unwrap(), few); + // a list that isn't samples is still refused + let mut bad = v.clone(); + bad["masks"][0]["components"][0]["shape"]["samples"] = json!([[0.1, 0.2]]); + assert!(DevelopSettings::from_json(&bad).is_err()); + } + #[test] fn merge_partial() { let s = DevelopSettings::default(); diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index a40f094c2..ceef048c8 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -921,6 +921,35 @@ impl Default for BrushStroke { } } +/// Most samples a colour-range mask keeps. It compares every pixel against every sample, so render and export cost +/// is pixels x samples; the interactive sampler stops at 5, and a sidecar, preset or `mask.add` with thousands could +/// stall a render or lose the GPU device (issue #2556). +pub const MAX_COLOR_RANGE_SAMPLES: usize = 64; + +/// Deserialize a colour range's samples, keeping the first [`MAX_COLOR_RANGE_SAMPLES`] (the rest are skipped without +/// being stored), however the settings arrive: sidecar, catalog, history, preset or merge. +fn de_color_range_samples<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct Capped; + impl<'de> serde::de::Visitor<'de> for Capped { + type Value = Vec<[f64; 3]>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of OkLab colours") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while out.len() < MAX_COLOR_RANGE_SAMPLES { + match seq.next_element::<[f64; 3]>()? { + Some(s) => out.push(s), + None => return Ok(out), + } + } + while seq.next_element::()?.is_some() {} + Ok(out) + } + } + d.deserialize_seq(Capped) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum MaskShape { @@ -941,8 +970,9 @@ pub enum MaskShape { feather: f64, invert: bool, }, - /// Colour range: sampled OkLab colours with a refine amount 0..100. + /// Colour range: sampled OkLab colours (at most [`MAX_COLOR_RANGE_SAMPLES`]) with a refine amount 0..100. ColorRange { + #[serde(deserialize_with = "de_color_range_samples")] samples: Vec<[f64; 3]>, refine: f64, }, diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 2fd44d566..ceea76e1b 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -1,6 +1,6 @@ //! Masking and Remove (spot) commands on the active photo. -use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; +use lightcraft_develop::{BrushStroke, LocalAdjustments, MAX_COLOR_RANGE_SAMPLES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; use lightcraft_geom::Point; use serde_json::{Value, json}; @@ -8,11 +8,6 @@ use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_p use crate::segment::{Click, MAX_CLICKS}; use crate::{Result, Session}; -/// A colour-range mask compares every pixel against every sample, so render and export cost is -/// pixels x samples. The interactive sampler stops at 5; automation (`mask.add`/`mask.update`) -/// passed its `samples` array straight through, so a few hundred samples on a large render hung. -const MAX_COLOR_RANGE_SAMPLES: usize = 64; - /// Caps a colour-range shape's sample list at [`MAX_COLOR_RANGE_SAMPLES`] (render cost is pixels x samples). fn cap_color_range_samples(shape: &mut MaskShape) { if let MaskShape::ColorRange { samples, .. } = shape { diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index d13b95125..60c180d2c 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -980,6 +980,23 @@ mod tests { assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()])); } + /// Issue #2556: a sidecar's `lc:settings` colour-range mask keeps at most `MAX_COLOR_RANGE_SAMPLES` samples. + #[test] + fn sidecar_color_range_samples_are_capped() { + use lightcraft_develop::{MAX_COLOR_RANGE_SAMPLES, Mask, MaskComponent, MaskOp, MaskShape}; + let samples: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64 / 1000.0, 0.1, -0.1]).collect(); + let mut d = DevelopSettings::default(); + let shape = MaskShape::ColorRange { samples: samples.clone(), refine: 50.0 }; + d.masks.push(Mask { id: 1, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], ..Default::default() }); + let xmp = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&d.to_json().to_string())); + let sc = super::parse_sidecar(&xmp, crate::crs::Target::Rendered).unwrap(); + let Some(DevelopPatch::Full(s)) = sc.develop else { panic!("expected full settings") }; + let Some(MaskShape::ColorRange { samples: got, .. }) = s.masks.first().and_then(|m| m.components.first()).map(|c| &c.shape) else { + panic!("expected a colour range") + }; + assert_eq!(got.as_slice(), &samples[..MAX_COLOR_RANGE_SAMPLES]); + } + use super::*; use lightcraft_catalog::Meta; diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 90aed7974..f9bbc23f6 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -1017,8 +1017,10 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } MaskShape::ColorRange { samples, refine } => { let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine); + // at most the CPU's samples (`MAX_COLOR_RANGE_SAMPLES`): cost is pixels x samples + let samples = samples.iter().take(lightcraft_develop::MAX_COLOR_RANGE_SAMPLES); p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]); - aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32))); + aux.extend(samples.flat_map(|s| s.map(|v| v as f32))); Some(3) } MaskShape::Brush { strokes } => { diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 73383d1d4..6b77f84a2 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -251,7 +251,9 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R } MaskShape::ColorRange { samples, refine } => { let tol = color_range_tolerance(*refine); - let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect(); + // (a settings value built in code can skip the deserializer's cap: cost is pixels x samples) + let samples: Vec<[f32; 3]> = + samples.iter().take(lightcraft_develop::MAX_COLOR_RANGE_SAMPLES).map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect(); for (v, c) in out.data.iter_mut().zip(&img.data) { let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain))); let d = samples diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..aee9b24b9 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-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** patch (LR-MASK-COLORRANGE: a colour-range mask keeps at most 64 samples however it is loaded, #2556; earlier: LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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 @@ -299,7 +299,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | -| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider | +| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider; at most 64 samples (`MAX_COLOR_RANGE_SAMPLES`, render cost is pixels × samples) however the mask arrives: `mask.add` / `mask.update`, a sidecar's `lc:settings`, a preset, `develop.merge`, `render --settings` or the catalog and history keep the first 64 (issue #2556) | | LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (the pressed endpoint stays selected through fast/crossing drags; one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | | LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent`, `cmd:tool.brush` | Add/Subtract > Brush creates a component with the chosen operation; Erase removes paint within that brush component | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-30 | patch | LR-MASK-COLORRANGE: a colour-range mask keeps its first 64 samples however it is loaded (sidecar `lc:settings`, presets, `develop.merge`, `render --settings`, catalog and history), not only through `mask.add` / `mask.update`, so a document with thousands can't stall a render or lose the GPU device (issue #2556); status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..78c9ae22b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-30 + +### A colour-range mask keeps at most 64 samples, however it arrives +- A colour-range mask compares every pixel with every colour it has sampled, so a mask with thousands of samples + could stall a render or export, or lose the GPU for the rest of the session. `mask.add` and `mask.update` already + kept the first 64; a sidecar's `lc:settings`, a preset, `develop.merge`, `render --settings` and the catalog and + edit history now keep the first 64 too, in order, and the extra ones are skipped as they are read. Masks made in + the app (up to 5 samples) are unchanged. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From cfbd10953499712bff30aa7c75c05801b91293eb Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:25:37 +0100 Subject: [PATCH 34/44] LR-MASK-OBJECTS: a SAM 3 checkpoint in Meta's original format is named as such, not as an unsupported dtype (#3378) The sam3.safetensors on community mirrors (AEmotionStudio/sam3, 1038lab/sam3) is a conversion of Meta's original sam3.pt: its tensors keep Meta's names, and its RoPE tables are C64. Renamed to model.safetensors, it failed to load with "unsupported dtype Some("C64")", which read like a LightCraft limitation. Weights::open now lists a tensor of another dtype instead of failing the whole file; reading it fails with the dtype error (LightCraft computes RoPE itself and never reads those tables). Sam3::load_on then checks the format: when the backbone's first tensor in Hugging Face naming is missing and the file has Meta's detector.backbone. or tracker. tensors, the error says it is Meta's original checkpoint and that model.safetensors from facebook/sam3 in the Hugging Face format is needed. docs/ai-masks.md says so for installing by hand. --- crates/segment/src/lib.rs | 34 ++++++++++++++++++++++++++ crates/segment/src/weights.rs | 46 +++++++++++++++++++++++++++++------ docs/ai-masks.md | 4 ++- docs/parity-checklist.md | 2 +- docs/whats-new.md | 8 ++++++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index 6c24d0836..6fd077ff9 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -149,6 +149,7 @@ impl Sam3 { return Err(Error::Missing(dir.to_path_buf())); } let weights = weights::Weights::open(&dir.join(WEIGHTS_FILE))?; + hugging_face_format(&weights)?; let backbone = vit::Vit::new(Self::vb(&weights, &device).pp("detector_model.vision_encoder.backbone"))?; Ok(Self { dir: dir.to_path_buf(), device, weights, backbone, tracker_neck: None, tracker: None, detector: None, tokenizer: None }) } @@ -260,6 +261,20 @@ impl Encoded { } } +#[cfg(not(target_arch = "wasm32"))] +/// Meta's original checkpoint (`sam3.pt`, or a `sam3.safetensors` conversion of it as on community +/// mirrors) names its tensors differently: say so, rather than name the first tensor it lacks (#3378). +fn hugging_face_format(weights: &weights::Weights) -> Result<()> { + let first = "detector_model.vision_encoder.backbone.embeddings.patch_embeddings.projection.weight"; + if !weights.contains(first) && (weights.has_prefix("detector.backbone.") || weights.has_prefix("tracker.")) { + return Err(Error::Model(format!( + "{WEIGHTS_FILE} is Meta's original checkpoint (sam3.pt, or a sam3.safetensors conversion of it as on community mirrors); \ + LightCraft needs the Hugging Face format: {WEIGHTS_FILE} from facebook/sam3" + ))); + } + Ok(()) +} + #[cfg(not(target_arch = "wasm32"))] fn sigmoid(x: f32) -> f32 { 1.0 / (1.0 + (-x).exp()) @@ -273,3 +288,22 @@ pub use detector::Output as DetectorOutput; pub fn sync(model: &Sam3) -> Result<()> { Ok(model.device.synchronize()?) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn meta_original_checkpoint_is_named_as_such() { + // #3378: a community mirror's `sam3.safetensors` (Meta's key names, C64 RoPE tables) saved as model.safetensors + let dir = std::env::temp_dir().join(format!("lc-sam3-original-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let header = r#"{"detector.backbone.vision_backbone.trunk.blocks.0.attn.freqs_cis":{"dtype":"C64","shape":[2],"data_offsets":[0,16]},"tracker.sam_mask_decoder.iou_token.weight":{"dtype":"F32","shape":[2],"data_offsets":[16,24]}}"#; + std::fs::rename(weights::tests::write("original", header, 24), dir.join(WEIGHTS_FILE)).unwrap(); + std::fs::write(dir.join("vocab.json"), "{}").unwrap(); + std::fs::write(dir.join("merges.txt"), "").unwrap(); + let err = Sam3::load_cpu(&dir).err().unwrap().to_string(); + assert!(err.contains("Meta's original checkpoint") && err.contains("facebook/sam3"), "{err}"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/segment/src/weights.rs b/crates/segment/src/weights.rs index 48ec2ef65..ef47a7230 100644 --- a/crates/segment/src/weights.rs +++ b/crates/segment/src/weights.rs @@ -16,6 +16,9 @@ use candle_nn::var_builder::SimpleBackend; use crate::{Error, Result}; struct Entry { + /// `Err`: the header's name for a dtype LightCraft doesn't read (Meta's original checkpoint + /// stores its RoPE tables as C64); the tensor is listed, and reading it fails + dtype: std::result::Result, shape: Vec, start: u64, end: u64, @@ -48,8 +51,8 @@ impl Weights { continue; } let dtype = match v.get("dtype").and_then(|d| d.as_str()) { - Some("F32") => DType::F32, - other => return Err(Error::Model(format!("{name}: unsupported dtype {other:?} (the SAM 3 checkpoint is F32)"))), + Some("F32") => Ok(DType::F32), + other => Err(format!("{other:?}")), }; let shape: Vec = v .get("shape") @@ -59,15 +62,18 @@ impl Weights { let offsets: Vec = v.get("data_offsets").and_then(|s| s.as_array()).map(|a| a.iter().filter_map(|x| x.as_u64()).collect()).unwrap_or_default(); let [start, end] = offsets[..] else { return Err(Error::Model(format!("{name}: bad data_offsets"))) }; - let want = shape.iter().try_fold(dtype.size_in_bytes(), |a, d| a.checked_mul(*d)); - if end.checked_sub(start) != want.map(|w| w as u64) { - return Err(Error::Model(format!("{name}: size does not match its shape"))); + // an unreadable tensor's size isn't checked: its bytes are never read + if let Ok(dtype) = dtype { + let want = shape.iter().try_fold(dtype.size_in_bytes(), |a, d| a.checked_mul(*d)); + if end.checked_sub(start) != want.map(|w| w as u64) { + return Err(Error::Model(format!("{name}: size does not match its shape"))); + } } // the bytes must be in the file (a damaged or truncated download fails here, not later) if (8 + n).checked_add(end).is_none_or(|e| e > file_len) { return Err(Error::Model(format!("{}: tensor {name} lies outside the file (incomplete or damaged download?)", path.display()))); } - entries.insert(name, Entry { shape, start, end }); + entries.insert(name, Entry { dtype, shape, start, end }); } Ok(Arc::new(Self { file: Mutex::new(file), entries, data_start: 8 + n })) } @@ -76,6 +82,11 @@ impl Weights { self.entries.contains_key(name) } + /// Whether any tensor's name starts with `prefix`. + pub fn has_prefix(&self, prefix: &str) -> bool { + self.entries.keys().any(|k| k.starts_with(prefix)) + } + /// The shape stored in the header for `name`, without reading its data. pub fn shape(&self, name: &str) -> Option<&[usize]> { self.entries.get(name).map(|e| e.shape.as_slice()) @@ -84,6 +95,9 @@ impl Weights { /// Tensor `name` as stored, on the CPU. pub fn read(&self, name: &str) -> Result { let e = self.entries.get(name).ok_or_else(|| Error::Model(format!("missing tensor `{name}`")))?; + if let Err(dtype) = &e.dtype { + return Err(Error::Model(format!("{name}: unsupported dtype {dtype} (the SAM 3 checkpoint is F32)"))); + } // checked against the file's length in `open` let len = usize::try_from(e.end.saturating_sub(e.start)).map_err(|_| Error::Model(format!("{name}: too large")))?; let mut bytes = vec![0u8; len]; @@ -122,10 +136,10 @@ impl SimpleBackend for Backend { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; - fn write(name: &str, header: &str, data_len: usize) -> std::path::PathBuf { + pub(crate) fn write(name: &str, header: &str, data_len: usize) -> std::path::PathBuf { let p = std::env::temp_dir().join(format!("lc-weights-{name}-{}.safetensors", std::process::id())); let mut b = (header.len() as u64).to_le_bytes().to_vec(); b.extend_from_slice(header.as_bytes()); @@ -147,6 +161,22 @@ mod tests { let _ = std::fs::remove_file(p); } + #[test] + fn a_tensor_of_another_dtype_fails_when_read_not_when_opened() { + // Meta's original checkpoint stores RoPE tables as C64 next to its F32 weights (#3378) + let p = write( + "c64", + r#"{"freqs_cis":{"dtype":"C64","shape":[2],"data_offsets":[0,16]},"a":{"dtype":"F32","shape":[2],"data_offsets":[16,24]}}"#, + 24, + ); + let w = Weights::open(&p).unwrap(); + assert!(w.contains("freqs_cis")); + let err = w.read("freqs_cis").unwrap_err().to_string(); + assert!(err.contains("unsupported dtype") && err.contains("C64"), "{err}"); + assert_eq!(w.read("a").unwrap().dims(), &[2]); + let _ = std::fs::remove_file(p); + } + #[test] fn tensors_must_lie_inside_the_file() { let ok = write("ok", r#"{"a":{"dtype":"F32","shape":[2,2],"data_offsets":[0,16]}}"#, 16); diff --git a/docs/ai-masks.md b/docs/ai-masks.md index 845dc0e23..a140bae5b 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -143,7 +143,9 @@ the Mozilla root certificates; no OpenSSL, `ring` or `aws-lc`, nothing compiled ### By hand Put `model.safetensors`, `vocab.json` and `merges.txt` from `facebook/sam3` in the model folder: -`models/sam3/` in the settings folder above, or the folder `LIGHTCRAFT_SAM3_DIR` names. Developers +`models/sam3/` in the settings folder above, or the folder `LIGHTCRAFT_SAM3_DIR` names. It must be +the Hugging Face format (3,439,938,512 bytes): `sam3.safetensors` from community mirrors, renamed, is +Meta's original checkpoint, and LightCraft says so instead of loading it. Developers can use the installer scripts, which download from Hugging Face with your token and verify the weights: diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..0689b803f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -293,7 +293,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`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.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; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), 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-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.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; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), 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; Meta's original checkpoint installed in place of the Hugging Face one is named as such, #3378). 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); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..e06c39116 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-30 + +### A SAM 3 model in the wrong format says so +- Object and Describe need `model.safetensors` from `facebook/sam3` in the Hugging Face format. The + `sam3.safetensors` on community mirrors is Meta's original checkpoint, with other tensor names; installed in its + place, AI masks failed with "unsupported dtype C64". LightCraft now says that the file is Meta's original + checkpoint and which file it needs instead. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From bc12ec8e40617928b44764030ddb90347d25e89e Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:31:01 +0100 Subject: [PATCH 35/44] Never crash: a mask id at the u32 limit doesn't overflow the next mask's id Mask ids come unchecked from develop.merge, sidecars (lc:settings) and pastes. With a mask id of u32::MAX, next_mask_id() and Preset::apply added 1 to it: debug builds panicked, release builds wrapped to 0, so the second new mask got the same id as the first. next_mask_id() now falls back to the lowest id no mask uses when the highest is at the limit, and Preset::apply takes each preset mask's id from it instead of counting on its own, so mask.add, mask.brushStroke, mask.duplicate and presets with masks all get unused ids. --- crates/develop/src/lib.rs | 22 ++++++++++++++++++++-- crates/develop/src/presets.rs | 23 ++++++++++++++++++++--- crates/engine/src/tests.rs | 11 +++++++++++ docs/whats-new.md | 7 +++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index 4631e5e49..dd059c117 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -161,9 +161,15 @@ impl DevelopSettings { } } - /// Next free mask id. + /// Next free mask id: one past the highest, or, when that is `u32::MAX` (ids come from files, + /// pastes and agents), the lowest one no mask uses. pub fn next_mask_id(&self) -> u32 { - self.masks.iter().map(|m| m.id).max().map_or(1, |m| m + 1) + let used = |id: u32| self.masks.iter().any(|m| m.id == id); + match self.masks.iter().map(|m| m.id).max() { + None => 1, + // fewer masks than ids: one of the first `masks.len() + 1` is free + Some(max) => max.checked_add(1).or_else(|| (1..=u32::MAX).find(|&id| !used(id))).unwrap_or(0), + } } } @@ -363,4 +369,16 @@ mod tests { s.reset_section(Section::Detail); assert_eq!(s.enhance.denoise_on, None); } + + #[test] + fn next_mask_id_after_the_highest_id_is_unused() { + // ids come from files, pastes and agents: one at the limit must not overflow (a panic in + // debug builds) or wrap to an id another mask has + let mut s = DevelopSettings::default(); + assert_eq!(s.next_mask_id(), 1); + s.masks = vec![Mask { id: u32::MAX, ..Default::default() }, Mask { id: 1, ..Default::default() }]; + let next = s.next_mask_id(); + assert!(!s.masks.iter().any(|m| m.id == next), "{next}"); + assert_eq!(next, 2); + } } diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index b3edc801c..d5a658f51 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -220,14 +220,12 @@ impl Preset { o.remove("masks"); } let mut out = apply_partial(s, &rest, amount); - let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0); for m in masks { let Ok(mut m) = serde_json::from_value::(m.clone()) else { continue }; if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) { continue; } - next += 1; - m.id = next; + m.id = out.next_mask_id(); m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0); out.masks.push(m); } @@ -293,4 +291,23 @@ mod tests { deep_merge(&mut a, &json!({"x": {"b": 3}, "y": [2, 3]})); assert_eq!(a, json!({"x": {"a": 1, "b": 3}, "y": [2, 3]})); } + + #[test] + fn preset_masks_get_unused_ids_after_a_mask_id_at_the_limit() { + let s = DevelopSettings { masks: vec![crate::Mask { id: u32::MAX, name: "Theirs".into(), ..Default::default() }], ..Default::default() }; + let p = Preset { + id: "p".into(), + name: "P".into(), + group: "User".into(), + settings: json!({"masks": [{"id": 7, "name": "A"}, {"id": 7, "name": "B"}]}), + favorite: false, + builtin: false, + }; + let out = p.apply(&s, 1.0); + let mut ids: Vec = out.masks.iter().map(|m| m.id).collect(); + assert_eq!(ids.len(), 3); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 3, "{:?}", out.masks); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae03859d2..db77c3cba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -412,6 +412,17 @@ fn connected_brush_strokes_start_where_the_last_one_ended() { assert_eq!(strokes(&s).len(), 2); } +#[test] +fn a_mask_id_at_the_limit_does_not_overflow_the_next_masks() { + let mut s = demo(); + s.execute("develop.merge", &json!({"settings": {"masks": [{"id": u32::MAX, "name": "Pasted"}]}})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.duplicate", &json!({})).unwrap(); + let ids: Vec = active_dev(&s).masks.iter().map(|m| m.id).collect(); + assert_eq!(ids.len(), 3); + assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "distinct ids: {ids:?}"); +} + #[test] fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() { let mut s = demo(); diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..934c3c47f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-30 + +### Reliability +- A photo with a mask id at the 32-bit limit (from a damaged or hand-edited sidecar, a paste or an agent) no longer + breaks the masks added after it: new masks, duplicates and the masks of a preset each get an id no other mask has. + Before, the next id wrapped around to 0, so a second new mask shared the first one's id (debug builds crashed). + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From 5bcdc439f60867e914bcbc242c8578bc3c14f52c Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:21:57 +0100 Subject: [PATCH 36/44] Build the used mask ids once in next_mask_id's fallback Scanning every mask for every candidate id made a dense run of ids quadratic. Collect them into a set first. --- crates/develop/src/lib.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index dd059c117..f2d1e63bf 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -164,11 +164,16 @@ impl DevelopSettings { /// Next free mask id: one past the highest, or, when that is `u32::MAX` (ids come from files, /// pastes and agents), the lowest one no mask uses. pub fn next_mask_id(&self) -> u32 { - let used = |id: u32| self.masks.iter().any(|m| m.id == id); match self.masks.iter().map(|m| m.id).max() { None => 1, // fewer masks than ids: one of the first `masks.len() + 1` is free - Some(max) => max.checked_add(1).or_else(|| (1..=u32::MAX).find(|&id| !used(id))).unwrap_or(0), + Some(max) => max + .checked_add(1) + .or_else(|| { + let used: std::collections::HashSet = self.masks.iter().map(|m| m.id).collect(); + (1..=u32::MAX).find(|id| !used.contains(id)) + }) + .unwrap_or(0), } } } From 99d4de32c7e98db86a172dd67dc06f6bda10d80a Mon Sep 17 00:00:00 2001 From: Antonio Sokic <5701985+finaler1x@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:43:50 +0100 Subject: [PATCH 37/44] Issue #1764: running out of device memory falls back without stopping the GPU An upload whose buffer couldn't be allocated tried again through create_buffer_init, which maps the new buffer and panics inside wgpu when that allocation fails too ("Failed to get mapped range for buffer created with mapped_at_creation"). The panic stopped GPU use for the session. Uploads and kernel parameters now go through the queue into an unmapped buffer, and a failed upload is not retried, so out of memory stays an out-of-memory fallback to the CPU for that render. Claude-Session: https://claude.ai/code/session_01SF8KiKFYZpuGzdTuzsm3cr --- crates/gpu/src/ctx.rs | 95 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 7bbbbc68d..b47dab4a0 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -3,8 +3,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use wgpu::util::DeviceExt; - /// A kernel module: WGSL source, its storage bindings (name, writable, element type) after the /// parameter block `P` (binding 0), and its entry points. struct Module { @@ -520,13 +518,37 @@ impl Gpu { } // (dropping the view schedules the copy) drop(view); - return Buf { buf: Some(Tracked::new(buf)), len }; } + // no view: the buffer couldn't be allocated (out of device memory). The error scope has recorded why and + // the render fails over to the CPU; allocating it again would only fail again (issue #1764) + return Buf { buf: Some(Tracked::new(buf)), len }; } - let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytes, usage }); + let buf = self.init_buffer(bytes, usage); Buf { buf: Some(Tracked::new(buf)), len } } + /// A buffer holding `contents` (zero-padded to the copy alignment), written through the queue. Not + /// `create_buffer_init`: it maps the new buffer, and mapping one that couldn't be allocated (out of device + /// memory) panics inside wgpu instead of raising an error the render's scopes catch (issue #1764). + fn init_buffer(&self, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + let align = wgpu::COPY_BUFFER_ALIGNMENT as usize; + let size = contents.len().max(align).next_multiple_of(align); + let buf = self.device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: size as u64, + usage: usage | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + if size == contents.len() { + self.queue.write_buffer(&buf, 0, contents); + } else { + let mut padded = contents.to_vec(); + padded.resize(size, 0); + self.queue.write_buffer(&buf, 0, &padded); + } + buf + } + fn over_limit(&self, size: u64) { fail( FailKind::Limit, @@ -570,11 +592,7 @@ impl Gpu { return; }; assert_eq!(bufs.len(), k.nbuf, "{name}: binding count"); - let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: None, - contents: bytemuck::cast_slice(if p.is_empty() { &[0u32] } else { p }), - usage: wgpu::BufferUsages::STORAGE, - }); + let params = self.init_buffer(bytemuck::cast_slice(if p.is_empty() { &[0u32] } else { p }), wgpu::BufferUsages::STORAGE); let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: params.as_entire_binding() }]; for (i, b) in bufs.iter().enumerate() { let buf = b.map(|b| b.raw()).unwrap_or(&self.dummy); @@ -902,6 +920,65 @@ mod tests { println!("CHILD: survived"); } + /// A render that runs out of device memory falls back to the CPU and leaves the GPU in use: the upload + /// whose buffer couldn't be allocated used to try again through `create_buffer_init`, which panicked + /// mapping the second failed buffer, and the panic stopped GPU use for the session (issue #1764). + #[test] + fn running_out_of_device_memory_falls_back_without_stopping_the_gpu() { + if run_child("out_of_memory_child").is_none() { + lightcraft_testkit::skip!(Gpu, "no GPU device, or its memory never ran out"); + } + } + + #[test] + #[ignore = "run in its own process by running_out_of_device_memory_falls_back_without_stopping_the_gpu"] + fn out_of_memory_child() { + let Some(g) = crate::device() else { + println!("CHILD: no GPU"); + return; + }; + // hold device memory until an allocation fails: 1 GiB at a time, then smaller, so little is left + let mut held = Vec::new(); + let mut full = false; + for size in [1u64 << 30, 1 << 28, 1 << 26] { + for _ in 0..128 { + let scope = g.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let usage = wgpu::BufferUsages::STORAGE; + let buf = g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size, usage, mapped_at_creation: false }); + if pollster::block_on(scope.pop()).is_some() { + full = true; + break; + } + held.push(buf); + } + } + if !full { + println!("CHILD: no GPU (its memory never ran out)"); + return; + } + // a render that needs more than is left: a larger one when the driver still finds room for the first + let s = lightcraft_develop::DevelopSettings::default(); + let mut ran_out = false; + for (w, h) in [(4000, 3000), (8000, 6000)] { + let src = std::sync::Arc::new(lightcraft_raster::Rgb32f { width: w, height: h, data: vec![[0.18, 0.18, 0.18]; w * h] }); + let req = lightcraft_pipeline::RenderRequest::fit(w, h); + match crate::try_render(&src, &lightcraft_pipeline::SourceInfo::default(), &s, &req, None) { + Ok(_) => continue, + Err(crate::Fallback::Failed(reason)) => assert!(reason.contains("out of device memory"), "{reason}"), + Err(crate::Fallback::NotTried) => panic!("the GPU wasn't tried"), + } + ran_out = true; + break; + } + assert_eq!(crate::unavailable_reason(), None, "the GPU stays in use"); + drop(held); + if !ran_out { + println!("CHILD: no GPU (the renders found room anyway)"); + return; + } + println!("CHILD: survived"); + } + /// Empties the buffer pool when dropped, as the UI does (`trim_pool(0)`) and as returning a /// buffer to a full pool does. struct TrimOnDrop(&'static super::Gpu); From 003ab49b5ccf283339bec6436c06f36a47a2adc5 Mon Sep 17 00:00:00 2001 From: Antonio Sokic <5701985+finaler1x@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:31:12 +0100 Subject: [PATCH 38/44] Release shared source after out-of-memory fallback Clear SHARED_SOURCE in the OutOfMemory branch so the next render does not reuse a zeroed buffer, and keep the OOM test's allocations within the device's max_buffer_size, failing on unexpected validation errors. --- crates/gpu/src/ctx.rs | 17 +++++++++++++---- crates/gpu/src/lib.rs | 3 +++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index b47dab4a0..c235b93ae 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -937,15 +937,24 @@ mod tests { println!("CHILD: no GPU"); return; }; - // hold device memory until an allocation fails: 1 GiB at a time, then smaller, so little is left + // hold device memory until an allocation fails: 1 GiB at a time, then smaller, so little is + // left; no larger than the device allows in one buffer, which would be a validation error + let max = g.device.limits().max_buffer_size & !(wgpu::COPY_BUFFER_ALIGNMENT - 1); + let mut sizes: Vec = [1u64 << 30, 1 << 28, 1 << 26].into_iter().map(|s| s.min(max)).collect(); + sizes.dedup(); let mut held = Vec::new(); let mut full = false; - for size in [1u64 << 30, 1 << 28, 1 << 26] { + for size in sizes { for _ in 0..128 { - let scope = g.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let validation = g.device.push_error_scope(wgpu::ErrorFilter::Validation); + let oom = g.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); let usage = wgpu::BufferUsages::STORAGE; let buf = g.device.create_buffer(&wgpu::BufferDescriptor { label: None, size, usage, mapped_at_creation: false }); - if pollster::block_on(scope.pop()).is_some() { + let ran_out = pollster::block_on(oom.pop()).is_some(); + if let Some(e) = pollster::block_on(validation.pop()) { + panic!("holding {size} bytes of device memory: {e}"); + } + if ran_out { full = true; break; } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 452e2dd67..f1b952f3e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -520,6 +520,9 @@ pub fn try_render( format!("{what}: {}; the GPU is not used again", f.reason) } ctx::FailKind::OutOfMemory => { + // an upload that ran out of memory left a zeroed buffer; the shared source + // may be one, and the next render of the photo would reuse it + render::release_shared_source(); gpu.trim(0); format!("{what}: {}", f.reason) } From 35c7abb7cc377a9f5d87a3a07a22765e2ac9801b Mon Sep 17 00:00:00 2001 From: Antonio Sokic <5701985+finaler1x@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:01:05 +0100 Subject: [PATCH 39/44] Issue #1764: keep a failed upload out of the shared source Record the out-of-memory failure when the staging buffer can't be allocated, and don't publish the zeroed buffer to SHARED_SOURCE when the render has failed, so a concurrent render can't reuse it. --- crates/gpu/src/ctx.rs | 4 ++++ crates/gpu/src/render.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index c235b93ae..c15aa51d7 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -518,6 +518,10 @@ impl Gpu { } // (dropping the view schedules the copy) drop(view); + } else { + // recorded now, not only when the error scope pops: the zeroed buffer must not be + // shared before then (`render::shared_source`) + fail(FailKind::OutOfMemory, "out of device memory: the upload's staging buffer couldn't be allocated".into()); } // no view: the buffer couldn't be allocated (out of device memory). The error scope has recorded why and // the render fails over to the CPU; allocating it again would only fail again (issue #1764) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 90aed7974..26634d437 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -466,6 +466,10 @@ fn shared_source(src: &Arc, upload: impl FnOnce() -> Buf) -> Arc { *g = None; SOURCE_UPLOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let buf = Arc::new(upload()); + // a failed upload's buffer holds zeros: this render falls back, and no other may reuse it + if crate::ctx::failed() { + return buf; + } *g = Some((Arc::downgrade(src), buf.clone())); buf } From 30bbbc75705af81948e800a886c3cbc0ce099f8a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 02:07:51 +0000 Subject: [PATCH 40/44] Integration: #3182 keep the Colour Range regression comment on its own test --- crates/ui-egui/src/tests/masking.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index f73b1d230..a7cbc82bf 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -1698,8 +1698,6 @@ fn assert_close(a: &serde_json::Value, b: &serde_json::Value) { assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 0.05), "{a:?} vs {b:?}"); } -/// Regression: the Colour Range "Pick" button armed the eyedropper, but with the Masking panel -/// open the loupe's clicks went to the mask overlay, so no sample was ever taken. #[test] fn adding_radial_component_disarms_color_range_picker() { let mut h = detail("panel.masking"); @@ -1722,6 +1720,8 @@ fn adding_radial_component_disarms_color_range_picker() { assert!(rx > 0.22, "dragging a radial handle resizes the component: {rx}"); } +/// Regression: the Colour Range "Pick" button armed the eyedropper, but with the Masking panel +/// open the loupe's clicks went to the mask overlay, so no sample was ever taken. #[test] fn color_range_pick_button_samples_the_photo() { let mut h = detail("panel.masking"); From 737c6867f0446f0ed33eb21d95c43856a0a41913 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 02:08:13 +0000 Subject: [PATCH 41/44] Integration: #3298 put the noisy_scene doc comment back on noisy_scene --- crates/pipeline/src/tests_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index bf8b8690e..0d7508bc2 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -156,7 +156,6 @@ fn windows_outside_or_degenerate_are_clamped() { } } -/// A textured, noisy source: what noise reduction and edge refinement act on. /// A model's segmentation on a `side²` grid: an ellipse in the middle of the photo. fn middle_seg(side: usize) -> lightcraft_develop::SegMask { let logits: Vec = (0..side * side) @@ -169,6 +168,7 @@ fn middle_seg(side: usize) -> lightcraft_develop::SegMask { lightcraft_develop::SegMask::from_logits(side, &logits) } +/// A textured, noisy source: what noise reduction and edge refinement act on. fn noisy_scene() -> Rgb32f { let base = scene(); Rgb32f::from_fn(base.width, base.height, |x, y| { From 76a03de5076bd540181038a57952bc56725be127 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 02:11:04 +0000 Subject: [PATCH 42/44] Integration: #2421 adapt the window-surface test to choose(files, compose_window) --- apps/lightcraft/src/window_gpu.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 3701b5884..e8caedf73 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -324,7 +324,7 @@ mod tests { assert_ne!(surface, WgpuConfiguration::default().surface, "egui-wgpu's own default is the high-throughput one"); // what `main` hands eframe (on Windows `choose` probes the adapters first; not here) if !cfg!(windows) { - assert_eq!(choose(&Files::new(None)).wgpu_options.surface, surface); + assert_eq!(choose(&Files::new(None), false).wgpu_options.surface, surface); } } From 3f68029b4bc800eb2b00250afb08344d121065a4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 02:21:14 +0000 Subject: [PATCH 43/44] Integration: #2639 pass the develop settings to brush_tool (port to panels/detail/masks.rs) --- crates/ui-egui/src/panels/detail/masks.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index ac87317b9..cf52f1f75 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -360,7 +360,7 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg return; } if app.ui.tool == "brush" { - brush_tool(app, ui, resp, map, p, clip); + brush_tool(app, ui, resp, map, p, clip, d); return; } grip_gestures(app, ui, resp, map, d, &grips); @@ -517,7 +517,8 @@ fn object_tool( } /// Brush tool: the cursor, the stroke being painted and its commit. -fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect) { +fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect, d: &DevelopSettings) { + let active = app.session.active_mask; let long = (map.rect.width().max(map.rect.height())) as f64; let r = (app.ui.brush_size as f64 * long) as f32; if let Some(h) = resp.hover_pos() { From a461614d56014fb8e76b616bbefa79d9e02c2644 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 02:21:18 +0000 Subject: [PATCH 44/44] Integration: cargo fmt --- crates/ui-egui/src/panels/detail/masks.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index cf52f1f75..d8e789b7a 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -517,7 +517,15 @@ fn object_tool( } /// Brush tool: the cursor, the stroke being painted and its commit. -fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect, d: &DevelopSettings) { +fn brush_tool( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + p: &egui::Painter, + clip: Rect, + d: &DevelopSettings, +) { let active = app.session.active_mask; let long = (map.rect.width().max(map.rect.height())) as f64; let r = (app.ui.brush_size as f64 * long) as f32;