LR-EDIT-COLOR-TEMP: Kelvin white balance for raws whose camera has colour matrices (#730)

Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.

Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
  catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
  the same from the decoded file. A corpus test asserts they agree.
  Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
  the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
  (JPEG fit / profile / spectral fallback) is unchanged, and the slider
  adapts from that white with Bradford. The camera-space rebalance stays
  reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
  crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
  #510 as-shot shift.

Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
  white balance without a scale keeps its old meaning. On a spectral-Kelvin
  raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
  adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
  cone space (local::legacy_white). Von Kries scalings compose exactly, so
  the matrix matches to f32 and the pixels are identical
  (pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
  media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
  reset) first runs local::normalize_wb: a legacy value becomes the Custom
  Kelvin value with the same look and is marked Kelvin. Partials without a
  scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
  by older imports when the file is next decoded or reloaded (system op,
  not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.

Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
This commit is contained in:
Jason Baldwin
2026-10-10 16:09:31 +02:00
parent b70ae1741e
commit 76e633c9e1
36 changed files with 1010 additions and 94 deletions
+27 -10
View File
@@ -168,9 +168,12 @@ pub fn specs() -> Vec<CommandSpec> {
}
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
lightcraft_pipeline::local::normalize_wb(d, info);
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
}
}
for (k, v) in &vals {
controls::set(d, k, *v);
@@ -209,6 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
let info = s.source_info(active(s, "develop.adjust")?);
edit(s, "develop.adjust", spec.label, |d| {
if c == "wb.temp" || c == "wb.tint" {
lightcraft_pipeline::local::normalize_wb(d, &info);
if d.wb.mode == WbMode::AsShot {
d.wb.temp = info.as_shot_temp;
d.wb.tint = info.as_shot_tint;
@@ -240,10 +244,17 @@ pub fn specs() -> Vec<CommandSpec> {
Some(l) => (*l).clone(),
None => {
let info = s.source_info(id);
DevelopSettings {
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
let mut fresh = DevelopSettings {
wb: lightcraft_develop::WhiteBalance {
mode: WbMode::AsShot,
temp: info.as_shot_temp,
tint: info.as_shot_tint,
..Default::default()
},
..Default::default()
}
};
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
fresh
}
};
// a fresh start renders with the current process (a section or slider reset doesn't change it)
@@ -377,7 +388,7 @@ pub fn specs() -> Vec<CommandSpec> {
m => m
.preset()
.map(|(t, ti)| {
// presets are relative to daylight for rendered files
// presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
})
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
@@ -387,6 +398,7 @@ pub fn specs() -> Vec<CommandSpec> {
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
// mode is kept for the UI's WB dropdown.
edit(s, "develop.wb", "White Balance", |d| {
lightcraft_pipeline::local::normalize_wb(d, &info);
d.wb.mode = mode;
d.wb.temp = t.clamp(2000.0, 50000.0);
d.wb.tint = tint.clamp(-150.0, 150.0);
@@ -416,6 +428,7 @@ pub fn specs() -> Vec<CommandSpec> {
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
edit(s, "develop.wbPick", "White Balance", |d| {
lightcraft_pipeline::local::normalize_wb(d, &info);
d.wb.mode = WbMode::Custom;
d.wb.temp = t;
d.wb.tint = tint;
@@ -847,10 +860,14 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
.filter_map(|(id, d)| {
let mut nd = (*d).clone();
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
if ctl == "wb.temp" || ctl == "wb.tint" {
// each photo on its own scale: Kelvin (an old relative value converted first) or relative
let info = s.source_info(id);
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
if nd.wb.mode == WbMode::AsShot {
nd.wb.temp = info.as_shot_temp;
nd.wb.tint = info.as_shot_tint;
}
}
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
controls::set(&mut nd, &ctl, v + delta);