mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0da998f24d
commit
8120d5a861
@@ -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;
|
||||
|
||||
+100
-4
@@ -104,6 +104,9 @@ pub struct MergeState {
|
||||
pub error: Option<String>,
|
||||
/// Options whose preview failed (not retried until they change).
|
||||
pub failed_options: Option<MergeDialog>,
|
||||
/// 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<Value>,
|
||||
/// Photos the dialog merges (the selection when it opened).
|
||||
@@ -158,6 +161,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> {
|
||||
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() {
|
||||
|
||||
@@ -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<u64> = 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);
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
`<name>-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
|
||||
|
||||
@@ -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…` |
|
||||
|
||||
Reference in New Issue
Block a user