From a142179f2028537776a5ee501a0e0b9b760b9fa2 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Wed, 14 Oct 2026 12:56:47 +0200 Subject: [PATCH 1/4] LR-MERGE-HDR: keep the reference's clipped highlights where other frames moved; deghost catches ghosts in dark skies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A star moving across a night bracket became a black hole: where the reference was clipped, frames in which the star had moved read the dark sky and replaced it. Such frames are now left out there (a clipped reference pixel means at least that bright). Deghost compared ≤ 1024 px versions and, in dark areas, let the brighter frames predict themselves, so their ghosts looked consistent; it now uses ≤ 2048 px maps (same feather share) and trusts the reference down to 0.1 % of its clip. Aligned frames are warped one at a time, each replacing its original, instead of all copies at once. Co-Authored-By: Claude Opus 5.5 --- crates/merge/src/hdr.rs | 47 ++++++++++++++++++++++++--------------- crates/merge/tests/hdr.rs | 45 +++++++++++++++++++++++++++++++++++++ docs/merge.md | 12 +++++++--- 3 files changed, 83 insertions(+), 21 deletions(-) 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/docs/merge.md b/docs/merge.md index 7c298cb10..537ab0f2c 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -25,8 +25,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 +55,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. From 6fb0d22326b3e1f0c4720cf6f809334760992d00 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Wed, 14 Oct 2026 12:56:59 +0200 Subject: [PATCH 2/4] LR-MERGE-HDR: decode full-size merge frames one at a time; AHD writes its tiles straight into the picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 7-frame bracket of 67 MP raws ran out of memory: the merge decoded the frames four at a time, and the AHD demosaic collected every tile before copying them into a second full-size picture. Full-size frames now decode one after the other (the demosaic is parallel inside; previews stay parallel), and AHD fills the picture by rows of tiles. Peak memory of 67 MP HDR merges: 3 frames 4.8 → 3.8 GB, 4 frames 6.3 → 4.7 GB, about the same time; AHD output is pixel-identical. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/merge.rs | 26 ++++++++++++-------------- crates/raw/src/demosaic/ahd.rs | 25 +++++++++++++------------ docs/merge.md | 3 +++ 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9..01000f429 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -105,20 +105,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/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/docs/merge.md b/docs/merge.md index 537ab0f2c..c3d86fb8b 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -74,4 +74,7 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) - *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. From b52762797fcff3955938f32863acc87fa1066953 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Wed, 14 Oct 2026 13:37:13 +0200 Subject: [PATCH 3/4] LR-MERGE-HEADLESS: HDR for Each Bracket Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/lightcraft-cli/src/main.rs | 10 +-- crates/catalog/src/stacks.rs | 94 ++++++++++++++++++++++++++++ crates/engine/src/cmd/merge.rs | 31 ++++++++- crates/engine/src/merge.rs | 4 ++ crates/engine/src/tests_merge.rs | 31 +++++++++ crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/merge.rs | 40 +++++++++++- crates/ui-egui/src/tests_activity.rs | 45 +++++++++++++ docs/merge.md | 9 ++- docs/parity-checklist.md | 7 ++- docs/whats-new.md | 9 +++ 20 files changed, 279 insertions(+), 11 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..1b2c4acb2 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 @@ -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(); 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 01000f429..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; 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/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..a595ba580 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..a8970e651 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..c6efb0e92 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -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", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..e0f04f153 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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": "ハイコントラスト白黒", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..775e56eba 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..7dde476e8 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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": "Убирать мелкие мягкие тёмные пятна от пыли на матрице на ровных участках", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..58d3dd001 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -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": "Контрастне чорно-біле", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..9c026269b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..364483649 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..802d9cb49 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1238,6 +1238,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/merge.md b/docs/merge.md index c3d86fb8b..8a2622aba 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 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..2dfc22064 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..0ce5f4613 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. From 857ffcd41c1994faf2c090c863e691a88754d647 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Wed, 14 Oct 2026 13:37:20 +0200 Subject: [PATCH 4/4] =?UTF-8?q?LR-MERGE-HDR:=20=F0=9F=9F=A1=20after=20test?= =?UTF-8?q?ing=20on=20real=20brackets;=20gap=2029?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested on real Sony A7R VI brackets (67 MP): the merged radiance agrees with every frame within ~1 % in all three channels and the measured exposure steps match EXIF within 0.03 EV, and the black-hole stars and the merge memory are fixed. What keeps the row 🟡 goes to gap 29: AI Denoise doesn't run on merged (linear) DNGs as Lightroom's does, JPEG brackets have no camera response curve, and every frame is held as 32-bit float (~0.86 GB per 67 MP frame). Merge area 100 % → 88 % in target-app-parity.md. Co-Authored-By: Claude Opus 5.5 --- docs/gaps.md | 6 ++++-- docs/merge.md | 1 + docs/parity-checklist.md | 12 ++++++------ docs/target-app-parity.md | 5 +++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index ee6e58d1c..11125a67c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** patch (gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 29 added: HDR merge on real brackets — AI Denoise on merged DNGs, JPEG response curves, per-frame memory) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -42,6 +42,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 25 | **File writes**: lossy DNG; AVIF import; Render to DNG as a catalog command (the 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 @@ -72,6 +73,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 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-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | diff --git a/docs/merge.md b/docs/merge.md index 8a2622aba..3485e02d1 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -74,6 +74,7 @@ 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 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2dfc22064..67ab83c53 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets: moving clipped highlights fixed, gaps remain; LR-MERGE-HEADLESS: 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 @@ -40,7 +40,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%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | +| **Total** | 408 | 54 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 49.7% of 173. ## Top gaps and shortcut notes @@ -347,7 +347,7 @@ 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`, `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…` | @@ -790,7 +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-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 ba62abb40..21e732c98 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (merge 100% → 88%: LR-MERGE-HDR 🟡 after testing on real brackets) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -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 | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 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-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points |