From 1130bda456dd0999e0325e3d41fc3280e5b59e14 Mon Sep 17 00:00:00 2001 From: Ege Gursel Date: Thu, 15 Oct 2026 00:23:41 -0400 Subject: [PATCH 1/2] fix(import): keep Lightroom luminance ranges open at white, report unmapped local sliders Two of the three problems reported in #1840: - A luminance range at full strength up to white (LumRange "a b 1 1", a typical highlights mask) ended at our scene white (0 EV) with no feather, so anything brighter (the sun, speculars, a sky after a positive exposure) fell out of the mask along a hard edge. That end now stays open. - Correction sliders we have no counterpart for (LocalGrain, ...) were dropped silently. They are now listed in `unmapped` as "Mask: LocalGrain"; LocalCurveRefineSaturation 100 counts as neutral. Adobe base looks, the third problem, are handled by #1469. Also adds my entry to contributors/people.toml. --- contributors/people.toml | 4 ++++ crates/engine/src/crs_masks.rs | 44 +++++++++++++++++++++++++++++++++- docs/xmp-interop.md | 3 ++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9c..2548de569 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -31,3 +31,7 @@ display_name = "sanyamsingh001" [people.ArekatlaNishanthchowdary] real_name = "Arekatla Nishanth Chowdary" sync_github_name = true + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b..ea831e9c3 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -87,6 +87,21 @@ fn adjust(c: &XmpValue) -> Value { Value::Object(a) } +/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set: +/// reported, never dropped silently. +fn unmapped_local(c: &XmpValue) -> Vec { + let XmpValue::Struct(m) = c else { return Vec::new() }; + m.keys() + .filter_map(|k| { + let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?; + // 100 = "curve saturation unchanged", written with every correction + let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 }; + let set = num(c, k).is_some_and(|v| v != neutral); + (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string()) + }) + .collect() +} + /// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log /// luminance on −8..+4 EV mapped to 0..1. fn lightness_to_range(l: f64) -> f64 { @@ -163,8 +178,12 @@ fn component(m: &XmpValue, aspect: f64) -> Result { Some("2") => { let q: Vec = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); let [a, b, c, d] = q[..] else { return Err("luminance range".into()) }; + // full strength at white (1, the top of their scale) takes in everything brighter: ours + // goes on above scene white, so that end stays open instead of cutting off at 0 EV + let open_top = c >= 1.0; let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d)); - json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)}) + let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) }; + json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather}) } Some("3") => { let q: Vec = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); @@ -201,6 +220,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec, Vec) { if parts.is_empty() { continue; } + skipped.extend(unmapped_local(c)); // brush dabs of one correction are one brush component let mut merged: Vec = Vec::new(); for p in parts { @@ -454,6 +474,28 @@ mod tests { assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); } + #[test] + fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#" + + + + + + + + +"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); + } + #[test] fn lrtemplate_corrections() { let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d3..ee78c67eb 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -181,11 +181,12 @@ mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive correct | `LocalExposure2012` | `adjust.exposure` | stored as a fraction, ×4 EV | | `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 | | `LocalToningHue` | `adjust.color_hue` | degrees | +| other `Local*` sliders (`LocalGrain`…) | — | no counterpart in our masks: reported as `Mask: LocalGrain` when set (`LocalCurveRefineSaturation` 100 is neutral) | | `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point | | `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape); `Flipped` → invert | | `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases | | `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed | -| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported | +| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; a range at full strength at white (third `LumRange` value 1) stays open above our scene white; colour ranges are reported | | `MaskBlendMode` 0 / 1 / 2, `MaskInverted` | add / subtract / intersect, invert | | Applying a preset adds its masks to the photo's own (masks the photo already has are not added twice), and the Amount From 31f4ad43febfe7efacafe863713c2367cb63253b Mon Sep 17 00:00:00 2001 From: Ege Gursel Date: Thu, 15 Oct 2026 00:29:43 -0400 Subject: [PATCH 2/2] contributors: re-add my people.toml entry lost in the merge --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..437a836cf 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,7 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true