diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index f26010e98..2cdf67381 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -142,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3 let (image, alpha) = match opts.max_size { Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => { let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64); - let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell }; + // (Lanczos at 2:1 and more as well: a box average there halves the contrast of + // detail near the output's resolution limit and aliases what is finer) + let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell }; let img = fit(&image, mw as usize, mh as usize, f); let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f)); (img, alpha) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index bc8c3a32b..ce7bf4a65 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> { Ok((a as u32, b as u32)) } -fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) { +/// How a new aspect ratio is turned (issue #2253). +#[derive(Clone, Copy, PartialEq)] +enum Turn { + /// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop + /// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image. + LikeCrop, + /// As given: a custom `[w, h]` taller than wide is a portrait crop. + AsGiven, +} + +fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) { + let r = d.crop.geometry.rect_px(w, h); + let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h }; + let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) }); d.crop.aspect = aspect; - let a = aspect.map(|(aw, ah)| { - // orientation of the crop follows the image (landscape image → landscape crop) - let (aw, ah) = (aw as f64, ah as f64); - if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw } - }); + let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64); let angle = d.crop.geometry.angle; d.crop.geometry = crop_fit_angle(w, h, angle, a); } @@ -628,11 +637,19 @@ pub fn specs() -> Vec { "Crop Aspect", [], None, - "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}", + "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either", has_active, |s, p| { let id = active(s, "crop.aspect")?; let (w, h) = crop_dims(s, id); + // checked first, whatever the aspect: a bad value is an error on every path + let turn = match p.get("orientation") { + Some(Value::String(o)) if o == "crop" => Turn::LikeCrop, + Some(Value::String(o)) if o == "given" => Turn::AsGiven, + Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")), + None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven, + None => Turn::LikeCrop, + }; let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some()); let lock_current = match p.get("aspect").and_then(Value::as_str) { Some("current") => true, @@ -667,7 +684,7 @@ pub fn specs() -> Vec { if aspect.is_none() { d.crop.aspect = None; } else { - set_aspect(d, w, h, aspect); + set_aspect(d, w, h, aspect, turn); } Ok(()) }) diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index f47e099b6..518ce54aa 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -152,6 +152,37 @@ mod tests { } } + /// ⌥-drag views on a profiled display (issue #937): the histogram stays the photo's sRGB one + /// (not the view's, nor the display-primary pixels'), 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, d: Option>| { + session.loupe_job(id, 320, 240, true).unwrap().with_display(d).with_overlay(o).run().rendered.unwrap() + }; + let plain = run(Overlay::None, Some(d.clone())); + let clip = run(Overlay::ShadowClip, Some(d.clone())); + let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma()); + assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}"); + // each channel's mean level: the photo's, as a render without a display profile counts it + let means = |h: &Histogram| { + [&h.r, &h.g, &h.b].map(|c| c.iter().enumerate().map(|(i, n)| i as f64 * f64::from(*n)).sum::() / f64::from(h.total.max(1))) + }; + let srgb = means(&run(Overlay::None, None).histogram); + for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::Grey, Overlay::SharpenMask] { + let v = means(&run(o, Some(d.clone())).histogram); + assert!(srgb.iter().zip(v).all(|(p, v)| (p - v).abs() < 0.05), "{o:?}: the photo's sRGB histogram {srgb:?}, got {v:?}"); + } + 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/import.rs b/crates/engine/src/import.rs index 9aa847b01..84af5acb5 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1566,6 +1566,7 @@ impl Session { display: None, slide: None, preview_cut: None, + display_fit: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25e6ef52f..0bd5c03d7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -186,6 +186,10 @@ pub struct Interaction { pub label: String, pub photo: PhotoId, pub original: Arc, + /// Whether an edit during the interaction changed the crop itself. If none did, the crop the + /// photo shows was fitted by Constrain Crop, and the final commit starts again from the original + /// crop, so Auto Sync carries the edit and not the active photo's fitted crop. + pub crop_edited: bool, } /// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. @@ -881,22 +885,58 @@ impl Session { /// undo step; otherwise it is committed with a history entry. pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> { let now = (self.clock)(); - let settings = Arc::new(settings); - if let Some(i) = &self.interaction + // Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990) + let requested = settings; + let settings = Arc::new(self.fit_constrained_crop(id, requested.clone())); + let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry); + if let Some(i) = &mut self.interaction && i.photo == id { + i.crop_edited |= crop_edited; return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) }); } let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?]; - ops.extend(self.auto_sync_ops(id, &settings, label)); + ops.extend(self.auto_sync_ops(id, &requested, label)); let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } }; self.commit(label, op) } - /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the - /// other selected photos: only the settings that changed; never spot removal or red eye (they - /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor - /// history / snapshot restores. + /// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue + /// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections, + /// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner + /// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as + /// drawn (the render still constrains it). + fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings { + if !d.geometry.constrain_crop { + return d; + } + let Some(photo) = self.catalog.photo(id) else { return d }; + let before = &photo.develop; + let turned_on = !before.geometry.constrain_crop; + let warp_changed = before.geometry != d.geometry + || before.optics != d.optics + || before.orientation != d.orientation + || ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s)); + let crop_edited = before.crop.geometry != d.crop.geometry; + if !(turned_on || (warp_changed && !crop_edited)) { + return d; + } + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + photo.width.max(1) as usize, + photo.height.max(1) as usize, + &d, + true, + photo.embedded_lens.as_ref(), + ); + d.crop.geometry.rect = frame.crop.rect; + d + } + + /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked, + /// before its crop is fitted) over to the other selected photos: only the settings that changed; + /// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process + /// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each + /// photo's own crop: the active photo's fitted crop is never copied over another's. fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec { if !self.auto_sync || self.active() != Some(id) @@ -922,7 +962,7 @@ impl Session { .filter(|x| **x != id) .filter_map(|x| self.develop_of(*x).map(|d| (*x, d))) .filter_map(|(x, d)| { - let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0); + let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0)); (synced != *d).then(|| self.develop_op(x, synced, label)).flatten() }) .collect() @@ -944,7 +984,7 @@ impl Session { } let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?; let original = self.develop_of(id).unwrap_or_default(); - self.interaction = Some(Interaction { label: label.into(), photo: id, original }); + self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false }); Ok(()) } @@ -955,9 +995,15 @@ impl Session { if *cur == *i.original { return Ok(()); } - // Restore the original silently, then commit the final value as one step. + // Restore the original silently, then commit the final value as one step. A crop nobody + // edited goes back to the original's: Constrain Crop fits it again for the final warp, and + // Auto Sync carries the edit, not the active photo's fitted crop (review of #1990). self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?; - self.set_develop(i.photo, (*cur).clone(), &i.label) + let mut last = (*cur).clone(); + if !i.crop_edited { + last.crop.geometry = i.original.crop.geometry; + } + self.set_develop(i.photo, last, &i.label) } pub fn cancel_interaction(&mut self) -> Result<()> { @@ -1249,8 +1295,12 @@ mod tests_albums_local; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_constrain_crop; +#[cfg(test)] mod tests_crop_angle; #[cfg(test)] +mod tests_crop_aspect; +#[cfg(test)] mod tests_denoise; #[cfg(test)] mod tests_export; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index e91f0bfff..32ad1da53 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -84,6 +84,9 @@ pub struct Library { /// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes /// exist only in memory ([`Session::unsaved`]). pub unsaved_error: Option, + /// Why the last write of presets.json failed; `None` once it is written. While set, the preset + /// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266). + presets_error: Option, /// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off). retry_at: Option, /// What forgetting untouched Local records did when the library opened. @@ -577,6 +580,7 @@ impl Session { report, last_error: None, unsaved_error: None, + presets_error: None, retry_at: None, forgot_local: None, presets_saved, @@ -651,10 +655,17 @@ impl Session { let Some(lib) = self.library.as_mut() else { return Ok(()) }; if state.hash == lib.presets_saved.hash { lib.presets_saved = state; + lib.presets_error = None; } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - // the state stays stale: the next save tries again + // the state stays stale: retried by the next command and the frame loop (backing off); + // meanwhile the presets count as unsaved, so the UI warns and a backup refuses log::error!("library: presets: {e}"); + lib.presets_error = Some(format!("presets: {e}")); + lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF); } else { + if lib.presets_error.take().is_some() { + log::info!("library: presets written to disk"); + } lib.presets_saved = state; } Ok(()) @@ -664,7 +675,8 @@ impl Session { /// changed outside [`Session::execute`]). pub fn persist_if_dirty(&mut self) { let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t); - if self.library.is_some() && !self.pending_log.is_empty() && !backing_off { + let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some()); + if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off { let _ = self.persist(); } else if let Some(lib) = self.library.as_mut() { poll_compaction(lib); @@ -672,11 +684,14 @@ impl Session { } /// Changes applied in memory but not yet written to disk because saving failed: the number of - /// queued ops and why (`None` when everything is saved, or no library is open). Retried by - /// every command and by [`Session::persist_if_dirty`]. + /// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library + /// is open). Retried by every command and by [`Session::persist_if_dirty`]. pub fn unsaved(&self) -> Option<(usize, &str)> { let lib = self.library.as_ref()?; - lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e)) + let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()); + let presets = lib.presets_error.as_deref(); + let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some()); + ops.or(presets).map(|why| (n, why)) } /// Flush everything and write a snapshot (on quit). Ends an open interaction first. diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..59dc546e4 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -277,7 +277,9 @@ impl SourceRef { if from.image.width.max(from.image.height) <= *max_edge { return Ok((**from).clone()); } - let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near + // the output's resolution limit and aliases what is finer + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, @@ -761,6 +763,24 @@ pub struct RenderJob { /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option, + /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once + /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up + /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at + /// small ratios and aliases it at large ones. The caches keep the full render. + pub display_fit: Option<(usize, usize)>, +} + +/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). +pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { + use lightcraft_raster::resample::{Filter, fit}; + let f = lightcraft_raster::Image { + width: img.width, + height: img.height, + data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), + }; + let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } } pub struct RenderResult { @@ -784,6 +804,14 @@ pub struct RenderResult { impl RenderJob { /// Render at draft quality (interactive drags). Gets its own result key. + /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see + /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. + pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { + self.display_fit = Some((w, h)); + self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self + } + pub fn draft(mut self) -> Self { if self.request.quality != Quality::Draft { self.request.quality = Quality::Draft; @@ -968,11 +996,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 or grey view: rendered in sRGB (the clipping test and the histogram + // read sRGB values), then converted for the display below + let srgb_view = self.request.overlay.renders_in_srgb(); + let request = if srgb_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 srgb_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); @@ -994,6 +1035,15 @@ impl RenderJob { { cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } + // the display fit, after the caches have the full render + if let Some((fw, fh)) = self.display_fit + && self.request.quality == Quality::Full + { + let (w, h) = (rendered.image.width, rendered.image.height); + if w > fw.max(1) || h > fh.max(1) { + rendered.image = fit_for_display(&rendered.image, fw, fh); + } + } RenderResult { request_id: self.request_id, source_key: self.source_key, @@ -1266,6 +1316,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1319,6 +1370,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1373,6 +1425,7 @@ impl crate::Session { display: None, slide: None, preview_cut, + display_fit: None, }) } @@ -1798,6 +1851,28 @@ mod thumbnail_hash_tests { mod tests { use super::*; + /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; + /// a draft, and a render no larger than the drawn size, are left as rendered. + #[test] + fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; + let long = plain.width.max(plain.height); + assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); + let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); + let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; + assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); + let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); + let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; + assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); + let a = s.loupe_job(id, 1024, 1024, true).unwrap(); + let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); + assert_ne!(a.key, b.key, "the fit is part of the result key"); + } + #[test] fn renders_know_the_original_size_whichever_level_they_come_from() { // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs new file mode 100644 index 000000000..9f01ed05d --- /dev/null +++ b/crates/engine/src/tests_constrain_crop.rs @@ -0,0 +1,147 @@ +//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the +//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so +//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop. + +use lightcraft_geom::{CropGeometry, Rect}; +use lightcraft_pipeline::geometry::Frame; +use serde_json::json; + +use crate::Session; + +fn crop(s: &Session) -> CropGeometry { + s.develop_of(s.active().unwrap()).unwrap().crop.geometry +} + +/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)? +fn fits(s: &Session) -> bool { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let mut d = (*p.develop).clone(); + d.geometry.constrain_crop = true; + let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref()); + let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect); + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9) +} + +fn smaller(a: Rect, b: Rect) -> bool { + a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3 +} + +fn constrain(s: &mut Session, on: bool) { + s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap(); +} + +fn vertical(s: &mut Session, v: f64) { + s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap(); +} + +#[test] +fn a_geometry_slider_tightens_the_crop_while_constrained() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + constrain(&mut s, true); + assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off"); + vertical(&mut s, -60.0); + assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect); + assert!(fits(&s), "and holds no blank corner"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider"); +} + +#[test] +fn ticking_constrain_after_the_warp_tightens_the_crop() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + vertical(&mut s, -60.0); + assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners"); + assert!(!fits(&s)); + constrain(&mut s, true); + assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect); + // unticking keeps the tighter crop, as in Lightroom + let tight = crop(&s).rect; + constrain(&mut s, false); + assert_eq!(crop(&s).rect, tight); +} + +#[test] +fn a_crop_drawn_by_hand_is_kept_as_drawn() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted"); +} + +/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's +/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop. +#[test] +fn auto_sync_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + // B: a small crop off-centre; both constrained + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + // A active, both selected, Auto Sync on: a Geometry edit on A + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + vertical(&mut s, -60.0); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!(db.geometry.vertical, -60.0, "the edit reached B"); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}"); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + assert!(fits(&s), "and fits B's warp"); +} + +/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the +/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B. +#[test] +fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap(); + for v in [-20.0, -40.0, -60.0] { + vertical(&mut s, v); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0)); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}"); + for photo in [a, b] { + s.selection = crate::Selection { ids: vec![photo], active: Some(photo) }; + assert!(fits(&s), "each crop fits its photo's warp"); + } +} + +/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning +/// another section's eye off leaves a crop drawn by hand as it is. +#[test] +fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change"); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap(); + assert!(fits(&s), "the Geometry eye is one: the crop fits again"); +} diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests_crop_aspect.rs new file mode 100644 index 000000000..1b8698691 --- /dev/null +++ b/crates/engine/src/tests_crop_aspect.rs @@ -0,0 +1,86 @@ +//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5", +//! "original") takes the crop's present orientation, landscape or portrait after X, as in +//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop. + +use serde_json::{Value, json}; + +use crate::Session; + +/// The demo photo, landscape, with its crop's width and height in pixels. +fn crop_px(s: &Session) -> (f64, f64) { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let (w, h) = (p.width as f64, p.height as f64); + let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }; + let r = p.develop.crop.geometry.rect_px(w, h); + (r.width(), r.height()) +} + +fn landscape_photo() -> Session { + let mut s = Session::with_demo(); + let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap(); + s.selection = crate::Selection { ids: vec![id], active: Some(id) }; + s +} + +fn aspect(s: &mut Session, p: Value) { + s.execute("crop.aspect", &p).unwrap(); +} + +fn ratio(s: &Session) -> f64 { + let (w, h) = crop_px(s); + w / h +} + +#[test] +fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": "2x3"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s)); + s.execute("crop.rotateAspect", &json!({})).unwrap(); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s)); + // another named ratio after X stays portrait (it used to turn back to the image's landscape) + aspect(&mut s, json!({"aspect": "4x5"})); + assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": "16x9"})); + assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s)); +} + +#[test] +fn a_custom_ratio_is_used_as_given() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3]})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": [3, 2]})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); +} + +/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a +/// landscape photo it lands landscape, whatever photo it was chosen on. +#[test] +fn orientation_overrides_and_is_checked() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); + aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s)); + let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{e}"); +} + +/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked +/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes. +#[test] +fn a_bad_orientation_is_refused_before_anything_is_locked() { + let mut s = landscape_photo(); + let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop.clone(); + for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] { + for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] { + let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}"); + assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched"); + } + } + // the valid words still lock as is + aspect(&mut s, json!({"aspect": "current", "orientation": "crop"})); + assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some()); +} diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests_persist.rs index 4fb3f0234..7ebf8464b 100644 --- a/crates/engine/src/tests_persist.rs +++ b/crates/engine/src/tests_persist.rs @@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() { s.close_library().unwrap(); assert_eq!(s.unsaved(), None); } + +/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as +/// unsaved (the top bar warns, the web backup refuses) until a later save writes it. +#[test] +fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() { + let files = Flaky::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert!(s.unsaved().is_none()); + files.set_failing(true); + s.execute("preset.create", &json!({"name": "Mine"})).unwrap(); + let (n, why) = s.unsaved().expect("the preset is unsaved"); + assert_eq!(n, 1); + assert!(why.contains("presets") && why.contains("disk full"), "{why}"); + let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default(); + assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine")); + // still failing: still unsaved + s.persist().unwrap(); + assert!(s.unsaved().is_some()); + files.set_failing(false); + s.persist().unwrap(); + assert!(s.unsaved().is_none(), "written once the disk works again"); + assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine")); +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7efae6a57..1752d119e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -116,7 +116,8 @@ pub fn last_fallback() -> Option { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Fallback { /// The GPU was not tried: it is unavailable, disabled or closing ([`unavailable_reason`]), or - /// the request is one only the CPU renders (high bit depth, soft proof, HDR range overlay). + /// the request is one only the CPU renders (high bit depth, soft proof, HDR range or sharpening + /// mask overlay). NotTried, /// The render was tried on the GPU and failed, for this reason (the same text /// [`last_fallback`] reports). The CPU renders it instead. @@ -451,10 +452,13 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R /// Whether the GPU renders requests of this kind at all (a given render can still fall back to /// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on -/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it, +/// the CPU, and so does Visualize HDR, which needs the float HDR render, and the sharpening mask, +/// which needs the detail planes. [`try_render`] follows it, /// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart. pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool { - depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange + depth == lightcraft_pipeline::OutputDepth::U8 + && !proofed + && !matches!(overlay, lightcraft_pipeline::Overlay::HdrRange | lightcraft_pipeline::Overlay::SharpenMask) } /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index c877e3d9d..150df5b9c 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -680,6 +680,27 @@ 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); } + // The ⌥-drag views (issue #937): clipping and grey, on an edit that clips both ends. + let mut s = DevelopSettings::default(); + typical(&mut s); + s.light.whites = 60.0; + s.light.blacks = -60.0; + check("grey overlay", &src, &raw, &s, &RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(640, 640) }); + // The clipping views test each channel for exactly 0 / 255 (both renderers draw them with + // `visualize::apply` on their 8-bit image): like Visualize Spots, a 1-LSB difference at the + // threshold flips a pixel, so compare the share of differing pixels, and that the views agree + // on what clips. + for (overlay, edge) in [(Overlay::ShadowClip, 0u8), (Overlay::HighlightClip, 255)] { + let req = RenderRequest { overlay, ..RenderRequest::fit(640, 640) }; + let plain = render(&src, &raw, &s, &RenderRequest::fit(640, 640)).image; + 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; + let clipped = plain.data.iter().filter(|p| p[..3].contains(&edge)).count() as f64 / plain.data.len() as f64; + eprintln!("{overlay:<28?} clipped {:.3}% differing {:.4}%", clipped * 100.0, differ * 100.0); + assert!(clipped > 0.001, "{overlay:?}: the edit clips nothing to compare"); + assert!(differ < 0.002, "{overlay:?}: {differ}"); + } } #[test] diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index eb65d51a2..1fc68be00 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -5,7 +5,7 @@ use lightcraft_color::spline::{Lut1, MonotoneCurve}; use lightcraft_color::transfer::linear_to_srgb; use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle}; use lightcraft_geom::Point; -use lightcraft_raster::Rgba8; +use lightcraft_raster::{Plane, Rgba8}; use crate::colorops::ColorOps; use crate::geometry::Frame; @@ -14,6 +14,36 @@ use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc} use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; +/// Sharpening's Masking as 0..1. +fn sharpen_masking(s: &DevelopSettings) -> f32 { + (s.detail.sharpen_masking / 100.0) as f32 +} + +/// How much sharpening applies at a pixel whose fine detail (log luminance minus its texture +/// blur) is `det`, with Masking `masking` (0..1): everywhere at 0, only on stronger edges above. +#[inline] +fn sharpen_weight(masking: f32, det: f32) -> f32 { + if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 } +} + +/// [`sharpen_weight`] at every pixel of `p` (the view [`crate::Overlay::SharpenMask`] shows). +pub(crate) fn sharpen_weight_plane(p: &Prepared, s: &DevelopSettings) -> Plane { + let masking = sharpen_masking(s); + if masking <= 0.0 { + return Plane::filled(p.log_l.width, p.log_l.height, 1.0); + } + // the detail band the render sharpens (made here when sharpening is off) + let fresh; + let blur = match &p.texture_blur { + Some(b) if (b.width, b.height) == (p.log_l.width, p.log_l.height) => &**b, + _ => { + fresh = lightcraft_raster::blur::gaussian(&p.log_l, crate::local::texture_sigma(p.px_per_long)); + &fresh + } + }; + p.log_l.zip_map(blur, |l, b| sharpen_weight(masking, l - b)) +} + #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); @@ -244,7 +274,7 @@ impl FinishParams { tex, dehaze, sharpen: (s.detail.sharpen_amount / 150.0) as f32, - sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32, + sharpen_mask: sharpen_masking(s), air: air_pre * gain, air_pre, gain, @@ -509,8 +539,7 @@ pub(crate) fn finish_with( let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs()); delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame; if sp != 0.0 { - let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 }; - delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; + delta += sp * 1.3 * det.clamp(-0.8, 0.8) * sharpen_weight(sharpen_mask, det); } } // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 455cec022..c87efaf0f 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -663,8 +663,11 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, } /// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a -/// fresh evaluation. +/// fresh evaluation. For [`Overlay::SharpenMask`], the weight sharpening gets at each pixel. fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + if o == Overlay::SharpenMask { + return Some(finish::sharpen_weight_plane(prep, &plan.settings)); + } 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 679acabfe..b6180086c 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -488,6 +488,11 @@ pub struct PlaneSigmas { pub chroma: Option, } +/// Gaussian sigma of the texture / sharpening detail band at `px_per_long` pixels per long edge. +pub(crate) 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; let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); @@ -508,7 +513,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().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().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/visualize.rs b/crates/pipeline/src/visualize.rs index 8f3cf1884..af5c2c290 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -9,12 +9,20 @@ //! - **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`]. +//! - **Clipping while ⌥-dragging** Blacks / Whites / Exposure ([`Overlay::ShadowClip`], +//! [`Overlay::HighlightClip`]): every output channel at 0 (or 255) shown, everything else blank, +//! so the clipped areas, and the channels that clip, are all that's left to see. +//! - **Sharpening mask while ⌥-dragging Masking** ([`Overlay::SharpenMask`]): the weight the +//! render gives sharpening at each pixel, white on black (the CPU renderer draws it: it needs the +//! detail planes). +//! - **Grey while ⌥-dragging** Sharpening or luminance Noise Reduction ([`Overlay::Grey`]): the +//! render in grey, so detail and noise are judged without colour. use std::borrow::Cow; use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; use lightcraft_color::transfer::srgb_to_linear; -use lightcraft_color::{REC2020, SRGB}; +use lightcraft_color::{LUMA_709, REC2020, SRGB}; use lightcraft_develop::DevelopSettings; use lightcraft_raster::{Plane, Rgba8}; @@ -39,6 +47,17 @@ 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 (⌥ on Blacks): white, black where every channel is 0, and where only some + /// are, the colour of the channels that aren't. + ShadowClip, + /// Highlight clipping (⌥ on Whites / Exposure): black, white where every channel is 255, and + /// where only some are, the colour of those channels. + HighlightClip, + /// Sharpening's weight after Masking (⌥ on Masking): white where it sharpens, black where + /// Masking holds it back. + SharpenMask, + /// The render in grey (⌥ on the Sharpening and luminance Noise Reduction sliders). + Grey, } /// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white. @@ -125,6 +144,10 @@ 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::ShadowClip => (6, 0.0), + Overlay::HighlightClip => (7, 0.0), + Overlay::SharpenMask => (8, 0.0), + Overlay::Grey => (9, 0.0), } } @@ -143,6 +166,10 @@ impl Overlay { opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8, } } + 6 => Overlay::ShadowClip, + 7 => Overlay::HighlightClip, + 8 => Overlay::SharpenMask, + 9 => Overlay::Grey, _ => Overlay::None, } } @@ -156,9 +183,20 @@ 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::ShadowClip => 0x5000, + Overlay::HighlightClip => 0x6000, + Overlay::SharpenMask => 0x7000, + Overlay::Grey => 0x8000, } } + /// The ⌥-drag views that show none of the photo's colours: the clipping views (which test the + /// sRGB values the histogram counts) and the grey ones. A display render draws them in sRGB, + /// then converts, so their histogram (taken before the overlay) is the photo's sRGB one. + pub fn renders_in_srgb(self) -> bool { + matches!(self, Overlay::ShadowClip | Overlay::HighlightClip | Overlay::SharpenMask | Overlay::Grey) + } + /// 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 { @@ -181,12 +219,21 @@ 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. +/// an [`Overlay::Mask`] shows, or the sharpening weight of an [`Overlay::SharpenMask`], 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]>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} + Overlay::ShadowClip => clipping(img, false), + Overlay::HighlightClip => clipping(img, true), + Overlay::Grey => grey(img), + Overlay::SharpenMask => { + if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + mask_view(img, a, MaskView::WhiteOnBlack, [255; 3], 100); + } + } Overlay::PointColorRange(i) => { if let Some(p) = plan.settings.point_colors.get(i as usize) { point_color_range(img, &PointK::new(p)); @@ -224,7 +271,7 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa for (x, px) in row.iter_mut().enumerate() { let a = a.data[y * w + x].clamp(0.0, 1.0); let p = [px[0] as f32, px[1] as f32, px[2] as f32]; - let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let grey = luma8(*px); let out: [f32; 3] = match view { MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op), MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op), @@ -238,6 +285,25 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa }); } +/// Rec. 709 luma of an 8-bit pixel (0..255). +fn luma8(p: [u8; 4]) -> f32 { + LUMA_709[0] * p[0] as f32 + LUMA_709[1] * p[1] as f32 + LUMA_709[2] * p[2] as f32 +} + +/// The clipping view: with `high`, each channel at 255 on, the rest off (black where nothing +/// clips); otherwise each channel at 0 off, the rest on (white where nothing clips). +fn clipping(img: &mut Rgba8, high: bool) { + let on = move |v: u8| if (high && v == 255) || (!high && v != 0) { 255 } else { 0 }; + img.map_in_place(|p| [on(p[0]), on(p[1]), on(p[2]), 255]); +} + +fn grey(img: &mut Rgba8) { + img.map_in_place(|p| { + let g = luma8(p).round().clamp(0.0, 255.0) as u8; + [g, g, g, 255] + }); +} + /// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. const SPOT_SIGMA: f64 = 0.006; @@ -255,7 +321,7 @@ fn spots(img: &mut Rgba8, t: u8, ppl: f64) { } let l = lightcraft_raster::Plane::from_fn(w, h, |x, y| { let p = img.data[y * w + x]; - (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0 + luma8(p) / 255.0 }); let b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32); let thr = spot_threshold(t); @@ -275,7 +341,7 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) { let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]); let [l, ch, h] = lab_to_lch(oklab_from_2020(c)); let a = k.weight(l, ch, h); - let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32; + let grey = luma8(*px); for c in 0..3 { px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8; } @@ -296,7 +362,7 @@ pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) { let i = (y * w + x) * 3; let Some(c) = v.get(i..i + 3) else { continue }; let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2]; - let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55; + let grey = luma8(*p) * 0.55; if lum > 1.0 { let stops = lum.log2(); let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)]; @@ -343,6 +409,66 @@ mod tests { assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37)); } + #[test] + fn clipping_views_show_only_the_clipped_channels() { + // left to right: black, a red that clips only in red, mid grey, white + let src = Rgb32f::from_fn(40, 10, |x, _| match x / 10 { + 0 => [0.0; 3], + 1 => [0.9, 0.02, 0.02], + 2 => [0.18; 3], + _ => [8.0; 3], + }); + let (info, s) = (SourceInfo::default(), DevelopSettings::default()); + let shot = |o: Overlay| render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(40, 10) }); + let plain = shot(Overlay::None).image; + let at = |i: &Rgba8, x: usize| i.data[5 * 40 + x]; + assert_eq!((at(&plain, 5), at(&plain, 35)), ([0, 0, 0, 255], [255; 4]), "the scene clips at both ends"); + assert_eq!(at(&plain, 15)[0], 255, "the red clips in red"); + let hi = shot(Overlay::HighlightClip); + assert_eq!([5, 15, 25, 35].map(|x| at(&hi.image, x)), [[0, 0, 0, 255], [255, 0, 0, 255], [0, 0, 0, 255], [255; 4]]); + let lo = shot(Overlay::ShadowClip); + assert_eq!(at(&lo.image, 5), [0, 0, 0, 255], "black where every channel clips"); + assert_eq!([25, 35].map(|x| at(&lo.image, x)), [[255; 4]; 2], "white where none does"); + // only the view changes: the histogram is the photo's + assert_eq!(hi.histogram.clipping(), shot(Overlay::None).histogram.clipping()); + for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::SharpenMask, Overlay::Grey] { + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::None.key()); + } + } + + #[test] + fn sharpen_mask_view_follows_masking() { + // a hard vertical edge between two flat halves + let src = Rgb32f::from_fn(200, 100, |x, _| if x < 100 { [0.05; 3] } else { [0.4; 3] }); + let info = SourceInfo::default(); + let mut s = DevelopSettings::default(); + s.detail.sharpen_amount = 60.0; + let shot = + |s: &DevelopSettings| render(&src, &info, s, &RenderRequest { overlay: Overlay::SharpenMask, ..RenderRequest::fit(200, 100) }).image; + let at = |i: &Rgba8, x: usize| i.data[50 * 200 + x][0]; + // Masking 0: sharpening applies everywhere + let open = shot(&s); + assert!(open.data.iter().all(|p| *p == [255; 4]), "all white at Masking 0"); + // Masking 100: only the edge is sharpened + s.detail.sharpen_masking = 100.0; + let edge = shot(&s); + assert_eq!((at(&edge, 20), at(&edge, 180)), (0, 0), "flat areas held back"); + assert!(at(&edge, 100).max(at(&edge, 99)) > 200, "the edge sharpened: {}", at(&edge, 100)); + // the mask shows with sharpening off too + s.detail.sharpen_amount = 0.0; + assert_eq!(shot(&s).data, edge.data); + } + + #[test] + fn grey_view_drops_the_colour() { + let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]); + let req = RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(8, 8) }; + let v = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &req).image; + assert!(v.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0)); + } + #[test] fn point_color_range_keeps_the_selected_colour_only() { // left half orange, right half blue diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 3643b4314..3b589ba1b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2694,12 +2694,14 @@ mod tests { h.request("ui.key", json!({"key": "O", "shift": true}), t); assert_eq!(h.app.ui.crop_overlay_orient, 1); assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); - let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some(); - assert!(!locked(&h)); - h.request("ui.key", json!({"key": "A"}), t); + // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks + let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); assert!(locked(&h)); h.request("ui.key", json!({"key": "A"}), t); assert!(!locked(&h)); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + h.request("ui.key", json!({"key": "A"}), t); + assert!(locked(&h)); assert!(!h.app.ui.visualize_spots); } @@ -2881,15 +2883,56 @@ mod tests { assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); assert!(dir.is_dir(), "the folder itself is untouched"); + // a second hidden place: the row lists them, and each comes back on its own (issue #2114: + // the row used to bring every hidden place back at once) + let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); + let _ = std::fs::remove_dir_all(&dir2); + std::fs::create_dir_all(&dir2).unwrap(); + let path2 = dir2.to_string_lossy().to_string(); + for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let menu = |h: &mut Headless| -> Vec { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() + }; + assert!(menu(&mut h).is_empty(), "the list opens from the row"); assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.is_empty()); + let items = menu(&mut h); + assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); + h.settle(SETTLE); + assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); + assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); + assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); + // two hidden again: Restore All brings every one back + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); if !hidden_before { // (with Home hidden for this test, the folder is back inside Home's tree instead) assert!(ids(&mut h).contains(&format!("source:local:{path}"))); } let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); } /// Local: a kept folder stays a root while its subfolders (and other locations) are @@ -4433,6 +4476,43 @@ mod tests { h.settle(SETTLE); } + /// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the + /// loupe render is, not a preview-level render the GPU shrinks. + #[test] + fn a_hover_preview_is_fitted_to_the_drawn_size() { + use crate::render::Slot; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); + let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); + assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); + } + + /// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of + /// rising to the preview level. + #[test] + fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { + use crate::render::Slot; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); + } + #[test] fn trackpad_navigation_keeps_the_image_cursor_between_events() { let mut h = demo([1000.0, 700.0]); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 15434237a..0ef3e0448 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -37,6 +37,9 @@ pub enum Icon { StarFilled, Circle, FlagPick, + /// The Pick flag a photo carries: pennant filled, so a pick reads at a glance (#1013). The + /// outlined [`Icon::FlagPick`] stays for buttons and filters. + FlagPickFilled, FlagReject, Gear, Filmstrip, @@ -387,6 +390,14 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); } + FlagPickFilled => { + pen.line(&[(5.0, 17.0), (5.0, 3.5)]); + // the notched pennant is concave: two convex halves meet at the notch, overlapping + // along the pole side so their anti-aliased edges leave no seam + pen.fill(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (5.0, 7.5)], color); + pen.fill(&[(5.0, 6.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)], color); + pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); + } FlagReject => { pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..b953e1a85 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,8 @@ mod tests_album_picker; #[cfg(test)] mod tests_album_remove; #[cfg(test)] +mod tests_alt_preview; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_before_after; @@ -140,6 +142,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_profile_menu; +#[cfg(test)] mod tests_profiles_layout; #[cfg(test)] mod tests_quit_unsaved; @@ -696,7 +700,10 @@ impl LightcraftApp { if d.crop != lightcraft_develop::Crop::default() { return d.crop.aspect; } - match self.ui.crop_default_aspect.as_ref()? { + // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one + // Lock toggle (remembered as "free") unlocks it for good + let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); + match &remembered { Value::String(s) if s == "original" => { let p = self.session.catalog.photo(photo)?; let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); @@ -715,11 +722,13 @@ impl LightcraftApp { /// Called only after a crop gesture begins, inside its undo transaction. pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it + let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); if self.session.interaction.is_some() && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - && let Some(aspect) = self.ui.crop_default_aspect.clone() && aspect != serde_json::json!("free") - && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})) + // the remembered ratio, turned like the new photo's crop (as a named ratio is) + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) { // A remembered value the command refuses (a hand-edited settings file) would refuse // every crop drag on an untouched photo: forget it and let this drag go ahead. diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 62cbe8968..69b76bcb8 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -132,6 +132,8 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } else { t.icon }; + // the Pick button shows the flag the photo carries: filled while it is a pick + let icon = if on && f == Flag::Pick { Icon::FlagPickFilled } else { icon }; paint(pill_ui.painter(), r.shrink(3.0), icon, c); if resp.clicked() { let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 6c1b5ba18..75e1e91f7 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -345,7 +345,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo x += 5.0 * 13.0 + 4.0; let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0)); match photo.flag { - Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick), + Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPickFilled, t.pick), Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), _ => {} } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 94462e6dc..bff041033 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -145,6 +145,21 @@ pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) } +/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is +/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred +/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its +/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to +/// the sampler. +pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (w, h) = (size[0] as f32, size[1] as f32); + if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { + return rect; + } + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) +} + /// Keep the viewport inside the photo; axes smaller than the viewport stay centred. fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { let bound = |visible: f32, image: f32, centre: f32| { @@ -320,11 +335,11 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { /// Whether an overlay can be rendered on a region tile without changing what the tile means. /// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask -/// alphas as the full render; the other diagnostic overlays either need whole-frame context or -/// deliberately replace the loupe. +/// alphas as the full render, and the clipping and grey views work per pixel of it; the other +/// diagnostic overlays need whole-frame context or deliberately replace the loupe. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { use lightcraft_pipeline::Overlay; - matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. }) + matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. } | Overlay::ShadowClip | Overlay::HighlightClip | Overlay::Grey) } /// What a window render of the loupe needs to know about the view it belongs to. @@ -522,7 +537,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - let overlay = view_overlay(app, &d); + let overlay = view_overlay(app, &d, ui.input(|i| i.modifiers.alt && i.pointer.primary_down())); // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; // `ready` first: `available` would wait for a device still being created) let gpu = interacting @@ -531,7 +546,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); - let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); + let now = ui.input(|i| i.time); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key), now); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -553,23 +569,46 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); } // time the full-size drafts where they are what shows: under a window the whole-frame draft - // is small anyway, and the frame's time is the window's upload - if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { + // is small anyway, and the frame's time is the window's upload. A view the GPU doesn't draw + // (⌥ pressed mid-drag) renders on the CPU and says nothing about the GPU drafts. + if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none() && gpu) { app.drag_scale.observe(key, ms, app.perf.host_frame_ms); } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet - let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); if app.size_hold.holding(now) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } - let want = plan.main_edge; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + // At rest the whole frame renders at no less than the preview source level: the source is then + // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near + // the output's limit) and shrunk once, by the Lanczos display fit below. + // (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized) + let want = if interacting || plan.window_edge.is_some() { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + }; + // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 + // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along + // one axis only. A box that matches the source to rounding is rendered at exactly its size. + let short = |e: usize, a: f32| ((e as f32 / a).round() as usize).max(1); + let (rw, rh) = if aspect >= 1.0 { (want, short(want, aspect)) } else { (short(want, 1.0 / aspect), want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); - let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; + let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger, or the preview level) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); @@ -587,6 +626,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // once it is ready (nothing is committed) let hover_key = match app.hover_preview.clone() { Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize); let key = job.key; app.renderer.request(Slot::Hover, job, 105); (key, h.label) @@ -625,6 +666,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if (split || show_before || split_view) && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more @@ -692,7 +739,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { return "none"; }; - let image_rect = fit_texture_rect(r, tex.size); + let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; @@ -715,14 +762,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); - p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text); + // on the photo itself, so it gets the hover label's dark pill to stay readable over light pictures + let g = p.layout_no_wrap(crate::i18n::tr("Before").to_string(), t.font(12.0), Color32::WHITE); + let bg = label_badge_rect(img_rect, g.size()); + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { let hover_rect = fit_texture_rect(img_rect, tex.size); p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); - let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); + let bg = label_badge_rect(hover_rect, g.size()); p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); shown = "hover"; @@ -910,6 +961,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the +/// photo's top left, inset 8 points, with the `text` size plus padding. +fn label_badge_rect(photo: Rect, text: egui::Vec2) -> Rect { + Rect::from_min_size(photo.left_top() + vec2(8.0, 8.0), text + vec2(16.0, 8.0)) +} + /// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date /// and size, or with the camera and exposure. fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { @@ -1342,13 +1399,42 @@ 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, +/// The view holding ⌥ (Option) while dragging slider `control` shows in the loupe, as in Lightroom +/// (issue #937): clipping on Blacks (shadows) and Whites / Exposure (highlights), the sharpening +/// mask on Masking, the image in grey on the other Sharpening and the luminance Noise Reduction +/// sliders. Releasing ⌥ or the slider brings the photo back; nothing is saved. +pub(crate) fn slider_preview(control: &str) -> Option { + SLIDER_PREVIEWS.iter().find(|(id, _)| *id == control).map(|(_, o)| *o) +} + +/// The controls with a [`slider_preview`], and the view each shows. +pub(crate) const SLIDER_PREVIEWS: [(&str, lightcraft_pipeline::Overlay); 10] = { + use lightcraft_pipeline::Overlay::{Grey, HighlightClip, ShadowClip, SharpenMask}; + [ + ("light.blacks", ShadowClip), + ("light.whites", HighlightClip), + ("light.exposure", HighlightClip), + ("detail.sharpenMasking", SharpenMask), + ("detail.sharpenAmount", Grey), + ("detail.sharpenRadius", Grey), + ("detail.sharpenDetail", Grey), + ("detail.nrLuminance", Grey), + ("detail.nrDetail", Grey), + ("detail.nrContrast", Grey), + ] +}; + +/// The diagnostic overlay the loupe shows (the ⌥-drag preview of the slider being dragged while +/// `alt`: ⌥ is held with the button down; the selected mask, Point Color's visualized range, /// Visualize Spots). -pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { +pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings, alt: bool) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::{MaskView, Overlay}; if app.ui.fullscreen { return Overlay::None; } + if alt && let Some(o) = app.ui.dragging_control.as_deref().and_then(slider_preview) { + 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()) { @@ -2698,7 +2784,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: } x += ph.rating as f32 * 9.0 + 2.0; match ph.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), Flag::None => {} } @@ -2709,10 +2795,21 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: #[cfg(test)] mod preview_geometry_tests { - use super::{fit_rect, fit_texture_rect}; + use super::{fit_rect, fit_texture_rect, label_badge_rect}; use crate::state::Zoom; use egui::{Rect, pos2, vec2}; + /// The "Before" label sits on a dark pill that wraps the text with padding on every side. + #[test] + fn before_label_badge_wraps_the_text() { + let text = vec2(40.0, 14.0); + let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); + let bg = label_badge_rect(photo, text); + assert_eq!(bg.min, pos2(28.0, 38.0)); + assert_eq!(bg.size(), vec2(56.0, 22.0)); + assert!(photo.contains_rect(bg)); + } + #[test] fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); @@ -3052,6 +3149,27 @@ mod pen_tests { } } +#[cfg(test)] +mod snap_tests { + use super::*; + + /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no + /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. + #[test] + fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { + let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); + let s = snap_fit_rect(r, [1088, 720], 1.0); + assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); + // at 1.5 points per pixel the snap is to physical pixels and the size stays in points + let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); + assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); + let other = snap_fit_rect(r, [2560, 1694], 1.0); + assert_eq!(other, r); + let bad = snap_fit_rect(r, [1088, 720], f32::NAN); + assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); + } +} + #[cfg(test)] mod filter_pill_tests { use super::*; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ded1c0bd0..89e134aed 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -169,7 +169,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // content, and the sliders below would run past its edge let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); - egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); + let below = r.rect.bottom(); + egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d, below)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( ui, @@ -429,8 +430,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and -/// Browse…. -fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { +/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and +/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the +/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. +fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); +} + +fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { use lightcraft_engine::presets::{PROFILES, profile_groups}; let t = Tokens::get(ui.ctx()); ui.set_min_width(200.0); @@ -463,12 +470,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for g in profile_groups() { let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { - ui.set_min_width(170.0); - for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p.id, p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p.id, p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } // imported LUT profile groups let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); @@ -477,12 +489,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { - ui.set_min_width(170.0); - for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { - item(ui, "group", &p.id, &p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } ui.separator(); if let Some((name, _)) = app.session.profile_info(cur) { diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index b35335cc9..24ec07c1e 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -162,6 +162,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { Flag::Reject if on => t.reject, _ => Color32::TRANSPARENT, }; + let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon }; let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color })); if resp.clicked() { filter(app, json!({"flag": if on { Value::Null } else { json!(id) }})); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6365b784..9e7e925c5 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -709,7 +709,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } x += photo.rating as f32 * 13.0 + 4.0; match photo.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject), Flag::None => {} } @@ -1010,7 +1010,7 @@ pub fn label_param(l: Option) -> String { } /// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the -/// members of an expanded stack. Clicking it expands/collapses the stack. +/// members of an expanded stack. Clicking or double-clicking it expands/collapses the stack. pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.catalog.stack(sid) else { return }; @@ -1034,7 +1034,8 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: } paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - if resp.clicked() { + // a double-click (Lightroom's way) toggles once: its second click would undo the first + if resp.clicked() && !resp.double_clicked() { let _ = app.run("stack.toggle", json!({"ids": [id.0]})); } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3bb8deba2..107151b1e 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -793,6 +793,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { struct LocalNow { places: LocalPlaces, current: Option, + /// The places removed from Local, as (name, path), in the order they were hidden. + hidden: Vec<(String, String)>, } /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, @@ -817,11 +819,14 @@ fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); let current = browsing.as_ref().map(|b| b.path.clone()); + // a hidden built-in place keeps its name (Home, not the user's folder name) + let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); + let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); if current.is_some() { app.ui.local_browse_root = places.browse_root.clone(); } - Some(LocalNow { places, current }) + Some(LocalNow { places, current, hidden }) } /// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. @@ -846,18 +851,43 @@ fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { } } } - let hidden = app.ui.hidden_locations.len(); + // the way back for hidden places: one comes back from the row itself; several are listed, so + // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) + let hidden = now.hidden.len(); if hidden > 0 { let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) - .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")) - .clicked() - { - let _ = app.run("local.restoreHidden", json!({})); + let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); + if let [(_, only)] = now.hidden.as_slice() { + if resp.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": only})); + } + } else { + egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); } } } +/// The list behind "Show N hidden locations": each place by name (its path on hover), then +/// Restore All. +fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { + for (name, path) in hidden { + let r = ui.button(name).on_hover_text(path); + register(ui.ctx(), format!("local:restoreHidden:{path}"), r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": path})); + ui.close(); + } + } + ui.separator(); + let r = ui.button(crate::i18n::tr("Restore All")); + register(ui.ctx(), "local:restoreHidden:all", r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({})); + ui.close(); + } +} + /// Whether two paths name the same folder, however they are spelled (separators, trailing /// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). pub(crate) fn same_folder(a: &str, b: &str) -> bool { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 953008e0d..6f0a2a690 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -109,16 +109,50 @@ const GIVE_UP_FACTOR: f64 = 2.0; /// The full-size drafts (after the first) a drag times before it may give up on full size. const GIVE_UP_AFTER: usize = 3; -/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. -const SLOW_DRAGS_KEPT: usize = 256; +/// The most (photo, control) pairs [`DragScale`] keeps a verdict on; past it, the one remembered +/// longest ago is forgotten. +const VERDICTS_KEPT: usize = 256; + +/// A host frame time (ms) longer than this is not a texture upload but a window that was hidden, +/// occluded or stalled: it counts as unknown, like a frame time from no host at all. +const FRAME_MS_KNOWN: f64 = 1000.0; + +/// A drag counts against its (photo, control) pair only when its full-size drafts took longer than +/// this (ms): a quarter over the budget, so a drag that only just misses it keeps full size. +const VERDICT_MS: f64 = DRAFT_BUDGET_MS * 1.25; + +/// How long (s) a pair found slow drafts small before a drag of it tries full size again: the +/// machine may only have been busy. Each retry that is slow too doubles the wait, up to +/// [`RETRY_MAX_S`]. A retry costs at most a few slow drafts before the drag gives up on full size. +const RETRY_AFTER_S: f64 = 30.0; + +/// The longest wait (s) before a slow pair tries full size again. +const RETRY_MAX_S: f64 = 240.0; + +/// What [`DragScale`] remembers about the drags of one control on one photo. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Verdict { + /// One drag was over budget at full size: maybe a busy moment, so the next stays full size. + Suspect, + /// Two drags in a row were: drafts are small until `until` (the host's clock, s), then a drag + /// tries full size again; `wait` is how long this verdict holds (s). + Slow { until: f64, wait: f64 }, +} /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it /// ends (a change in the middle would make the resolution jump), unless its full-size drafts are -/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of -/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] -/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT -/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large -/// draft costs a texture upload too, which only the time a draft took to show tells. +/// far over budget. A drag renders at full size when the GPU draws the view and the same control +/// on this photo isn't known to be too slow at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue +/// #330). Whether the GPU drew a render is not enough on its own: some views (a LUT profile, +/// refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large draft +/// costs a texture upload too, which only the time a draft took to show tells. +/// +/// A drag's time is wall-clock time, what the user waits for. So that a moment the machine was busy +/// (another app, a backup) can't make a slider draft small for the rest of the session, a pair is +/// slow only after two drags in a row over [`VERDICT_MS`], and only for a while: after +/// [`RETRY_AFTER_S`] a drag tries full size again, a fast one forgets the verdict and a slow one +/// doubles the wait. A verdict is for its photo only: the same control on another photo (another +/// size, other edits) is timed afresh. #[derive(Clone, Debug, Default)] pub struct DragScale { /// The drag running now: (photo, control) and its scale. @@ -130,26 +164,28 @@ pub struct DragScale { /// The render time (ms) of a draft that reached the screen this frame: the time of the frame /// that uploads it is only known on the next one. pending: Option, - /// Drags whose full-size drafts went over budget. - slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, + /// Pairs whose full-size drafts went over budget, the one remembered longest ago first. + verdicts: std::collections::VecDeque<((lightcraft_catalog::PhotoId, String), Verdict)>, } impl DragScale { /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame - /// render on screen (a render from before the drag is not one of its drafts). - pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option) -> Option { + /// render on screen (a render from before the drag is not one of its drafts); `now` the host's + /// clock (s; egui's `input.time`). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option, now: f64) -> Option { let Some((photo, control)) = drag else { - self.finish(); + self.finish(now); return None; }; match &self.current { Some(((p, c), s)) if *p == photo && c == control => return Some(*s), - Some(_) => self.finish(), + Some(_) => self.finish(now), None => {} } let key = (photo, control.to_string()); - let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + let small = matches!(self.verdict(&key), Some(Verdict::Slow { until, .. }) if now < until); + let s = if gpu && !small { 1.0 } else { CPU_DRAFT_SCALE }; self.current = Some((key, s)); self.seen = shown; Some(s) @@ -167,7 +203,7 @@ impl DragScale { return; } if let Some(render) = self.pending.take() { - let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + let frame = last_frame_ms.filter(|ms| ms.is_finite() && (0.0..=FRAME_MS_KNOWN).contains(ms)).unwrap_or(0.0); if self.samples.len() < 1024 { self.samples.push(render + frame); } @@ -188,21 +224,58 @@ impl DragScale { } } - /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + /// Remember drags of `control` on `photo` as too slow for full-size drafts, for good (tests). #[cfg(test)] pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { - self.slow.insert((photo, control.to_string())); + self.set_verdict((photo, control.to_string()), Some(Verdict::Slow { until: f64::INFINITY, wait: RETRY_MAX_S })); } - /// The drag ended: remember it if its full-size drafts were over budget. - fn finish(&mut self) { - if let Some((key, _)) = self.current.take() - && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) - { - if self.slow.len() >= SLOW_DRAGS_KEPT { - self.slow.clear(); + fn verdict(&self, key: &(lightcraft_catalog::PhotoId, String)) -> Option { + self.verdicts.iter().find(|(k, _)| k == key).map(|(_, v)| *v) + } + + /// Set or (`None`) forget a pair's verdict. A new pair forgets the one remembered longest ago + /// when the list is full (not all of them: each forgotten pair costs one more slow drag). + fn set_verdict(&mut self, key: (lightcraft_catalog::PhotoId, String), verdict: Option) { + let at = self.verdicts.iter().position(|(k, _)| *k == key); + match (at, verdict) { + (Some(i), Some(v)) => { + if let Some(entry) = self.verdicts.get_mut(i) { + entry.1 = v; + } } - self.slow.insert(key); + (Some(i), None) => { + self.verdicts.remove(i); + } + (None, Some(v)) => { + while self.verdicts.len() >= VERDICTS_KEPT { + self.verdicts.pop_front(); + } + self.verdicts.push_back((key, v)); + } + (None, None) => {} + } + } + + /// The drag ended. A drag timed at full size updates its pair's verdict: over [`VERDICT_MS`] + /// a first time makes it suspect, a second slow for [`RETRY_AFTER_S`], and a slow retry + /// doubles the wait; within it, the verdict is forgotten. A drag never timed at full size (it + /// drafted small, or was too short) changes nothing. + fn finish(&mut self, now: f64) { + if let Some((key, _)) = self.current.take() + && let Some(median) = median_after_first(&self.samples) + { + let verdict = if median > VERDICT_MS { + let wait = match self.verdict(&key) { + None => None, + Some(Verdict::Suspect) => Some(RETRY_AFTER_S), + Some(Verdict::Slow { wait, .. }) => Some((wait * 2.0).min(RETRY_MAX_S)), + }; + Some(wait.map_or(Verdict::Suspect, |wait| Verdict::Slow { until: now + wait, wait })) + } else { + None + }; + self.set_verdict(key, verdict); } self.samples.clear(); self.seen = None; @@ -440,10 +513,10 @@ mod tests { fn a_drag_shrinks_the_draft_only_on_the_cpu() { use lightcraft_catalog::PhotoId; let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); } // Given a drag that started before the GPU was ready, it keeps its size until it ends: the @@ -453,33 +526,120 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None), Some(1.0)); + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); } - // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, - // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts - // small; other controls and photos keep full size + /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. + fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale + } + + // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask + // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on + // that photo drafts small; once could be a busy moment. Other controls and photos keep full size #[test] - fn a_drag_over_budget_drafts_small_the_next_time() { + fn a_drag_over_budget_twice_drafts_small_the_next_time() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { d.observe(key, ms, None); d.observe(key, ms, None); // a render is timed once, however many frames show it } - assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); + } + + // Given a pair found slow, it drafts small only for a while: then a drag tries full size + // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict + #[test] + fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); + } + + // Given a drag only just over budget, it doesn't count against the pair (it would flip between + // the two sizes); one clearly over does + #[test] + fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); + } + + // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size + #[test] + fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); + } + + // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it + // can't read tries full size + #[test] + fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } } // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be @@ -489,18 +649,18 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); + d.scale(drag, true, None, 0.0); for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); // hostile times are ignored, never a panic for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); } // Given renders within budget whose frames (the texture upload) take it over, the drag counts @@ -510,50 +670,89 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); - for key in 1..=5 { - d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); } - d.observe(5, 8.0, Some(12.0)); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); // the same renders without the host's frame times (headless) stay within budget let mut headless = DragScale::default(); - headless.scale(drag, true, None); - for key in 1..=6 { - headless.observe(key, 8.0, None); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); } - headless.scale(None, true, None); - assert_eq!(headless.scale(drag, true, None), Some(1.0)); + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); // a hostile frame time counts as nothing, never a panic let mut hostile = DragScale::default(); - hostile.scale(drag, true, None); + hostile.scale(drag, true, None, 0.0); for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { hostile.observe(key, 8.0, Some(frame)); } - hostile.scale(None, true, None); - assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); + } + + // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: + // clearing them all would bring back a slow full-size drag for every pair + #[test] + fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=VERDICTS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); + } + + // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as + // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such + // drags, since one slow drag only makes the pair suspect + #[test] + fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); } // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end, and the next - // drag starts small + // the drag drops to the small draft after a few of them instead of at its end; after a second + // such drag, the next starts small #[test] fn a_drag_far_over_budget_drafts_small_before_it_ends() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); let mut scales = Vec::new(); for key in 1..=8 { d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None)); + scales.push(d.scale(drag, true, None, 0.0)); } // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); } // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f8c8cc0d4..7268d8a2b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -699,7 +699,8 @@ pub struct UiState { /// Mirroring of the triangle / spiral crop guides (0..4). pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, - /// Last explicitly chosen aspect, retained across photos and app launches. + /// Last explicitly chosen aspect, retained across photos and app launches; `None` (nothing + /// chosen yet) crops locked to the photo's own shape, as Lightroom starts (#991). pub crop_default_aspect: Option, pub show_filenames: bool, /// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO · 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..24abcd7ce --- /dev/null +++ b/crates/ui-egui/src/tests_alt_preview.rs @@ -0,0 +1,93 @@ +//! Headless tests of the ⌥-drag previews (issue #937): holding ⌥ (Option) while dragging Blacks, +//! Whites, Exposure or a Sharpening / Noise Reduction slider shows a diagnostic view in the loupe. + +use std::time::Duration; + +use egui::{Event, Modifiers, PointerButton}; +use lightcraft_pipeline::Overlay; +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::detail::{SLIDER_PREVIEWS, slider_preview}; +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 ⌥"); + // ⌥ 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)); + // ⌥ 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}"); +} + +#[test] +fn each_slider_has_its_preview() { + assert_eq!(slider_preview("light.blacks"), Some(Overlay::ShadowClip)); + for c in ["light.whites", "light.exposure"] { + assert_eq!(slider_preview(c), Some(Overlay::HighlightClip), "{c}"); + } + assert_eq!(slider_preview("detail.sharpenMasking"), Some(Overlay::SharpenMask)); + for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] { + assert_eq!(slider_preview(c), Some(Overlay::Grey), "{c}"); + } + for c in ["light.contrast", "color.saturation", "detail.nrColor"] { + assert_eq!(slider_preview(c), None, "{c}"); + } + // every mapped id is a real control, once (a rename would silently drop its preview) + for (i, (c, _)) in SLIDER_PREVIEWS.iter().enumerate() { + assert!(lightcraft_develop::controls::find(c).is_some(), "{c}"); + assert!(!SLIDER_PREVIEWS[..i].iter().any(|(d, _)| d == c), "{c} twice"); + } +} diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 7aafb212e..6c85ecf59 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -172,3 +172,48 @@ fn an_unedited_raws_thumbnail_skips_the_sensor_decode() { assert!(!skips_the_real_render(false, true)); assert!(!skips_the_real_render(false, false)); } + +/// Press and release the primary button at `at`, one frame apart from the next click (as a real +/// double-click comes in: one click per frame). +fn click_at(h: &mut Headless, at: egui::Pos2) { + h.events.push(egui::Event::PointerMoved(at)); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); +} + +/// Double-clicking a stack badge expands or collapses the stack, as in Lightroom (issue #2233): its +/// second click used to undo the first, and the stack stayed as it was. A single click still toggles it. +#[test] +fn double_clicking_a_stack_badge_toggles_the_stack_once() { + let mut h = grid(ViewMode::PhotoGrid); + let top = PhotoId(2001); + let sid = StackId(21); + let collapsed = |h: &Headless| h.app.session.catalog.stack(sid).unwrap().collapsed; + select(&mut h, top); + let r = h.request("engine.execute", json!({"command": "stack.toggle", "params": {"ids": [top.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(collapsed(&h)); + for want in [false, true] { + let badge = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn").center(); + click_at(&mut h, badge); + // the first click re-lays the grid; the second must still land on this badge, or the test + // would pass without the fix (a missed second click toggles nothing) + let moved = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn"); + assert!(moved.contains(badge), "the badge moved from under the pointer: {moved:?}"); + click_at(&mut h, badge); + h.settle(SETTLE); + assert_eq!(collapsed(&h), want, "a double-click toggles the stack once"); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a double-click on the badge doesn't open the photo"); + // the next double-click is apart from this one + for _ in 0..60 { + h.step(); + } + } + let badge = widget(&h, &format!("stack:{}", top.0)).unwrap().center(); + click_at(&mut h, badge); + h.settle(SETTLE); + assert!(!collapsed(&h), "a single click toggles it"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 4e397208e..0ab11654b 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -159,23 +159,23 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { let d = develop(&h); assert!(d.masks[0].adjust.exposure > 0.0); assert!(d.masks[0].visible, "hiding the overlay must not disable the mask"); - assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None); let mask = d.masks[0].clone(); let r = h.request("ui.key", json!({"key": "o"}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.app.ui.mask_overlay, "O can restore the overlay"); assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. })); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::Mask { .. })); // Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected // mask overlay so it is not shadowed by the always-on single-mask overlay. exec(&mut h, "view.maskOverlayAll", json!({})); assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. })); exec(&mut h, "view.maskOverlayAll", json!({})); assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off"); - assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. })); // Failed edits and global adjustments must not hide the mask overlay. assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err()); @@ -274,12 +274,12 @@ fn mask_overlay_keys_and_pins() { assert_eq!(h.app.session.active_mask, Some(2)); // the loupe asks the renderer for the selected mask's overlay let d = develop(&h); - let o = crate::panels::detail::view_overlay(&h.app, &d); + let o = crate::panels::detail::view_overlay(&h.app, &d, false); assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 }); // O toggles it, Shift+O cycles the colour (and leaves the crop overlay alone) h.request("ui.key", json!({"key": "o"}), T); assert!(!h.app.ui.mask_overlay); - assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None); h.request("ui.key", json!({"key": "o"}), T); let crop = h.app.ui.crop_overlay; let colour = h.app.ui.mask_overlay_color; @@ -539,6 +539,55 @@ fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() { assert_eq!(develop(&h), before); } +/// Issue #991: with no aspect ever chosen, a crop starts locked to the photo's own shape (Lightroom's +/// default), so a handle drag keeps the proportions; after the Lock is switched off once, the next +/// photo's crop starts free again. +#[test] +fn a_fresh_crop_starts_locked_to_original_until_the_lock_is_switched_off() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + let before = develop(&h); + assert!(before.crop.aspect.is_none()); + let p = h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().clone(); + let own = (p.width * 100, p.height * 100); + assert_eq!(h.app.crop_tool_aspect(), Some(own), "the Lock shows engaged on an untouched photo"); + let drag = json!({"events": [ + {"kind": "down", "x": 0.995, "y": 0.995}, + {"kind": "drag", "x": 0.85, "y": 0.85}, + {"kind": "drag", "x": 0.75, "y": 0.75}, + {"kind": "up", "x": 0.75, "y": 0.75} + ]}); + let r = h.request("ui.pointer", drag.clone(), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(develop(&h).crop.aspect, Some(own), "the drag kept the photo's proportions"); + assert_eq!(h.app.ui.crop_default_aspect, None, "nothing was chosen explicitly"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before); + // Lock off once: remembered as free, and from then on the same drag crops without a ratio + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + h.settle(SETTLE); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.crop_tool_aspect(), None); + let before = develop(&h); + // a rotating drag outside the box (any crop drag starts cropping); the release on its own + for events in [ + json!([{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]), + json!([{"kind": "up", "x": -0.05, "y": 0.3}]), + ] { + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_ne!(develop(&h).crop, before.crop, "the drag cropped"); + assert_eq!(develop(&h).crop.aspect, None, "and left the ratio free"); + let _ = drag; +} + /// A remembered ratio the crop command refuses (a hand-edited settings file) is forgotten, and the /// drag still crops: it must not block every crop of an untouched photo. #[test] diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 070921256..7e4b9f37e 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -212,7 +212,10 @@ mod in_the_loupe { h.settle(SETTLE); let id = h.app.session.active().expect("a photo"); let d = h.app.session.develop_of(id).expect("settings"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown"); + assert!( + matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::Mask { .. }), + "the mask overlay is shown" + ); assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan"); let main = rendered_long_edge(&h); assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); @@ -250,7 +253,7 @@ mod in_the_loupe { let id = h.app.session.active().expect("a photo"); let d = h.app.session.develop_of(id).expect("settings"); assert!( - matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::AllMasks { .. }), + matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::AllMasks { .. }), "the all-masks overlay is shown" ); let main = rendered_long_edge(&h); diff --git a/crates/ui-egui/src/tests_profile_menu.rs b/crates/ui-egui/src/tests_profile_menu.rs new file mode 100644 index 000000000..3bd644467 --- /dev/null +++ b/crates/ui-egui/src/tests_profile_menu.rs @@ -0,0 +1,52 @@ +//! #2059: the profile dropdown and its submenus stay inside the window. A folder of many imported +//! profiles used to make its submenu run past the window's bottom edge, and the profiles below it +//! could not be reached. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(5); +const SETTLE: Duration = Duration::from_secs(10); +const FOLDER: &str = "Imported"; + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn go(h: &mut Headless, method: &str, id: &str) { + let r = h.request(method, json!({"id": id}), T); + assert_eq!(r["ok"], true, "{method} {id}: {r}"); + h.settle(SETTLE); +} + +#[test] +fn a_folder_of_many_imported_profiles_scrolls_inside_the_window() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + for i in 0..80 { + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: format!("lut:imported-{i}"), + name: format!("Imported {i}"), + group: FOLDER.into(), + file: String::new(), + }); + } + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + go(&mut h, "ui.clickWidget", "dropdown:profile"); + let menu = widget(&h, "menu-level:1").expect("the profile menu is open"); + assert!(menu.bottom() <= h.size.y + 0.5, "the profile menu {menu:?} runs past the window's bottom {}", h.size.y); + go(&mut h, "ui.hoverWidget", &format!("profileMenu:lutGroupMenu:{FOLDER}")); + let sub = widget(&h, "menu-level:2").expect("the imported folder's submenu is open"); + assert!(sub.bottom() <= h.size.y + 0.5, "the submenu {sub:?} runs past the window's bottom {}", h.size.y); + // Its 80 rows don't all fit: some are scrolled out of view rather than drawn off screen. + let rows: Vec = (0..80).filter_map(|i| widget(&h, &format!("profileMenu:group:lut:imported-{i}"))).collect(); + assert!(!rows.is_empty(), "no imported profile rows drawn"); + assert!(rows.iter().any(|r| !sub.contains_rect(*r)), "every row fits a 900 pt window"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1ee6e564b..bc33df252 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; ⌥-drag previews on Blacks / Whites / Exposure / Sharpening / luminance NR, #937; LR-VIEW-CLIPPING and LR-EDIT-DETAIL-SHARPEN downgraded to 🟡: J doesn't paint clipped pixels, Radius / Detail unused) · **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 @@ -28,10 +28,10 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 16 | 1 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 43 | 3 | 3 | 1 | 27/28 (96%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 419 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) | +| **Total** | 417 | 63 | 59 | 35 | 192/201 (96%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.7% of 176. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.2%** of 539 in-scope rows — P0 97.8% of 201 · P1 95.0% of 161 · P2 55.7% of 176. ## Top gaps and shortcut notes @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | @@ -103,11 +103,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | | LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | -| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | @@ -140,7 +140,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | 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-CLIPPING | Clipping indicators | P0 | 🟡 | `cmd:view.clipping`, `crates/ui-egui/src/panels/detail.rs` (`clipping_overlay`, `slider_preview`) | J only frames the photo red (highlights) / blue (shadows) when any pixel clips; it doesn't paint the clipped pixels. ⌥-dragging Blacks / Whites / Exposure shows the clipped pixels and channels (`Overlay::ShadowClip` / `HighlightClip`, issue #937) | | 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; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | | 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; | @@ -180,12 +180,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` | ⌥-drag: highlight clipping view (#937) | | 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-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | ⌥-drag: highlight clipping view, black where nothing clips (#937) | +| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | ⌥-drag: shadow clipping view, white where nothing clips (#937) | | LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | | 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`) | | @@ -206,8 +206,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` | ⌥-drag: Masking shows the sharpening mask white on black, the other sliders the image in grey (#937). Radius and Detail are saved but the render doesn't use them yet (fixed band at 0.18% of the long edge, `local::texture_sigma`) | +| 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); ⌥-drag: the image in grey (#937) | | 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 | ⬜ | | | @@ -220,7 +220,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | -| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests_constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | @@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | @@ -797,6 +797,9 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-20 | patch | LR-VIEW-ZOOM: the 20 ms rule clears a slow-drag verdict only on a drag timed at full size; a drag that drafts small leaves it unchanged, as the code does; status unchanged | +| 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged | +| 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | @@ -876,6 +879,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) | | 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-11 | minor | ⌥-drag previews (issue #937) noted on LR-EDIT-LIGHT-EXPOSURE / WHITES / BLACKS and LR-EDIT-DETAIL-NR; LR-VIEW-CLIPPING ✅ → 🟡 (J frames the photo, doesn't paint clipped pixels), LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach 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/whats-new.md b/docs/whats-new.md index 1c5c9db60..7e7bd4bbe 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-19 + +### Hidden Local locations come back one at a time +- With several locations removed from Local, "Show N hidden locations" now lists them, so each comes back on its own + (Restore All is there too). The row used to bring every hidden location back at once (issue #2114). + ## 2026-10-18 ### Fill Location from GPS: no more neighbouring town in City @@ -8,6 +14,25 @@ and `metadata.reverseGeocode` takes `maxDistance` (metres): past it, City stays empty (State and Country still fill) and the reply names the town it found as `nearestCity`. +### A preset that couldn't be saved is no longer lost silently +- When presets.json can't be written (a full disk, or the browser's storage quota), the new or changed preset now + counts as unsaved, like other changes: the top bar warns, saving is retried, and quitting asks first (issue #2266). + Before, the failure was only logged, and the preset could be lost without any warning. + +### Portrait crop ratios stay portrait +- A crop ratio no longer flips to the photo's orientation (issue #2253): after X turns the crop portrait, picking + another ratio (4 × 5, 2 × 3…) keeps it portrait, as in Lightroom, and a Custom ratio taller than wide gives a + portrait crop. + +## 2026-10-15 + +### Constrain Crop tightens the crop you see +- Geometry ▸ Constrain Crop now changes the crop itself, as in Lightroom (issue #1927). Ticking it, or moving a + Geometry slider, Upright or a lens correction while it is ticked, shrinks the crop about its centre until the blank + corners left by the correction are gone, and the Crop tool shows the tighter crop. Before, it only applied when the + photo was shown outside the Crop tool, so in the Crop panel, where the checkbox is, nothing seemed to change. A crop + you draw by hand is kept as drawn, and unticking keeps the tighter crop. + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve