mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-MERGE-HEADLESS: HDR for Each Bracket Set
Photo ▸ Photo Merge ▸ HDR for Each Bracket Set (`merge.hdrBatch`) merges many brackets in one go with the last HDR options, as Lightroom merges a selection of stacks: one HDR per stack among the photos, then per bracket among the unstacked ones (`Catalog::bracket_sets`: photos taken less than about a second after the previous one's shutter closed, at exposures at least ½ EV apart; bursts at one exposure and earlier merge results are left out). The app runs the merges one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); the command runs them inline for the CLI and MCP (`lightcraft-cli merge hdr-batch FILES…`) and reports merged and failed sets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6fb0d22326
commit
b52762797f
@@ -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
|
||||
@@ -373,7 +374,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();
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -636,6 +636,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",
|
||||
|
||||
@@ -619,6 +619,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",
|
||||
|
||||
@@ -600,6 +600,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",
|
||||
|
||||
@@ -506,6 +506,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": "ハイコントラスト白黒",
|
||||
|
||||
@@ -609,6 +609,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",
|
||||
|
||||
@@ -501,6 +501,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": "Убирать мелкие мягкие тёмные пятна от пыли на матрице на ровных участках",
|
||||
|
||||
@@ -747,6 +747,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": "Контрастне чорно-біле",
|
||||
|
||||
@@ -738,6 +738,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": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑",
|
||||
|
||||
@@ -738,6 +738,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": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點",
|
||||
|
||||
@@ -1238,6 +1238,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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
@@ -350,7 +350,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`) | 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-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)
|
||||
|
||||
@@ -556,7 +556,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` | |
|
||||
@@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`): one HDR per stack or bracket among the photos, one after the other |
|
||||
| 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,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.
|
||||
|
||||
Reference in New Issue
Block a user