diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs
index c1b17be6d..c190cd489 100644
--- a/crates/engine/src/merge.rs
+++ b/crates/engine/src/merge.rs
@@ -183,7 +183,7 @@ impl MergeJob {
orientation: r.orientation,
metadata: r.metadata,
baseline_exposure: r.baseline_exposure,
- wb_multipliers: None,
+ wb_multipliers: r.wb_multipliers,
};
f.orient();
merged.push(f);
diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs
index 7c596c4a7..23f960187 100644
--- a/crates/merge/src/hdr.rs
+++ b/crates/merge/src/hdr.rs
@@ -26,7 +26,7 @@ use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use crate::align::{self, Alignment};
-use crate::frame::{Frame, FrameColor};
+use crate::frame::{Frame, FrameColor, consensus_multipliers};
use crate::{MergeError, Progress, Result, check};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -83,6 +83,9 @@ pub struct HdrResult {
pub orientation: lightcraft_geom::Orientation,
pub metadata: lightcraft_meta::Metadata,
pub baseline_exposure: f64,
+ /// The inputs' as-shot white-balance multipliers they agree on ([`crate::frame::consensus_multipliers`]), already in
+ /// `color`; kept so merged brackets stitched into a panorama can vote as frames of their own.
+ pub wb_multipliers: Option<[f32; 3]>,
}
#[inline]
@@ -265,6 +268,12 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress)
None => Plane::new(0, 0),
};
let rf = &frames[reference];
+ // the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none
+ let wb_multipliers = consensus_multipliers(&frames);
+ let mut color = rf.color.clone();
+ if let Some(multipliers) = wb_multipliers {
+ color.set_as_shot_multipliers(multipliers);
+ }
Ok(HdrResult {
radiance: out,
ghost,
@@ -272,11 +281,12 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress)
ev,
ev_exif,
alignments,
- color: rf.color.clone(),
+ color,
raw: rf.raw,
orientation: rf.orientation,
metadata: rf.metadata.clone(),
baseline_exposure: rf.baseline_exposure,
+ wb_multipliers,
})
}
diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs
index 4139250f6..84474a494 100644
--- a/crates/merge/tests/white_balance.rs
+++ b/crates/merge/tests/white_balance.rs
@@ -1,4 +1,4 @@
-//! A panorama of raw files keeps their as-shot white balance (issue #1144).
+//! A panorama or an HDR merge of raw files keeps their as-shot white balance (issue #1144).
//!
//! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has
//! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast.
@@ -6,6 +6,7 @@
use lightcraft_color::{D50, Xy};
use lightcraft_geom::Orientation;
use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip};
+use lightcraft_merge::hdr::{HdrOptions, HdrResult, merge_hdr};
use lightcraft_merge::output::color_data;
use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch};
use lightcraft_merge::{DngSamples, no_progress, write_linear_dng};
@@ -189,3 +190,57 @@ fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() {
let raw = dng_of(&res);
assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None));
}
+
+/// Exposures `evs` (EV, relative to the view's) of one view, one frame each, with `wb` as their multipliers.
+fn bracket(color: &ColorData, evs: &[f64], wb: &[Multipliers]) -> Vec {
+ let view = lightcraft_merge::synth::pano_views(320, 240, 280.0, &[0.0]).unwrap().remove(0);
+ evs.iter()
+ .zip(wb)
+ .map(|(ev, m)| {
+ let k = 2f32.powf(*ev as f32);
+ let mut frame = raw_frame(view.map(|p| p.map(|v| (v * k).min(1.0))), color, *m);
+ frame.exposure = Some(*ev);
+ frame
+ })
+ .collect()
+}
+
+fn hdr(frames: Vec) -> HdrResult {
+ merge_hdr(frames, &HdrOptions::default(), &no_progress).unwrap()
+}
+
+fn hdr_neutral(res: &HdrResult) -> Option<[f64; 3]> {
+ match &res.color {
+ FrameColor::Camera(c) => c.as_shot_neutral,
+ FrameColor::Linear { .. } => None,
+ }
+}
+
+#[test]
+fn an_hdr_merge_of_raw_frames_has_their_median_white_balance() {
+ let source = camera_without_white_balance();
+ let wb = [Some([2.1, 1.0, 1.55]), Some(DAYLIGHT), Some([1.9, 1.0, 1.7])];
+ let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &wb));
+ assert_eq!(res.wb_multipliers, Some(DAYLIGHT));
+ assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
+ // the DNG says it, to the precision of a TIFF rational
+ let bytes = write_linear_dng(&res.radiance, &res.color, res.orientation, &res.metadata, res.baseline_exposure, DngSamples::Half).unwrap();
+ let raw = lightcraft_raw::decode(&bytes).unwrap();
+ assert_close(raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"), [0.5, 1.0, 0.625], 2e-6);
+ // an even count takes the mean of the middle two; frames without multipliers do not vote
+ let res = hdr(bracket(&source, &[-1.0, 1.0], &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]));
+ assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6);
+ let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &[None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0])]));
+ assert_eq!(res.wb_multipliers, Some([2.0, 1.0, 1.6]));
+ assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6);
+}
+
+#[test]
+fn an_hdr_merge_without_multipliers_keeps_the_reference_colour_as_before() {
+ let source = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() };
+ let frames = bracket(&source, &[-2.0, 0.0, 2.0], &[None, None, None]);
+ let reference = frames[1].color.clone();
+ let res = hdr(frames);
+ assert_eq!((res.reference, res.wb_multipliers), (1, None));
+ assert_eq!(res.color, reference);
+}
diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md
index f092e88f0..1661bf793 100644
--- a/docs/camera-preview-colour.md
+++ b/docs/camera-preview-colour.md
@@ -195,15 +195,16 @@ HDR merge of three copies of the photo against the photo alone (200 px, CIE76):
percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too).
A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a
-frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama takes its white balance
-from the frames that are in it: the median R/G and the median B/G of their multipliers (the mean of the middle two
-for an even count; frames without usable multipliers, or outside the panorama, do not vote), written as
-`AsShotNeutral`, the reciprocal, and any `AsShotWhiteXY` of the source dropped (a reader takes it first, and the
-DNG spec has the two exclude each other). So the panorama is balanced for what the camera measured for most of its
-frames, not for whichever frame came first. With no multipliers anywhere (DNG sources) the first frame's tags are kept
-as before. For frames balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG
-renders as before; it matters when the camera balanced the frames differently, and for a caller that loads frames
-without a colour hook (`load_frame`), whose raw frames carried no white balance at all (issue #1144).
+frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama, and an HDR merge from its
+inputs, takes its white balance from the frames that are in it: the median R/G and the median B/G of their multipliers
+(the mean of the middle two for an even count; frames without usable multipliers, or outside the panorama, do not
+vote), written as `AsShotNeutral`, the reciprocal. Any `AsShotWhiteXY` of the source is dropped with it: a reader takes
+it first, and the DNG spec has the two exclude each other. So the result is balanced for what the camera measured for
+most of its frames, not for whichever frame came first; an HDR panorama's merged brackets vote with their bracket's
+median. With no multipliers anywhere (DNG sources) the reference or first frame's tags are kept as before. For frames
+balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG renders as before; it matters
+when the camera balanced the frames differently, and for a caller that loads frames without a colour hook
+(`load_frame`), whose raw frames carried no white balance at all (issue #1144).
## Nikon crop and preview colour metadata
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
index 7fea9cf66..49c10e79a 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-13 · **Change:** minor (LR-MERGE-PANO: a panorama of raws takes its as-shot white balance from the median of its frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO, LR-MERGE-HDR: a panorama and an HDR merge of raws take their as-shot white balance from the median of their frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **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
@@ -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` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; 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` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; 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; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144 |
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; 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) |
@@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) |
+| 2026-10-13 | minor | LR-MERGE-HDR: an HDR merge's DNG takes its `AsShotNeutral` from the median of its inputs' vendor white-balance multipliers instead of the reference frame's tags (issue #1144); status unchanged (✅) |
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) |
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |