Merge PR #1898: LR-MERGE-HEADLESS: merge dialogs remember the last settings per kind

This commit is contained in:
Brandon Thomas
2026-10-18 09:20:21 -04:00
6 changed files with 78 additions and 8 deletions
+5
View File
@@ -300,6 +300,10 @@ pub struct Session {
pub xmp: sidecar::XmpPrefs,
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
pub last_export: Option<serde_json::Value>,
/// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
/// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
/// them. Persisted in prefs.json.
pub last_merge: std::collections::BTreeMap<String, serde_json::Value>,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec<export::ExportPreset>,
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
@@ -461,6 +465,7 @@ impl Session {
library_identity: Arc::new(()),
xmp: sidecar::XmpPrefs::default(),
last_export: None,
last_merge: Default::default(),
export_presets: Vec::new(),
print: Default::default(),
print_templates: Vec::new(),
+4
View File
@@ -207,6 +207,8 @@ struct PrefsFile {
/// Parameters of the last export (for Export with Previous).
#[serde(alias = "last_export")]
last_export: Option<serde_json::Value>,
/// Options of the last Photo Merge, per command.
last_merge: std::collections::BTreeMap<String, serde_json::Value>,
/// The user's export presets.
export_presets: Vec<crate::export::ExportPreset>,
/// Print templates.
@@ -492,6 +494,7 @@ impl Session {
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
self.last_merge = prefs.last_merge;
self.export_presets = prefs.export_presets;
self.print_templates = prefs.print_templates;
self.metadata_presets = prefs.metadata_presets;
@@ -746,6 +749,7 @@ impl Session {
let v = serde_json::to_vec_pretty(&PrefsFile {
xmp: self.xmp,
last_export: self.last_export.clone(),
last_merge: self.last_merge.clone(),
export_presets: self.export_presets.clone(),
print_templates: self.print_templates.clone(),
metadata_presets: self.metadata_presets.clone(),
+19
View File
@@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() {
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn last_merge_settings_persist_with_the_library() {
let lib = temp_dir("merge");
let opts = json!({"align": false, "deghost": "high"});
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.last_merge.insert("merge.hdr".into(), opts.clone());
s.save_prefs().unwrap();
s.close_library().unwrap();
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts));
assert!(!s.last_merge.contains_key("merge.panorama"));
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn default_copyright_and_creator_fill_gaps_on_import() {
let dir = temp_dir("copyright");
+16 -7
View File
@@ -108,8 +108,6 @@ pub struct MergeState {
pub last_result: Option<Value>,
/// Photos the dialog merges (the selection when it opened).
pub ids: Vec<PhotoId>,
/// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings.
pub last: std::collections::HashMap<String, MergeDialog>,
/// HDR for Each Bracket Set: the sets still to merge after the running one.
pub batch: std::collections::VecDeque<Vec<PhotoId>>,
}
@@ -148,7 +146,15 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b
Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None })
}
/// Open the merge dialog for the selection.
/// The options of the last merge of kind `command` started (kept in the library preferences), or
/// the defaults the first time. Fields missing from an older or hand-edited prefs.json take their
/// defaults; options that don't parse at all fall back to the defaults.
pub fn last_options(app: &LightcraftApp, command: &str) -> MergeDialog {
let saved = app.session.last_merge.get(command).and_then(|v| serde_json::from_value::<MergeDialog>(v.clone()).ok());
MergeDialog { command: command.into(), ..saved.unwrap_or_default() }
}
/// Open the merge dialog for the selection, with the options of the last merge of this kind.
pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> {
let ids = app.session.selected();
if ids.len() < 2 {
@@ -161,7 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> {
if let Some(t) = app.merge.preview_task.take() {
t.cancel.store(true, Ordering::Relaxed);
}
app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) });
app.ui.dialog = Some(crate::state::Dialog::Merge { opts: last_options(app, command) });
Ok(json!({"photos": app.merge.ids.len()}))
}
@@ -180,7 +186,10 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result<Value,
guard.progress(0, 1000);
task.guard = Some(guard);
app.merge.final_task = Some(task);
app.merge.last.insert(opts.command.clone(), opts.clone());
app.session.last_merge.insert(opts.command.clone(), opts.params());
if let Err(e) = app.session.save_prefs() {
log::warn!("merge: {e}");
}
Ok(json!({"started": true, "photos": ids.len()}))
}
@@ -192,7 +201,7 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, Strin
return Err("select at least 2 photos to merge".into());
}
app.merge.ids = ids;
let opts = app.merge.last.get(command).cloned().unwrap_or_else(|| MergeDialog::for_command(command));
let opts = last_options(app, command);
start_final(app, &opts)
}
@@ -217,7 +226,7 @@ pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> {
let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) };
app.merge.ids = ids;
let opts = app.merge.last.get("merge.hdr").cloned().unwrap_or_else(|| MergeDialog::for_command("merge.hdr"));
let opts = last_options(app, "merge.hdr");
start_final(app, &opts).map(|_| ())
}
+33
View File
@@ -361,6 +361,39 @@ fn final_merge_shows_a_row_and_cancels() {
let _ = std::fs::remove_dir_all(dir);
}
/// The merge dialog opens with the options of the last merge of its kind (per kind), and "with Last
/// Settings" uses them; a kind never merged opens with the defaults.
#[test]
fn merge_dialog_remembers_the_last_settings_per_kind() {
let (mut h, dir) = bracket("merge-last");
let opts = crate::merge::MergeDialog {
align: false,
deghost: "high".into(),
auto_settings: false,
..crate::merge::MergeDialog::for_command("merge.hdr")
};
crate::merge::open(&mut h.app, "merge.hdr").unwrap();
h.app.ui.dialog = None;
crate::merge::start_final(&mut h.app, &opts).unwrap();
let row = h.app.session.activity.list()[0].id;
h.app.session.activity.cancel(row).unwrap();
assert!(h.step_until(T, |h| h.app.merge.final_task.is_none()));
crate::merge::open(&mut h.app, "merge.hdr").unwrap();
let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") };
assert_eq!(shown, &opts);
crate::merge::open(&mut h.app, "merge.panorama").unwrap();
let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") };
assert_eq!(shown, &crate::merge::MergeDialog::for_command("merge.panorama"));
h.app.ui.dialog = None;
// older or hand-edited preferences: missing fields take their defaults, junk is ignored
h.app.session.last_merge.insert("merge.panorama".into(), json!({"projection": "cylindrical", "command": "merge.hdr"}));
h.app.session.last_merge.insert("merge.hdrPanorama".into(), json!("junk"));
let pano = crate::merge::last_options(&h.app, "merge.panorama");
assert_eq!((pano.command.as_str(), pano.projection.as_str(), pano.auto_crop), ("merge.panorama", "cylindrical", true));
assert_eq!(crate::merge::last_options(&h.app, "merge.hdrPanorama"), crate::merge::MergeDialog::for_command("merge.hdrPanorama"));
let _ = std::fs::remove_dir_all(dir);
}
/// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the
/// rest.
#[test]
+1 -1
View File
@@ -355,7 +355,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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-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…` |
| 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), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). 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…` |
## P. Enhance (ENH)