diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d6c721792..25e6ef52f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// 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, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec, /// 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(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 51655aea5..e91f0bfff 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -207,6 +207,8 @@ struct PrefsFile { /// Parameters of the last export (for Export with Previous). #[serde(alias = "last_export")] last_export: Option, + /// Options of the last Photo Merge, per command. + last_merge: std::collections::BTreeMap, /// The user's export presets. export_presets: Vec, /// Print templates. @@ -492,6 +494,7 @@ impl Session { let prefs = settings.read::(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(), diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index c69bb7da2..4c9d9582d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -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"); diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index d4c9a0bc6..61485a271 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -108,8 +108,6 @@ pub struct MergeState { pub last_result: Option, /// Photos the dialog merges (the selection when it opened). pub ids: Vec, - /// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings. - pub last: std::collections::HashMap, /// HDR for Each Bracket Set: the sets still to merge after the running one. pub batch: std::collections::VecDeque>, } @@ -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::(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 { let ids = app.session.selected(); if ids.len() < 2 { @@ -161,7 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result { 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 Result Result 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(|_| ()) } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 126b3ef58..bce9d2eb1 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -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] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index de7ba697f..aaf2e1dff 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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)