# Conflicts:
#	crates/engine/src/cmd/develop.rs
#	crates/engine/src/files.rs
#	docs/mcp.md
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Claude
2026-10-11 12:02:46 +00:00
37 changed files with 1009 additions and 93 deletions
+53 -2
View File
@@ -38,11 +38,21 @@ impl DevelopSettings {
serde_json::from_value(v.clone())
}
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
/// Kelvin settings.
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
DevelopSettings::from_json(&v)
let mut out = DevelopSettings::from_json(&v)?;
if let Some(wb) = partial.get("wb").and_then(Value::as_object)
&& (wb.contains_key("temp") || wb.contains_key("tint"))
&& !wb.contains_key("scale")
{
out.wb.scale = WbScale::Legacy;
}
Ok(out)
}
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
@@ -167,6 +177,47 @@ mod tests {
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
}
#[test]
fn white_balance_scale_reads_absent_as_legacy_and_writes_only_kelvin() {
// settings from before the scale existed: no field, `Legacy`, and written back without it
let old = json!({"wb": {"mode": "custom", "temp": 5800.0, "tint": 3.0}});
let s = DevelopSettings::from_json(&old).unwrap();
assert_eq!(s.wb.scale, WbScale::Legacy);
assert_eq!(s.to_json()["wb"], old["wb"]);
assert_eq!(DevelopSettings::default().wb.scale, WbScale::Legacy);
assert!(DevelopSettings::default().to_json()["wb"].get("scale").is_none());
// written on the Kelvin scale: said so, and read back
let mut k = s.clone();
k.wb.scale = WbScale::Kelvin;
assert_eq!(k.to_json()["wb"]["scale"], json!("kelvin"));
assert_eq!(DevelopSettings::from_json(&k.to_json()).unwrap(), k);
assert_ne!(k.hash64(), s.hash64());
// an unknown scale from a newer build is an error, not a panic
assert!(DevelopSettings::from_json(&json!({"wb": {"scale": "mired"}})).is_err());
}
#[test]
fn a_partial_white_balance_without_a_scale_is_legacy() {
let mut base = DevelopSettings::default();
base.wb.scale = WbScale::Kelvin;
// an old preset's white balance lands on Kelvin settings: it keeps its relative meaning
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0}}), 1.0);
assert_eq!((out.wb.scale, out.wb.temp), (WbScale::Legacy, 5800.0));
let out = apply_partial(&base, &json!({"wb": {"tint": 12.0}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Legacy);
// one that says Kelvin stays Kelvin; a mode alone leaves the scale as it was
let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0, "scale": "kelvin"}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
let out = apply_partial(&base, &json!({"wb": {"mode": "cloudy"}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
let out = apply_partial(&base, &json!({"light": {"exposure": 1.0}}), 1.0);
assert_eq!(out.wb.scale, WbScale::Kelvin);
// the plain merge (`develop.merge`) follows the same rule
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0}})).unwrap().wb.scale, WbScale::Legacy);
assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0, "scale": "kelvin"}})).unwrap().wb.scale, WbScale::Kelvin);
assert_eq!(base.merged(&json!({"wb": {"mode": "auto"}})).unwrap().wb.scale, WbScale::Kelvin);
}
#[test]
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
let s = DevelopSettings::default();
+1
View File
@@ -187,6 +187,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let base = s.to_json();
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
for c in crate::controls::CONTROLS {
if let Some(v) = crate::controls::get(&out, c.id) {
+43 -5
View File
@@ -242,7 +242,13 @@ impl WbMode {
WbMode::Custom => "Custom",
}
}
/// Preset temperature/tint for raw files (K, tint).
/// Preset temperature/tint for raw files with a Kelvin scale (K, tint): the CIE daylight series D55 / D65 /
/// D75 (5503, 6504, 7504 K; CIE 15:2004) for Daylight / Cloudy / Shade, CIE Illuminant A (2856 K) for
/// Tungsten, a typical cool-white fluorescent (CIE F2, 4230 K, is the lamp; 3800 K with a magenta correction
/// is what a camera's preset neutralises) and a flash at daylight; the tints are the small magenta
/// corrections daylight-series whites need above the Planckian locus, as Lightroom's presets of the same
/// names show them in its panel (observed values, no Adobe data). On the relative scale (rendered files, raws
/// without matrices) `develop.wb` scales them around 6500 K = as shot.
pub fn preset(self) -> Option<(f64, f64)> {
match self {
WbMode::Daylight => Some((5500.0, 10.0)),
@@ -256,9 +262,35 @@ impl WbMode {
}
}
/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files
/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an
/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom).
/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on.
///
/// Raws whose camera has colour matrices (in the file, or measured for the model) edit white balance in Kelvin;
/// before that, raws without matrices in the file were all edited relative to their as-shot look, where 6500 K / 0
/// means *as shot*. Settings written then carry no scale and keep that meaning (`Legacy`): the pipeline turns them
/// into the same colour change on the Kelvin scale (`lightcraft_pipeline::local::legacy_to_kelvin`), so no photo's
/// look changes. Settings written on the Kelvin scale say so (`Kelvin`). The scale is irrelevant on files the
/// Kelvin scale does not reach (rendered photos, raws without matrices): their values keep the relative meaning.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WbScale {
/// Written before the scale existed, or by a preset / partial JSON without one: relative (6500 K / 0 = as shot)
/// on a raw developed from a fitted look, Kelvin on a raw whose file carries matrices. Not written.
#[default]
Legacy,
/// Absolute Kelvin and tint, as Lightroom writes them for raws.
Kelvin,
}
impl WbScale {
fn is_legacy(&self) -> bool {
*self == WbScale::Legacy
}
}
/// White balance. For raw files with colour matrices `temp` is the scene illuminant in Kelvin (`scale` says the
/// values were written on that scale); for rendered files (JPEG etc.) and raws without matrices the image is
/// assumed D65-balanced and `temp`/`tint` are the same model with an "as shot" of 6500 K / 0 (the UI shows a
/// relative −100..100 scale for those, like Lightroom).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WhiteBalance {
@@ -266,11 +298,17 @@ pub struct WhiteBalance {
pub temp: f64,
/// Correction direction: negative adds green, positive adds magenta.
pub tint: f64,
/// The scale `temp` / `tint` were written on; left out of the JSON when [`WbScale::Legacy`], so settings
/// from before it existed read and write unchanged.
#[serde(skip_serializing_if = "WbScale::is_legacy")]
pub scale: WbScale,
}
impl Default for WhiteBalance {
fn default() -> Self {
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 }
// (`Legacy`, like an absent field: a default photo's settings JSON stays what it was; the engine marks the
// scale when white balance is written on a raw that has one, `lightcraft_pipeline::local::normalize_wb`)
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0, scale: WbScale::Legacy }
}
}