diff --git a/Cargo.lock b/Cargo.lock index e6e9a4a42..575d5f8a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3594,6 +3594,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 ceebce2d3..613197459 100644 --- a/apps/lightcraft-cli/tests/it/cli.rs +++ b/apps/lightcraft-cli/tests/it/cli.rs @@ -486,7 +486,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"); @@ -500,15 +505,52 @@ 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)); } +/// 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(binary()) + .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/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index c65af517d..2f98abafe 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -82,6 +82,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() @@ -227,8 +232,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 @@ -241,6 +249,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/tests.rs b/apps/lightcraft/src/tests.rs index dac2f6df5..286838175 100644 --- a/apps/lightcraft/src/tests.rs +++ b/apps/lightcraft/src/tests.rs @@ -1,5 +1,20 @@ 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); diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index fefa65a51..e8caedf73 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(); @@ -95,7 +95,7 @@ pub fn choose(files: &Files) -> Chosen { let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true); 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)) @@ -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(); @@ -154,12 +159,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) => { @@ -174,11 +179,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, }); @@ -242,6 +247,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 { @@ -290,6 +311,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), false).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] @@ -334,4 +372,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()) }); } + + /// 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)); + } } diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index 8a320e576..cb6caa3ba 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -177,9 +177,20 @@ 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) + 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(|| { + let used: std::collections::HashSet = self.masks.iter().map(|m| m.id).collect(); + (1..=u32::MAX).find(|id| !used.contains(id)) + }) + .unwrap_or(0), + } } } @@ -287,6 +298,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(); @@ -399,4 +458,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/develop/src/settings.rs b/crates/develop/src/settings.rs index 0064a4f81..739171abd 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -922,6 +922,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 { @@ -942,8 +971,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 6ec6d39f5..12dcf76c1 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, MAX_RED_EYES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; +use lightcraft_develop::{BrushStroke, LocalAdjustments, MAX_COLOR_RANGE_SAMPLES, MAX_RED_EYES, 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/cmd/query.rs b/crates/engine/src/cmd/query.rs index a7cc1996c..5ceacb953 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -230,10 +230,29 @@ 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 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); } + // 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(); + 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) }, + "adapter": null, + "reason": if on { None } else { lightcraft_gpu::unavailable_reason() }, + "lastFallback": lightcraft_gpu::last_fallback(), + })); + } Ok(json!({ "enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 7bbe3ddbf..e735649f2 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); @@ -703,8 +704,13 @@ 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, in_photo_frame(orientation, user)); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); } @@ -764,6 +770,71 @@ 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 +/// 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; + } + 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)); + } + } + } +} + +/// 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 { @@ -1076,11 +1147,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); } @@ -1101,14 +1175,13 @@ 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), 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 1a8898778..a85828d63 100644 --- a/crates/engine/src/lightroom_catalog/tests.rs +++ b/crates/engine/src/lightroom_catalog/tests.rs @@ -421,8 +421,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] @@ -439,6 +446,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); @@ -450,11 +458,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)) @@ -471,6 +479,7 @@ fn catalog_crops_without_has_crop_are_imported() { 1.5, Orientation::Normal, 1.5, + Orientation::Normal, ) .unwrap(); let crop = &partial["crop"]["geometry"]; @@ -481,11 +490,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()); } @@ -509,7 +520,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) @@ -522,16 +533,74 @@ 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()); } +#[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, 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}"); + 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, 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"]); + [ + &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]); + // 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, Orientation::Normal).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 /// of aspect `w` (height 1) shown with orientation `o`. fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { @@ -554,7 +623,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!( @@ -584,7 +654,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); @@ -607,6 +677,87 @@ 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, 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(); + 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(); + 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"]); + 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] +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(); @@ -622,8 +773,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); } diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index fc5789334..9f3e518a4 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -1026,6 +1026,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/engine/src/tests.rs b/crates/engine/src/tests.rs index 8076c40c8..37b300dbf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -415,6 +415,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/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 7bbbbc68d..d3e56f749 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -1,9 +1,7 @@ //! The wgpu device, the compiled kernels, buffers, dispatch and readback. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; - -use wgpu::util::DeviceExt; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; /// A kernel module: WGSL source, its storage bindings (name, writable, element type) after the /// parameter block `P` (binding 0), and its entry points. @@ -77,6 +75,102 @@ 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 ~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> { + 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 +433,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 +449,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 }) } @@ -520,13 +590,41 @@ impl Gpu { } // (dropping the view schedules the copy) drop(view); - return Buf { buf: Some(Tracked::new(buf)), len }; + } 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) + 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 +668,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); @@ -793,6 +887,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))); @@ -803,6 +908,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 { @@ -902,6 +1021,74 @@ 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; 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 sizes { + for _ in 0..128 { + 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 }); + 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; + } + 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); 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..51a8aa45b 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 @@ -308,13 +310,33 @@ 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(|| { - let _ = device(); + if !env_disabled() && ENABLED.load(Ordering::SeqCst) && GPU.get().is_none() { + static STARTED: AtomicBool = AtomicBool::new(false); + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + let spawned = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { + 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() { + STARTED.store(false, Ordering::SeqCst); + } } } @@ -520,6 +542,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) } @@ -550,10 +575,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 c5df00a77..235ba9be6 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -652,15 +652,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, } @@ -711,7 +767,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 { @@ -732,45 +788,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> { @@ -884,6 +925,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 { @@ -1117,4 +1177,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); + } } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index e7996a3bc..9d6172d9c 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 } @@ -1028,12 +1032,26 @@ 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 } => { - 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/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 eab5a4831..7432ebb7c 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() { diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 73383d1d4..ca182ea81 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 @@ -281,7 +283,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 +345,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 +390,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 { @@ -524,6 +558,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 +583,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 +636,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; } @@ -598,7 +658,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 { @@ -665,9 +725,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 +746,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..3d0356e98 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); @@ -115,6 +224,52 @@ fn radial_inside_and_invert() { 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). @@ -148,7 +303,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 +313,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)); @@ -167,6 +324,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/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; diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index b82a19f45..0d7508bc2 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -156,6 +156,18 @@ fn windows_outside_or_degenerate_are_clamped() { } } +/// 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) +} + /// A textured, noisy source: what noise reduction and edge refinement act on. fn noisy_scene() -> Rgb32f { let base = scene(); @@ -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/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/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 773945015..6a056bbfb 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -2254,6 +2254,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í)", "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/de.json b/crates/ui-egui/locales/de.json index 5bc124f76..405c54a46 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -3469,5 +3469,6 @@ "Set up name suggestions…": "Namensvorschläge einrichten…", "Top bar": "Obere Leiste", "Tool strip": "Werkzeugleiste", - "Bottom bar": "Untere Leiste" + "Bottom bar": "Untere Leiste", + "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 2c9ec94b7..e43ae8c49 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2389,5 +2389,6 @@ "Set up name suggestions…": "Configurar sugerencias de nombres…", "Top bar": "Barra superior", "Tool strip": "Barra de herramientas", - "Bottom bar": "Barra inferior" + "Bottom bar": "Barra inferior", + "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 e9e237c0c..e032cf52f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -3572,5 +3572,6 @@ "⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.": "⇧-clic et la ligne passe progressivement des anciennes valeurs aux nouvelles. La ligne entière forme une seule étape d'annulation.", "Top bar": "Barre supérieure", "Tool strip": "Barre d’outils", - "Bottom bar": "Barre inférieure" + "Bottom bar": "Barre inférieure", + "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 5da41269a..a0d39d061 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2317,6 +2317,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)", "smart album value|No": "No", "smart album value|Yes": "Sì", "Add Print Cell": "Aggiungi cella di stampa", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 942f4fa1d..5f80d7896 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -2387,5 +2387,6 @@ "Set up name suggestions…": "名前の候補を設定…", "Top bar": "上部バー", "Tool strip": "ツールバー", - "Bottom bar": "下部バー" + "Bottom bar": "下部バー", + "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 160cbc65b..0ea7d3d80 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -3658,5 +3658,6 @@ "Set up name suggestions…": "სახელების შეთავაზებების დაყენება…", "Top bar": "ზედა ზოლი", "Tool strip": "ხელსაწყოების ზოლი", - "Bottom bar": "ქვედა ზოლი" + "Bottom bar": "ქვედა ზოლი", + "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 2bfb3f896..bb3729a5b 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -2174,6 +2174,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)", "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/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3d27fa1fa..a0884dc56 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2233,5 +2233,6 @@ "Set up name suggestions…": "Configurar sugestões de nomes…", "Top bar": "Barra superior", "Tool strip": "Barra de ferramentas", - "Bottom bar": "Barra inferior" + "Bottom bar": "Barra inferior", + "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 738d9fd7b..598893c3b 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2595,5 +2595,6 @@ "Set up name suggestions…": "Настроить подсказки имён…", "Top bar": "Верхняя панель", "Tool strip": "Панель инструментов", - "Bottom bar": "Нижняя панель" + "Bottom bar": "Нижняя панель", + "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 8c9211108..a4efe34a6 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2385,5 +2385,6 @@ "Set up name suggestions…": "Налаштувати підказки імен…", "Top bar": "Верхня панель", "Tool strip": "Панель інструментів", - "Bottom bar": "Нижня панель" + "Bottom bar": "Нижня панель", + "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 07494e845..ea867f35e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2195,6 +2195,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 闪烁(下次启动时生效)", "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.": "此版本无法运行识别模型:可以添加和选择,但无法使用。", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d1956900a..90b3670ec 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -2388,5 +2388,6 @@ "Set up name suggestions…": "設定姓名建議…", "Top bar": "頂部列", "Tool strip": "工具列", - "Bottom bar": "底部列" + "Bottom bar": "底部列", + "Prevent G-SYNC/FreeSync flicker (applies at next launch)": "防止 G-SYNC/FreeSync 閃爍(下次啟動時生效)" } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5f02ef793..bba91d2f7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -232,6 +232,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/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 3dcafeb14..9a5c612f1 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -34,7 +34,7 @@ impl LightcraftApp { pub(super) 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 { diff --git a/crates/ui-egui/src/dialogs/settings/performance.rs b/crates/ui-egui/src/dialogs/settings/performance.rs index e65402c20..370cf08ba 100644 --- a/crates/ui-egui/src/dialogs/settings/performance.rs +++ b/crates/ui-egui/src/dialogs/settings/performance.rs @@ -8,8 +8,18 @@ use super::{CACHE_SIZES, LABEL_W, LightcraftApp, PREVIEW_LIMITS, RichText, Token pub(super) 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() { + // `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; + // 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() { 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(), diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 365ae2311..d8e789b7a 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); @@ -336,11 +356,11 @@ 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" { - 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); @@ -437,14 +457,52 @@ 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) +} + +/// 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. 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, + 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; + } + 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})) { @@ -459,7 +517,16 @@ fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response } /// 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() { @@ -487,10 +554,21 @@ fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, } } // 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); + // 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(); - 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/state/settings.rs b/crates/ui-egui/src/state/settings.rs index fff943b4c..ffd2a0f8d 100644 --- a/crates/ui-egui/src/state/settings.rs +++ b/crates/ui-egui/src/state/settings.rs @@ -136,6 +136,8 @@ pub struct AppSettings { pub import_dropped_directly: 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). @@ -220,6 +222,7 @@ impl Default for AppSettings { close_dialogs_outside: false, import_dropped_directly: false, gpu: true, + compose_window: false, preview_limit: 0, external_editor: String::new(), external_file: EditFile::default(), diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index c8d6d6c2e..f7fbc6053 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -363,6 +363,122 @@ 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); +} + +/// 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); +} + +/// 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. @@ -1698,6 +1814,28 @@ 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:?}"); } +#[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}"); +} + /// 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] @@ -1883,6 +2021,114 @@ 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"); + + // 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] fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { let mut h = detail("panel.masking"); diff --git a/docs/ai-masks.md b/docs/ai-masks.md index 845dc0e23..41873cb5a 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), @@ -143,7 +153,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/gaps.md b/docs/gaps.md index a33abb7c4..9727fe346 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-29 · **Change:** minor (LR-LIB-VIRTUALCOPY: default number exhaustion is validated before copy creation; gap 8: lens profile work is tracked in #329; 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; typed GPS input now rejects malformed coordinates before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in coordinates, elevations and timestamps; malformed references error; CDATA stays literal; ranked gaps unchanged; gap 8: lens profile work is tracked in #329; 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; import: checked rename-sequence exhaustion; import review: remembered thumbnail size and density-aware previews (#2237, part of #2056); pre-import loupe/keyboard culling remains open; no ranked gap closes; gap 8: lens profile work is tracked in #329; 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 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; gap 8: lens profile work is tracked in #329; 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 8: Sony ILCE-7RM6 in-file distortion conversion extends to the APS-C ILCE-6700; LR-META-EXIF-FULL rejects extra EXIF clock components and preserves date fallbacks; gap 8: lens profile work is tracked in #329; 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; JPEG ICC APP2 sequence validation; gap 8: lens profile work is tracked in #329; 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 (LR-LIB-VIRTUALCOPY: default number exhaustion is validated before copy creation; gap 8: lens profile work is tracked in #329; 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; typed GPS input now rejects malformed coordinates before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in coordinates, elevations and timestamps; malformed references error; CDATA stays literal; ranked gaps unchanged; gap 8: lens profile work is tracked in #329; 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; import: checked rename-sequence exhaustion; import review: remembered thumbnail size and density-aware previews (#2237, part of #2056); pre-import loupe/keyboard culling remains open; no ranked gap closes; gap 8: lens profile work is tracked in #329; 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 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; gap 8: lens profile work is tracked in #329; 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 8: Sony ILCE-7RM6 in-file distortion conversion extends to the APS-C ILCE-6700; LR-META-EXIF-FULL rejects extra EXIF clock components and preserves date fallbacks; gap 8: lens profile work is tracked in #329; 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; JPEG ICC APP2 sequence validation; gap 8: lens profile work is tracked in #329; 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 8: lens profile work is tracked in #329; 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; **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 @@ -40,10 +40,10 @@ 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 | 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) | +| 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; 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**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), ⌘⌥⇧C / ⌘⌥⇧V (Copy / Paste Metadata), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | | 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target. The Info panel's Show Metadata For choice (Target Photo / Selected Photos) shows the values the selected photos share and marks each field where they differ as Mixed; the All Metadata dialog still shows one photo's values with no "mixed" indication | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 1–3 | [ui-parity](ui-parity.md) | @@ -99,6 +99,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-24 | minor | Gap 8: lens profile work is tracked in #329; hours unchanged | | 2026-10-25 | patch | LR-META-EXIF-FULL rejects extra clock components rather than projecting a plausible prefix; valid DateTimeDigitized, IFD0 DateTime and XMP fallbacks remain available, raw tag values stay visible, and the status / feature counts do not change | | 2026-10-25 | patch | JPEG ICC APP2 chunks are validated as a complete, uniquely numbered sequence before colour conversion; ranked gaps and readiness counts unchanged | +| 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-24 | patch | Typed GPS input now validates the complete coordinate expression, rejecting unknown text, wrong-axis or conflicting hemispheres, and invalid DMS minutes/seconds before changing metadata; ranked gaps, estimates and readiness counts unchanged | @@ -139,6 +140,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 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 | @@ -175,3 +177,4 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 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-24 | patch | LR-META-XMP: sidecar keywords are written as names in `dc:subject` and paths in `lr:hierarchicalSubject`, so other DAMs can search them; ranked gaps and estimates unchanged | +| 2026-10-20 | minor | Opt-in Windows DirectComposition preference added; VRR hardware verification pending; parity counts unchanged | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index f88f81c39..8dceb96ca 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 @@ -302,6 +311,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), +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. + ## 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 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 75924ef79..553856b16 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-29 · **Change:** minor (LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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; LRC-LIB-MISSING: `library.missing` from the control channel checks the files on a worker thread instead of the UI thread; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon; LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched; LR-IMP-FORMATS: JPEG ICC APP2 chunk validation; LR-BEHAV-TITLEBAR: a dialog leaves the title bar undimmed, as a macOS sheet does; LR-BEHAV-ACCESS: the bars are toolbars and the panels landmarks for screen readers; LR-BEHAV-ACCESS: every dialog and prompt is exposed as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-ACCESS: icon-only buttons are named by their label, with the shortcut as the description; MENU-HELP-HELP: on macOS the Help menu is registered with the application only once the menu bar is installed (muda ignores the call before), so AppKit knows which menu is Help; LR-BEHAV-ACCESS: a slider's value field is named ` value`, with how to use it as its description; MENU-BAR: every shortcut the app shows on macOS lists its modifiers ⌃⌥⇧⌘, as the menu bar does; MENU-BAR: on macOS two menu rows that run the same command each get their own native row and label (File ▸ Export… and File ▸ Export with Preset ▸ Custom…; File ▸ New Album… and Photo ▸ Add to Album ▸ New Album…); LR-BEHAV-DIALOGS: a one-line prompt that is refused stays open and says why, and a New Folder on Disk says it was made; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-VIEW-HISTOGRAM: the shadow and highlight clipping buttons take their tile and chevron from the theme, so the chevron reads in every theme; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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; LRC-LIB-MISSING: `library.missing` from the control channel checks the files on a worker thread instead of the UI thread; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-CROP-AUTO: Auto straighten reads the vertical vanishing point and the horizon; LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched; LR-IMP-FORMATS: JPEG ICC APP2 chunk validation; LR-BEHAV-TITLEBAR: a dialog leaves the title bar undimmed, as a macOS sheet does; LR-BEHAV-ACCESS: the bars are toolbars and the panels landmarks for screen readers; LR-BEHAV-ACCESS: every dialog and prompt is exposed as a dialog (a modal one, an alert dialog for the prompts); LR-BEHAV-ACCESS: icon-only buttons are named by their label, with the shortcut as the description; MENU-HELP-HELP: on macOS the Help menu is registered with the application only once the menu bar is installed (muda ignores the call before), so AppKit knows which menu is Help; LR-BEHAV-ACCESS: a slider's value field is named ` value`, with how to use it as its description; MENU-BAR: every shortcut the app shows on macOS lists its modifiers ⌃⌥⇧⌘, as the menu bar does; MENU-BAR: on macOS two menu rows that run the same command each get their own native row and label (File ▸ Export… and File ▸ Export with Preset ▸ Custom…; File ▸ New Album… and Photo ▸ Add to Album ▸ New Album…); LR-BEHAV-DIALOGS: a one-line prompt that is refused stays open and says why, and a New Folder on Disk says it was made; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-VIEW-HISTOGRAM: the shadow and highlight clipping buttons take their tile and chevron from the theme, so the chevron reads in every theme; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; 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; 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 @@ -299,13 +299,13 @@ 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; 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 | +| 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); 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 | +| 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 | @@ -316,7 +316,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 | ⬜ | | | @@ -440,7 +440,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/dialogs/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/dialogs/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/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/dialogs/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; a declared key that `ui.json` already gives another command stays with that command until ↺ takes it back; 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 | @@ -844,6 +844,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | Export integration: Integrated main 5ca49b3e, retaining Width & Height resizing, export format/bit-depth validation and batch panic cancellation alongside the existing watermark and HDR JPEG size-limit fixes. No feature-count changes. | | 2026-10-23 | minor | LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: Settings ▸ General ▸ At launch (Open the last library / Ask me) with a launch window listing the recent libraries, Alt/Option at launch asks once, File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}` (issue #1290); photos dropped while no library is open wait for the one opened (presets are refused then); status and counts unchanged (✅) | | 2026-10-23 | minor | LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… (and Open Library's offer for a Lightroom catalog) asks whether the catalog merges into the open library or goes into a new library made next to it; Settings ▸ General or "Don't ask again" switches the question off; `file.importLightroom` takes `into` / `name` / `location` (#2672); status unchanged (🟡) | +| 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-22 | minor | LR-VIEW-INFOOVERLAY: the info overlay shows the star rating and flag, and in the full-screen preview on macOS it starts below the window controls (issue #2460); status unchanged | | 2026-10-22 | patch | KEY-GRID: both grid rows in View show the cycle shortcut and follow keymap changes; direct bindings and native accelerator ownership are preserved (#2500); status and counts unchanged | | 2026-10-22 | minor | LRC-PRINT-LAYOUT: the Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) are selectable in the page setup and Print dialog and print to PDF at their metric sizes | @@ -920,6 +921,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 (✅) | @@ -984,6 +986,7 @@ 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 | | 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) | | 2026-10-21 | minor | LR-MERGE-HDR / LR-MERGE-PANO: HDR alignment warps, panorama projection and Boundary Warp sample Lanczos-3 instead of bilinear (`lightcraft_raster::sample`); status unchanged | | 2026-10-18 | minor | LR-EDIT-OPTICS-PROFILE: raws that name no lens model (Nikon DSLRs record only the lens specification in their maker note) look up the lens database by a name built from it (`24-70mm f/2.8`) and use only a profile named exactly that; status unchanged (🟡) | @@ -1036,3 +1039,5 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-28 | patch | LR-BEHAV-ACCESS: every dialog, the library chooser and the quit, library-problem and settings-file prompts are exposed as a modal dialog (an alert dialog for the prompts), and the update check's answer as a non-modal one, instead of a group with the title, and an alert dialog carries what it says (the library problem's path, error and explanation; a prompt's text) as its description (`tests/it/dialog_role.rs`); not tried with a real screen reader; status stays 🟡 | | 2026-10-28 | patch | LR-BEHAV-ACCESS: the top bar, tool strip and bottom bar are announced as toolbars and the sidebar, photos, Tools / Presets panel and filmstrip as landmarks, each with a name; status stays 🟡 | | 2026-10-29 | patch | LR-BEHAV-TITLEBAR: a dialog's backdrop leaves the integrated title bar undimmed, as a macOS sheet does (the bar's controls stay inactive, a click on it is not a click outside the dialog, dragging its empty space still moves the window); 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 (🟡) | +| 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/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 diff --git a/docs/whats-new.md b/docs/whats-new.md index bdacac52b..2179a4a0f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -44,6 +44,24 @@ 64-bit number no longer crashes LightCraft (debug builds) when it is read: the file is refused, or read as far as its data go. +### 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. + +### 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. + +### 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-29 ### A person's page opens in seconds in a large library @@ -78,6 +96,14 @@ grey chevron. They take the theme's own raised fill, and the chevron stands out from it in Charcoal, Midnight, Silver and Paper alike. +### 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. + 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, 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-28 ### The empty photo grid names Import Photos' shortcut as it is bound @@ -220,6 +246,15 @@ IFD0 DateTime or embedded XMP value instead, while All Metadata continues to show the original tag. This also keeps malformed timestamps from creating false matches when geotagging from a track log. +### 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 ### A right-click on a text field while you carry photos opens no menu @@ -476,6 +511,13 @@ `IMG_1.jpg` is now refused when no sidecar is there yet, as writing over an existing one already was. The library would have read the new file back as that photo's edits. +### 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. + + ### Reliability - Opening a library no longer writes through a link left at the lock's owner note (`catalog.lock.owner`): the link is removed and a new note made in its place, so the file it pointed to is never changed (as for the catalog's