From b5c7eddf8fc1447df8b7fa315ef4152ddfc1a6dc Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Thu, 15 Oct 2026 17:24:05 -0500 Subject: [PATCH] Photo Merge: Merge a photo named twice only once (#1947) --- crates/engine/src/merge.rs | 6 ++++-- crates/engine/src/tests_merge.rs | 24 ++++++++++++++++++++++++ docs/whats-new.md | 7 +++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 26c69c63d..76bcfd7f2 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -404,10 +404,12 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { } impl Session { - /// Plan a merge of `ids` (catalog order of the given ids is kept). + /// Plan a merge of `ids` (catalog order of the given ids is kept). A photo named more than once is merged once + /// (issue #1947): it adds nothing to the merge, and each copy would be decoded and held in memory. pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result { let mut sources = Vec::new(); - for id in ids { + let mut seen = std::collections::HashSet::new(); + for id in ids.iter().filter(|id| seen.insert(**id)) { let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?; match &p.source { Source::File { path } => sources.push((*id, path.clone())), diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index aaa59acb1..ed524ae3d 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -226,3 +226,27 @@ fn panorama_size_is_held_to_memory() { assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits"); assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB"); } + +/// A photo named twice is merged once (issue #1947): `[a, a, a]` is one photo, too few to merge, and `[a, b, a, b]` +/// merges two frames rather than holding four in memory. +#[test] +fn a_photo_named_twice_is_merged_once() { + let dir = temp_dir("dup-ids"); + let mut paths = Vec::new(); + for (i, b) in lightcraft_merge::synth::bracket_dngs(96, 64, &[-1.0, 1.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, b).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + let (a, b) = (ids[0], ids[1]); + for cmd in ["merge.hdr", "merge.panorama"] { + let e = s.execute(cmd, &json!({"ids": [a, a, a], "preview": true})).unwrap_err(); + assert!(e.to_string().contains("at least 2"), "{cmd}: {e}"); + } + let (kind, finish) = crate::merge::parse("merge.hdr", &json!({})).unwrap(); + let job = s.plan_merge(kind, finish, &[a, b, a, b, a].map(lightcraft_catalog::PhotoId), true).unwrap(); + assert_eq!(job.sources.iter().map(|(p, _)| p.0).collect::>(), vec![a, b]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 97e1367a8..9819ae670 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-15 + +### Photo Merge takes each photo once +- A merge that names the same photo more than once (through `ids` on the control channel, MCP or the CLI) now merges + it once (issue #1947). Naming one photo three times no longer "merges" it with itself, and a long repeated list no + longer decodes the photo again for every entry. + ## 2026-10-14 ### Lightroom radial masks on the right side