diff --git a/contributors/people.toml b/contributors/people.toml index 8d8a864df..08d002e8b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -53,3 +53,7 @@ real_name = "Nathaniel Ledford" real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false + +[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 f5384e23d..848251c65 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -99,6 +99,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 { @@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result { 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(); @@ -252,6 +271,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 { @@ -506,6 +526,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 c467a471a..b7bfa0038 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -192,11 +192,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="true"` means effect inside: in `MaskGroupBasedCorrections` with `Version` 2 (what Lightroom writes for an ordinary radial) and in the legacy `CircularGradientBasedCorrections` Radial Filter, whose default (no `Flipped`) is outside. A `MaskGroupBasedCorrections` radial without `Version` is inside whatever `Flipped` says, and its `Feather` is halved (Lightroom rescales it that way when it upgrades one). Inverting is `MaskInverted` | | `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