# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:29:33 -04:00
25 changed files with 397 additions and 61 deletions
+6 -4
View File
@@ -5,7 +5,7 @@
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
//! lightcraft-cli render <in> -o <out> [--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 <first>-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();
+94
View File
@@ -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<Vec<PhotoId>> {
let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some();
let mut sets: Vec<Vec<PhotoId>> = 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<PhotoId> = 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<(i64, &str, PhotoId, f64, f64)>> = 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<i64> {
@@ -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"];
+30 -1
View File
@@ -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<Value> {
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<Value> {
const ID: &str = "merge.hdrBatch";
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
let ids: Vec<PhotoId> = 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::<Vec<_>>(), "error": e.to_string()})),
}
}
Ok(json!({"merged": merged, "failed": failed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -69,5 +89,14 @@ pub fn specs() -> Vec<CommandSpec> {
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
),
]
}
+16 -14
View File
@@ -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<Vec<Frame>, 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.
+31
View File
@@ -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);
}
+29 -18
View File
@@ -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<lightcraft_raw::Opcode>,
}
/// 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<Frame>, opts: &HdrOptions, progress: &Progress)
alignments[i] = a;
}
check(progress, 0.35, "Resampling")?;
let warped: Vec<(usize, Rgb32f, Vec<bool>)> = (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<Frame>, 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<Frame>, 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<Plane>, 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<f32>)> = (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);
}
}
+45
View File
@@ -161,3 +161,48 @@ fn deghost_removes_a_moving_object() {
let mean = high.ghost.data.iter().sum::<f32>() / 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<Frame> {
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");
}
+13 -12
View File
@@ -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
}
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "ハイコントラスト白黒",
+1
View File
@@ -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",
+1
View File
@@ -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": "Убирать мелкие мягкие тёмные пятна от пыли на матрице на ровных участках",
+1
View File
@@ -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": "Контрастне чорно-біле",
+1
View File
@@ -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": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑",
+1
View File
@@ -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": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點",
+1
View File
@@ -1276,6 +1276,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"merge.hdrLast" => 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"),
+39 -1
View File
@@ -110,6 +110,8 @@ pub struct MergeState {
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>>,
}
impl MergeState {
@@ -194,6 +196,31 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, Strin
start_final(app, &opts)
}
/// HDR for Each Bracket Set (`merge.hdrBatch`): merge each bracket set among the photos in the background, one after
/// the other, with the options last used for HDR (the defaults the first time).
pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
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();
+45
View File
@@ -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<u64> = 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();
+3 -1
View File
@@ -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) |
+20 -5
View File
@@ -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
`<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected.
`<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
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.
+5 -4
View File
@@ -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 |
+2 -1
View File
@@ -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 |
+9
View File
@@ -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.