diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 8e2e36e70..a04f2cee9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -5,7 +5,7 @@ //! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] //! lightcraft-cli render -o [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] //! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] -//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… +//! lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… //! lightcraft-cli synth-merge hdr|panorama -o DIR //! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu] //! lightcraft-cli commands [--json] @@ -79,9 +79,10 @@ USAGE: -o, --output OUT PNG path (a final screenshot is written here if the script took none) --size WxH window size in points (default 1600x1000) --scale S pixels per point (default 1) - lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… + lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… Photo Merge: writes -HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and - prints the result as JSON. Options: + prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken + less than a second apart at different exposures), e.g. a whole folder. Options: --deghost none|low|medium|high --no-align --bracket N (HDR panorama) --projection auto|spherical|cylindrical|perspective --boundary-warp 0..100 --auto-crop --fill-edges --no-auto-settings @@ -372,7 +373,8 @@ fn merge(args: &[String]) -> Result<(), String> { Some("hdr") => "merge.hdr", Some("panorama" | "pano") => "merge.panorama", Some("hdr-panorama") => "merge.hdrPanorama", - _ => return Err("merge: expected hdr, panorama or hdr-panorama".into()), + Some("hdr-batch") => "merge.hdrBatch", + _ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()), }; let mut p = serde_json::Map::new(); let mut files = Vec::new(); diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 6ffb11f52..1c6063256 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -231,6 +231,60 @@ impl Catalog { } } +impl Catalog { + /// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge + /// results), then the unstacked ones in capture order, a set running while each starts within about a second of the + /// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at + /// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out. + pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec> { + let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some(); + let mut sets: Vec> = Vec::new(); + let mut stacks = HashSet::new(); + let mut loose = Vec::new(); + for id in ids { + match self.stack_of(*id) { + Some(s) if stacks.insert(s.id) => { + let set: Vec = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect(); + if set.len() >= 2 { + sets.push(set); + } + } + Some(_) => {} + None => loose.push(*id), + } + } + // (capture second, file name, id, shutter seconds, EV) + let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose + .iter() + .filter_map(|id| self.photos.get(id)) + .filter(|p| !p.deleted && !merged(p)) + .filter_map(|p| { + let t = iso_seconds(p.captured.as_deref()?)?; + let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?; + let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64; + let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64; + Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2())) + }) + .collect(); + timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2))); + timed.dedup_by_key(|x| x.2); + let mut runs: Vec> = Vec::new(); + for x in timed { + match runs.last_mut() { + Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x), + _ => runs.push(vec![x]), + } + } + for r in runs { + let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4))); + if r.len() >= 2 && hi - lo >= 0.5 { + sets.push(r.iter().map(|x| x.2).collect()); + } + } + sets + } +} + /// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`, /// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse. pub fn iso_seconds(s: &str) -> Option { @@ -358,6 +412,46 @@ mod tests { assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id()); } + #[test] + fn bracket_sets_by_time_and_exposure() { + // (time, shutter, ISO, file name) + let shots = [ + ("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket + ("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"), + ("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"), + ("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it + ("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket + ("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"), + ("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure + ("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"), + ("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart + ("2026-06-27T23:35:01", "2", 100, "N2.ARW"), + ("2026-06-27T23:35:04", "2", 800, "N3.ARW"), + ("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times + ("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"), + ]; + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for (t, sh, iso, name) in shots { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some(t.into()); + p.meta.shutter = sh.into(); + p.meta.iso = Some(iso); + p.meta.aperture = Some(5.6); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + let op = c.group_ops(ids[11], &[ids[12]], true).unwrap(); + c.apply(op).unwrap(); + let sets = c.bracket_sets(&ids); + assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]); + // a stack's merge result on top is left out; a stack selected twice is merged once + let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]); + } + #[test] fn auto_stack_by_capture_time() { let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"]; diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a..cb9904244 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -1,4 +1,4 @@ -//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`. +//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`. //! //! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with //! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames @@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result { Ok(info) } +/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]), +/// one after the other. A set that fails is reported and the others still run. +fn run_batch(s: &mut Session, p: &Value) -> Result { + const ID: &str = "merge.hdrBatch"; + let (kind, finish) = crate::merge::parse("merge.hdr", p)?; + let ids: Vec = s.targets(p); + let sets = s.catalog.bracket_sets(&ids); + if sets.is_empty() { + return Err(bad(ID, crate::merge::NO_BRACKET_SETS)); + } + let (mut merged, mut failed) = (Vec::new(), Vec::new()); + for set in sets { + match s.merge_now(kind.clone(), finish.clone(), &set) { + Ok(v) => merged.push(v), + Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::>(), "error": e.to_string()})), + } + } + Ok(json!({"merged": merged, "failed": failed})) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -69,5 +89,14 @@ pub fn specs() -> Vec { can_merge, |s, p| run(s, "merge.hdrPanorama", p) ), + cmd!( + "merge.hdrBatch", + "HDR for Each Bracket Set", + ["Photo", "Photo Merge"], + None, + "{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}", + can_merge, + run_batch + ), ] } diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9..fe182cebc 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -20,6 +20,10 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +/// Why `merge.hdrBatch` found nothing to merge. +pub const NO_BRACKET_SETS: &str = + "no bracket sets among these photos: select stacks, or photos taken less than a second apart at different exposures"; + /// Long edge of the frames of a preview merge. pub const PREVIEW_EDGE: usize = 1024; @@ -105,20 +109,18 @@ impl MergeJob { use rayon::prelude::*; let done = std::sync::atomic::AtomicUsize::new(0); let n = self.sources.len(); - let frames: std::result::Result, String> = self - .sources - .par_iter() - .map(|(_, path)| { - let bytes = (self.read)(path)?; - // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) - let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) - .map_err(|e| format!("{path}: {e}")); - let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); - f - }) - .collect(); - frames + let one = |(_, path): &(PhotoId, String)| { + let bytes = (self.read)(path)?; + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .map_err(|e| format!("{path}: {e}")); + let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); + f + }; + // Full-size photos are decoded one at a time (the demosaic is parallel inside): decoding several 60 MP raws at + // once multiplied the decoders' scratch memory, and a 7-frame bracket ran out of memory. + if self.preview.is_some() { self.sources.par_iter().map(one).collect() } else { self.sources.iter().map(one).collect() } } /// Run the merge. `progress(fraction, stage)` returns false to cancel. diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index cb42dbc2a..366e72476 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -168,3 +168,34 @@ fn panorama_command_with_auto_crop() { assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// HDR for Each Bracket Set: two stacks of brackets → two merged DNGs, each next to its first photo; photos that form +/// no set are refused. +#[test] +fn hdr_batch_merges_each_stack() { + let dir = temp_dir("hdr-batch"); + let mut paths = Vec::new(); + // different exposures, or the import would take set B for duplicates of set A + for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5])] { + for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &evs).unwrap().into_iter().enumerate() { + let p = dir.join(format!("{set}_{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); + assert_eq!(ids.len(), 6); + let err = s.execute("merge.hdrBatch", &json!({"ids": [ids[0]]})).unwrap_err().to_string(); + assert!(err.contains("no bracket sets"), "{err}"); + s.execute("stack.group", &json!({"ids": ids[..3]})).unwrap(); + s.execute("stack.group", &json!({"ids": ids[3..]})).unwrap(); + let r = s.execute("merge.hdrBatch", &json!({"ids": [ids[0], ids[3]], "autoSettings": false})).unwrap(); + let merged = r["merged"].as_array().unwrap(); + assert_eq!(merged.len(), 2, "{r}"); + assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); + let names: Vec<&str> = merged.iter().map(|m| m["path"].as_str().unwrap()).collect(); + assert!(names[0].ends_with("A_0-HDR.dng") && names[1].ends_with("B_0-HDR.dng"), "{names:?}"); + assert_eq!(s.catalog.photos().count(), 8); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 59aa5286c..95750ba0b 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -12,9 +12,11 @@ //! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer //! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none //! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a -//! tiny weight so fully saturated pixels still get a value. -//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test -//! against the reference: the reference radiance (or, where the reference is clipped/too dark, +//! tiny weight so fully saturated pixels still get a value. Where the reference is at or near its clip, the scene is +//! at least that bright: a frame reading far darker there saw something that moved (a star, the moon, a light) and is +//! left out, so a clipped highlight of the reference is never replaced by content from another moment. +//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 2048 px version, a per-pixel consistency test +//! against the reference: the reference radiance (or, where the reference is clipped or black, //! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that //! disagree by more than a threshold (in log units, with a noise floor) are masked there. Masks //! are dilated and feathered (Gaussian), upsampled, and remove the frame's weight. Their union @@ -87,6 +89,10 @@ pub struct HdrResult { pub lens: Vec, } +/// Long edge (at most) of the frames the deghost test compares: small moving things (a star at 200 mm) still span a few +/// pixels there. +const DEGHOST_EDGE: usize = 2048; + #[inline] fn smoothstep(a: f32, b: f32, x: f32) -> f32 { let t = ((x - a) / (b - a)).clamp(0.0, 1.0); @@ -161,15 +167,9 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) alignments[i] = a; } check(progress, 0.35, "Resampling")?; - let warped: Vec<(usize, Rgb32f, Vec)> = (0..n) - .into_par_iter() - .filter(|&i| i != reference && alignments[i].model != "identity") - .map(|i| { - let (img, v) = align::warp(&frames[i].image, &alignments[i].h); - (i, img, v) - }) - .collect(); - for (i, img, v) in warped { + // one frame at a time (the warp is parallel inside): each warped copy replaces its frame before the next is made + for i in (0..n).filter(|&i| i != reference && alignments[i].model != "identity") { + let (img, v) = align::warp(&frames[i].image, &alignments[i].h); frames[i].image = img; valid[i] = Some(v); } @@ -215,6 +215,9 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) let i_px = y * w + x; let mut acc = [0f64; 3]; let mut ws = 0f64; + // the reference at or near its clip: the radiance is at least this + let mr = maxc(fr[reference].image.data[i_px]); + let bound = if mr >= 0.80 * clips[reference] { mr / k[reference] } else { 0.0 }; for i in 0..n { if vd[i].as_ref().is_some_and(|v| !v[i_px]) { continue; @@ -227,6 +230,9 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) } if i == reference { wt += 1e-9; + } else if bound > 0.0 { + // a frame far darker than that saw something that moved there + wt *= smoothstep(0.35, 0.6, m / k[i] / bound); } if i != reference && let Some((maps, fac)) = gs @@ -362,35 +368,40 @@ fn deghost_maps( ) -> (Vec, usize) { let n = frames.len(); let (w, h) = (frames[0].width(), frames[0].height()); - let fac = w.max(h).div_ceil(1024).max(1); + let fac = w.max(h).div_ceil(DEGHOST_EDGE).max(1); + // the dilation/feather radius was chosen for a 1024 px map: keep it the same share of the picture + let r = (r as f32 * (w.max(h) as f32 / fac as f32 / 1024.0).max(1.0)).round() as usize; let small: Vec<(Rgb32f, Vec)> = (0..n).into_par_iter().map(|i| shrink(&frames[i].image, valid[i].as_deref(), fac)).collect(); let (sw, sh) = (small[0].0.width, small[0].0.height); let rpos = order.iter().position(|&i| i == reference).unwrap_or(0); - let good = |i: usize, p: usize| -> bool { + // `dark`: the share of the clip below which a frame's (averaged) pixel is too noisy to predict from + let good = |i: usize, p: usize, dark: f32| -> bool { let f = &frames[i]; let m = maxc(small[i].0.data[p]); - let dark = if f.raw { 0.01 } else { 0.04 }; small[i].1[p] > 0.99 && m < 0.8 * f.clip && m > dark * f.clip }; + let dark = |i: usize| if frames[i].raw { 0.01 } else { 0.04 }; // predicted reference radiance per pixel let pred: Vec<[f32; 3]> = (0..sw * sh) .into_par_iter() .map(|p| { let rad = |i: usize| small[i].0.data[p].map(|v| v / k[i]); - if good(reference, p) { + // the reference predicts down to a tenth of the other frames' floor (the map's averaging tames its noise): in a + // dark sky the brighter frames must not predict themselves, or their ghosts would look consistent + if good(reference, p, dark(reference) * 0.1) { return rad(reference); } let bright = maxc(small[reference].0.data[p]) >= 0.8 * frames[reference].clip; if bright { for q in (0..rpos).rev() { - if good(order[q], p) { + if good(order[q], p, dark(order[q])) { return rad(order[q]); } } rad(order[0]) } else { for &i in &order[rpos + 1..] { - if good(i, p) { + if good(i, p, dark(i)) { return rad(i); } } diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index 68b9ba4b9..7368e56a3 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -161,3 +161,48 @@ fn deghost_removes_a_moving_object() { let mean = high.ghost.data.iter().sum::() / high.ghost.data.len() as f32; assert!(mean < 0.08, "the overlay is local (mean {mean})"); } + +/// A dark night sky (≈ 0.3 % of the reference's white) with a soft star of peak `peak` (Gaussian, σ = 3 px) at (cx, cy) +/// in truth coordinates. +fn night_with_star(cx: f32, cy: f32, peak: f32) -> Rgb32f { + Rgb32f::from_fn(W + 2 * M, H + 2 * M, |x, y| { + let sky = 0.003 * (1.0 + 0.3 * (x as f32 / (W + 2 * M) as f32)); + let d2 = (x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2); + [sky + peak * (-d2 / 18.0).exp(); 3] + }) +} + +/// Night brackets of a star that moves between the frames (the sky turns during a tripod bracket): its core clips in the +/// reference and in the brighter frame, not in the darkest one. +fn moving_star_frames(xs: [f32; 3]) -> Vec { + let ks = [0.0625f32, 1.0, 8.0]; + (0..3).map(|i| shoot(&night_with_star(xs[i], 210.0 + M as f32, 10.0), ks[i], 0.0, 0.0, 30 + i as u64)).collect() +} + +#[test] +fn a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved() { + let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32]; + let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + assert_eq!(r.reference, 1); + // the reference saw the star clipped here, so the scene is at least that bright; the frames where the star had moved + // read the dark sky there and must not replace it (which left a black hole in real night brackets) + let core = r.radiance.get(320, 210); + let m = core[0].max(core[1]).max(core[2]); + assert!(m > 0.9, "the reference's clipped star core was erased: {core:?}"); +} + +#[test] +fn deghost_removes_a_bright_frames_ghost_in_a_dark_sky() { + let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32]; + let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::High }, &no_progress).unwrap(); + // where only the brightest frame had the star, the sky is what the reference saw: no ring of its unclipped halo + let mut worst = 0f32; + for y in 196..224 { + for x in 346..374 { + let p = r.radiance.get(x, y); + worst = worst.max(p[1]); + } + } + assert!(worst < 0.02, "the brightest frame's star is left as a ghost (peak {worst})"); + assert!(r.ghost.get(360, 210) > 0.5, "the overlay marks the ghost"); +} diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 3f17413ff..d97b56215 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -19,20 +19,21 @@ const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); - let tiles: Vec<(usize, usize)> = (0..h.div_ceil(TILE)).flat_map(|ty| (0..w.div_ceil(TILE)).map(move |tx| (tx * TILE, ty * TILE))).collect(); - let results: Vec<(usize, usize, usize, usize, Vec<[f32; 3]>)> = tiles - .par_iter() - .map(|&(x0, y0)| { - let (tw, th) = (TILE.min(w - x0), TILE.min(h - y0)); - (x0, y0, tw, th, tile(m, x0, y0, tw, th)) - }) - .collect(); let mut out = Rgb32f::new(w, h); - for (x0, y0, tw, th, px) in results { - for y in 0..th { - out.data[(y0 + y) * w + x0..(y0 + y) * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); - } + if w == 0 || h == 0 { + return out; } + // one row of tiles per task, written straight into the picture (collecting the tiles first held a second copy) + out.data.par_chunks_mut(w * TILE).enumerate().for_each(|(ty, band)| { + let (y0, th) = (ty * TILE, band.len() / w); + for x0 in (0..w).step_by(TILE) { + let tw = TILE.min(w - x0); + let px = tile(m, x0, y0, tw, th); + for y in 0..th { + band[y * w + x0..y * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); + } + } + }); out } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 02c72b584..e217813d9 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -637,6 +637,7 @@ "HDR panorama": "HDR-Panorama", "HDR panoramas": "HDR-Panoramen", "HDR with Last Settings": "HDR mit letzten Einstellungen", + "HDR for Each Bracket Set": "HDR für jede Belichtungsreihe", "HDR…": "HDR…", "Has Edits": "Enthält Bearbeitungen", "Has GPS": "Enthält GPS", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 1dab1ae49..6ab2f3783 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -641,6 +641,7 @@ "HDR panorama": "Panorámica HDR", "HDR panoramas": "panorámicas HDR", "HDR with Last Settings": "HDR con los últimos ajustes", + "HDR for Each Bracket Set": "HDR para cada horquillado", "HDR…": "HDR…", "Has Edits": "Con ediciones", "Has GPS": "Con GPS", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index cbb548eaa..859b8cb84 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -601,6 +601,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "panoramas HDR", "HDR with Last Settings": "HDR avec les derniers réglages", + "HDR for Each Bracket Set": "HDR pour chaque bracketing", "HDR…": "HDR…", "Has Edits": "Avec retouches", "Has GPS": "Avec GPS", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index d9ba7b2c8..e20da78f4 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -507,6 +507,7 @@ "HDR panorama": "HDRパノラマ", "HDR panoramas": "HDRパノラマ", "HDR with Last Settings": "前回の設定でHDR合成", + "HDR for Each Bracket Set": "ブラケットごとにHDR合成", "Has Edits": "編集", "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 006307f3c..a7c7d0454 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -610,6 +610,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "panoramas HDR", "HDR with Last Settings": "HDR com os últimos ajustes", + "HDR for Each Bracket Set": "HDR para cada bracketing", "HDR…": "HDR…", "Has Edits": "Tem edições", "Has GPS": "Tem GPS", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a4dc8da5d..6c8524d95 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -502,6 +502,7 @@ "HDR Panorama with Last Settings": "HDR-панорама с последними настройками", "HDR Panorama…": "HDR-панорама…", "HDR with Last Settings": "HDR с последними настройками", + "HDR for Each Bracket Set": "HDR для каждого брекетинга", "HDR…": "HDR…", "Heal": "Лечение", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Убирать мелкие мягкие тёмные пятна от пыли на матрице на ровных участках", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index df0b83d98..3665facdb 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -749,6 +749,7 @@ "HDR panorama": "HDR-панорама", "HDR panoramas": "HDR-панорами", "HDR with Last Settings": "HDR з останніми налаштуваннями", + "HDR for Each Bracket Set": "HDR для кожної серії експозицій", "Has Edits": "Має зміни", "Has GPS": "Має GPS", "High Contrast B&W": "Контрастне чорно-біле", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 03290bf2f..e662b28ff 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -740,6 +740,7 @@ "HDR Panorama with Last Settings": "使用上次设置合并 HDR 全景", "HDR Panorama…": "HDR 全景…", "HDR with Last Settings": "使用上次设置合并 HDR", + "HDR for Each Bracket Set": "为每组包围曝光合并 HDR", "HDR…": "HDR…", "Heal": "修复", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 40cdc0fd3..4bc88663b 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -740,6 +740,7 @@ "HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景", "HDR Panorama…": "HDR 全景…", "HDR with Last Settings": "使用上次設定合併 HDR", + "HDR for Each Bracket Set": "為每組包圍曝光合併 HDR", "HDR…": "HDR…", "Heal": "修復", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0187fa303..e10c94ac7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1276,6 +1276,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::merge::start_last(app, "merge.hdr"), + "merge.hdrBatch" => crate::merge::start_batch(app, p), "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index a603692fa..4374ac591 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -110,6 +110,8 @@ pub struct MergeState { 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>, } impl MergeState { @@ -194,6 +196,31 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result Result { + if app.merge.final_task.is_some() { + return Err("a merge is already running".into()); + } + let ids = app.session.targets(p); + let sets = app.session.catalog.bracket_sets(&ids); + if sets.is_empty() { + return Err(lightcraft_engine::merge::NO_BRACKET_SETS.into()); + } + let n = sets.len(); + app.merge.batch = sets.into(); + next_in_batch(app)?; + Ok(json!({"started": true, "sets": n})) +} + +/// Start the next bracket set of a batch, if any. +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")); + start_final(app, &opts).map(|_| ()) +} + /// Per frame: keep the preview in sync with the dialog's options, collect finished jobs. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { // preview for the open dialog @@ -272,12 +299,23 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { app.merge.last_result = Some(v); app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what)); } - Err(_) if t.cancel.load(Ordering::Relaxed) => app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), + Err(_) if t.cancel.load(Ordering::Relaxed) => { + // cancelling one merge of a batch stops the batch + app.merge.batch.clear(); + app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)) + } Err(e) => { app.ui.status = e.to_string(); app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)); } } + // a failed set doesn't stop the others + while !app.merge.batch.is_empty() { + match next_in_batch(app) { + Ok(()) => break, + Err(e) => app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)), + } + } } Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 34c7f7491..493998fd3 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -360,6 +360,51 @@ fn final_merge_shows_a_row_and_cancels() { 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] +fn batch_merge_runs_each_set_in_turn() { + let dir = std::env::temp_dir().join(format!("lc-activity-batch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + // different exposures, or the import would take set B for duplicates of set A + for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5]), ("C", [-1.0, 1.0, 3.0])] { + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap().into_iter().enumerate() { + let p = dir.join(format!("{set}_{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(); + assert_eq!(ids.len(), 9); + for set in ids.chunks(3) { + session.execute("stack.group", &json!({"ids": set})).unwrap(); + } + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(T); + let r = crate::menus::run_ui_command(&mut h.app, "merge.hdrBatch", &json!({})).unwrap().unwrap(); + assert_eq!(r["sets"], 3); + assert_eq!(h.app.merge.batch.len(), 2, "one running, two queued"); + let rows = h.app.session.activity.list(); + assert_eq!((rows.len(), rows.first().map(|r| r.kind)), (1, Some("merge"))); + // the first merge finishes and the second starts by itself + assert!(h.step_until(T, |h| h.app.merge.batch.len() == 1 && h.app.merge.final_task.is_some())); + assert_eq!(h.app.session.catalog.len(), 10); + // cancelling the second drops the third + let rows = h.app.session.activity.list(); + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + assert!(h.app.merge.batch.is_empty()); + assert_eq!(h.app.session.catalog.len(), 10, "nothing more merged"); + assert!(h.app.session.activity.list().is_empty()); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn find_missing_shows_an_indeterminate_row() { let mut h = demo(); diff --git a/docs/gaps.md b/docs/gaps.md index c8b4932f1..49c9a3b3a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -42,6 +42,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | +| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -49,7 +50,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Gaps by kind -- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19 +- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19, 29 - **UI / UX:** 4, 23, 24, 28 - **File format:** 2, 6, 7, 10, 13, 20, 25 (raw ones in [raw-parity.md](raw-parity.md)) - **Hardware:** 14, 17, 22, 27 @@ -76,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w |---|---|---| | 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | | 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) | +| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain | | 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice | | 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile | | 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) | diff --git a/docs/merge.md b/docs/merge.md index 7c298cb10..3485e02d1 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -10,12 +10,17 @@ All algorithms are our own implementations from published papers (cited in the m *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 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. + `-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 + bracket (photos taken less than a second apart, after the shutter time, at exposures at least ½ EV apart; a burst at + one exposure is skipped), one after the other in the activity stack. - **Commands** (CLI, MCP, control channel): - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}` - `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}` -- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`; + - `merge.hdrBatch {ids?, align, deghost, autoSettings, stack}` → `{merged: [{id, path, …}], failed: [{photos, error}]}` +- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [options] FILES…` (`hdr-batch` finds the brackets + among the files, e.g. a whole folder); `lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene). ## What it does @@ -25,8 +30,11 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max -channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise -floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. +channel))`. Where the reference is at or near its clip the scene is at least that bright, so a frame that reads far +darker there (an object moved: a star, the moon, a passing light) is left out and the clipped highlight stays (always +on: a moving star otherwise left a black hole). Deghost: a per-pixel consistency test on ≤ 2048 px versions against the +reference radiance (with a noise floor; the reference predicts down to 0.1 % of its clip, so a brighter frame's ghost in +a night sky is caught), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. Lens corrections: frames are merged uncorrected, and the DNG's `OpcodeList3` carries the reference frame's (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or a camera's correction a raw reader converts, e.g. Sony, Panasonic), re-expressed for its default crop (`Opcode::cropped`). So Optics ▸ Enable Lens @@ -52,7 +60,10 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) - HDR, 3 brackets ±2 EV with 14-bit noise and handheld shifts: alignment error < 0.3 px, exposure error < 0.02 EV, radiance median log error < 0.03 (noise-limited, bias < 0.01), recovered - highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High. + highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High; a star that moves + across a night sky keeps its clipped core and, with High, leaves no ghost of the brightest frame. +- Real brackets (Sony A7R VI, 67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % from 3 to 90 % of + their clip in all three channels; measured exposure steps match EXIF within 0.03 EV. - Panorama, 5 views over 182° with a 1.3× exposure drift: relative rotations < 0.1°, focal < 1 %, BA RMS ≈ 0.35 px; median log error vs ground truth < 0.02; no seam steps. @@ -63,9 +74,13 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) panoramas carry no lens corrections, nor does an HDR whose frames don't share an orientation (they are turned before merging). - No camera response calibration for JPEG brackets (sRGB decoding is taken as linear light). +- AI Denoise doesn't run on merged DNGs (they are linear RGB; the denoiser takes Bayer raws), unlike Lightroom's. - Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion, not patch synthesis. - *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its caption also lists them. - Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- HDR memory: every frame is held as 32-bit float RGB (~0.86 GB per 67 MP frame at the peak, plus ~1.2 GB); full-size + photos are decoded one at a time. Measured on 67 MP raws: 3 frames 3.8 GB, 4 frames 4.7 GB, 5 frames 5.5 GB (was + 4.8 / 6.3 GB for 3 / 4). Half-float frames would halve the per-frame share. - The browser build has no file access for merges yet. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1f8ca1430..9505dd46b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -42,7 +42,7 @@ cited here still exists. Formerly docs/parity.md. | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | -| O. Merge (MERGE) | 4 | 0 | 0 | 0 | — | — | +| O. Merge (MERGE) | 3 | 1 | 0 | 0 | — | — | | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | @@ -353,10 +353,10 @@ 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`) | 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`); JPEG brackets treated as linear; 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 | +| 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. 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; 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` | no dialog; the options of the last merge of that kind (defaults the first time) | +| 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…` | ## P. Enhance (ENH) @@ -562,7 +562,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | | MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt | | MENU-PHOTO-STACK | Stack submenu | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:dialog.autoStack` | | -| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast` | | +| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast`, `cmd:merge.hdrBatch` | | | MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | | | MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | | | MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | `cmd:develop.treatment` | | @@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove |---|---|---| | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) | | 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it | +| 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a6b21150b..9f56bde58 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -75,7 +75,7 @@ from the checklist sections and the deep-dive documents. | Remove, heal, Enhance (denoise, Raw Details, Super Resolution, Lens Blur) | 7% | 45% | 30% | 70–130 h | checklist I 75%, P 0% | | Presets, profiles, versions, history, sync | 5% | 95% | 85% | 8–15 h | | | Export | 6% | 89% | 75% | 15–30 h | checklist S 88.9% | -| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 100%, HDR 70% (no HDR display) | +| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 88% (HDR merge 🟡: AI Denoise on merged DNGs, JPEG response, memory; gap 29), HDR 70% (no HDR display) | | Views, compare, zoom, culling | 5% | 90% | 70% | 15–30 h | zoom detail P0 gap | | Classic output modules (Map, Book, Slideshow, Print, Web, publish, tethering) | 4% | 15% | 10% | 90–150 h | Classic extras 48% counts many small keys; the modules themselves are mostly ⬜ | | Video | 1% | 0% | 0% | 25–45 h | catalogued only | @@ -302,6 +302,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. |---|---|---| | 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | | 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | +| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | | 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) | | 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (raw-parity.md): use-weighted share whose camera decodes 85% → 86%, RAW decoding 77% (unchanged); mainstream depth 72.1% → 72.4%, essentials depth 77.5% → 77.6%; headline numbers unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 8c2b22bf5..9f0b9c10d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,15 @@ ## 2026-10-14 +### HDR merges of moving lights, and many brackets at once +- A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer + leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a + dark sky is now removed too. +- Photo ▸ Photo Merge ▸ HDR for Each Bracket Set merges every bracket among the selected photos with the last HDR + settings, one after the other: each stack, and photos taken less than a second apart at different exposures. + From the command line: `lightcraft-cli merge hdr-batch FOLDER`. +- Merging uses about a quarter less memory: three 67 MP raws need 3.8 GB instead of 4.8 GB. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.