//! Interchange: camera-raw-settings (`crs:`) develop fields from XMP → our develop settings. //! //! Other raw developers store their edits as `crs:` properties in XMP sidecars, in DNG-embedded //! XMP and in XMP preset files. This module maps the commonly used, documented-by-observation //! fields to a *partial* [`DevelopSettings`](lightcraft_develop::DevelopSettings) JSON object //! (only the fields present in the packet), which is then merged like a preset. Our pipeline is //! not theirs, so the result is a best-effort approximation of the look, not a pixel match. //! The full table is in `docs/xmp-interop.md`. //! //! Implemented from the public XMP specification (ISO 16684-1) and black-box observation of what //! each field means; no third-party code or preset content was used. use std::collections::BTreeMap; use lightcraft_develop::{MIXER_BANDS, Preset}; use serde_json::{Map, Value, json}; /// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]). pub type Props = BTreeMap>; /// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw /// with a Kelvin scale (colour matrices in the file, or measured for its camera) takes `Temperature` /// as Kelvin; everything developed relative to its as-shot look (rendered files, and raws without /// matrices anywhere, [`lightcraft_catalog::relative_wb_camera`]) needs a shift from the other app's /// as-shot white instead, because on that scale 6500 K / 0 means *as shot* (issue #510). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Target { /// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale. Any, /// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our /// relative scale. Rendered, /// A raw developed with an absolute white balance (DNG and other files with matrices, and raws /// whose camera has measured matrices): Kelvin as written. RawAbsolute, /// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]). RawRelative, } impl Target { /// The target for a file of this media kind and format (its extension or Lightroom's /// `fileFormat` name) shot on `camera` (Exif make and model as the catalog keeps them, or /// empty when unknown); `preview_only` for a raw that can't be decoded yet. pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool, camera: &str) -> Target { if kind != lightcraft_catalog::MediaKind::Raw || preview_only { Target::Rendered } else if lightcraft_catalog::relative_wb_camera(format, camera) { Target::RawRelative } else { Target::RawAbsolute } } /// The target for a photo in the catalog. pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target { Target::for_file(p.kind, &p.format, p.preview_only.is_some(), &p.meta.camera) } } /// Band names as they appear in `crs:` field names, in our mixer order. const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"]; /// `crs:` fields that describe the packet rather than an adjustment. const NON_ADJUSTMENT: &[&str] = &[ "crs:Version", "crs:ProcessVersion", "crs:Name", "crs:ShortName", "crs:Group", "crs:UUID", "crs:PresetType", "crs:SupportsAmount", "crs:SupportsColor", "crs:SupportsMonochrome", "crs:SupportsHighDynamicRange", "crs:SupportsNormalDynamicRange", "crs:SupportsSceneReferred", "crs:SupportsOutputReferred", "crs:CameraModelRestriction", "crs:Copyright", "crs:ContactInfo", "crs:HasSettings", "crs:AlreadyApplied", "crs:RawFileName", "crs:HasCrop", // preset/library bookkeeping and tool options that don't change the rendering "crs:Cluster", "crs:SupportsAmount2", "crs:RequiresRGBTables", "crs:SortName", "crs:Description", "crs:CropConstrainToWarp", "crs:OverrideLookVignette", // the file's as-shot white, recorded next to the edit (ours comes from the file itself) "crs:AsShotTemperature", "crs:AsShotTint", ]; /// True if the packet carries any `crs:` adjustment (not just bookkeeping fields). pub fn has_adjustments(props: &Props) -> bool { if props.get("crs:AlreadyApplied").and_then(|v| v.first()).is_some_and(|s| s.eq_ignore_ascii_case("true")) { // The pixels already contain these settings (e.g. an exported/rendered file). return false; } props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k))) } thread_local! { /// Keys read by [`to_partial`] while [`to_partial_report`] runs. static READ: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; /// Adjustments [`to_partial`] read but deliberately left out, for the report. static SKIPPED: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } fn note(k: &str) { READ.with(|r| { if let Some(s) = r.borrow_mut().as_mut() { s.insert(k.to_string()); } }); } fn skipped(what: &str) { SKIPPED.with(|r| { if let Some(s) = r.borrow_mut().as_mut() { s.push(what.to_string()); } }); } fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> { note(k); props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty()) } fn num(props: &Props, k: &str) -> Option { let s = first(props, k)?; let s = s.strip_prefix('+').unwrap_or(s); if let Some((n, d)) = s.split_once('/') { let (n, d) = (n.trim().parse::().ok()?, d.trim().parse::().ok()?); return (d != 0.0).then_some(n / d).filter(|v| v.is_finite()); } s.parse::().ok().filter(|v| v.is_finite()) } fn boolean(props: &Props, k: &str) -> Option { first(props, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1") } /// Insert `v` at the dotted `path` of a JSON object, creating intermediate objects. fn put(out: &mut Value, path: &str, v: Value) { let mut cur = out; let mut parts = path.split('.').peekable(); while let Some(p) = parts.next() { let Value::Object(m) = cur else { return }; if parts.peek().is_none() { m.insert(p.to_string(), v); return; } cur = m.entry(p.to_string()).or_insert_with(|| Value::Object(Map::new())); } } /// Relative temperature (−100..100, used for rendered files) → Kelvin, matching the UI's scale /// (a mired shift around 6500 K). pub fn rel_to_kelvin(r: f64) -> f64 { 1e6 / (1e6 / 6500.0 - r.clamp(-100.0, 100.0) * 0.8) } /// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255. fn curve(props: &Props, k: &str) -> Option { note(k); let items = props.get(k)?; let mut pts = Vec::new(); for it in items { let (x, y) = it.split_once(',')?; // (`parse` reads "NaN" and "inf" too: a curve with such a point is not a curve) let (x, y) = (x.trim().parse::().ok().filter(|v| v.is_finite())?, y.trim().parse::().ok().filter(|v| v.is_finite())?); pts.push(json!({"x": (x / 255.0).clamp(0.0, 1.0), "y": (y / 255.0).clamp(0.0, 1.0)})); } if pts.len() < 2 { return None; } // A straight 0,0 → 255,255 line is the identity: store it as "no curve". let identity = pts.len() == 2 && items.iter().all(|it| it.split(',').map(|v| v.trim()).collect::>().windows(2).all(|w| w[0] == w[1])); Some(if identity { json!([]) } else { Value::Array(pts) }) } /// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object. /// /// `target` decides how the white balance is read (see [`Target`]). pub fn to_partial(props: &Props, target: Target) -> Value { let mut out = Value::Object(Map::new()); let o = &mut out; let n = |o: &mut Value, crs: &str, path: &str| { if let Some(v) = num(props, &format!("crs:{crs}")) { put(o, path, json!(v)); } }; // ---- Light (process version 2012+ names) n(o, "Exposure2012", "light.exposure"); n(o, "Contrast2012", "light.contrast"); n(o, "Highlights2012", "light.highlights"); n(o, "Shadows2012", "light.shadows"); n(o, "Whites2012", "light.whites"); n(o, "Blacks2012", "light.blacks"); // Lightroom's Light panel "HDR" button if let Some(on) = boolean(props, "crs:HDREditMode") { put(o, "hdr.enabled", json!(on)); } // older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the // earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50) let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some(); // presets written by newer versions keep the old fields at their defaults: ignore them there let pv2012 = props.keys().any(|k| k.ends_with("2012")); let old = |o: &mut Value, crs: &str, path: &str, f: &dyn Fn(f64) -> f64| { let v = num(props, &format!("crs:{crs}")); if !pv2012 && !has(o, path) && let Some(v) = v { put(o, path, json!(f(v).clamp(-100.0, 100.0))); } }; old(o, "Exposure", "light.exposure", &|v| v); old(o, "Contrast", "light.contrast", &|v| v - 25.0); old(o, "HighlightRecovery", "light.highlights", &|v| -v); old(o, "FillLight", "light.shadows", &|v| v); old(o, "Shadows", "light.blacks", &|v| -(v - 5.0) * 4.0); old(o, "Brightness", "light.whites", &|v| (v - 50.0) * 0.6); // ---- Presence n(o, "Texture", "effects.texture"); n(o, "Clarity2012", "effects.clarity"); old(o, "Clarity", "effects.clarity", &|v| v); n(o, "Dehaze", "effects.dehaze"); n(o, "Vibrance", "color.vibrance"); n(o, "Saturation", "color.saturation"); // ---- White balance let mode = first(props, "crs:WhiteBalance").map(|m| match m.to_ascii_lowercase().replace(' ', "").as_str() { "asshot" => "asShot", "auto" => "auto", "daylight" => "daylight", "cloudy" => "cloudy", "shade" => "shade", "tungsten" => "tungsten", "fluorescent" => "fluorescent", "flash" => "flash", _ => "custom", }); let abs = num(props, "crs:Temperature").map(|t| (t, num(props, "crs:Tint"))); let rel = num(props, "crs:IncrementalTemperature") .or(num(props, "crs:IncrementalTint").map(|_| 0.0)) .map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint"))); // A Kelvin value for something we develop relative to its as-shot look: the other app's // as-shot white (`AsShotTemperature` / `AsShotTint`, written next to the edit) is the // reference, so the edit is the same mired shift from ours (issue #510). let shifted = || { let (t, tint) = abs?; let as_shot = num(props, "crs:AsShotTemperature").filter(|k| *k > 0.0)?; if t <= 0.0 { return None; } let r = (1e6 / as_shot - 1e6 / t) / 0.8; let tint = tint.map(|v| v - num(props, "crs:AsShotTint").unwrap_or(0.0)); Some((rel_to_kelvin(r), tint)) }; let wb = match target { Target::RawAbsolute => abs, Target::Any => abs.or(rel), Target::Rendered => rel.or_else(shifted).or(abs), Target::RawRelative => rel.or_else(shifted), }; match (mode, wb) { (Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)), (m, Some((t, tint))) => { put(o, "wb.mode", json!(m.unwrap_or("custom"))); put(o, "wb.temp", json!(t)); if let Some(tint) = tint { put(o, "wb.tint", json!(tint)); } // Kelvin as the other app wrote it: say so, so the value stays Kelvin on a raw with that scale (a // preset's Kelvin is read as the relative scale on the files that have only that, as before) if wb == abs && matches!(target, Target::RawAbsolute | Target::Any) { put(o, "wb.scale", json!("kelvin")); } } // a custom Kelvin with nothing to shift it from: read on the relative scale it would be a // large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so // the photo stays As Shot (ΔE 12 there) and the report says so (_, None) if target == Target::RawRelative && abs.is_some() => { put(o, "wb.mode", json!("asShot")); skipped("Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)"); } (Some(m), None) => put(o, "wb.mode", json!(m)), (None, None) => {} } // ---- Treatment + B&W mix if let Some(bw) = boolean(props, "crs:ConvertToGrayscale") { put(o, "treatment", json!(if bw { "bw" } else { "color" })); } for (band, crs) in MIXER_BANDS.iter().zip(CRS_BANDS) { n(o, &format!("HueAdjustment{crs}"), &format!("mixer.{band}.hue")); n(o, &format!("SaturationAdjustment{crs}"), &format!("mixer.{band}.sat")); n(o, &format!("LuminanceAdjustment{crs}"), &format!("mixer.{band}.lum")); n(o, &format!("GrayMixer{crs}"), &format!("bw_mix.{band}")); } // ---- Tone curve: parametric regions + point curves n(o, "ParametricShadows", "curve.shadows"); n(o, "ParametricDarks", "curve.darks"); n(o, "ParametricLights", "curve.lights"); n(o, "ParametricHighlights", "curve.highlights"); n(o, "ParametricShadowSplit", "curve.split_shadows"); n(o, "ParametricMidtoneSplit", "curve.split_mid"); n(o, "ParametricHighlightSplit", "curve.split_highlights"); n(o, "CurveRefineSaturation", "curve.refine_saturation"); if let Some(c) = curve(props, "crs:ToneCurve").filter(|c| c.as_array().is_some_and(|a| !a.is_empty())) { // the older single curve, used when no 2012 curve is present if !props.contains_key("crs:ToneCurvePV2012") { put(o, "curve.master", c); } } // Lightroom Classic reads the red / green / blue curves only when the packet holds the master // 2012 curve and all three channel curves (as its own packets always do): a packet missing any // of them imports without channel curves (master + red, master + red + green, or red / green / // blue without the master all leave the photo's channels unchanged there) if let Some(c) = curve(props, "crs:ToneCurvePV2012") { put(o, "curve.master", c); let channels = [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] .map(|(crs, ch)| (curve(props, &format!("crs:{crs}")), ch)); if channels.iter().all(|(c, _)| c.is_some()) { for (c, ch) in channels { if let Some(c) = c { put(o, &format!("curve.{ch}"), c); } } } } // ---- Color grading (split toning fields are shared with the older split-toning panel) n(o, "SplitToningShadowHue", "grading.shadows.hue"); n(o, "SplitToningShadowSaturation", "grading.shadows.sat"); n(o, "ColorGradeShadowLum", "grading.shadows.lum"); n(o, "SplitToningHighlightHue", "grading.highlights.hue"); n(o, "SplitToningHighlightSaturation", "grading.highlights.sat"); n(o, "ColorGradeHighlightLum", "grading.highlights.lum"); n(o, "ColorGradeMidtoneHue", "grading.midtones.hue"); n(o, "ColorGradeMidtoneSat", "grading.midtones.sat"); n(o, "ColorGradeMidtoneLum", "grading.midtones.lum"); n(o, "ColorGradeGlobalHue", "grading.global.hue"); n(o, "ColorGradeGlobalSat", "grading.global.sat"); n(o, "ColorGradeGlobalLum", "grading.global.lum"); n(o, "ColorGradeBlending", "grading.blending"); n(o, "SplitToningBalance", "grading.balance"); // ---- Detail n(o, "Sharpness", "detail.sharpen_amount"); n(o, "SharpenRadius", "detail.sharpen_radius"); n(o, "SharpenDetail", "detail.sharpen_detail"); n(o, "SharpenEdgeMasking", "detail.sharpen_masking"); n(o, "LuminanceSmoothing", "detail.nr_luminance"); n(o, "LuminanceNoiseReductionDetail", "detail.nr_detail"); n(o, "LuminanceNoiseReductionContrast", "detail.nr_contrast"); n(o, "ColorNoiseReduction", "detail.nr_color"); n(o, "ColorNoiseReductionDetail", "detail.nr_color_detail"); n(o, "ColorNoiseReductionSmoothness", "detail.nr_color_smoothness"); // ---- Effects: post-crop vignette + grain n(o, "PostCropVignetteAmount", "vignette.amount"); n(o, "PostCropVignetteMidpoint", "vignette.midpoint"); n(o, "PostCropVignetteRoundness", "vignette.roundness"); n(o, "PostCropVignetteFeather", "vignette.feather"); n(o, "PostCropVignetteHighlightContrast", "vignette.highlights"); if let Some(st) = num(props, "crs:PostCropVignetteStyle") { let style = match st as i64 { 2 => "colorPriority", 3 => "paintOverlay", _ => "highlightPriority", }; put(o, "vignette.style", json!(style)); } n(o, "GrainAmount", "grain.amount"); n(o, "GrainSize", "grain.size"); n(o, "GrainFrequency", "grain.roughness"); // ---- Optics (manual corrections; lens profiles are ours, only the switch carries over). // Our profile corrections are the file's own (DNG-embedded) ones, which Lightroom applies // whatever its "Enable Profile Corrections" box says [inferred]: so only an enabling switch // carries over, and `LensProfileEnable=0` (the default in most presets) leaves ours as it is. if boolean(props, "crs:LensProfileEnable") == Some(true) { put(o, "optics.lens_profile", json!(true)); } if let Some(b) = boolean(props, "crs:AutoLateralCA") { put(o, "optics.remove_ca", json!(b)); } n(o, "LensManualDistortionAmount", "optics.distortion"); n(o, "VignetteAmount", "optics.vignetting"); n(o, "VignetteMidpoint", "optics.vignetting_midpoint"); n(o, "DefringePurpleAmount", "optics.defringe_purple_amount"); n(o, "DefringePurpleHueLo", "optics.defringe_purple_hue_lo"); n(o, "DefringePurpleHueHi", "optics.defringe_purple_hue_hi"); n(o, "DefringeGreenAmount", "optics.defringe_green_amount"); n(o, "DefringeGreenHueLo", "optics.defringe_green_hue_lo"); n(o, "DefringeGreenHueHi", "optics.defringe_green_hue_hi"); // ---- Calibration n(o, "ShadowTint", "calibration.shadows_tint"); n(o, "RedHue", "calibration.red_hue"); n(o, "RedSaturation", "calibration.red_sat"); n(o, "GreenHue", "calibration.green_hue"); n(o, "GreenSaturation", "calibration.green_sat"); n(o, "BlueHue", "calibration.blue_hue"); n(o, "BlueSaturation", "calibration.blue_sat"); // ---- Geometry n(o, "PerspectiveVertical", "geometry.vertical"); n(o, "PerspectiveHorizontal", "geometry.horizontal"); n(o, "PerspectiveRotate", "geometry.rotate"); n(o, "PerspectiveScale", "geometry.scale"); n(o, "PerspectiveAspect", "geometry.aspect"); n(o, "PerspectiveX", "geometry.offset_x"); n(o, "PerspectiveY", "geometry.offset_y"); if let Some(u) = num(props, "crs:PerspectiveUpright") { let mode = match u as i64 { 1 => "auto", 2 => "level", 3 => "vertical", 4 => "full", 5 => "guided", _ => "off", }; put(o, "geometry.upright", json!(mode)); } // ---- Crop (normalized edges of the unrotated image + straighten angle) match boolean(props, "crs:HasCrop") { Some(true) => { let e = |k: &str, d: f64| num(props, &format!("crs:Crop{k}")).unwrap_or(d).clamp(0.0, 1.0); let (l, t, r, b) = (e("Left", 0.0), e("Top", 0.0), e("Right", 1.0), e("Bottom", 1.0)); if r > l && b > t { put( o, "crop.geometry", json!({"rect": {"x0": l, "y0": t, "x1": r, "y1": b}, "angle": num(props, "crs:CropAngle").unwrap_or(0.0).clamp(-45.0, 45.0)}), ); } } Some(false) => put(o, "crop.geometry", json!({"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0})), None => {} } out } /// [`to_partial`], plus the adjustments in the packet it could not carry over (field names /// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…). /// /// With `values` (the packet's structured properties), local corrections become masks too /// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that /// can't be carried over are reported as `Mask: `. pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, target: Target, aspect: f64) -> (Value, Vec) { READ.with(|r| *r.borrow_mut() = Some(Default::default())); SKIPPED.with(|r| *r.borrow_mut() = Some(Vec::new())); let mut out = to_partial(props, target); let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default(); let mut mask_skips = SKIPPED.with(|r| r.borrow_mut().take()).unwrap_or_default(); if let Some(values) = values { let (masks, skipped) = crate::crs_masks::masks(values, aspect); if !masks.is_empty() { put(&mut out, "masks", Value::Array(masks)); } if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) { read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string())); mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}"))); } let (spots, skipped) = crate::crs_masks::spots(values, aspect); if !spots.is_empty() { put(&mut out, "spots", Value::Array(spots)); } if values.keys().any(|k| crate::crs_masks::SPOT_CONTAINERS.contains(&k.as_str())) { read.extend(crate::crs_masks::SPOT_CONTAINERS.iter().map(|c| c.to_string())); mask_skips.extend(skipped.into_iter().map(|k| format!("Spot: {k}"))); } } // fields that only switch a panel on/off or name things: not adjustments by themselves; and // Point Color slots that are all empty (-1) let value_is = |k: &str, f: &dyn Fn(&[String]) -> bool| props.get(&format!("crs:{k}")).is_some_and(|v| f(v)); let empty_points = |v: &[String]| v.iter().all(|s| s.split(',').all(|n| n.trim().parse::().is_ok_and(|x| x == -1.0) || n.trim().is_empty())); let quiet = |k: &str| { k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix" || (k == "PointColors" && value_is(k, &empty_points)) }; let mut unmapped: Vec = props .keys() .filter(|k| k.starts_with("crs:")) .map(|k| k.split('/').next().unwrap_or(k)) .filter(|k| !NON_ADJUSTMENT.contains(k) && !read.contains(*k)) .map(|k| k.trim_start_matches("crs:").to_string()) .filter(|k| !quiet(k)) .collect(); unmapped.dedup(); unmapped.extend(mask_skips); (out, unmapped) } /// The selected camera profile name from an XMP packet. The name is metadata only: it does not /// identify or carry the profile's rendering data. pub fn camera_profile_name(props: &Props) -> Option<&str> { first(props, "crs:CameraProfile") } /// Whether an XMP packet contains a saved Lightroom Upright matrix. Lightroom's matrix uses a /// coordinate convention that LightCraft does not currently import. pub fn has_saved_upright_transform(props: &Props) -> bool { props.keys().any(|key| { key.strip_prefix("crs:").is_some_and(|name| { name.starts_with("UprightTransform_") && props.get(key).is_some_and(|values| values.iter().any(|v| !v.trim().is_empty())) }) }) } /// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets. /// Returns `None` when the packet has no adjustments we understand. pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option { let d = lightcraft_meta::parse_xmp(xmp).ok()?; let props = &d.properties; let settings = to_partial(props, Target::Any); if settings.as_object().is_none_or(Map::is_empty) { return None; } let name = first(props, "crs:Name").unwrap_or(fallback_name).to_string(); let group = first(props, "crs:Group").unwrap_or("Imported Presets").to_string(); let uuid = first(props, "crs:UUID").map(|u| u.to_ascii_lowercase()); let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}")))); Some(Preset { id, name, group, settings, favorite: false, builtin: false }) } #[cfg(test)] mod tests { use super::*; use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial}; /// A hand-written sidecar in attribute form (as many tools write it). const SIDECAR: &str = r#" 0, 064, 52192, 205255, 255 0, 0255, 255 "#; fn props(x: &str) -> Props { lightcraft_meta::parse_xmp(x).unwrap().properties } #[test] fn maps_common_fields() { let p = props(SIDECAR); assert!(has_adjustments(&p)); let partial = to_partial(&p, Target::RawAbsolute); let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); assert_eq!(s.wb.mode, WbMode::Custom); assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); assert_eq!(s.light.exposure, 0.65); assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0)); assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0)); assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0)); assert_eq!(s.mixer.orange.hue, -7.0); assert_eq!(s.mixer.blue.sat, -22.0); assert_eq!(s.mixer.aqua.lum, 13.0); assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); assert_eq!(s.curve.master.len(), 4); assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); assert_eq!(s.vignette.amount, -21.0); assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); assert_eq!(s.geometry.upright, Upright::Level); assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); let cal = s.calibration; assert_eq!( (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) ); let c = s.crop.geometry; assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); } /// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve: /// it is left out, rather than stored with a point the curve editor can't place. #[test] fn curves_with_non_finite_points_are_left_out() { for bad in ["NaN, 0", "64, inf", "-inf, 3"] { let x = SIDECAR.replace("64, 52", &format!("{bad}")); let partial = to_partial(&props(&x), Target::RawAbsolute); let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master); assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite())); } } #[test] fn only_present_fields_are_in_the_partial() { let x = r#""#; let partial = to_partial(&props(x), Target::Any); assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); } #[test] fn hdr_edit_mode_turns_hdr_editing_on_or_off() { let x = |v: &str| { format!( r#""# ) }; let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); assert!(on.hdr.enabled); assert_eq!(on.light.exposure, 1.5); assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); assert!(true_form.hdr.enabled); let mut hdr = DevelopSettings::default(); hdr.hdr.enabled = true; assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); // without the field nothing about HDR changes let plain = r#""#; assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); } #[test] fn element_form_bw_and_incremental_wb() { let x = r#" True -35 25 -6 4800 "#; let p = props(x); let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); assert_eq!(rendered.bw_mix.blue, -35.0); assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); assert_eq!(rendered.wb.tint, -6.0); let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); assert_eq!(raw.wb.temp, 4800.0); } #[test] fn named_wb_and_already_applied() { let x = r#""#; let p = props(x); assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); assert!(!has_adjustments(&p)); let bookkeeping = r#""#; assert!(!has_adjustments(&props(bookkeeping))); } #[test] fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white // of 3650 K / −2 (written for this test from the numbers in the issue). let x = r#""#; let p = props(x); let rel = to_partial(&p, Target::RawRelative); // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale let t = rel["wb"]["temp"].as_f64().unwrap(); assert!((t - 6275.0).abs() < 5.0, "{t}"); assert_eq!(rel["wb"]["tint"], -3.0); assert_eq!(rel["wb"]["mode"], "custom"); // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured // illuminant (and a preset) keeps the Kelvin value assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); // the as-shot fields are a reference, not an adjustment: nothing to report assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); // no tint next to the as-shot tint: the custom tint is taken as the shift itself let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); } #[test] fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: // 3578 K read on the relative scale would be a large blue shift (issue #510) let x = r#""#; let p = props(x); let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); assert_eq!(partial["wb"], json!({"mode": "asShot"})); assert_eq!(partial["light"]["exposure"], 0.3); assert_eq!(unmapped.len(), 1, "{unmapped:?}"); assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); // a raw with a measured illuminant and a rendered file read it as before, and report nothing assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); // an unusable reference (zero) counts as none let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); } #[test] fn target_follows_the_file() { use lightcraft_catalog::MediaKind; assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); // Lightroom's `fileFormat` name for proprietary raws assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); // a raw shown from its embedded preview develops like a rendered file assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); // a camera with measured matrices edits in Kelvin (issue #730), under any of its names assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); let mut p = lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); p.kind = MediaKind::Raw; assert_eq!(Target::for_photo(&p), Target::RawRelative); p.meta.camera = "SONY ILCE-7M3".into(); assert_eq!(Target::for_photo(&p), Target::RawAbsolute); } /// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value /// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on /// a raw without matrices still takes the #510 shift. #[test] fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { use lightcraft_catalog::MediaKind; let x = r#""#; let p = props(x); let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); let shifted = to_partial(&p, a7m2); assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); assert_eq!(shifted["wb"]["tint"], 4.0); assert!(shifted["wb"].get("scale").is_none()); // without the reference the Kelvin camera still reads it, the other stays As Shot let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); // a named preset carries no numbers either way let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); } #[test] fn xmp_preset_with_name_and_group() { // Written for this test: a minimal preset-shaped packet with localized name/group. let x = r#" Punchy Test My Looks "#; let p = preset_from_xmp(x, "file").unwrap(); assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd")); assert_eq!(p.settings, json!({"light": {"contrast": 25.0}})); assert!(!p.builtin); assert!(preset_from_xmp("", "f").is_none()); } /// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { format!( r#" {extra_elems} "# ) } #[test] fn channel_curves_need_the_master_and_all_channel_curves() { // Lightroom applies red / green / blue curves only when the master and all three are present let seq = |name: &str, pts: &str| format!("{pts}"); let identity = "0, 0255, 255"; let bent = "0, 064, 40255, 255"; let packet = |curves: &[String]| { format!( r#" {} "#, curves.concat() ) }; let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); let (master, red, green, blue) = ( seq("ToneCurvePV2012", identity), seq("ToneCurvePV2012Red", identity), seq("ToneCurvePV2012Green", bent), seq("ToneCurvePV2012Blue", identity), ); // a channel curve alone, or with the master but without its two siblings: ignored let alone = partial(std::slice::from_ref(&green)); assert!(alone.pointer("/curve/green").is_none(), "{alone}"); let master_and_green = partial(&[master.clone(), green.clone()]); assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); // all four: read let all = partial(&[master, red, green, blue]); assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); } fn unmapped(x: &str) -> Vec { to_partial_report(&props(x), None, Target::Any, 1.5).1 } #[test] fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); assert_eq!(got, ["FutureSlider", "PointColors"]); } #[test] fn disabled_lens_profile_switch_keeps_embedded_corrections() { // Lightroom applies a file's built-in lens corrections regardless of its profile switch, // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off let p = props(&preset_packet("", "")); let mut d = DevelopSettings::default(); d.optics.lens_profile = true; assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); } }