From 8120d5a861c627058ee97f232c4b8a1ebcc1438c Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Thu, 15 Oct 2026 12:36:41 +0200 Subject: [PATCH] LR-MERGE-HDR: click the merge preview to zoom in, drag to pan Like Lightroom's merge preview: a click zooms in on the point clicked (one preview pixel per screen pixel, at least twice the fitted size), a drag pans, another click fits again. The zoom stays while options change, so deghost levels and projections can be compared at the same spot. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/merge.rs | 104 +++++++++++++++++++++- crates/ui-egui/src/tests_merge_preview.rs | 74 +++++++++++++++ docs/merge.md | 3 +- docs/parity-checklist.md | 2 +- 5 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/tests_merge_preview.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..30b360c4c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -128,6 +128,8 @@ mod tests_masking_layout; #[cfg(test)] mod tests_menubar; #[cfg(test)] +mod tests_merge_preview; +#[cfg(test)] mod tests_offline; #[cfg(test)] mod tests_open_lightroom; diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index d4c9a0bc6..22f9869f0 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -104,6 +104,9 @@ pub struct MergeState { pub error: Option, /// Options whose preview failed (not retried until they change). pub failed_options: Option, + /// The preview zoomed in by a click: the point of it (0..1 across and down) at the middle of + /// the box. `None` fits it in the box. Kept while the options change, to compare them there. + pub zoom: Option<(f32, f32)>, /// The last finished merge (`{id, path, …}`). pub last_result: Option, /// Photos the dialog merges (the selection when it opened). @@ -158,6 +161,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result { app.merge.error = None; app.merge.preview = None; app.merge.preview_pixels = None; + app.merge.zoom = None; if let Some(t) = app.merge.preview_task.take() { t.cancel.store(true, Ordering::Relaxed); } @@ -356,6 +360,56 @@ fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { app.merge.failed_options.as_ref() == Some(opts) } +/// Where a preview of `size` pixels is drawn in `area`: fitted, or zoomed in on the point `zoom` +/// to one preview pixel per screen pixel (at least twice the fitted size), kept over the box +/// where it covers it. Returns the rectangle and the point actually at the middle. +fn preview_rect(area: egui::Rect, size: [usize; 2], ppp: f32, zoom: Option<(f32, f32)>) -> (egui::Rect, Option<(f32, f32)>) { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let fit = (area.width() / w).min(area.height() / h); + let Some((u, v)) = zoom else { + return (egui::Rect::from_center_size(area.center(), egui::vec2(w * fit, h * fit)), None); + }; + let s = (1.0 / ppp.max(0.1)).max(fit * 2.0); + let img = egui::vec2(w * s, h * s); + // the point clicked to the middle, but no gap at an edge where the image covers the box + let axis = |t: f32, len: f32, lo: f32, centre: f32, span: f32| -> (f32, f32) { + if len <= span { + return (centre - len / 2.0, 0.5); + } + let start = (centre - t.clamp(0.0, 1.0) * len).clamp(lo + span - len, lo); + (start, (centre - start) / len) + }; + let (x, u) = axis(u, img.x, area.left(), area.center().x, area.width()); + let (y, v) = axis(v, img.y, area.top(), area.center().y, area.height()); + (egui::Rect::from_min_size(egui::pos2(x, y), img), Some((u, v))) +} + +/// Click on the preview: zoom in on the point clicked, or back out; drag pans while zoomed in. +fn preview_input(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img: egui::Rect) { + let zoomed = app.merge.zoom.is_some(); + if resp.hovered() { + let cursor = match (zoomed, resp.dragged()) { + (false, _) => egui::CursorIcon::ZoomIn, + (true, false) => egui::CursorIcon::Grab, + (true, true) => egui::CursorIcon::Grabbing, + }; + ui.ctx().set_cursor_icon(cursor); + } + if resp.clicked() { + app.merge.zoom = if zoomed { + None + } else { + resp.interact_pointer_pos() + .map(|q| (((q.x - img.left()) / img.width()).clamp(0.0, 1.0), ((q.y - img.top()) / img.height()).clamp(0.0, 1.0))) + }; + } else if resp.dragged() + && let Some((u, v)) = app.merge.zoom + { + let d = resp.drag_delta(); + app.merge.zoom = Some((u - d.x / img.width(), v - d.y / img.height())); + } +} + /// The dialog body (options on the left, preview on the right). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) { let t = crate::theme::Tokens::get(ui.ctx()); @@ -363,13 +417,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) // preview ui.vertical(|ui| { let box_size = egui::vec2(520.0, 360.0); - let (rect, _) = ui.allocate_exact_size(box_size, egui::Sense::hover()); + let (rect, resp) = ui.allocate_exact_size(box_size, egui::Sense::click_and_drag()); ui.painter().rect_filled(rect, 4.0, t.canvas); crate::widgets::register(ui.ctx(), "merge.preview", rect); if let Some((tex, size, _, _)) = &app.merge.preview { - let s = (box_size.x / size[0] as f32).min(box_size.y / size[1] as f32); - let r = egui::Rect::from_center_size(rect.center(), egui::vec2(size[0] as f32 * s, size[1] as f32 * s)); - ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + let (tex, size) = (tex.id(), *size); + let (r, zoom) = preview_rect(rect, size, ui.ctx().pixels_per_point(), app.merge.zoom); + app.merge.zoom = zoom; + ui.painter_at(rect).image(tex, r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + preview_input(app, ui, &resp, r); } if let Some(task) = &app.merge.preview_task { let (f, stage) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); @@ -488,6 +544,46 @@ fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) - mod tests { use super::*; + fn area() -> egui::Rect { + egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(520.0, 360.0)) + } + + #[test] + fn the_preview_fits_until_clicked() { + let (r, zoom) = preview_rect(area(), [1024, 683], 1.0, None); + assert_eq!(zoom, None); + assert!((r.width() - 520.0).abs() < 0.5 && r.height() < 360.0 && (r.center() - area().center()).length() < 0.5, "{r:?}"); + } + + /// Like Lightroom's: a click zooms in to one preview pixel per screen pixel on the point clicked. + #[test] + fn a_click_zooms_in_on_the_point_clicked() { + // a panorama preview, wide enough that one pixel per pixel is more than twice the fitted size + let (r, zoom) = preview_rect(area(), [1600, 1000], 1.0, Some((0.4, 0.55))); + assert_eq!(r.size(), egui::vec2(1600.0, 1000.0)); + let (u, v) = zoom.unwrap(); + assert!((u - 0.4).abs() < 1e-4 && (v - 0.55).abs() < 1e-4, "{u} {v}"); + let at = r.min + egui::vec2(r.width() * 0.4, r.height() * 0.55); + assert!((at - area().center()).length() < 0.5, "the point clicked is in the middle: {at:?}"); + // on a Retina screen, one preview pixel per physical pixel, but always at least twice the fitted size + let (r, _) = preview_rect(area(), [1024, 683], 2.0, Some((0.5, 0.5))); + assert!((r.width() - 1040.0).abs() < 0.5, "{r:?}"); + } + + /// Zoomed in near a corner (or panned past it), the image still covers the box. + #[test] + fn a_zoomed_preview_stays_over_the_box() { + for p in [(0.0, 0.0), (1.0, 1.0), (-3.0, 7.0)] { + let (r, zoom) = preview_rect(area(), [1024, 683], 1.0, Some(p)); + assert!(r.contains_rect(area()), "{p:?}: {r:?}"); + let (u, v) = zoom.unwrap(); + assert!((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v), "{p:?}: {u} {v}"); + } + // a wide panorama is shorter than the box: centred up and down + let (r, _) = preview_rect(area(), [1024, 120], 1.0, Some((0.9, 0.0))); + assert!((r.center().y - area().center().y).abs() < 0.5 && r.left() < area().left() && r.right() >= area().right(), "{r:?}"); + } + /// Issue #1455: a panorama held to the computer's memory, or to the largest size, says so. #[test] fn a_smaller_panorama_says_why() { diff --git a/crates/ui-egui/src/tests_merge_preview.rs b/crates/ui-egui/src/tests_merge_preview.rs new file mode 100644 index 000000000..09419758d --- /dev/null +++ b/crates/ui-egui/src/tests_merge_preview.rs @@ -0,0 +1,74 @@ +//! Headless tests of the Photo Merge dialog's preview: click to zoom in, drag to pan, click to fit. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(120); + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn press(h: &mut Headless, pos: egui::Pos2, pressed: bool) { + h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); +} + +fn click_at(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(pos)); + press(h, pos, true); + press(h, pos, false); + h.settle(T); +} + +#[test] +fn a_click_on_the_merge_preview_zooms_in_and_a_drag_pans() { + let dir = std::env::temp_dir().join(format!("lc-merge-zoom-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); + h.settle(T); + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + assert!(h.step_until(T, |h| h.app.merge.preview.is_some() && h.app.merge.preview_task.is_none()), "the preview rendered"); + h.settle(T); + assert_eq!(h.app.merge.zoom, None, "the preview opens fitted"); + + let area = rect(&h, "merge.preview"); + // right of and above the middle: a point of the fitted image whatever its aspect + let at = area.center() + egui::vec2(area.width() * 0.2, area.height() * -0.1); + click_at(&mut h, at); + let (u, v) = h.app.merge.zoom.expect("the click zoomed in"); + assert!(u > 0.55 && v < 0.5, "zoomed in on the point clicked ({u}, {v})"); + + // drag left: the image follows the pointer, so a point further right comes to the middle + let from = area.center(); + let to = from - egui::vec2(60.0, 0.0); + h.app.synthetic.push(egui::Event::PointerMoved(from)); + press(&mut h, from, true); + h.step(); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + press(&mut h, to, false); + h.settle(T); + let (u2, v2) = h.app.merge.zoom.expect("still zoomed in after a drag"); + assert!(u2 > u + 0.01 && (v2 - v).abs() < 1e-3, "the drag panned right ({u}, {v}) → ({u2}, {v2})"); + + click_at(&mut h, area.center()); + assert_eq!(h.app.merge.zoom, None, "a second click fits the preview again"); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/docs/merge.md b/docs/merge.md index e3f3e71ad..4a535bb68 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -8,7 +8,8 @@ All algorithms are our own implementations from published papers (cited in the m - **App:** select the photos, then *Photo Merge ▸ HDR…* (`Ctrl+H`), *Panorama…* (`Ctrl+M`) or *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews - the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge + the merge at ≤ 1024 px and re-runs the preview when an option changes; as in Lightroom, a click on the preview + zooms in on that point (drag to pan, click again to fit), and it stays there while options change; *Merge* runs the full merge in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as `-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. *Photo Merge ▸ HDR for Each Bracket Set* merges many brackets in one go with the last HDR options: one HDR per stack among the selected photos, then per diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..2e36fa590 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -352,7 +352,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, click-to-zoom preview (drag pans; the zoom stays while options change), auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` |