diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index b7b79067..7b805ae2 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -330,6 +330,28 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "crop.autoStraighten", + "Auto Straighten", + [], + None, + "{} — level the horizon (or plumb lines) with the crop angle", + has_active, + |s, _| { + let id = active(s, "crop.autoStraighten")?; + let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?; + let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default(); + let d = s.develop_of(id).unwrap_or_default(); + let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else { + return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"})); + }; + // Same convention as the crop angle (verified: a horizon descending 6° to the right + // comes out level with angle = level ≈ −6°). + let angle = level.clamp(-45.0, 45.0); + s.execute("crop.straighten", &json!({"angle": (angle * 100.0).round() / 100.0}))?; + Ok(json!({"changed": true, "angle": angle})) + } + ), cmd!( "crop.aspect", "Crop Aspect", diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index b71471f6..f0b48da3 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -77,14 +77,31 @@ impl Buf { impl Drop for Buf { fn drop(&mut self) { if let Some(b) = self.buf.take() { - RETIRED.with(|r| r.borrow_mut().push(b)); + // during thread teardown the list may already be gone: then the buffer leaks + let mut b = Some(b); + let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take())); + if let Some(b) = b { + std::mem::forget(b); + } + } + } +} + +/// Buffers dropped on a thread since its last submit. +struct Retired(std::cell::RefCell>); + +impl Drop for Retired { + /// At thread exit wgpu's own thread-locals may already be destroyed, and dropping a buffer + /// then aborts the process: leak the few buffers left instead (the device owns their memory). + fn drop(&mut self) { + for b in self.0.get_mut().drain(..) { + std::mem::forget(b); } } } thread_local! { - /// Buffers dropped on this thread since its last submit. - static RETIRED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) }; } /// Most bytes kept in the free pool. @@ -233,7 +250,7 @@ impl Gpu { /// Submit command buffers, then make the buffers this thread dropped before reusable. pub fn submit(&self, cmds: impl IntoIterator) { self.queue.submit(cmds); - let retired = RETIRED.with(|r| std::mem::take(&mut *r.borrow_mut())); + let retired = RETIRED.with(|r| std::mem::take(&mut *r.0.borrow_mut())); let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); f.extend(retired); let mut total: u64 = f.iter().map(|b| b.size()).sum(); diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index f2e7c237..99c8a140 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -467,3 +467,23 @@ fn debug_upright_scene() { eprintln!("{m:?}: {:?}", crate::upright::solve(m, &a).0); } } + +/// Sign convention for Auto straighten: a horizon descending 6° to the right needs crop angle −6°, +/// which `level_degrees` must report (the crop panel uses it as the crop angle directly). +#[test] +fn level_degrees_sign_matches_crop_angle() { + let (w, h) = (900usize, 600usize); + let t = 6f32.to_radians().tan(); + let src = Rgb32f::from_fn(w, h, |x, y| { + let yh = h as f32 / 2.0 + (x as f32 - w as f32 / 2.0) * t; + if (y as f32) < yh { + [0.6, 0.7, 0.9] + } else if (((y as f32 - yh) / 30.0) as i32) % 2 == 0 { + [0.05, 0.1, 0.2] + } else { + [0.1, 0.2, 0.3] + } + }); + let deg = crate::upright::level_degrees(&src, &SourceInfo::default(), &DevelopSettings::default()).expect("lines found"); + assert!((deg + 6.0).abs() < 0.5, "{deg}"); +} diff --git a/crates/pipeline/src/upright.rs b/crates/pipeline/src/upright.rs index f6d25056..93dffad0 100644 --- a/crates/pipeline/src/upright.rs +++ b/crates/pipeline/src/upright.rs @@ -569,6 +569,13 @@ pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> A a } +/// The rotation (degrees, clockwise +) that levels `src`'s dominant horizon/plumb lines, if they agree +/// (the consensus rule of Upright Level). Used by the crop panel's Auto straighten. +pub fn level_degrees(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option { + let a = analyze_source(src, info, s); + level_angle(&a.vertical, &a.horizontal) +} + /// The automatic Upright correction for `mode` on `src` (identity for Off and Guided). pub fn auto_transform(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, mode: Upright) -> Homography { if matches!(mode, Upright::Off | Upright::Guided) { diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0ae29c29..b91cba02 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -242,6 +242,10 @@ impl Headless { mod tests { use super::*; + /// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out + /// settle would show a half-rendered UI. + const SETTLE: Duration = Duration::from_secs(120); + fn demo(size: [f32; 2]) -> Headless { let services = crate::Services { png: None, ..Default::default() }; let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); @@ -253,7 +257,7 @@ mod tests { fn demo_grid_snapshot_has_ui_pixels() { let t0 = Instant::now(); let mut h = demo([1200.0, 760.0]); - let img = h.snapshot(Duration::from_secs(20)); + let img = h.snapshot(SETTLE); eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames()); assert_eq!(img.size, [1200, 760]); // not blank: many distinct colours @@ -275,11 +279,13 @@ mod tests { let mut h = demo([900.0, 600.0]); let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); - h.snapshot(Duration::from_secs(20)) + h.snapshot(SETTLE) }; let (a, b) = (shot(), shot()); assert_eq!(a.size, b.size); - let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x != y).count(); + // The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in + // the background), which differ by ≤ 1–2 LSB; anything more is a real difference. + let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count(); assert_eq!(diff, 0, "{diff} pixels differ between two runs"); } @@ -324,7 +330,7 @@ mod tests { assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); - let img = h.snapshot(Duration::from_secs(20)); + let img = h.snapshot(SETTLE); assert_eq!(img.size, [1000, 700]); // Detail: auto-advance moves to the next photo h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); @@ -336,7 +342,7 @@ mod tests { h.request("ui.key", json!({"key": "escape"}), t); assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); // let in-flight renders finish: worker threads must not outlive the test process' TLS - h.settle(Duration::from_secs(20)); + h.settle(SETTLE); } /// The filter bar drives `library.filter` and saves the view as a smart album. @@ -364,19 +370,19 @@ mod tests { h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t); assert_eq!(h.app.session.filter, Default::default()); assert_eq!(h.app.session.visible_cloned().len(), all); - h.settle(Duration::from_secs(20)); + h.settle(SETTLE); } #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); - let r = h.request("ui.screenshot", json!({}), Duration::from_secs(20)); + let r = h.request("ui.screenshot", json!({}), SETTLE); assert_eq!(r["ok"], true, "{r}"); assert_eq!(r["result"]["width"], 800); assert_eq!(r["result"]["height"], 500); let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5)); assert_eq!(r["ok"], true); - let r = h.request("ui.screenshot", json!({"headless": true}), Duration::from_secs(20)); + let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE); assert_eq!(r["result"]["width"], 640, "{r}"); } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 165a7df8..186ffd55 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -28,6 +28,10 @@ pub enum Gesture { start: lightcraft_geom::Rect, angle: f64, }, + /// Straighten tool: a line drawn along something that should be level (or plumb). + StraightenLine { + a: Pos2, + }, CropRotate { start_angle: f64, a0: f32, @@ -330,6 +334,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons guided_overlay(app, ui, resp, map, frame, d); return; } + if app.ui.tool == "straighten" { + straighten_overlay(app, ui, resp); + return; + } let quad = frame_crop_quad(d, frame); let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); let p = ui.painter(); @@ -791,3 +799,41 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } }); } + +/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. +/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. +fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.double_clicked() { + let _ = app.run("crop.autoStraighten", json!({})); + app.ui.tool.clear(); + app.gesture = None; + return; + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + app.gesture = Some(Gesture::StraightenLine { a: q }); + } + let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return }; + let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + let p = ui.painter(); + p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140))); + p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + if resp.drag_stopped() { + app.gesture = None; + let v = b - a; + if v.length() > 8.0 { + let mut deg = v.y.atan2(v.x).to_degrees() as f64; + // fold to the nearest axis: a line closer to vertical straightens plumb lines + while deg > 45.0 { + deg -= 90.0; + } + while deg < -45.0 { + deg += 90.0; + } + let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0})); + } + app.ui.tool.clear(); + } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 0ffd398a..20d63a35 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -191,6 +191,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { control(app, ui, d, c, true); } sub_title(ui, "Vignette"); + { + use lightcraft_develop::VignetteStyle as V; + let styles = [ + (V::HighlightPriority, "Highlight", "highlightPriority"), + (V::ColorPriority, "Color", "colorPriority"), + (V::PaintOverlay, "Paint", "paintOverlay"), + ]; + let items: Vec<(&str, &str)> = styles.iter().map(|(_, l, k)| (*l, *k)).collect(); + let active = styles.iter().position(|(v, _, _)| *v == d.vignette.style); + let mut chosen = None; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + chosen = crate::widgets::segmented(ui, "vignetteStyle", &items, active, 3); + }); + if let Some(i) = chosen { + let _ = app.run( + "develop.merge", + serde_json::json!({"settings": {"vignette": {"style": serde_json::to_value(styles[i].0).unwrap_or_default()}}}), + ); + } + } for c in ["vignette.amount", "vignette.midpoint", "vignette.feather", "vignette.roundness", "vignette.highlights"] { let enabled = c == "vignette.amount" || d.vignette.amount != 0.0; control(app, ui, d, c, enabled); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index cf0321d5..1e2592cf 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -116,6 +116,17 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten")); super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v}))); } + padded(ui, |ui| { + ui.horizontal(|ui| { + let on = app.ui.tool == "straighten"; + if text_button(ui, "straightenTool", "Straighten Tool", on).on_hover_text("Drag along the horizon; double-click for Auto").clicked() { + app.ui.tool = if on { String::new() } else { "straighten".into() }; + } + if text_button(ui, "straightenAuto", "Auto", false).clicked() { + let _ = app.run("crop.autoStraighten", json!({})); + } + }); + }); padded(ui, |ui| { ui.label("Overlay"); { diff --git a/docs/parity.md b/docs/parity.md index a522e01f..dc1a3ccd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -26,9 +26,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | -| F. Edit panel — global adjustments (EDIT) | 35 | 3 | 10 | 1 | 27/28 (96%) | 8/14 (57%) | +| F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | | G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | -| H. Crop & rotate (CROP) | 5 | 4 | 0 | 1 | 5/6 (83%) | 0/3 (0%) | +| H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | | K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | | Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 202 | 106 | 192 | 37 | 150/198 (76%) | 47/144 (33%) | +| **Total** | 205 | 103 | 192 | 37 | 152/198 (77%) | 48/144 (33%) | ## Top gaps @@ -259,7 +259,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | -| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | 🟡 | `ctl:vignette.*`, `crates/pipeline/src/finish.rs` | style (highlight / colour priority / paint) rendered but not selectable in the UI | +| 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` | | @@ -303,8 +303,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `crates/ui-egui/src/panels/detail.rs` | | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect` | no "As Shot"; custom ratio via command params only | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | 🟡 | `cmd:crop.straighten` | rotate by dragging outside the crop; no draw-along-horizon tool / double-click auto | -| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:geometry.upright` (`level`) | via Upright Level; no Auto button in the crop panel | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically | +| 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` | | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |