Merge main (straighten, vignette style) into the polish branch
Also: GPU retired-buffer list leaks its buffers at thread exit instead of dropping them (wgpu's thread-locals may already be gone; the drop aborted the test process intermittently), and headless UI tests wait up to 120 s for renders (they timed out on a loaded machine). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
5f401d2eef
|
|
@ -330,6 +330,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
}
|
||||
),
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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<Vec<wgpu::Buffer>>);
|
||||
|
||||
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<Vec<wgpu::Buffer>> = 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<Item = wgpu::CommandBuffer>) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<f64> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Pos2> = 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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` | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue