diff --git a/crates/color/src/spline.rs b/crates/color/src/spline.rs index c8860d950..c2c75f305 100644 --- a/crates/color/src/spline.rs +++ b/crates/color/src/spline.rs @@ -56,6 +56,10 @@ impl MonotoneCurve { } pub fn eval(&self, x: f64) -> f64 { + // NaN passes both bound checks below and would send the search past either end + if x.is_nan() { + return x; + } let n = self.xs.len(); if x <= self.xs[0] { return self.ys[0] + self.ms[0] * (x - self.xs[0]).min(0.0) * 0.0; @@ -76,8 +80,9 @@ impl MonotoneCurve { + (t3 - t2) * h * self.ms[i + 1] } - /// Sample into a LUT of `n` entries over `0..1`. + /// Sample into a LUT of `n` entries (at least 2) over `0..1`. pub fn to_lut(&self, n: usize) -> Lut1 { + let n = n.max(2); Lut1 { v: (0..n).map(|i| self.eval(i as f64 / (n - 1) as f64) as f32).collect() } } @@ -93,16 +98,24 @@ pub struct Lut1 { } impl Lut1 { + /// At least 2 entries, like [`Lut1::from_fn`]. pub fn identity(n: usize) -> Lut1 { - Lut1 { v: (0..n).map(|i| i as f32 / (n - 1) as f32).collect() } + Lut1::from_fn(n, |x| x) } + /// `f` sampled at `n` evenly spaced points over `0..1` (at least 2). pub fn from_fn(n: usize, f: impl Fn(f32) -> f32) -> Lut1 { + let n = n.max(2); Lut1 { v: (0..n).map(|i| f(i as f32 / (n - 1) as f32)).collect() } } + /// `v` is public, so a table shorter than 2 entries is read as constant (1) or the identity (0). #[inline] pub fn eval(&self, x: f32) -> f32 { + let x = x.clamp(0.0, 1.0); let n = self.v.len(); - let f = x.clamp(0.0, 1.0) * (n - 1) as f32; + if n < 2 { + return self.v.first().copied().unwrap_or(x); + } + let f = x * (n - 1) as f32; let i = (f as usize).min(n - 2); let t = f - i as f32; self.v[i] + (self.v[i + 1] - self.v[i]) * t @@ -163,6 +176,22 @@ mod tests { assert!((c.eval(0.3) - 0.3).abs() < 2e-3); } + #[test] + fn nan_input_and_short_luts_do_not_panic() { + let c = MonotoneCurve::new(&[(0.0, 0.0), (0.5, 0.6), (1.0, 1.0)]); + assert!(c.eval(f64::NAN).is_nan()); + assert!(c.eval(-f64::NAN).is_nan()); + for n in [0, 1] { + assert_eq!(c.to_lut(n).v.len(), 2, "{n}"); + assert!(Lut1::identity(n).is_identity(), "{n}"); + assert_eq!(Lut1::from_fn(n, |x| x).v.len(), 2, "{n}"); + } + assert_eq!(Lut1 { v: vec![] }.eval(0.3), 0.3); + assert_eq!(Lut1 { v: vec![] }.eval(-1.0), 0.0, "inputs outside 0..1 clamp, as for any table"); + assert_eq!(Lut1 { v: vec![] }.eval(2.0), 1.0); + assert_eq!(Lut1 { v: vec![0.7] }.eval(0.3), 0.7); + } + proptest::proptest! { #[test] fn monotone_for_increasing_data(ys in proptest::collection::vec(0.0f64..1.0, 3..8)) { diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index 715e8ac33..9d384f57c 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -97,6 +97,10 @@ impl ControlSpec { } pub fn format(&self, v: f64) -> String { let s = format!("{:.*}", self.decimals as usize, v); + if v.is_finite() && !s.bytes().any(|b| matches!(b, b'1'..=b'9')) { + // Rounds to zero: no sign. + return s.trim_start_matches('-').to_string(); + } if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s } } /// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin @@ -410,6 +414,9 @@ pub static INDEXED: &[ControlSpec] = &[ fn split_indexed(id: &str) -> Option<(&str, usize, &str)> { let mut it = id.splitn(3, '.'); let (fam, i, field) = (it.next()?, it.next()?, it.next()?); + if i.is_empty() || !i.bytes().all(|b| b.is_ascii_digit()) { + return None; + } Some((fam, i.parse().ok()?, field)) } @@ -493,6 +500,17 @@ pub fn in_section(section: Section) -> impl Iterator self.wb.mode = WbMode::AsShot, Section::Detail => self.enhance.denoise_on = None, + // the creative profile is not a control: the chosen look goes back to the default one, + // and so does the camera's own local tone mapping + Section::Profile => { + self.profile = Profile::default(); + } _ => {} } } @@ -326,6 +332,26 @@ mod tests { assert!(s.is_unedited()); } + #[test] + fn resetting_the_profile_section_restores_the_default_profile() { + // the chosen creative profile is not a control, so resetting the section must restore it too + let mut s = DevelopSettings::default(); + s.profile.id = "lc.muted.matte-soft".into(); + s.profile.amount = 40.0; + s.reset_section(Section::Profile); + assert_eq!(s.profile.id, "lc.color", "the creative profile is reset to the default one"); + assert_eq!(s.profile.amount, 100.0); + } + + #[test] + fn resetting_the_profile_section_clears_the_camera_local_tone() { + // the camera's local tone mapping is not a control either: a Profile reset switches it off + let mut s = DevelopSettings::default(); + s.profile.camera_local_tone = true; + s.reset_section(Section::Profile); + assert!(!s.profile.camera_local_tone); + } + #[test] fn section_toggles() { let mut s = DevelopSettings::default(); diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index a40f094c2..0064a4f81 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -134,6 +134,7 @@ pub struct DevelopSettings { pub orientation: Orientation, pub masks: Vec, pub spots: Vec, + /// Red eye corrections; the first [`MAX_RED_EYES`] render. pub red_eye: Vec, pub lens_blur: LensBlur, pub enhance: Enhance, @@ -1069,6 +1070,10 @@ impl Default for Spot { } } +/// Most red eye / pet eye corrections a photo renders. Every output pixel goes through every eye, so render cost is +/// pixels x eyes; a sidecar, preset or agent with thousands could stall a render for hours (issue #2556). +pub const MAX_RED_EYES: usize = 64; + /// A red eye / pet eye correction: the user's ellipse (centre normalized, radii as fractions of /// the long edge); the pupil inside it is found automatically. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index de21a59dc..df1089925 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -409,7 +409,7 @@ pub fn specs() -> Vec { "Reset Section", [], None, - "{section: light|curve|color|mixer|grading|effects|vignette|grain|detail|optics|geometry}", + "{section: profile|light|curve|color|mixer|grading|effects|vignette|grain|detail|optics|geometry}", has_active, |s, p| { let sec = section_param(p, "develop.resetSection")?; diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 2fd44d566..6ec6d39f5 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -1,6 +1,6 @@ //! Masking and Remove (spot) commands on the active photo. -use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; +use lightcraft_develop::{BrushStroke, LocalAdjustments, MAX_RED_EYES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; use lightcraft_geom::Point; use serde_json::{Value, json}; @@ -1025,6 +1025,9 @@ pub fn specs() -> Vec { catchlight: None, }; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + if s.develop_of(id).is_some_and(|d| d.red_eye.len() >= MAX_RED_EYES) { + return Err(bad(c, format!("at most {MAX_RED_EYES} red eye corrections"))); + } let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); dd.red_eye.push(eye); let n = dd.red_eye.len(); diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index f47e099b6..34840e46d 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -152,6 +152,25 @@ mod tests { } } + /// Alt-drag previews on a profiled display (issue #937): the histogram stays the photo's (not + /// the preview's), and the clipping view is tested and drawn in sRGB, so it stays black and white. + #[test] + fn diagnostic_views_keep_the_photos_histogram() { + use lightcraft_pipeline::Overlay; + let mut session = crate::Session::with_demo(); + let id = session.visible_cloned()[0]; + let d = std::sync::Arc::new(p3()); + let mut run = |o| session.loupe_job(id, 320, 240, true).unwrap().with_display(Some(d.clone())).with_overlay(o).run().rendered.unwrap(); + let plain = run(Overlay::None); + let clip = run(Overlay::ClipShadows); + let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma()); + assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}"); + let px = &clip.image.data; + // (the profile may round white to 254) + let binary = px.iter().filter(|p| p[..3].iter().all(|v| *v <= 2 || *v >= 253)).count(); + assert!(binary as f64 > 0.99 * px.len() as f64, "{binary} of {}", px.len()); + } + #[test] fn load_reports_and_rejects() { let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id())); diff --git a/crates/engine/src/media/render.rs b/crates/engine/src/media/render.rs index a87a0792c..4c28e1214 100644 --- a/crates/engine/src/media/render.rs +++ b/crates/engine/src/media/render.rs @@ -270,11 +270,24 @@ impl RenderJob { // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); - let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + // a clipping preview tests sRGB values, like the histogram: rendered in sRGB, then + // converted for the display below + let clip_view = self.request.overlay.is_clipping(); + let request = if clip_view { RenderRequest { display: None, ..self.request } } else { self.request }; + let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &request, self.stages.as_deref(), gpu); // for a display profile: the image goes to the display's values, the histogram // and the cached view preview stay sRGB let mut srgb = None; let display = match (&self.display, self.request.display) { + // a diagnostic view: the histogram stays the photo's (taken before the overlay + // was drawn), and nothing goes to the view cache + (Some(d), Some(_)) if self.request.overlay != lightcraft_pipeline::Overlay::None => { + let converted = if clip_view { d.profile.from_srgb(&mut rendered.image) } else { d.profile.correct(&mut rendered.image) }; + if let Err(e) = converted { + log::warn!("display profile: {e}"); + } + Some(d.id()) + } (Some(d), Some(_)) => { let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); diff --git a/crates/engine/src/tests/color.rs b/crates/engine/src/tests/color.rs index 1ce7a6918..4805c70ec 100644 --- a/crates/engine/src/tests/color.rs +++ b/crates/engine/src/tests/color.rs @@ -75,6 +75,12 @@ fn red_eye_commands_and_controls() { assert_eq!(active_dev(&s).red_eye.len(), 1); assert!(s.execute("redeye.delete", &json!({"index": 5})).is_err()); assert!(s.execute("redeye.add", &json!({"rx": 0.03})).is_err()); + // at most MAX_RED_EYES: every pixel goes through every eye (issue #2556) + while active_dev(&s).red_eye.len() < lightcraft_develop::MAX_RED_EYES { + s.execute("redeye.add", &json!({"center": [0.5, 0.5], "rx": 0.01, "ry": 0.01})).unwrap(); + } + assert!(s.execute("redeye.add", &json!({"center": [0.5, 0.5], "rx": 0.01, "ry": 0.01})).is_err()); + assert_eq!(active_dev(&s).red_eye.len(), lightcraft_develop::MAX_RED_EYES); let d = active_dev(&s); assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d); } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index a602b1e8d..e7996a3bc 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -786,6 +786,14 @@ pub fn render( lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref()) } }); + // a sharpening view: the detail planes this render sharpened with + let overlay_mask = overlay_mask.or_else(|| { + req.overlay.reads_detail().then(|| { + let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone(); + let tex = prep.texture.as_deref().map(|b| cx.read_plane(b, w, h)); + lightcraft_pipeline::visualize::sharpen_plane(req.overlay, &plan.settings, &l, tex.as_ref(), plan.px_per_long) + })? + }); lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref()); // spots grew the window the render worked on: cut it back to the request if let Some(k) = plan.keep { diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index 6a8169b30..eab5a4831 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -693,6 +693,34 @@ fn overlays_match() { let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) }; check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req); } + // Alt-drag previews: clipping and greyscale on the finished image; the sharpening views from + // each renderer's own detail planes (with sharpening on, and off: no texture plane then). + let mut s = DevelopSettings::default(); + (s.light.whites, s.light.blacks, s.detail.sharpen_masking) = (80.0, -80.0, 40.0); + for amount in [50.0, 0.0] { + s.detail.sharpen_amount = amount; + for o in [Overlay::ClipHighlights, Overlay::ClipShadows, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask] { + let req = RenderRequest { overlay: o, ..RenderRequest::fit(640, 640) }; + if matches!(o, Overlay::ClipHighlights | Overlay::ClipShadows) { + // a binary view: a 1-LSB difference at 0 / 255 flips a channel, so compare shares + let cpu = render(&src, &raw, &s, &req).image; + let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image; + let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64; + assert!(differ < 0.002, "{o:?} sharpen {amount}: {differ}"); + } else if o == Overlay::Grayscale { + check(&format!("{o:?} preview"), &src, &raw, &s, &req); + } else { + // the sharpening views amplify the detail planes (the mask through a steep + // smoothstep): allow a few larger differences where the planes differ slightly + let cpu = render(&src, &raw, &s, &req).image; + let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image; + let (mean, max, _) = diff(&cpu, &gpu); + let far = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a[0].abs_diff(b[0]) > 8).count() as f64 / cpu.data.len() as f64; + eprintln!("{o:?} sharpen {amount:<4} mean {mean:.4} max {max} >8: {:.4}%", far * 100.0); + assert!(mean < 1.0 && far < 0.005, "{o:?} sharpen {amount}: mean {mean:.4}, >8 LSB {far}"); + } + } + } } #[test] diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs index e3c6f45f4..4141f4ae0 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -31,7 +31,7 @@ impl DateTime { // chrono rejects impossible days (leap years and all); the time is checked // by hand so a leap second (`second == 60`) stays acceptable. let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some(); - let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60); + let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.unsigned_abs() <= 18 * 60); ok.then_some(self) } @@ -164,7 +164,8 @@ impl DateTime { } if let Some(o) = self.offset_minutes { let sign = if o < 0 { '-' } else { '+' }; - s += &format!("{sign}{:02}:{:02}", o.abs() / 60, o.abs() % 60); + let o = o.unsigned_abs(); + s += &format!("{sign}{:02}:{:02}", o / 60, o % 60); } s } @@ -246,6 +247,14 @@ mod tests { assert_eq!(DateTime::parse_offset("+99:00"), None); } + #[test] + fn extreme_offsets_do_not_overflow() { + // `DateTime` deserializes with public fields, so an offset can arrive without passing `valid()` + let d = DateTime { offset_minutes: Some(i16::MIN), ..DateTime::parse_iso("2024-06-01T13:45:59").unwrap() }; + assert_eq!(d.to_iso(), "2024-06-01T13:45:59-546:08"); + assert_eq!(d.valid(), None); + } + #[test] fn unicode_offsets_are_rejected_without_panicking() { for offset in ["+1€", "-1€", "+€1", "-€1"] { diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 4fccd1134..aa194252e 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -20,6 +20,15 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 { t * t * (3.0 - 2.0 * t) } +/// How much of the sharpening a pixel gets (0..1) under Masking `masking` (0..1), from its fine +/// detail `det` (log luminance minus its texture blur): 1 everywhere at 0, only stronger edges +/// as it rises. The GPU kernel (`finish.wgsl`) computes the same; Alt-drag on Masking shows it +/// ([`crate::Overlay::SharpenMask`]). +#[inline] +pub fn sharpen_mask_weight(masking: f32, det: f32) -> f32 { + if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 } +} + /// Parametric region curve (encoded domain) composed with the master point curve. fn curve_luts(c: &ToneCurve) -> Option<[Lut1; 3]> { let parametric = c.highlights != 0.0 || c.lights != 0.0 || c.darks != 0.0 || c.shadows != 0.0; @@ -78,6 +87,10 @@ pub const GRAIN_HASH: [u32; 4] = [0x8da6_b343, 0xd816_3841, 0xcb1a_b31f, 0x5bd1_ #[inline] fn grain_noise(x: f32, y: f32, seed: u32) -> f32 { + // A degenerate frame (zero-width crop) maps pixels to NaN/inf coordinates: no grain there. + if !x.is_finite() || !y.is_finite() { + return 0.0; + } let (x0, y0) = (x.floor(), y.floor()); let (fx, fy) = (x - x0, y - y0); let h = |i: i32, j: i32| { @@ -208,7 +221,15 @@ impl FinishParams { }; let ev = s.light.exposure as f32; let gain = 2f32.powf(ev); - let grain = (s.grain.amount > 0.0 && effects).then(|| { + // A degenerate frame (zero-width crop, zero scale) has no finite grain mapping: skip grain (CPU and GPU). + let out_to_norm = frame.out_to_norm(w, h); + let finite_frame = px_per_long.is_finite() + && px_per_long > 0.0 + && frame.ow.is_finite() + && frame.oh.is_finite() + && frame.ow.max(frame.oh) > 0.0 + && out_to_norm.0.iter().all(|v| v.is_finite()); + let grain = (s.grain.amount > 0.0 && effects && finite_frame).then(|| { let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * px_per_long as f32; ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) }); @@ -240,7 +261,7 @@ impl FinishParams { gain, ev, grain, - out_to_norm: frame.out_to_norm(w, h), + out_to_norm, ow: frame.ow, oh: frame.oh, w, @@ -511,7 +532,9 @@ pub(crate) fn finish_with( lum = lum.clamp(0.0, 1.0); } let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); - e = e.map(|v| v + k); + if k.is_finite() { + e = e.map(|v| v + k); + } } let e = match &p_lut { Some((l, k)) if hdr => { @@ -647,7 +670,7 @@ fn local_tone_delta(p: &Prepared, fp: &FinishParams, index: usize, base: f32, l1 let tame = 1.0 - 0.6 * smooth(0.4, 1.6, detail.abs()); delta += texture * 1.1 * detail.clamp(-1.0, 1.0) * tame; if sharpen != 0.0 { - let mask = if fp.sharpen_mask > 0.0 { smooth(fp.sharpen_mask * 0.25, fp.sharpen_mask * 0.25 + 0.15, detail.abs()) } else { 1.0 }; + let mask = sharpen_mask_weight(fp.sharpen_mask, detail); delta += sharpen * 1.3 * detail.clamp(-0.8, 0.8) * mask; } } @@ -764,6 +787,13 @@ fn enc(v: f32) -> u8 { mod tests { use super::*; + #[test] + fn grain_noise_is_finite_for_degenerate_coordinates() { + for (x, y) in [(f32::NAN, 1.0), (1.0, f32::INFINITY), (f32::NEG_INFINITY, f32::NAN)] { + assert_eq!(grain_noise(x, y, 7), 0.0); + } + } + #[test] fn refine_saturation_restores_the_pre_curve_saturation() { let before = [0.5, 0.3, 0.2]; diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index b5afd692d..d56e8fe82 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -680,9 +680,12 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, Some(acc.map(|v| v / n)) } -/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a -/// fresh evaluation. +/// The plane an overlay shows: a sharpening view's detail plane, or the alpha of a mask overlay +/// (the one the render evaluated, or for a hidden mask a fresh evaluation). fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + if o.reads_detail() { + return visualize::sharpen_plane(o, &plan.settings, &prep.log_l, prep.texture_blur.as_deref(), plan.px_per_long); + } let m = o.mask(&plan.settings)?; if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { return Some(e.alpha.clone()); diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index a9dd9cbe6..6d09cf755 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -590,6 +590,11 @@ pub struct PlaneSigmas { pub chroma: Option, } +/// Gaussian sigma (pixels) of the texture / sharpening band at `px_per_long`. +pub fn texture_sigma(px_per_long: f64) -> f32 { + (0.0018 * px_per_long as f32).max(0.6) +} + pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas { let ppl = px_per_long as f32; // A hidden mask, or one with no components, does not change the picture. Its sliders must @@ -614,7 +619,7 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS || local_any(|a| a.sharpness) || local_any(|a| a.noise) || s.masks.iter().filter(live).any(|m| m.adjust.defringe > 0.0)) - .then(|| (0.0018 * ppl).max(0.6)); + .then(|| texture_sigma(px_per_long)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); let chroma = (local_any(|a| a.moire) || s.masks.iter().filter(live).any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 }; diff --git a/crates/pipeline/src/redeye.rs b/crates/pipeline/src/redeye.rs index 3bad1d634..bb2944388 100644 --- a/crates/pipeline/src/redeye.rs +++ b/crates/pipeline/src/redeye.rs @@ -142,6 +142,7 @@ pub fn resolve(src: &Rgb32f, eyes: &[RedEye], orientation: Orientation, frame: & } let to_out = frame.norm_to_out(w, h); eyes.iter() + .take(lightcraft_develop::MAX_RED_EYES) .map(|e| { let p = detect(src, orientation, frame.ow, frame.oh, e); let r = (p.radius * (0.5 + e.pupil_size.clamp(0.0, 100.0) / 100.0)).min(e.rx.max(e.ry) * 1.1); @@ -246,6 +247,16 @@ mod tests { assert!(!detect(&plain, Orientation::Normal, 200.0, 100.0, &eye).found); } + #[test] + fn at_most_max_red_eyes_render() { + // a sidecar, preset or agent can hold any number; each one costs a pass over every pixel (issue #2556) + let src = eye_image(40, 20, 18.0, 10.0, 2.0, [0.5, 0.04, 0.04]); + let s = lightcraft_develop::DevelopSettings { red_eye: vec![RedEye::default(); 10_000], ..Default::default() }; + let frame = Frame::new(40, 20, &s, true); + let eyes = resolve(&src, &s.red_eye, Orientation::Normal, &frame, 40, 20, 40.0); + assert_eq!(eyes.len(), lightcraft_develop::MAX_RED_EYES); + } + #[test] fn renders_fix_the_red_pupil_at_any_size() { use crate::{RenderRequest, SourceInfo, render}; diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 23eca0ba7..5c5f1b95c 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -840,3 +840,19 @@ fn sliver_crops_render_quickly_with_wide_stages_on() { assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}"); } } + +/// A crop of no width used to give grain NaN coordinates (and NaN pixels); the render stays finite. +#[test] +fn zero_width_crop_with_grain_renders_finite_pixels() { + let mut s = DevelopSettings::default(); + s.grain.amount = 60.0; + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.5, 0.1, 0.5, 0.9); + let mut req = RenderRequest::fit(240, 240); + req.depth = crate::OutputDepth::F32Linear; + let r = render(&scene(), &SourceInfo::default(), &s, &req); + if let Some(d) = r.deep + && let crate::DeepSamples::F32(v) = d.samples + { + assert!(v.iter().all(|x| x.is_finite()), "non-finite sample in a zero-width crop with grain"); + } +} diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 8f3cf1884..cda511baa 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -9,6 +9,11 @@ //! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour //! tint on a black-and-white image, the image on black / white, or the alpha as white on black //! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`]. +//! - **Alt/Option-drag previews** (Lightroom's temporary views while a slider is dragged with the +//! modifier held; [`Overlay::for_control`] picks one per control): shadow / highlight clipping +//! (white or black, with the clipped channels shown), greyscale (Sharpening Amount), the fine +//! detail band sharpening works on (Radius / Detail) and the sharpening mask (Masking). The +//! sharpening views read the render's own detail planes ([`sharpen_plane`]). use std::borrow::Cow; @@ -39,6 +44,19 @@ pub enum Overlay { /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). HdrRange, + /// Shadow clipping (Alt-drag on Blacks / Shadows): white, a channel at 0 drops out of the + /// colour, so pixels clipped in every channel are black and partly clipped ones are coloured. + ClipShadows, + /// Highlight clipping (Alt-drag on Exposure / Highlights / Whites): black, a channel at 255 + /// shows in the colour, so pixels clipped in every channel are white. + ClipHighlights, + /// The photo in greyscale (Alt-drag on Sharpening Amount). + Grayscale, + /// The fine detail band sharpening amplifies, as grey ± detail (Alt-drag on Sharpening Radius + /// / Detail). + SharpenDetail, + /// The sharpening mask, white where sharpening applies (Alt-drag on Sharpening Masking). + SharpenMask, } /// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white. @@ -125,6 +143,11 @@ impl Overlay { Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64), Overlay::HdrRange => (4, 0.0), + Overlay::ClipShadows => (6, 0.0), + Overlay::ClipHighlights => (7, 0.0), + Overlay::Grayscale => (8, 0.0), + Overlay::SharpenDetail => (9, 0.0), + Overlay::SharpenMask => (10, 0.0), } } @@ -143,6 +166,11 @@ impl Overlay { opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8, } } + 6 => Overlay::ClipShadows, + 7 => Overlay::ClipHighlights, + 8 => Overlay::Grayscale, + 9 => Overlay::SharpenDetail, + 10 => Overlay::SharpenMask, _ => Overlay::None, } } @@ -156,9 +184,44 @@ impl Overlay { Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity), Overlay::HdrRange => 0x4000, + Overlay::ClipShadows => 0x5000, + Overlay::ClipHighlights => 0x5001, + Overlay::Grayscale => 0x5002, + Overlay::SharpenDetail => 0x5003, + Overlay::SharpenMask => 0x5004, } } + /// The temporary preview Lightroom shows while develop control `id` is dragged with Alt + /// (Option on macOS) held, if it has one. + pub fn for_control(id: &str) -> Option { + match id { + "light.exposure" | "light.highlights" | "light.whites" => Some(Overlay::ClipHighlights), + "light.shadows" | "light.blacks" => Some(Overlay::ClipShadows), + "detail.sharpenAmount" | "detail.nrLuminance" | "detail.nrDetail" | "detail.nrContrast" => Some(Overlay::Grayscale), + "detail.sharpenRadius" | "detail.sharpenDetail" => Some(Overlay::SharpenDetail), + "detail.sharpenMasking" => Some(Overlay::SharpenMask), + _ => None, + } + } + + /// The clipping previews, which test the sRGB values the histogram counts (a render for a + /// display profile draws them in sRGB, then converts). + pub fn is_clipping(self) -> bool { + matches!(self, Overlay::ClipShadows | Overlay::ClipHighlights) + } + + /// Whether the overlay draws from the render's detail planes ([`sharpen_plane`]). + pub fn reads_detail(self) -> bool { + matches!(self, Overlay::SharpenDetail | Overlay::SharpenMask) + } + + /// Whether the overlay works pixel by pixel on the finished image, so a window render shows + /// the same as the whole frame would. + pub fn per_pixel(self) -> bool { + matches!(self, Overlay::None | Overlay::Mask { .. } | Overlay::ClipShadows | Overlay::ClipHighlights | Overlay::Grayscale) + } + /// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components). pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> { match self { @@ -180,10 +243,45 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } } -/// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask -/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of -/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components. -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) { +/// The plane a sharpening view draws ([`Overlay::reads_detail`]), from the render's log luminance +/// `log_l` and its texture blur (`None` when the render didn't need it: blurred here with the same +/// sigma). [`Overlay::SharpenDetail`]: the detail band (log luminance minus its blur, in EV); +/// [`Overlay::SharpenMask`]: the sharpening weight under the photo's Masking. Both renderers call +/// this with their own planes. +pub fn sharpen_plane(o: Overlay, s: &DevelopSettings, log_l: &Plane, texture: Option<&Plane>, px_per_long: f64) -> Option { + if !o.reads_detail() { + return None; + } + let own; + let blur = match texture.filter(|b| (b.width, b.height) == (log_l.width, log_l.height)) { + Some(b) => b, + None => { + own = lightcraft_raster::blur::gaussian(log_l, crate::local::texture_sigma(px_per_long)); + &own + } + }; + let masking = (s.detail.sharpen_masking / 100.0).clamp(0.0, 1.0) as f32; + let mask = o == Overlay::SharpenMask; + let data = log_l + .data + .iter() + .zip(&blur.data) + .map(|(l, b)| { + let det = l - b; + if mask { crate::finish::sharpen_mask_weight(masking, det) } else { det } + }) + .collect(); + Some(Plane { width: log_l.width, height: log_l.height, data }) +} + +/// [`Overlay::SharpenDetail`]: encoded grey per EV of detail (±0.4 EV spans black to white). +const DETAIL_GAIN: f32 = 320.0; + +/// Draw overlay `o` over `img` (rendered with `plan`). `plane` is what the overlay shows, at the +/// image's size: the evaluated alpha of the mask an [`Overlay::Mask`] shows (see +/// [`Overlay::mask`]) or a sharpening view's [`sharpen_plane`]; `masks` is the set of alphas an +/// [`Overlay::AllMasks`] shows, one per visible mask with components. +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, plane: Option<&Plane>, masks: Option<&[Plane]>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} @@ -194,10 +292,27 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, } Overlay::Spots(t) => spots(img, t, plan.px_per_long), Overlay::Mask { view, color, opacity, .. } => { - if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + if let Some(a) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) { mask_view(img, a, view, color, opacity); } } + Overlay::ClipShadows => clipping(img, false), + Overlay::ClipHighlights => clipping(img, true), + Overlay::Grayscale => grayscale(img), + Overlay::SharpenDetail | Overlay::SharpenMask => { + if let Some(p) = plane.filter(|a| (a.width, a.height) == (img.width, img.height)) { + let detail = o == Overlay::SharpenDetail; + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let v = p.data[y * w + x]; + let g = if detail { 128.0 + v * DETAIL_GAIN } else { 255.0 * v }; + let g = g.round().clamp(0.0, 255.0) as u8; + *px = [g, g, g, 255]; + } + }); + } + } Overlay::AllMasks { view, color, opacity } => { // one view of the union of the masks: painting the planes one after another // overwrites (White on Black, Image on Black…) or compounds (Color) the earlier ones @@ -215,6 +330,36 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, } } +/// [`Overlay::ClipHighlights`] (`high`) or [`Overlay::ClipShadows`]: each channel shows only +/// whether it is clipped, on black (highlights) or white (shadows). +pub fn clipping(img: &mut Rgba8, high: bool) { + let w = img.width; + for_rows(&mut img.data, w, |_, row| { + for px in row.iter_mut() { + for c in &mut px[..3] { + *c = if high { + if *c == 255 { 255 } else { 0 } + } else if *c == 0 { + 0 + } else { + 255 + }; + } + px[3] = 255; + } + }); +} + +fn grayscale(img: &mut Rgba8) { + let w = img.width; + for_rows(&mut img.data, w, |_, row| { + for px in row.iter_mut() { + let g = (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32).round().clamp(0.0, 255.0) as u8; + *px = [g, g, g, 255]; + } + }); +} + /// Draw mask alpha `a` over `img` as `view`. pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) { let op = opacity.min(100) as f32 / 100.0; @@ -409,6 +554,121 @@ mod tests { assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); } + #[test] + fn clipping_views_show_the_clipped_channels() { + let mut img = Rgba8 { + width: 5, + height: 1, + data: vec![[0, 0, 0, 255], [255, 255, 255, 255], [255, 40, 0, 255], [0, 128, 200, 255], [120, 130, 140, 255]], + }; + let mut hi = img.clone(); + clipping(&mut hi, true); + // black background, white where every channel clips, the clipped channels' colour otherwise + assert_eq!(hi.data, vec![[0, 0, 0, 255], [255; 4], [255, 0, 0, 255], [0, 0, 0, 255], [0, 0, 0, 255]]); + clipping(&mut img, false); + // white background, black where every channel clips, the unclipped channels' colour otherwise + assert_eq!(img.data, vec![[0, 0, 0, 255], [255; 4], [255, 255, 0, 255], [0, 255, 255, 255], [255; 4]]); + } + + #[test] + fn clipping_previews_follow_the_rendered_clipping() { + // left: deep shadow, middle: grey, right: bright highlight + let src = Rgb32f::from_fn(60, 20, |x, _| match x / 20 { + 0 => [0.0; 3], + 1 => [0.18; 3], + _ => [3.0; 3], + }); + let info = SourceInfo::default(); + let mut s = DevelopSettings::default(); + s.light.blacks = -100.0; + s.light.whites = 100.0; + let req = |o| RenderRequest { overlay: o, ..RenderRequest::fit(60, 20) }; + let plain = render(&src, &info, &s, &req(Overlay::None)); + let hi = render(&src, &info, &s, &req(Overlay::ClipHighlights)); + let lo = render(&src, &info, &s, &req(Overlay::ClipShadows)); + let at = |i: &Rgba8, x: usize| i.data[10 * 60 + x]; + assert_eq!(at(&plain.image, 55), [255; 4], "the highlight clips"); + assert_eq!(at(&plain.image, 5), [0, 0, 0, 255], "the shadow clips"); + assert_eq!((at(&hi.image, 55), at(&hi.image, 30), at(&hi.image, 5)), ([255; 4], [0, 0, 0, 255], [0, 0, 0, 255])); + assert_eq!((at(&lo.image, 5), at(&lo.image, 30), at(&lo.image, 55)), ([0, 0, 0, 255], [255; 4], [255; 4])); + // a preview only: the histogram is the photo's + assert_eq!(hi.histogram, plain.histogram); + assert_eq!(render(&src, &info, &s, &req(Overlay::None)).image, plain.image); + } + + #[test] + fn sharpening_previews_show_the_mask_and_detail() { + // a smooth ramp (left) and a hard edge (at x = 60) + let src = Rgb32f::from_fn(80, 40, |x, _| if x < 60 { [0.05 + 0.002 * x as f32; 3] } else { [0.6; 3] }); + let info = SourceInfo::default(); + let mut s = DevelopSettings::default(); + s.detail.sharpen_amount = 60.0; + let shot = |s: &DevelopSettings, o| render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + let (flat, edge) = (20 * 80 + 20, 20 * 80 + 60); + // Masking 0 sharpens everything: an all-white mask + let m = shot(&s, Overlay::SharpenMask); + assert!(m.data.iter().all(|p| *p == [255; 4])); + // Masking 100 keeps the edge, protects the ramp + s.detail.sharpen_masking = 100.0; + let m = shot(&s, Overlay::SharpenMask); + assert_eq!((m.data[edge], m.data[flat]), ([255; 4], [0, 0, 0, 255])); + // the same without sharpening on (the render has no texture plane then) + s.detail.sharpen_amount = 0.0; + assert_eq!(shot(&s, Overlay::SharpenMask), m); + // detail: mid grey on the ramp, light / dark either side of the edge + let d = shot(&s, Overlay::SharpenDetail); + assert!(d.data[flat][0].abs_diff(128) <= 3, "{:?}", d.data[flat]); + assert!(d.data[edge][0] > 200 && d.data[edge - 1][0] < 60, "{:?} {:?}", d.data[edge], d.data[edge - 1]); + // greyscale + let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]); + let g = render(&src, &info, &s, &RenderRequest { overlay: Overlay::Grayscale, ..RenderRequest::fit(8, 8) }).image; + assert!(g.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0)); + } + + #[test] + fn alt_previews_map_to_controls_and_round_trip() { + assert_eq!(Overlay::for_control("light.blacks"), Some(Overlay::ClipShadows)); + assert_eq!(Overlay::for_control("light.shadows"), Some(Overlay::ClipShadows)); + assert_eq!(Overlay::for_control("light.whites"), Some(Overlay::ClipHighlights)); + assert_eq!(Overlay::for_control("light.exposure"), Some(Overlay::ClipHighlights)); + assert_eq!(Overlay::for_control("light.highlights"), Some(Overlay::ClipHighlights)); + assert_eq!(Overlay::for_control("detail.sharpenAmount"), Some(Overlay::Grayscale)); + assert_eq!(Overlay::for_control("detail.sharpenDetail"), Some(Overlay::SharpenDetail)); + assert_eq!(Overlay::for_control("detail.sharpenMasking"), Some(Overlay::SharpenMask)); + for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] { + assert_eq!(Overlay::for_control(c), Some(Overlay::Grayscale), "{c}"); + } + assert_eq!(Overlay::for_control("detail.nrColor"), None); + assert_eq!(Overlay::for_control("light.contrast"), None); + // every mapped id is a real control (a rename would silently drop its preview) + for c in [ + "light.exposure", + "light.highlights", + "light.whites", + "light.shadows", + "light.blacks", + "detail.sharpenAmount", + "detail.sharpenRadius", + "detail.sharpenDetail", + "detail.sharpenMasking", + "detail.nrLuminance", + "detail.nrDetail", + "detail.nrContrast", + ] { + assert!(lightcraft_develop::controls::find(c).is_some(), "{c}"); + assert!(Overlay::for_control(c).is_some(), "{c}"); + } + assert!(Overlay::ClipShadows.is_clipping() && Overlay::ClipHighlights.is_clipping() && !Overlay::Grayscale.is_clipping()); + let all = [Overlay::ClipShadows, Overlay::ClipHighlights, Overlay::Grayscale, Overlay::SharpenDetail, Overlay::SharpenMask]; + for o in all { + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_eq!(all.iter().filter(|p| p.key() == o.key()).count(), 1); + assert_ne!(o.key(), Overlay::HdrRange.key()); + } + assert!(Overlay::ClipShadows.per_pixel() && !Overlay::SharpenDetail.per_pixel()); + } + #[test] fn all_masks_overlay_shows_every_visible_mask_at_once() { use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; diff --git a/crates/raw/proptest-regressions/vendor/rafc.txt b/crates/raw/proptest-regressions/vendor/rafc.txt new file mode 100644 index 000000000..7633abe0f --- /dev/null +++ b/crates/raw/proptest-regressions/vendor/rafc.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 8cb6b5999a8279e655a148a18b220d75b02c519f644583f4593166286297a3f4 # shrinks to data = [0, 0, 0, 8], reads = [(false, 13, 0), (true, 0, 15)] diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index 75f7674ac..245586f39 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -260,6 +260,53 @@ mod tests { assert!(xp > 20.0 && xp >= xb - 0.5, "xtrans directional {xp:.1} bilinear {xb:.1}"); } + /// Mean anisotropy of a demosaiced picture's fine luminance detail: per pixel, how much more its 3 × 3 high-pass + /// correlates with its horizontal neighbours than with its vertical ones (or the reverse). Noise has none; the + /// short horizontal and vertical strokes of a maze pattern have a lot. + fn anisotropy(img: &Rgb32f) -> f64 { + let (w, h) = (img.width, img.height); + let lum: Vec = img.data.iter().map(|p| 0.25 * p[0] + 0.5 * p[1] + 0.25 * p[2]).collect(); + let mean3 = |x: usize, y: usize| (0..9).map(|k| lum[(y + k / 3 - 1) * w + x + k % 3 - 1]).sum::() / 9.0; + let hp: Vec = + (0..w * h).map(|i| if i % w == 0 || i % w == w - 1 || i < w || i >= w * (h - 1) { 0.0 } else { lum[i] - mean3(i % w, i / w) }).collect(); + let (mut sum, mut n) = (0f64, 0f64); + for y in 2..h - 2 { + for x in 2..w - 2 { + let i = y * w + x; + sum += f64::from((hp[i] * (hp[i - 1] + hp[i + 1] - hp[i - w] - hp[i + w])).abs()); + n += 1.0; + } + } + sum / n + } + + #[test] + fn flat_noise_does_not_turn_into_a_maze() { + // a flat grey patch with sensor-like noise: AHD picked horizontal or vertical candidates per pixel by a + // homogeneity count that noise alone decides, so the picture became short horizontal and vertical strokes + let (w, h) = (192, 160); + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut noise = || { + // sum of four uniforms ≈ Gaussian, σ ≈ 0.02 + (0..4) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 40) as f32 / (1u64 << 24) as f32 - 0.5 + }) + .sum::() + * 0.035 + }; + let cfa = Cfa::bayer("RGGB").unwrap(); + let data = (0..w * h).map(|_| 0.2 + noise()).collect(); + let n = Normalized { width: w, height: h, cpp: 1, data, cfa: Some(cfa) }; + let (ahd, bilinear) = (anisotropy(&demosaic(&n, Method::Ahd)), anisotropy(&demosaic(&n, Method::Bilinear))); + eprintln!("anisotropy: ahd {ahd:.3e}, bilinear {bilinear:.3e}"); + // the hard choice gave about 11 times bilinear's, the blend about 7.5 times + assert!(ahd < 9.0 * bilinear, "ahd {ahd:.3e} bilinear {bilinear:.3e}"); + } + #[test] fn tiny_and_degenerate_inputs() { let cfa = Cfa::bayer("RGGB").unwrap(); @@ -277,6 +324,20 @@ mod tests { assert_eq!(demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.3]]); } + #[test] + fn a_nan_sample_spreads_no_further_than_the_chosen_candidate_takes_it() { + // vertical stripes: the vertical candidate wins outright beside them, and the horizontal one, which reads two + // samples to each side, is the only one that sees a NaN two columns away + let img = Rgb32f::from_fn(32, 24, |x, _| if (x / 2) % 2 == 0 { [0.2, 0.3, 0.25] } else { [0.7, 0.8, 0.6] }); + let cfa = Cfa::bayer("RGGB").unwrap(); + let mut n = mosaic_from_rgb(&img, &cfa); + n.data[12 * 32 + 16] = f32::NAN; + let out = demosaic(&n, Method::Ahd); + let bad = out.data.iter().filter(|p| p.iter().any(|v| !v.is_finite())).count(); + // the pixels that read the NaN sample themselves, as many as the outright pick gave; the blend made it 33 + assert!(bad <= 9, "{bad} pixels not finite"); + } + #[test] fn non_finite_samples_do_not_panic() { // regression: NaN samples (a float DNG) gave AHD and PPG NaN bounds for `f32::clamp`, which panics diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 6afcd9251..9a1d477e8 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -6,7 +6,8 @@ //! 2. Complete each candidate with red/blue by colour-difference (C − G) interpolation. //! 3. Convert both candidates to CIELab and measure, per pixel, how many of its 4 neighbours lie within the //! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity. -//! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties). +//! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate. The paper picks one outright; +//! we blend the two by how far apart their sums are (see [`BLEND`]), so a clear edge still takes one candidate. //! //! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written //! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the @@ -19,6 +20,11 @@ use rayon::prelude::*; const TILE: usize = 128; const MARGIN: usize = 6; +/// Difference of the two candidates' 3×3 homogeneity sums (each 0–36) from which the more homogeneous one is taken +/// alone; closer sums blend the two linearly, equal sums half and half. In a flat, noisy area noise alone decides +/// which sum is larger, and picking outright turned the noise into short horizontal and vertical strokes (a maze, +/// coloured where white balance is strong). 12 keeps the PSNR of the outright pick on the edge tests. +const BLEND: f32 = 12.0; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { ahd_window(m, 0, 0, m.w, m.h) @@ -157,12 +163,16 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> sh += hh[j] as u32; sv += hv[j] as u32; } - out.push(if sh > sv { + let wh = (0.5 + (sh as f32 - sv as f32) / (2.0 * BLEND)).clamp(0.0, 1.0); + // a candidate taken alone is copied, and one that isn't finite (beside a float raw's NaN sample) is left out, + // so the other's value doesn't become 0 · NaN + let finite = |p: &[f32; 3]| p.iter().all(|v| v.is_finite()); + out.push(if wh >= 1.0 || !finite(&rv[i]) { rh[i] - } else if sv > sh { + } else if wh <= 0.0 || !finite(&rh[i]) { rv[i] } else { - [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] + [0, 1, 2].map(|c| rh[i][c] * wh + rv[i][c] * (1.0 - wh)) }); } out diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 89a5ea48a..2b16c441a 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -469,10 +469,12 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { let be32 = |at: usize| bytes.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize); let (Some(size), Some(len)) = (be32(start), be32(start + 20)) else { continue }; let j = start + 24; - if len < 4 || j + len > start + size.max(24) || j + len > bytes.len() { + // 32-bit box lengths can wrap `usize` on wasm32: check the sums, then slice with `get` + let (Some(end), Some(lim)) = (j.checked_add(len), start.checked_add(size.max(24))) else { continue }; + if len < 4 || end > lim { continue; } - let jpeg = &bytes[j..j + len]; + let Some(jpeg) = bytes.get(j..end) else { continue }; // One that doesn't fit the budget left is skipped; a smaller one later may still fit. if walked + jpeg.len() > CR3_MAX_WALK { continue; diff --git a/crates/raw/src/preview/tests.rs b/crates/raw/src/preview/tests.rs index 80e3fdb52..7eb9b8dc1 100644 --- a/crates/raw/src/preview/tests.rs +++ b/crates/raw/src/preview/tests.rs @@ -199,6 +199,20 @@ fn picks_largest_dct_jpeg() { } } +/// #2569: `PRVW` box and JPEG lengths near `u32::MAX` (which wrap `usize` on wasm32) find no preview, never panic. +#[test] +fn cr3_preview_box_huge_lengths_are_rejected() { + let j = fake_jpeg(300); + for (size, len) in [(u32::MAX, u32::MAX), (u32::MAX, u32::MAX - 23), (24 + j.len() as u32, u32::MAX - 3)] { + let mut cr3 = b"\x00\x00\x00\x18ftypcrx \x00\x00\x00\x01crx isom".to_vec(); + cr3.extend_from_slice(&size.to_be_bytes()); + cr3.extend_from_slice(b"PRVW\x00\x00\x00\x00\x00\x01\x06T\x048\x00\x01"); + cr3.extend_from_slice(&len.to_be_bytes()); + cr3.extend_from_slice(&j); + assert_eq!(cr3_preview_boxes(&cr3), None, "size {size:#x}, len {len:#x}"); + } +} + /// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components. fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec { let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8]; diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index b6a1971ce..75186d2d3 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -54,6 +54,14 @@ pub fn check_image(data: &[u8], info: &ImageInfo) -> Result { if chunks.len() < expected { return Err(RawError::Corrupt(format!("{} image data chunks, expected {expected}", chunks.len()))); } + // Tile sizes don't depend on the image size, and every tile is decoded at its nominal size and held until the copy: + // a grid of tiles no larger than the image covers less than twice its width and height, so more than four times + // its samples (or one tile of more than TILE_SAMPLES_FLOOR on a tiny image) is no picture (issue #2554). + let ccpp = if info.planar == 2 { 1 } else { cpp as u64 }; + let nominal = chunks.iter().fold(0u64, |sum, c| sum.saturating_add(u64::from(c.width).saturating_mul(u64::from(c.height)).saturating_mul(ccpp))); + if nominal > (total as u64).saturating_mul(4).max(TILE_SAMPLES_FLOOR) { + return Err(RawError::Limit("image data chunks far larger than the image")); + } // Plausibility bound before allocating: no supported coding stores more than ~2000 samples per byte // (Deflate of constant data is the extreme), so tiny files cannot trigger huge allocations. let available: u64 = chunks.iter().map(|c| chunk_bytes(data, c).map_or(0, |s| s.len() as u64)).sum(); @@ -95,6 +103,9 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or } } +/// The nominal samples of all chunks [`check_image`] allows whatever the image size: one 2048 × 2048 tile. +const TILE_SAMPLES_FLOOR: u64 = 1 << 22; + /// Decode all chunks of `info` into one buffer of `width × height × cpp` samples. pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Packing) -> Result { let (w, h) = (info.width as usize, info.height as usize); @@ -493,8 +504,9 @@ mod lossy_tests { assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err()); } - /// A small image whose uncompressed tile claims to be enormous (16384 x 16384 nominal, a few bytes of data) is - /// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes. + /// A small image whose uncompressed tile claims to be enormous (2048 x 2048 nominal, a few bytes of data) is + /// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes. (A + /// tile larger still is refused by `check_image` before any decode: `tiles_far_larger_than_the_image_are_refused`.) #[test] fn a_huge_nominal_uncompressed_tile_with_little_data_is_refused() { let info = |tile: u32, bytes: u64| ImageInfo { @@ -515,7 +527,37 @@ mod lossy_tests { let small = vec![1u8; 2 * 16 * 16]; assert!(read_image(&small, &info(16, small.len() as u64), ByteOrder::Little, Packing::Msb).is_ok()); let data = vec![1u8; 4096]; - assert!(matches!(read_image(&data, &info(16384, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + assert!(matches!(read_image(&data, &info(2048, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + } + + /// A 16 × 16 image whose lossless-JPEG tile claims to be 16384 × 16384 (coded only as tall as the image, so the + /// file is small) is refused before the tile is allocated, by the header check too (issue #2554); a 256 × 256 + /// tile on the same image, as a writer with a fixed tile size stores it, still decodes. + #[test] + fn tiles_far_larger_than_the_image_are_refused() { + let read = |tile: u32, mode: crate::Mode| { + let t = ljpeg::encode(&vec![300u16; tile as usize * 16], tile as usize, 16, 1, 12, 1, 0); + let info = ImageInfo { + width: 16, + height: 16, + bits_per_sample: vec![12], + samples_per_pixel: 1, + compression: comp::JPEG, + photometric: 32803, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: tile, tile_height: tile }, + offsets: vec![0], + byte_counts: vec![t.len() as u64], + }; + read_image_in(mode, &t, &info, ByteOrder::Little, Packing::Msb) + }; + assert_eq!(read(256, crate::Mode::Full), Ok(RawData::U16(vec![300; 16 * 16]))); + for mode in [crate::Mode::Header, crate::Mode::Full] { + assert!(matches!(read(16384, mode), Err(RawError::Limit(_)))); + } } /// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter. diff --git a/crates/raw/src/vendor.rs b/crates/raw/src/vendor.rs index b0f65f49e..1610f20ee 100644 --- a/crates/raw/src/vendor.rs +++ b/crates/raw/src/vendor.rs @@ -225,9 +225,61 @@ fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { x.iter().all(|v| v.is_finite()).then_some(x) } +/// Test helper: make the single-strip `StripByteCounts` entry (LONG, count 1) that holds `len` declare `declared` +/// bytes instead, as a file cut short or a hostile header does. +#[cfg(test)] +pub(crate) fn declare_strip_len(file: &mut [u8], len: u32, declared: u32) { + let big = file.starts_with(b"MM"); + let entry = |v: u32| -> Vec { + if big { + [&279u16.to_be_bytes()[..], &4u16.to_be_bytes(), &1u32.to_be_bytes(), &v.to_be_bytes()].concat() + } else { + [&279u16.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat() + } + }; + let (old, new) = (entry(len), entry(declared)); + let at: Vec = file.windows(old.len()).enumerate().filter(|(_, w)| *w == old.as_slice()).map(|(i, _)| i).collect(); + assert_eq!(at.len(), 1, "one StripByteCounts entry of {len} bytes"); + file[at[0]..at[0] + new.len()].copy_from_slice(&new); +} + #[cfg(test)] mod tests { use super::*; + use lightcraft_tiff::tags as t; + + /// `file`, whose image IFD holds one strip, as two strips at the same offset whose StripByteCounts are LONG8 (which + /// classic TIFF readers accept) `[u64::MAX, 1]`: their sum is past `u64::MAX`. + pub(super) fn huge_strip_counts(mut file: Vec) -> Vec { + let tiff = Tiff::parse(&file).unwrap(); + let o = tiff.order; + let ifd = tiff.all_ifds().into_iter().find(|i| i.contains(t::STRIP_OFFSETS)).unwrap(); + let (base, rows, offset) = (ifd.offset as usize, ifd.u32(t::IMAGE_LENGTH).unwrap(), ifd.u32(t::STRIP_OFFSETS).unwrap()); + assert!(rows >= 2); + let at = file.len() as u32; + // appended: the two strip offsets (LONG), then the two byte counts (LONG8) + let mut tail = Vec::new(); + o.put_u32(&mut tail, offset); + o.put_u32(&mut tail, offset); + o.put_u64(&mut tail, u64::MAX); + o.put_u64(&mut tail, 1); + for i in 0..usize::from(o.read_u16(&file, base).unwrap()) { + let e = base + 2 + 12 * i; + let (ty, count, value) = match o.read_u16(&file, e).unwrap() { + t::STRIP_OFFSETS => (4u16, 2u32, at), + t::ROWS_PER_STRIP => (4, 1, rows.div_ceil(2)), + t::STRIP_BYTE_COUNTS => (16, 2, at + 8), + _ => continue, + }; + let mut entry = Vec::new(); + o.put_u16(&mut entry, ty); + o.put_u32(&mut entry, count); + o.put_u32(&mut entry, value); + file[e + 2..e + 12].copy_from_slice(&entry); + } + file.extend_from_slice(&tail); + file + } #[test] fn black_columns_by_parity() { diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 3938fa50d..b1a9983ca 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -628,7 +628,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let bits = info.bits() as u32; let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; let chunks = info.chunks(bytes.len() as u64); - let strip_len: u64 = chunks.iter().map(|c| c.len).sum(); + let strip_len = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)); // The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test // archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one // smaller), which is a different packing, so they are refused rather than read with the wrong layout. @@ -642,6 +642,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } 32767 if one_byte_per_sample => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + if src.len() as u64 != strip_len { + return Err(RawError::Corrupt("raw strip shorter than its rows".into())); + } let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100])); let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { @@ -659,6 +662,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } 32767 if packed12 => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + if src.len() as u64 != strip_len { + return Err(RawError::Corrupt("raw strip shorter than its rows".into())); + } let row_bytes = w / 2 * 3; let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { @@ -900,6 +906,15 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() } + /// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow. + #[test] + fn strip_byte_counts_past_u64_max_do_not_overflow() { + let file = crate::vendor::tests::huge_strip_counts(arw_with_strip(32 * 4)); + for mode in [Mode::Header, Mode::Full] { + let _ = super::decode(&file, mode); + } + } + /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as /// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see /// [`super::super::arw_lens_rm6`]). @@ -1561,4 +1576,28 @@ mod tests { assert!(is_quad_tiled(&file, &info)); assert_eq!(read_quad_tiles(&file, &info).unwrap(), mosaic); } + + /// #2561: a small file whose one-byte or packed 12-bit strip declares a large frame is refused before the full + /// decode allocates the frame. + #[test] + fn a_strip_cut_short_is_refused_before_allocating() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let (w, h) = (3000u32, 2000u32); + for declared in [w * h, w * h / 2 * 3] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSLR-A100".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0u8; 12]] }); + let mut f = TiffWriter::default().write(&[raw]).unwrap(); + crate::vendor::declare_strip_len(&mut f, 12, declared); + assert!(matches!(decode(&f, Mode::Full), Err(RawError::Corrupt(_))), "{declared} bytes declared"); + } + } } diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 92caeec26..172f83013 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -206,7 +206,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let (w, h) = (info.width as usize, info.height as usize); let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; let chunks = info.chunks(bytes.len() as u64); - let total: u64 = chunks.iter().map(|c| c.len).sum(); + let total = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)); let stated = cfa_from_exif(&tiff); let mut two_field = false; let (mut data, bits) = if info.compression != 1 { @@ -394,6 +394,15 @@ mod tests { b } + /// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow. + #[test] + fn strip_byte_counts_past_u64_max_do_not_overflow() { + let file = crate::vendor::tests::huge_strip_counts(orf(4, 2, 16, vec![0; 16])); + for mode in [Mode::Header, Mode::Full] { + let _ = super::decode(&file, mode); + } + } + /// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) of SHORT (type 3) or /// UNDEFINED (type 7) entries. Values longer than four bytes follow the IFD; their offsets count from the TIFF /// header, so `at` is where the note sits in the file. diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 9b7d92bc7..45efeec68 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -267,14 +267,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let chunks = info.chunks(bytes.len() as u64); let first = chunks.first().ok_or_else(|| RawError::Corrupt("PEF without image data".into()))?; let mut c = *first; - c.len = chunks.iter().map(|c| c.len).sum::().max(first.len); + c.len = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)).max(first.len); let src = chunk_bytes(bytes, &c) .or_else(|| bytes.get(first.offset as usize..)) .ok_or_else(|| RawError::Corrupt("PEF data outside file".into()))?; if read == Mode::Full { RawData::U16(decode_huffman(src, &huff, w, hgt, bits)?) } else { RawData::U16(Vec::new()) } } 1 => { - let strip: u64 = info.chunks(bytes.len() as u64).iter().map(|c| c.len).sum(); + let strip = info.chunks(bytes.len() as u64).iter().fold(0u64, |a, c| a.saturating_add(c.len)); let packing = if strip >= (w * hgt * 2) as u64 { Packing::Word16 } else { Packing::Msb }; read_image_in(read, bytes, &info, tiff.order, packing)? } @@ -707,4 +707,16 @@ mod tests { let file = pef_with_black_point(w, h, 64, |x, y| 64 + ((x * 7 + y * 13) % 5) as u16); assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h)); } + + /// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow, + /// for the Huffman-coded and the uncompressed samples. + #[test] + fn strip_byte_counts_past_u64_max_do_not_overflow() { + for compression in [65535u16, 1] { + let file = crate::vendor::tests::huge_strip_counts(pef_with_pattern(compression, [0, 1, 1, 2])); + for mode in [Mode::Header, Mode::Full] { + let _ = decode(&file, mode); + } + } + } } diff --git a/crates/raw/src/vendor/rafc.rs b/crates/raw/src/vendor/rafc.rs index f3b925b51..fd08f9869 100644 --- a/crates/raw/src/vendor/rafc.rs +++ b/crates/raw/src/vendor/rafc.rs @@ -114,26 +114,57 @@ impl<'a> Bits<'a> { Self { src, pos: 0, acc: 0, available: 0 } } + /// Tops the accumulator up to at least 57 bits, or to every remaining byte near the end of the stream. #[inline] - fn get(&mut self, n: u32) -> Result { - while self.available < n { - let b = self.src.get(self.pos).copied().ok_or_else(|| corrupt("truncated entropy stream"))?; + fn refill(&mut self) { + while self.available <= 56 { + let Some(&b) = self.src.get(self.pos) else { return }; self.pos += 1; self.acc = (self.acc << 8) | u64::from(b); self.available += 8; } + } + + #[inline] + fn get(&mut self, n: u32) -> Result { + if self.available < n { + self.refill(); + if self.available < n { + return Err(corrupt("truncated entropy stream")); + } + } self.available -= n; Ok(((self.acc >> self.available) & ((1u64 << n) - 1)) as u32) } - fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result { + /// Reads the unary prefix: the zeros before the next one bit, which is consumed. Errors exactly where reading + /// bit by bit would: more than `limit` zeros, or a stream that ends first. + #[inline] + fn zeros(&mut self, limit: u32) -> Result { let mut zeros = 0; - while self.get(1)? == 0 { - zeros += 1; - if zeros > limit { + loop { + if self.available == 0 { + self.refill(); + if self.available == 0 { + return Err(corrupt("truncated entropy stream")); + } + } + // the unread bits, left-aligned; the zeros shifted in below them are cut off by `min` + let run = (self.acc << (64 - self.available)).leading_zeros().min(self.available); + if zeros + run > limit { return Err(corrupt("invalid residual escape")); } + zeros += run; + if run < self.available { + self.available -= run + 1; + return Ok(zeros); + } + self.available = 0; } + } + + fn residual(&mut self, k: u32, bits: u32, range: u32, limit: u32) -> Result { + let zeros = self.zeros(limit)?; let code = if zeros == limit { self.get(bits)? + 1 } else { (zeros << k) | self.get(k)? }; if code > range { return Err(corrupt(&format!( @@ -501,5 +532,48 @@ mod tests { src.extend_from_slice(&data); let _ = decode(&src, 780, 6, bits, xtrans, Mode::Full); } + + /// The word-at-a-time reader returns what reading bit by bit returns, and fails where it fails: long zero + /// runs, the escape limit and the end of the stream included. + #[test] + fn bit_reader_matches_reading_bit_by_bit( + data in prop::collection::vec(prop_oneof![3 => Just(0u8), 1 => any::()], 0..64), + reads in prop::collection::vec((any::(), 0u32..=24, 0u32..=63), 1..200), + ) { + let bit = |i: usize| data.get(i / 8).map(|b| (b >> (7 - i % 8)) & 1 == 1); + let mut at = 0usize; + let mut reader = Bits::new(&data); + for (prefix, n, limit) in reads { + let expected = if prefix { + let mut zeros = 0; + loop { + let Some(b) = bit(at) else { break Err("truncated") }; + at += 1; + if b { + break Ok(zeros); + } + zeros += 1; + if zeros > limit { + break Err("escape"); + } + } + } else if at + n as usize > data.len() * 8 { + Err("truncated") + } else { + let value = (0..n as usize).fold(0, |v, i| (v << 1) | u32::from(bit(at + i) == Some(true))); + at += n as usize; + Ok(value) + }; + let actual = if prefix { reader.zeros(limit) } else { reader.get(n) }; + match (expected, actual) { + (Ok(want), Ok(got)) => prop_assert_eq!(want, got), + (Err(want), Err(got)) => { + prop_assert!(got.to_string().contains(want), "{got}"); + break; + } + (want, got) => prop_assert!(false, "expected {want:?}, got {got:?}"), + } + } + } } } diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index b22faeec0..dbdb21b52 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -62,7 +62,8 @@ fn keyed(mn: &Ifd, tag: u16, sign: i64) -> Option> { let key = mn.u64s(ENCRYPTION_KEY).filter(|k| k.len() == 11)?; let vals = mn.f64s(tag)?; // 32-bit entries: the stored value wraps modulo 2^32 (a negative number stored as a LONG) - Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add(sign * key[i % 11] as i64) as i32)).collect()) + // (a key value past i64::MAX must not overflow the multiply: only the low 32 bits are kept) + Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add((key[i % 11] as i64).wrapping_mul(sign)) as i32)).collect()) } /// The raw image's IFD: the one with a private compression value. @@ -97,7 +98,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let (w, h) = (info.width as usize, info.height as usize); let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; let chunks = info.chunks(bytes.len() as u64); - let strip: u64 = chunks.iter().map(|c| c.len).sum(); + let strip = chunks.iter().fold(0u64, |a, c| a.saturating_add(c.len)); let Some(coding) = coding(info.compression, w, h, strip).filter(|_| chunks.len() == 1) else { return Err(RawError::Unsupported(format!( "Samsung compressed raw (compression {}, {:.2} stored bits per sample)", @@ -107,6 +108,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let data = if mode == Mode::Full { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("SRW strip outside file".into()))?; + if src.len() as u64 != strip { + return Err(RawError::Corrupt("SRW strip shorter than its rows".into())); + } let mut d = vec![0u16; n]; match coding { Coding::Words => read_u16s(src, ByteOrder::Little, &mut d), @@ -283,19 +287,25 @@ mod tests { /// A maker note in the Samsung layout: a big-endian IFD of LONG vectors, offsets relative to the note's start. fn note(entries: &[(u16, Vec)]) -> Vec { + let typed: Vec<_> = entries.iter().map(|(tag, v)| (*tag, 4, v.len() as u32, v.iter().flat_map(|x| x.to_be_bytes()).collect())).collect(); + note_typed(&typed) + } + + /// [`note`] with any entry types: `(tag, type, count, big-endian value bytes)`. + fn note_typed(entries: &[(u16, u16, u32, Vec)]) -> Vec { let mut head = Vec::new(); let mut tail = Vec::new(); head.extend_from_slice(&(entries.len() as u16).to_be_bytes()); let values_at = 2 + 12 * entries.len() + 4; - for (tag, v) in entries { + for (tag, kind, count, v) in entries { head.extend_from_slice(&tag.to_be_bytes()); - head.extend_from_slice(&4u16.to_be_bytes()); - head.extend_from_slice(&(v.len() as u32).to_be_bytes()); - if v.len() == 1 { - head.extend_from_slice(&v[0].to_be_bytes()); + head.extend_from_slice(&kind.to_be_bytes()); + head.extend_from_slice(&count.to_be_bytes()); + if v.len() <= 4 { + head.extend(v.iter().copied().chain(std::iter::repeat(0)).take(4)); } else { head.extend_from_slice(&((values_at + tail.len()) as u32).to_be_bytes()); - tail.extend(v.iter().flat_map(|x| x.to_be_bytes())); + tail.extend_from_slice(v); } } head.extend_from_slice(&[0; 4]); @@ -355,6 +365,23 @@ mod tests { TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap() } + /// Strip byte counts that add up past `u64::MAX` (LONG8 `[u64::MAX, 1]`) are an error or a picture, not an overflow. + #[test] + fn strip_byte_counts_past_u64_max_do_not_overflow() { + let file = crate::vendor::tests::huge_strip_counts(srw(Spec { + w: 4, + h: 2, + compression: 32769, + strip: vec![0; 16], + pattern: None, + note: None, + picture: None, + })); + for mode in [Mode::Header, Mode::Full] { + let _ = super::decode(&file, mode); + } + } + /// The camera-stated description every test file carries: JPEG framing, white balance, levels. fn full_note(area: [u32; 4], white: i64, black: i64) -> Vec { let mut levels = vec![0i64; 4]; @@ -510,6 +537,27 @@ mod tests { assert!(matches!(decode(&bytes), Err(RawError::Unsupported(w)) if w.contains("CFAPattern"))); } + /// An encryption key value past i64::MAX (LONG8) overflowed `-1 * key` in debug builds. + #[test] + fn a_huge_encryption_key_does_not_panic() { + let key: Vec = std::iter::repeat_n((1u64 << 63).to_be_bytes(), 11).flatten().collect(); + let note = note_typed(&[(ENCRYPTION_KEY, 16, 11, key), (WB_UNCORRECTED, 4, 4, [0u8; 16].to_vec()), (WB_BLACK, 4, 4, [0u8; 16].to_vec())]); + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&ramp(64, 32, 4096)), + pattern: Some([2, 1, 1, 0]), + note: Some(note), + picture: None, + }); + let mn = maker_note(&bytes, &Tiff::parse(&bytes).unwrap()).unwrap(); + assert_eq!(mn.u64s(ENCRYPTION_KEY).map(|k| k[0]), Some(1 << 63), "the key reaches keyed()"); + assert_eq!(keyed(&mn, WB_BLACK, -1), Some(vec![0; 4]), "(2^63 mod 2^32 is 0)"); + assert!(decode(&bytes).is_ok()); + assert!(probe_info(&bytes).is_ok()); + } + /// A framing rectangle outside the array, or reversed, is ignored; truncated files fail without panicking. #[test] fn hostile_framing_and_truncation_do_not_panic() { @@ -540,4 +588,16 @@ mod tests { let _ = probe_info(&bytes[..cut]); } } + + /// #2561: a small file whose strip declares a large frame is refused before the full decode allocates the frame. + #[test] + fn a_strip_cut_short_is_refused_before_allocating() { + let (w, h) = (3000u32, 2000u32); + for (compression, declared) in [(32769, w * h * 2), (32770, w * h / 2 * 3)] { + let mut f = srw(Spec { w, h, compression, strip: vec![0u8; 12], pattern: Some([0, 1, 1, 2]), note: None, picture: None }); + crate::vendor::declare_strip_len(&mut f, 12, declared); + assert_eq!(probe(&f), Some(crate::RawFormat::Srw)); + assert!(matches!(decode(&f), Err(RawError::Corrupt(_))), "compression {compression}"); + } + } } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 6df596a7d..a434881ae 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -263,6 +263,8 @@ impl LightcraftApp { self.import_dropped_files(ctx); } self.check_external_editor(ctx); + // Alt (Option on macOS) while a slider is dragged shows its temporary preview + self.ui.alt_held = ctx.input(|i| i.modifiers.alt); // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). panels::library_problem::logic(self, ctx); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 39ca9956c..116c35b08 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -919,13 +919,20 @@ fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap } } -/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range, -/// Visualize Spots). +/// The diagnostic overlay the loupe shows (an Alt-drag preview, the selected mask, Point Color's +/// visualized range, Visualize Spots). pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::{MaskView, Overlay}; if app.ui.fullscreen { return Overlay::None; } + // Lightroom's temporary previews: clipping, greyscale, sharpening detail / mask + if app.ui.right == RightPanel::Edit + && app.ui.alt_held + && let Some(o) = app.ui.dragging_control.as_deref().and_then(Overlay::for_control) + { + return o; + } // Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay // so the shortcut is not shadowed by the always-on single-mask overlay. if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) { diff --git a/crates/ui-egui/src/panels/detail/window.rs b/crates/ui-egui/src/panels/detail/window.rs index f4ec89c5b..c9b78b542 100644 --- a/crates/ui-egui/src/panels/detail/window.rs +++ b/crates/ui-egui/src/panels/detail/window.rs @@ -9,6 +9,9 @@ pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> use lightcraft_pipeline::Overlay as O; match overlay { O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, + // clipping and greyscale previews act pixel by pixel on the finished image + o if o.per_pixel() => true, + _ => false, } } diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 1ef524a83..31b1303bf 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -28,9 +28,10 @@ use grading::grading; pub(crate) use grading::wheel_pointer_frame_end; #[cfg(test)] pub(crate) use grading::{WheelGrab, WheelLock, WheelMods, wheel_pick}; -use histogram::{histogram, soft_proofing}; +pub(crate) use histogram::histogram; +use histogram::soft_proofing; use mixer::{mixer, point_color}; -use sliders::{REL_TEMP, REL_TINT, hdr_row, k_to_rel, profile_menu, quick_develop, rel_to_k, reset_tip, section}; +use sliders::{REL_TEMP, REL_TINT, hdr_row, header_buttons, k_to_rel, profile_menu, quick_develop, rel_to_k, reset_tip, section}; pub use sliders::{apply_slider_out, sub_title}; pub(crate) use sliders::{control, reload_preview_only}; @@ -56,9 +57,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone()); - if app.ui.histogram { - histogram(app, ui, id); - } if app.ui.soft_proof { soft_proofing(app, ui, id); } @@ -67,32 +65,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover()); ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 8.0; - if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() { - let _ = app.run("develop.auto", json!({})); - app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied")); - } - let bw = crate::is_bw(&d); - if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() { - let _ = app.run("develop.treatment", json!({})); - } - // HDR editing, next to B&W as Lightroom has it - if text_button(ui, "hdr", crate::i18n::tr("HDR"), d.hdr.enabled) - .on_hover_text(crate::i18n::tr("Edit in HDR: highlights above SDR white, for HDR exports and displays")) - .clicked() - { - let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled})); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "reset", crate::i18n::tr("Reset"), false) - .on_hover_text(reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac)) - .clicked() - { - reset_clicked = true; - } - }); - }); + let tip = reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac); + let clicks = header_buttons(ui, crate::is_bw(&d), d.hdr.enabled, &tip); + if clicks.auto { + let _ = app.run("develop.auto", json!({})); + app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied")); + } + if clicks.bw { + let _ = app.run("develop.treatment", json!({})); + } + if clicks.hdr { + let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled})); + } + reset_clicked = clicks.reset; }); if let Some(why) = &preview_only { let reload = egui::Frame::NONE diff --git a/crates/ui-egui/src/panels/edit/histogram.rs b/crates/ui-egui/src/panels/edit/histogram.rs index ac11e691e..df21b1d79 100644 --- a/crates/ui-egui/src/panels/edit/histogram.rs +++ b/crates/ui-egui/src/panels/edit/histogram.rs @@ -33,7 +33,16 @@ fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: } } -pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { +/// The histogram of the last drag's small HDR render, while an HDR edit of `id` is being dragged. It stays in +/// `Renderer` after the drag, so an edit with HDR switched off must not read it: its drags would show the old HDR +/// histogram (`dragging`: a drag of an HDR edit is on). +fn live_hdr_histogram(live: Option<&(PhotoId, u64, Histogram)>, id: PhotoId, dragging: bool) -> Option { + live.filter(|l| dragging && l.0 == id).map(|l| l.2.clone()) +} + +/// The histogram, its clipping buttons and the EXIF strip, drawn above the Edit panel's scrolling sliders +/// ([`crate::panels::right::show`]). +pub(crate) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); @@ -48,7 +57,7 @@ pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) app.renderer.request(Slot::HdrHistogram, job, 95); } } - let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); + let live = live_hdr_histogram(app.renderer.live_hdr_histogram.as_ref(), id, interacting && hdr_edit); // else the loupe render of this photo, else its thumbnail (grid view before the loupe has // rendered it) let hist = live.or_else(|| preview_histogram(app, id)); @@ -155,8 +164,8 @@ pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) } } let mut clicked = resp.clicked(); - // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep - // clear of it, the left one too so they stay symmetric (#1640) + // both buttons keep clear of the panel's right edge, where its floating scroll bar widens when + // hovered beside the sliders, the left one too so they stay symmetric (#1640) let [shadow_rect, highlight_rect] = crate::panels::histogram::clipping_rects(ui, plot); for (name, rect, label, clipped) in [ ("shadows", shadow_rect, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), @@ -169,26 +178,12 @@ pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) let active = app.ui.show_clipping; // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. let box_rect = rect.shrink2(vec2(2.0, 2.0)); - p.rect_filled( - box_rect, - 3.0, - if active { - t.pressed - } else if response.hovered() { - t.hover - } else { - Color32::from_gray(60) - }, - ); + let (tile, mark) = clip_button_colors(&t, active, response.hovered(), clipped == Some(true)); + p.rect_filled(box_rect, 3.0, tile); if active { p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); } - paint( - p, - Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), - Icon::ChevronDown, - if clipped == Some(true) || active { t.text } else { t.text_dim }, - ); + paint(p, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, mark); let status = match clipped { Some(true) => "Clipping detected in this preview.", Some(false) => "No clipping detected in this preview.", @@ -317,10 +312,39 @@ pub(super) fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: Phot divider(ui); } +/// The tile and chevron colours of a clipping button, from the theme's own raised fills: the tile +/// sits one step above the plot's backdrop (`button`) and the chevron (bright when clipping shows, dim otherwise) stands 3:1 on it in every +/// state and theme. +fn clip_button_colors(t: &Tokens, active: bool, hovered: bool, clipped: bool) -> (Color32, Color32) { + let tile = if active || hovered { t.pressed } else { t.hover }; + (tile, if clipped || active { t.text } else { t.text_dim }) +} + #[cfg(test)] mod tests { use super::*; + /// The two clipping chevrons were a fixed dark-grey tile (`from_gray(60)`) with a dimmed mark: on + /// a light theme they were near-black tiles on a light panel, and the mark barely read. + #[test] + fn the_clipping_chevron_stands_3_to_1_against_its_tile_in_every_theme() { + use crate::theme::ThemeKind; + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + for (active, hovered, clipped) in + [(false, false, false), (false, false, true), (false, true, false), (true, false, false), (true, true, true)] + { + let (tile, mark) = clip_button_colors(&t, active, hovered, clipped); + let c = crate::widgets::button::contrast(mark, tile); + assert!(c >= 3.0, "{kind:?} active={active} hovered={hovered} clipped={clipped}: chevron {c:.2}:1 on its tile"); + } + // the idle tile is the theme's own raised fill, not a fixed gray, and a quiet one next to the plot's backdrop + let (idle, _) = clip_button_colors(&t, false, false, false); + assert_ne!(idle, t.button, "{kind:?}: the tile vanishes into the histogram's backdrop"); + assert!(crate::widgets::button::contrast(idle, t.button) < 3.0, "{kind:?}: the tile is a heavy block, not a quiet raised fill"); + } + } + #[test] fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { let ctx = egui::Context::default(); @@ -342,6 +366,21 @@ mod tests { assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); } + /// Issue #2819: the last HDR drag's histogram is kept in `Renderer`; with HDR switched off, a drag must not read it. + #[test] + fn a_drag_with_hdr_off_does_not_read_the_last_hdr_drags_histogram() { + let id = PhotoId(7); + let kept = (id, 3, Histogram::of_hdr(&[[2.0, 2.0, 2.0]], 5.0)); + assert!(kept.2.hdr.is_some()); + // a drag of an HDR edit: the small HDR render's histogram + assert_eq!(live_hdr_histogram(Some(&kept), id, true), Some(kept.2.clone())); + // HDR switched off, a drag of the SDR edit: the panel's own (SDR) histogram + assert_eq!(live_hdr_histogram(Some(&kept), id, false), None); + // another photo, or nothing rendered yet + assert_eq!(live_hdr_histogram(Some(&kept), PhotoId(8), true), None); + assert_eq!(live_hdr_histogram(None, id, true), None); + } + /// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows. #[test] fn reset_tooltip_names_the_shortcut_of_this_platform() { diff --git a/crates/ui-egui/src/panels/edit/sliders.rs b/crates/ui-egui/src/panels/edit/sliders.rs index 00adb9928..78a402087 100644 --- a/crates/ui-egui/src/panels/edit/sliders.rs +++ b/crates/ui-egui/src/panels/edit/sliders.rs @@ -296,6 +296,75 @@ pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, } } +/// Which of the Edit header's buttons were clicked. +#[derive(Default)] +pub(super) struct HeaderClicks { + pub(super) auto: bool, + pub(super) bw: bool, + pub(super) hdr: bool, + pub(super) reset: bool, +} + +/// The Edit header's buttons: Auto, B&W and HDR on the left, Reset on the right. A button that doesn't fit on the +/// row starts the next one (long translations in a narrow panel, such as German *Zurücksetzen* beside HDR), so none +/// covers another; Reset stays right-aligned on whichever row it lands. +pub(super) fn header_buttons(ui: &mut egui::Ui, bw: bool, hdr: bool, reset_tip: &str) -> HeaderClicks { + const GAP: f32 = 8.0; + let mut clicks = HeaderClicks::default(); + let avail = ui.available_width(); + let left = [("auto", "Auto", false), ("bw", "B&W", bw), ("hdr", "HDR", hdr)]; + // rows of `left` indices, filled greedily; `used` is the width taken on the current (last) row + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + let mut used = 0.0; + for (i, (_, label, _)) in left.iter().enumerate() { + let w = crate::widgets::text_button_size(ui, label); + if row.is_empty() { + used = w; + } else if used + GAP + w > avail { + rows.push(std::mem::take(&mut row)); + used = w; + } else { + used += GAP + w; + } + row.push(i); + } + rows.push(row); + let reset_on_last = used + GAP + crate::widgets::text_button_size(ui, "Reset") <= avail; + let reset = |ui: &mut egui::Ui, clicks: &mut HeaderClicks| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + clicks.reset = crate::widgets::text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(reset_tip).clicked(); + }); + }; + let last = rows.len() - 1; + for (r, row) in rows.iter().enumerate() { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = GAP; + for &i in row { + let (id, label, active) = left[i]; + let mut resp = crate::widgets::text_button(ui, id, crate::i18n::tr(label), active); + if id == "hdr" { + // HDR editing, next to B&W as Lightroom has it + resp = resp.on_hover_text(crate::i18n::tr("Edit in HDR: highlights above SDR white, for HDR exports and displays")); + } + let clicked = resp.clicked(); + match id { + "auto" => clicks.auto = clicked, + "bw" => clicks.bw = clicked, + _ => clicks.hdr = clicked, + } + } + if r == last && reset_on_last { + reset(ui, &mut clicks); + } + }); + } + if !reset_on_last { + ui.horizontal(|ui| reset(ui, &mut clicks)); + } + clicks +} + /// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). pub(super) fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { let base = crate::i18n::tr("Reset all edits"); @@ -304,3 +373,54 @@ pub(super) fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String None => base.to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The Edit header's buttons never cover each other or run past the panel, in any language: a button that doesn't + /// fit starts the next row, and Reset stays right-aligned. Before, Reset covered HDR in 11 of the 18 languages at + /// the default 222 pt (German *Zurücksetzen* among them) and in all but the two Chinese ones at some width. 200 pt + /// is about the narrowest panel (`RIGHT_WIDTH.min` less the margins). + #[test] + fn edit_header_buttons_do_not_overlap_in_any_language() { + use crate::i18n::Locale; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut wrapped = Vec::new(); + for &locale in Locale::ALL { + crate::i18n::set_language(locale); + for width in [200.0, 222.0, 260.0, 400.0] { + let mut rects = Vec::new(); + for frame in 0..2 { + let raw = crate::headless::HeadlessView::raw_input(vec2(width, 200.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + header_buttons(ui, false, false, ""); + }); + }); + out.textures_delta.clear(); + rects = crate::widgets::take_registry(&ctx); + } + let get = |id: &str| rects.iter().find(|(i, _)| i == id).map(|(_, r)| *r).unwrap(); + let buttons = ["button:auto", "button:bw", "button:hdr", "button:reset"].map(get); + for (i, a) in buttons.iter().enumerate() { + assert!(a.right() <= width + 0.5, "{locale:?} {width}: button {i} past the edge: {a:?}"); + for b in &buttons[i + 1..] { + assert!(!a.intersects(b.shrink(0.5)), "{locale:?} {width}: {a:?} overlaps {b:?}"); + } + } + let [_, _, hdr, reset] = buttons; + assert!((reset.right() - width).abs() < 0.5, "{locale:?} {width}: Reset stays right-aligned: {reset:?}"); + if reset.top() > hdr.top() { + wrapped.push((locale, width)); + } + } + } + crate::i18n::set_language(Locale::En); + assert!(wrapped.contains(&(Locale::De, 222.0)), "German wraps in the default panel: {wrapped:?}"); + // English keeps its single row from the default width up + assert!(!wrapped.iter().any(|&(l, w)| l == Locale::En && w >= 222.0), "{wrapped:?}"); + } +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ec178223d..b28445d5d 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -59,6 +59,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } return; }; + // the Edit panel's histogram stays above its sliders as they scroll (#2679) + if app.ui.right == RightPanel::Edit && app.ui.histogram { + ui.spacing_mut().item_spacing.y = 0.0; + super::edit::histogram(app, ui, id); + } let mut area = egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]); if let Some(offset) = held { area = area.vertical_scroll_offset(offset); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4c1d71c4f..161fad09b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -185,6 +185,10 @@ pub struct UiState { /// The develop control whose slider is being dragged (geometry sliders show a grid). #[serde(skip)] pub dragging_control: Option, + /// Alt (Option on macOS) is held this frame: a dragged Edit slider shows its temporary + /// preview ([`lightcraft_pipeline::Overlay::for_control`]). + #[serde(skip)] + pub alt_held: bool, pub search: String, /// Focus the search field on the next frame (Edit → Find…). #[serde(skip)] @@ -429,6 +433,7 @@ impl Default for UiState { unnamed_focus: false, hidden_locations: Vec::new(), dragging_control: None, + alt_held: false, external_edits: Vec::new(), was_focused: true, auto_import_at: 0.0, diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index aa535e377..09e7703d7 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -1,6 +1,7 @@ mod activity; mod album_picker; mod album_remove; +mod alt_preview; mod appearance; mod before_after; mod browse_active; diff --git a/crates/ui-egui/src/tests/alt_preview.rs b/crates/ui-egui/src/tests/alt_preview.rs new file mode 100644 index 000000000..7a212869f --- /dev/null +++ b/crates/ui-egui/src/tests/alt_preview.rs @@ -0,0 +1,71 @@ +//! Headless test of the Alt-drag previews (issue #937) through real input: a slider drag, Alt +//! (Option) pressed and released during it, and the loupe's pixels at each step. + +use std::time::Duration; + +use egui::{Event, Modifiers, PointerButton}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 1400.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + if !h.app.ui.section_open("light") { + h.app.ui.toggle_section("light"); + } + h.app.renderer.keep_pixels = true; + h.settle(SETTLE); + h +} + +/// Share of the loupe render's pixels whose channels are all 0 or 255 (a clipping view). +fn binary_share(h: &Headless) -> f64 { + let t = h.app.renderer.textures.get(&crate::render::Slot::Main).expect("loupe render"); + let px = &t.pixels.as_ref().expect("pixels kept").pixels; + let binary = px.iter().filter(|c| [c.r(), c.g(), c.b()].iter().all(|v| *v == 0 || *v == 255)).count(); + binary as f64 / px.len().max(1) as f64 +} + +#[test] +fn alt_during_a_blacks_drag_shows_shadow_clipping_until_released() { + let mut h = detail(); + assert!(binary_share(&h) < 0.5, "the photo itself is no clipping view"); + let track = h.app.widgets.iter().find(|(w, _)| w == "slider:light.blacks").map(|(_, r)| *r).expect("Blacks slider on screen"); + let from = track.center(); + let before = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks; + // a plain drag to the left, button held: the photo as usual + h.app.synthetic.push(Event::PointerMoved(from)); + h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + for i in 1..=8 { + h.app.synthetic.push(Event::PointerMoved(from - egui::vec2(6.0 * i as f32, 0.0))); + } + h.settle(SETTLE); + assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.blacks")); + assert!(binary_share(&h) < 0.5, "no preview without Alt"); + // Alt pressed mid-drag: the shadow clipping view + h.app.synthetic.push(Event::ModifiersChanged(Modifiers::ALT)); + h.settle(SETTLE); + assert!(binary_share(&h) > 0.99, "the clipping view: {}", binary_share(&h)); + // Alt released: the photo again, the drag still on + h.app.synthetic.push(Event::ModifiersChanged(Modifiers::NONE)); + h.settle(SETTLE); + assert!(binary_share(&h) < 0.5, "the photo is back"); + h.app.synthetic.push(Event::PointerButton { + pos: from - egui::vec2(48.0, 0.0), + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + h.settle(SETTLE); + assert_eq!(h.app.ui.dragging_control, None); + let after = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks; + assert!(after < before, "the drag itself still moved Blacks: {before} → {after}"); +} diff --git a/crates/ui-egui/src/tests/panels.rs b/crates/ui-egui/src/tests/panels.rs index 1c65ed184..54575499e 100644 --- a/crates/ui-egui/src/tests/panels.rs +++ b/crates/ui-egui/src/tests/panels.rs @@ -2614,6 +2614,43 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } +#[test] +fn alt_while_dragging_an_edit_slider_shows_its_preview() { + use crate::state::RightPanel; + use lightcraft_pipeline::Overlay; + let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit"})); + let id = h.app.session.active().expect("active photo"); + let d = (*h.app.session.develop_of(id).unwrap_or_default()).clone(); + let view = |h: &Headless| crate::panels::detail::view_overlay(&h.app, &d); + assert_eq!(h.app.ui.right, RightPanel::Edit); + h.app.ui.dragging_control = Some("light.blacks".into()); + assert_eq!(view(&h), Overlay::None, "dragging without Alt shows the photo"); + h.app.ui.alt_held = true; + assert_eq!(view(&h), Overlay::ClipShadows); + for (c, o) in [ + ("light.whites", Overlay::ClipHighlights), + ("light.exposure", Overlay::ClipHighlights), + ("detail.sharpenAmount", Overlay::Grayscale), + ("detail.sharpenRadius", Overlay::SharpenDetail), + ("detail.sharpenMasking", Overlay::SharpenMask), + ("light.contrast", Overlay::None), + ] { + h.app.ui.dragging_control = Some(c.into()); + assert_eq!(view(&h), o, "{c}"); + } + // Alt alone (no drag) or released mid-drag: back to the photo + h.app.ui.dragging_control = None; + assert_eq!(view(&h), Overlay::None); + h.app.ui.dragging_control = Some("light.whites".into()); + h.app.ui.alt_held = false; + assert_eq!(view(&h), Overlay::None); + // the Masking panel's local Blacks slider is not a global clipping preview + h.app.ui.alt_held = true; + h.app.ui.right = RightPanel::Masking; + h.app.ui.dragging_control = Some("light.blacks".into()); + assert_eq!(view(&h), Overlay::None); +} + /// An album's context menu ▸ Import ▸ Import from Folder… (and Import Photos…, Import from Device) /// opens the import review with that album chosen; a folder row doesn't offer it, and an `album` /// the review can't offer (a folder, or an album deleted while the scan ran) leaves its album empty. @@ -2804,6 +2841,30 @@ fn histogram_corners_toggle_clipping_warnings() { assert!(!h.app.ui.show_clipping); } +/// Issue #2679: the histogram stays at the top of the Edit panel while its sliders scroll under it. +#[test] +fn the_histogram_stays_in_view_while_the_sliders_scroll() { + let mut h = demo( + [1400.0, 700.0], + json!({"view": "detail", "right": "edit", "histogram": true, "openSections": ["light", "color", "effects", "detail", "optics"]}), + ); + let (hist, exposure) = (widget(&h, "histogram"), widget(&h, "slider:light.exposure")); + let panel = widget(&h, "panel:right_panel"); + let r = h.request("ui.move", json!({"x": panel.center().x, "y": panel.bottom() - 40.0}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + let r = h.request("ui.scroll", json!({"dy": -300.0}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.step(); + let moved = h.app.widgets.iter().find(|(w, _)| w == "slider:light.exposure").map(|(_, r)| *r); + assert!(moved.is_none_or(|r| r.top() < exposure.top() - 100.0), "the sliders scrolled: {exposure:?} → {moved:?}"); + assert_eq!(widget(&h, "histogram"), hist, "the histogram stayed where it was"); + for id in ["histogram:shadows", "histogram:highlights"] { + assert!(hist.contains_rect(widget(&h, id)), "{id} is still on the histogram"); + } +} + #[test] fn the_histogram_clipping_buttons_stay_clear_of_the_hovered_scroll_bar() { // every section open: the Edit panel scrolls, so its scroll bar runs past the histogram diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 81997de3b..3101a93dc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -924,7 +924,7 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response let t = Tokens::get(ui.ctx()); let font = t.semibold(11.5); let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text); - let size = vec2((galley.size().x + 18.0).max(37.0), 24.0); + let size = vec2(text_button_width(galley.size().x), 24.0); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); register(ui, format!("button:{id}"), r); @@ -941,6 +941,18 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response resp } +/// The width of a [`text_button`] whose label is `text_width` wide. +fn text_button_width(text_width: f32) -> f32 { + (text_width + 18.0).max(37.0) +} + +/// The width [`text_button`] takes for `label` (translated as it would be), to plan a row before drawing it. +pub fn text_button_size(ui: &Ui, label: &str) -> f32 { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(crate::i18n::tr(label).to_string(), t.semibold(11.5), t.text); + text_button_width(galley.size().x) +} + /// A segmented control: `labels` share the available width equally, `per_row` per row. Each segment is /// addressable as `button:{id}-{key}` (keys parallel to labels). Returns the clicked index. pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option, per_row: usize) -> Option { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7f3af507c..75924ef79 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) · **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) · **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 @@ -143,7 +143,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; the plot's five tonal zones also drag to adjust their Basic control (LRC-DEV-HISTOGRAM-DRAG); it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit/histogram.rs`, `crates/ui-egui/src/panels/right.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | stays at the top of the Edit panel while its sliders scroll under it (issue #2679, `the_histogram_stays_in_view_while_the_sliders_scroll`); HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; the plot's five tonal zones also drag to adjust their Basic control (LRC-DEV-HISTOGRAM-DRAG); it follows slider drags of an HDR edit live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag; an edit with HDR switched off keeps its SDR histogram while it is dragged (#2819); the shadow and highlight clipping buttons at its top corners are the theme's raised fill with a chevron that stands 3:1 on it in every theme and state | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | ## D. Search & filter (FILT) @@ -182,12 +182,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 | | LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | | | LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on); see Q. HDR | -| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | +| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | -| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp | -| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) | -| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | -| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp. Alt/Option-drag shows highlight clipping | +| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632). Alt/Option-drag shows shadow clipping (white, clipped channels in colour) | +| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites`, `crates/pipeline/src/visualize.rs` | Alt/Option-drag shows highlight clipping (black, clipped channels in colour) | +| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks`, `crates/pipeline/src/visualize.rs` | Alt/Option-drag shows shadow clipping (white, clipped channels in colour) | | LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*`, `crates/pipeline/src/curve.rs` | the graph draws the renderer's own curve model; dragging the line moves the region under the pointer (one undo step) | | LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | @@ -210,8 +210,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | -| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | -| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960) | +| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking`, `crates/pipeline/src/visualize.rs` | Amount and Masking work; Radius and Detail are stored (XMP round trip) but do not change the render yet (the band is a fixed 0.18 % of the long edge). Alt/Option-drag previews: Amount greyscale, Radius / Detail the detail band sharpening amplifies, Masking the mask (white = sharpened) | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960). Alt/Option-drag on Luminance, Detail and Contrast shows the photo in greyscale | | LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) | | LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | | LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | @@ -817,6 +817,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove |---|---|---| | 2026-10-27 | patch | LR-LIB-LABEL, MENU-PHOTO-LABEL, KEY-LABELS: Clear Color Label (`photo.clearLabel`) is a command of its own, so Help → Keyboard Shortcuts can give it a key (#2980); the Photo ▸ Set Color Label ▸ None row runs it; status unchanged | | 2026-10-25 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K / Ctrl+Alt+Shift+K (`dialog.keywordShortcut`, Set Keyword Shortcut) and ⇧K (`keyword.toggleShortcut`) add and remove the keyword shortcut's keywords; Keyword Shortcut leaves LRC-LIB-KEYWORDS' Not yet list; status counts unchanged (KEYC-METADATA stays 🟡) | +| 2026-10-27 | patch | LR-VIEW-HISTOGRAM: an edit with HDR switched off no longer shows the last HDR drag's histogram while it is dragged (#2819); status unchanged | +| 2026-10-29 | patch | LR-VIEW-HISTOGRAM: the clipping buttons' tile and chevron come from the theme's tokens instead of a fixed dark gray, so the chevron stands 3:1 on its tile in all four themes; status counts unchanged | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥K / Ctrl+Alt+K (`tool.enablePainting`, Photo ▸ Enable Painting) paints the painter box's keyword or stops painting; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K (`panel.editKeywords`, Edit Keywords) shows the Keywords panel with its Keyword List open and opens the picked keyword in Edit Keyword Tag; status counts unchanged (KEYC-METADATA stays 🟡) | @@ -838,6 +840,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged | | 2026-10-24 | patch | Integration batch 43: Integrated main 98702b5c, preserving the new fail-closed GPS metadata check for Original export. Watermark geometry/orientation and HDR JPEG size-limit fixes remain in their shared helpers; no feature-count changes. | | 2026-10-23 | patch | LR-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the merge dialog makes a new preview each time it opens, also after a failed one (#2708); status unchanged | +| 2026-10-23 | patch | LR-VIEW-HISTOGRAM: the histogram stays at the top of the Edit panel while its sliders scroll (#2679); status unchanged | | 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 (🟡) | @@ -964,6 +967,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged | | 2026-10-21 | minor | LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; reference pins stay within their catalog, unavailable or malformed photo ids refused, deleted reference not drawn and its pane released, a switch leaves Reference view; statuses unchanged | +| 2026-10-11 | minor | Alt/Option-drag previews on Exposure, Highlights, Shadows, Whites, Blacks and Sharpening (issue #937); LR-EDIT-DETAIL-SHARPEN ✅ → 🟡: Radius and Detail don't change the render | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raf-compression.md b/docs/raf-compression.md index 0141d893e..29056502e 100644 --- a/docs/raf-compression.md +++ b/docs/raf-compression.md @@ -17,7 +17,7 @@ Fujifilm now uses a guarded colour/tone estimate from each file’s own embedded - Signed prediction errors use adaptive Rice coding: a unary prefix followed by a context-dependent number of bits, with a bounded escape for large errors. Three pair phases have separate even/odd statistics. The 81 signed gradient contexts fold into 41 states. - Lossy quantizers change the error range and reconstruction step. Low-gradient sites retain finer precision (0, 1 or 2), with five folded contexts per precision and statistics that survive main-quantizer changes. The main context statistics reset when its quantizer changes. Their initial sum is `max(2, (range + 32) / 64)`; using 16 as the minimum breaks more heavily quantized files. -Stripes decode in parallel using the existing Rayon dependency. Offsets, sizes, dimensions and entropy reads are checked; truncated data, impossible codes and inconsistent headers return errors. Unknown container compression modes, disagreements between container and stream modes, and compressed X-Trans layouts other than the supported CFA return errors. Header-only probing validates the tables and stripe ranges without decompressing the image. +Stripes decode in parallel using the existing Rayon dependency. The entropy reader keeps up to 64 bits buffered and counts a unary prefix's zeros in one step instead of bit by bit; a property test checks it against reading bit by bit, errors included. Offsets, sizes, dimensions and entropy reads are checked; truncated data, impossible codes and inconsistent headers return errors. Unknown container compression modes, disagreements between container and stream modes, and compressed X-Trans layouts other than the supported CFA return errors. Header-only probing validates the tables and stripe ranges without decompressing the image. ## Verification @@ -36,7 +36,7 @@ The 17 CC0 files live in the gitignored corpus. Their published SHA-256 identiti Unconditional procedural tests cover Bayer/X-Trans, 12/14/16 bits, partial final stripes, quantizer changes, residual signs and escapes, malformed headers and truncated/random entropy streams. Regressions also check overflowing uncompressed row strides, unsupported modes and CFA layouts, and container/stream mode mismatches. -Local release decode measurements: about 0.16–0.24 seconds for 24–40 MP X-Trans files, 0.29 seconds for 50 MP Bayer, and 0.5–0.6 seconds for 100 MP Bayer. These measure sensor decoding, not the full develop/export pipeline. +Local release decode measurements: about 0.16–0.24 seconds for 24–40 MP X-Trans files, 0.29 seconds for 50 MP Bayer, and 0.5–0.6 seconds for 100 MP Bayer. These measure sensor decoding, not the full develop/export pipeline. The buffered prefix count took 13–17 % off the decode time on an Apple M2 (8 cores, minimum of 5 runs, old and new build alternated): X-T2 178 → 152 ms, X-S10 lossless 265 → 226 ms, X-E5 442 → 385 ms, X-H2 448 → 378 ms, GFX 50S 522 → 435 ms, GFX 100 762 → 644 ms, with sensor arrays unchanged bit for bit. App verification imported all four supplied compressed files, confirmed `previewOnly: null`, applied +2 EV exposure edits and inspected headless UI screenshots. CLI JPEG exports at a 1600-pixel long edge succeeded for all four files (0.58–0.76 seconds each, including process startup). diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 7d1fef0d1..0c849b63c 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Sony ILCE-6700 in-file distortion; Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Sony ILCE-6700 in-file distortion; Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices; AHD no longer turns flat noise into a maze pattern) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -194,7 +194,7 @@ sample files. A pointer for contributors, not legal advice. | Stage | Target | LightCraft | Status | |---|---|---|---| -| Bayer demosaic | Adobe's own, plus AI "Raw Details" | AHD (default), PPG, bilinear (`crates/raw/src/demosaic`) | 🟡 quality unmeasured against Lightroom; no Raw Details | +| Bayer demosaic | Adobe's own, plus AI "Raw Details" | AHD (default; blends its horizontal and vertical candidates where neither is clearly more homogeneous, so flat noise doesn't become a maze), PPG, bilinear (`crates/raw/src/demosaic`) | 🟡 quality unmeasured against Lightroom; demosaics before white balance (#1328, PR #1463); no Raw Details | | X-Trans demosaic | ✅ | in-house edge-weighted directional | 🟡 unmeasured | | Foveon | ✅ (older bodies) | — | ⬜ | | Monochrome / LinearRaw | ✅ | handled, no demosaic | ✅ | @@ -331,4 +331,5 @@ Beyond decoding: | 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) | | 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again | | 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) | +| 2026-10-23 | minor | AHD blends its horizontal and vertical candidates where their homogeneity is close, so flat noise no longer becomes a maze pattern; Bayer demosaic row notes the pre-white-balance demosaic (#1328); scores unchanged | | 2026-10-30 | patch | RAF camera look fitted on the vignetting-corrected sensor (`LOOK_VERSION` 13): renders were up to 0.3 EV too bright in midtones and 0.6 EV in shadows; X-S10 mean ΔE00 vs the camera JPEG 2.71 → 2.55 on 60 photos | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ace042f3e..25f8dfa5f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -87,6 +87,15 @@ auto-repeat used to flip the zoom back and forth (`crates/ui-egui/src/space_hand **Keyword shortcut keys (KEYC-METADATA):** `⌘⌥⇧K` (`Ctrl+Alt+Shift+K` on Windows and Linux) is Set Keyword Shortcut… (`dialog.keywordShortcut`): it asks for one or more keywords with commas between them and shows the ones set before; a new name goes inside the default parent, as in the Keywording box. A keyword's Use This as Keyword Shortcut in the Keyword List sets that keyword alone, and the list shows a + after each keyword of the keyword shortcut. `Shift+K` (`keyword.toggleShortcut`) adds the keyword shortcut's keywords to the selected photos, or removes them when every selected photo has them all (the help pages do not say how a mixed selection behaves; this rule is our choice); with no keyword shortcut it does nothing. A photo's context menu offers Add Keyword with the keyword shortcut's keywords. The keyword shortcut is a mark on its keywords in the library's keyword list (`keywordShortcut`, like Put New Keywords Inside This Keyword): a new name is listed, the mark moves with a renamed, moved or merged keyword and goes with a deleted one, and setting it is one undo step. The tests are in `crates/ui-egui/src/tests/keyword_list.rs`, `crates/engine/src/tests/keyword_list.rs` and `crates/catalog/src/keywords/tests.rs`. +**Alt/Option-drag previews (issue #937):** hold Alt (Option on macOS) while dragging an Edit slider, before or during +the drag. Exposure, Highlights and Whites show highlight clipping (black, clipped channels in their colour, white where +all three clip); Shadows and Blacks show shadow clipping (white, black where all three clip). Sharpening Amount shows +greyscale, Radius and Detail the detail band sharpening amplifies, Masking the mask (white = sharpened); luminance Noise +Reduction (Luminance, Detail, Contrast) shows greyscale. Releasing the key or the slider brings the photo back; the +edit, its history and the histogram are unchanged (on a profiled display too). Not yet: mask range sliders and the +Masking panel's local sliders. On Linux desktops that move windows with Alt+drag (KDE Plasma's +default, also some GNOME / Xfce setups) the window manager takes the drag first: set its window-move modifier to Meta. + **Still missing / broken:** - Tab / ⇧Tab (hide the side panels / all panels): Tab moves the keyboard focus here (accessibility), so it is not bound; F5 (hide the top panel) is not bound either. @@ -183,5 +192,6 @@ establish OS keyboard delivery or cross-platform acceptance. | 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-21 | minor | Reference Escape exits to Detail; catalog-local pins and missing/deleted reference handling verified with regressions and native Windows smoke | +| 2026-10-11 | minor | Alt/Option-drag slider previews (clipping, sharpening) and the Linux window-manager note | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | diff --git a/docs/whats-new.md b/docs/whats-new.md index 519af6d20..bdacac52b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -30,6 +30,20 @@ any screen (such as `1e308`) used to queue an infinite or NaN position. A debug build panicked on it and a release build handed it to every widget. Numbers outside ±1,000,000 are now an error, and nothing is queued. +### The histogram stays SDR when HDR is switched off +- After an HDR edit was dragged and HDR was then switched off, dragging a slider showed the old HDR histogram (with its + SDR / HDR colour bar) again until the drag ended. The histogram now follows the edit: an SDR edit shows the SDR + histogram while you drag, too. + +### At most 64 red eye corrections per photo +- Every pixel of a render goes through every red eye correction, so a sidecar, preset or agent with thousands of + them could stall a preview or export for hours. A photo now renders its first 64, and Red Eye refuses a 65th. + +### Reliability +- A damaged or crafted Olympus ORF, Samsung SRW, Sony ARW or Pentax PEF whose strip sizes add up past the largest + 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. + ## 2026-10-29 ### A person's page opens in seconds in a large library @@ -59,6 +73,11 @@ library given with `--library` or `LIGHTCRAFT_LIBRARY`, and the default location on first launch, are still created as before. +### The histogram's clipping buttons read in light themes +- The two buttons at the histogram's top corners (shadow and highlight clipping) are no longer near-black tiles with a + grey chevron. They take the theme's own raised fill, and the chevron stands out from it in Charcoal, Midnight, Silver + and Paper alike. + ## 2026-10-28 ### The empty photo grid names Import Photos' shortcut as it is bound @@ -324,6 +343,18 @@ closed the window. Those three now have the Cut / Copy / Paste menu too, and what is typed in Copyright or Creator is saved when another tab is clicked, where it used to be dropped. +### The histogram stays in view +- The histogram stays at the top of the Edit panel while its sliders scroll under it (issue #2679). It used to scroll + away with them, so it was out of sight while using the sliders further down the panel. + +### No maze pattern in noisy raws +- Flat areas of high-ISO raws, such as a dim wall or a blurred background, could show short horizontal and vertical + strokes instead of grain, often orange and blue under warm light. Noise reduction made them stand out more, because + it smooths the grain around them. The demosaic chose between a horizontal and a vertical guess at each pixel, and + in a flat area the noise alone decided which. Where neither guess is clearly better it now blends the two, so the + noise looks like grain. Edges are as sharp as before. Thumbnails and previews that LightCraft rendered from a raw + are redrawn; the camera's own embedded preview, shown for an unedited raw until its render is ready, is unchanged. + ### Tab leaves the name field in New Album, New Smart Album and the name prompts - In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the @@ -1285,6 +1316,18 @@ - The choice is saved in export presets and Export with Previous. Agents pass `destinationMode: "sameAsOriginal"` (or `"specificFolder"`) to `app.export`; `dir` is then not needed. +### Alt/Option-drag previews +- Hold Alt (Option on macOS) while dragging Exposure, Highlights or Whites to see which areas clip to white: the + photo turns black, fully clipped areas show white and areas clipped in one or two channels show those channels' + colour. Shadows and Blacks do the same for clipping to black, on white (issue #937). +- In Detail ▸ Sharpening the same key shows Amount in greyscale, Radius and Detail as the fine detail being + sharpened, and Masking as the mask: white is sharpened, black is protected. Radius and Detail don't change the + render yet, so their preview stays the same while you drag them. Noise Reduction's Luminance, Detail and Contrast + show the photo in greyscale. +- The preview lasts while the key and the slider are held, and never changes the edit, its history or exports. +- Linux: KDE Plasma (and some other desktops) move windows with Alt+drag. If Alt+drag moves the window instead, + set the window manager's move modifier to Meta. + ### Open Library knows a Lightroom catalog - Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` / `.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The