diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index b2a1a6960..176f0d5b6 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -296,6 +296,14 @@ pub struct Analysis { pub best: bool, } +/// Raw formats whose readers have vendor white-balance multipliers but no measured camera +/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means +/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's +/// `fileFormat` name, in any case. See [`Photo::relative_wb`]. +pub fn relative_wb_format(format: &str) -> bool { + ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| format.eq_ignore_ascii_case(f)) +} + impl Photo { pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { Photo { @@ -341,8 +349,7 @@ impl Photo { /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). pub fn relative_wb(&self) -> bool { - self.develops_raw() - && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) + self.develops_raw() && relative_wb_format(&self.format) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 0b0c83b94..f80b6b1ee 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -18,6 +18,43 @@ 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 measured illuminant takes `Temperature` as Kelvin; everything developed relative to its +/// as-shot look (rendered files, and raws whose readers have no illuminant, +/// [`lightcraft_catalog::relative_wb_format`]) 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 a measured + /// illuminant): 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); `preview_only` for a raw that can't be decoded yet. + pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target { + if kind != lightcraft_catalog::MediaKind::Raw || preview_only { + Target::Rendered + } else if lightcraft_catalog::relative_wb_format(format) { + 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()) + } +} + /// 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"]; @@ -69,6 +106,8 @@ pub fn has_adjustments(props: &Props) -> bool { 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) { @@ -79,6 +118,14 @@ fn note(k: &str) { }); } +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()) @@ -138,10 +185,8 @@ fn curve(props: &Props, k: &str) -> Option { /// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object. /// -/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the -/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever -/// is present, absolute first. -pub fn to_partial(props: &Props, raw: Option) -> Value { +/// `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| { @@ -200,10 +245,24 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { let rel = num(props, "crs:IncrementalTemperature") .or(num(props, "crs:IncrementalTint").map(|_| 0.0)) .map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint"))); - let wb = match raw { - Some(true) => abs, - Some(false) => rel.or(abs), - None => abs.or(rel), + // 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)), @@ -214,6 +273,13 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { put(o, "wb.tint", json!(tint)); } } + // 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) => {} } @@ -375,11 +441,12 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { /// 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>, raw: Option, aspect: f64) -> (Value, Vec) { +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())); - let mut out = to_partial(props, raw); + 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 = Vec::new(); + 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() { @@ -387,7 +454,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values } 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 = skipped.into_iter().map(|k| format!("Mask: {k}")).collect(); + mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}"))); } } // fields that only switch a panel on/off or name things: not adjustments by themselves; an @@ -421,7 +488,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values 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, None); + let settings = to_partial(props, Target::Any); if settings.as_object().is_none_or(Map::is_empty) { return None; } @@ -474,7 +541,7 @@ mod tests { fn maps_common_fields() { let p = props(SIDECAR); assert!(has_adjustments(&p)); - let partial = to_partial(&p, Some(true)); + 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)); @@ -516,7 +583,7 @@ mod tests { fn only_present_fields_are_in_the_partial() { let x = r#""#; - let partial = to_partial(&props(x), None); + let partial = to_partial(&props(x), Target::Any); assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); } @@ -531,12 +598,12 @@ mod tests { 4800 "#; let p = props(x); - let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0); + 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, Some(true)), 1.0); + let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); assert_eq!(raw.wb.temp, 4800.0); } @@ -545,13 +612,72 @@ mod tests { let x = r#""#; let p = props(x); - assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}})); + 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); + // 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), Target::RawRelative); + // Lightroom's `fileFormat` name for proprietary raws + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), 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), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered); + } + #[test] fn xmp_preset_with_name_and_group() { // Written for this test: a minimal preset-shaped packet with localized name/group. @@ -594,15 +720,15 @@ mod tests { "# ) }; - let alone = to_partial(&props(&packet("")), Some(true)); + let alone = to_partial(&props(&packet("")), Target::RawAbsolute); assert!(alone.pointer("/curve/green").is_none(), "{alone}"); let master = "0, 0255, 255"; - let with = to_partial(&props(&packet(master)), Some(true)); + let with = to_partial(&props(&packet(master)), Target::RawAbsolute); assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}"); } fn unmapped(x: &str) -> Vec { - to_partial_report(&props(x), None, None, 1.5).1 + to_partial_report(&props(x), None, Target::Any, 1.5).1 } #[test] @@ -622,8 +748,8 @@ mod tests { let p = props(&preset_packet("", "")); let mut d = DevelopSettings::default(); d.optics.lens_profile = true; - assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile); + 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, None), 1.0).optics.lens_profile); + assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); } } diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index feea9234c..b1c987ab0 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -298,7 +298,7 @@ mod tests { fn settings() -> (DevelopSettings, Vec) { let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 833d92e8e..2df4de96b 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -899,7 +899,8 @@ impl ImportJob { let packet = crate::sidecar::find_sidecar(&path, self.naming) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); - let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) { + let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some()); + let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(sc) => Some(sc), Err(e) => { log::warn!("import {path}: XMP: {e}"); diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 6983c2f8b..6c1c7724d 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -478,7 +478,13 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } } -fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec), String> { +fn mapped_settings( + text: &str, + target: crate::crs::Target, + aspect: f64, + orientation: Orientation, + stored_aspect: f64, +) -> Result<(Value, Vec), String> { let mut root = crate::preset_import::parse_lua(text)?; let deferred = strip_lua_sentinels(&mut root); let (mut props, values) = crate::preset_import::lua_settings_props(&root)?; @@ -488,7 +494,7 @@ fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) { props.insert("crs:HasCrop".into(), vec!["True".into()]); } - let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect); + let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect); catalog_crop(&mut partial, orientation, stored_aspect); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); @@ -717,7 +723,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex preserved += 1; } if !src.xmp.is_empty() { - match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) { + match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) { Ok(mut sc) => { if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { strip_xmp_sentinels(partial); @@ -745,7 +751,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64; match mapped_settings( &src.settings, - p.kind == lightcraft_catalog::MediaKind::Raw, + crate::crs::Target::for_photo(&p), p.width.max(1) as f64 / p.height.max(1) as f64, orientation, stored_aspect, @@ -936,7 +942,10 @@ mod tests { cyclic.collections[0].insert("parent".into(), json!(10)); assert!(apply(&mut s, cyclic, false).is_err()); assert_eq!(s.catalog.to_snapshot(), before); - assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err()); + assert!( + mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) + .is_err() + ); } #[test] fn compressed_xmp_is_bounded_and_settings_are_data_only() { @@ -946,17 +955,41 @@ mod tests { assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); encoded[0] = 0xff; assert!(xmp(Some(&json!(encoded))).is_err()); - let (partial, _) = - mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = mapped_settings( + "s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", + crate::crs::Target::RawAbsolute, + 1.5, + Orientation::Normal, + 1.5, + ) + .unwrap(); assert_eq!(partial["light"]["exposure"], 0.75); } + #[test] + fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { + // Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed + // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin + let settings = + "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); + assert_eq!(partial["light"]["exposure"], 0.5); + assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!( + (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), + (Some("custom"), Some(3578.0), Some(-5.0)) + ); + assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}"); + } + #[test] fn catalog_crops_without_has_crop_are_imported() { // Lightroom Classic catalogs store crop edges and angle but never `HasCrop` let (partial, unknown) = mapped_settings( "s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", - true, + crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, @@ -970,11 +1003,11 @@ mod tests { assert_eq!(crop["angle"], 0.0); assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) - let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); // no crop fields: the crop stays untouched - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); assert!(partial.get("crop").is_none()); } @@ -998,7 +1031,7 @@ mod tests { fn catalog_crops_follow_the_photo_orientation() { let crop = |o: Orientation| { let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; - let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); let g = &partial["crop"]["geometry"]; let r = &g["rect"]; [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) @@ -1011,13 +1044,13 @@ mod tests { assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); // a mirror turns the straighten the other way let angle = |o: Orientation| { - let (partial, _) = mapped_settings("s = { CropAngle = 2 }", true, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); partial["crop"]["geometry"]["angle"].as_f64().unwrap() }; assert_eq!(angle(Orientation::Rotate90), -2.0); assert_eq!(angle(Orientation::FlipH), 2.0); // no crop: nothing to orient - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); assert!(partial.get("crop").is_none()); } @@ -1043,7 +1076,7 @@ mod tests { for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { for angle in [6.5, -6.5] { let g = CropGeometry { angle, ..g }; - let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), true, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); let close = |a: f64, b: f64| (a - b).abs() < 1e-9; assert!( @@ -1073,7 +1106,7 @@ mod tests { (6100.0, 9150.0), ), ] { - let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; let (w, h) = (r.width() * ow, r.height() * oh); @@ -1112,7 +1145,9 @@ mod tests { assert_eq!(p.develop.light.shadows, 65.0); assert_eq!(p.develop.light.blacks, -22.0); assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); - let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = + mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) + .unwrap(); assert_eq!(partial["light"]["exposure"], -5.0); assert_eq!(partial["light"]["contrast"], -100.0); } diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 040d46c3b..044139ff9 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -464,7 +464,7 @@ pub fn group_from_dir(dir: &str) -> Option { } pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option, from_photo: bool) -> Option { - let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT); + let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT); if from_photo && let Some(o) = settings.as_object_mut() { // a photo's own framing and absolute white balance don't belong in a look o.remove("crop"); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 7e9249489..9d130c8ac 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -114,8 +114,10 @@ pub enum DevelopPatch { Partial(Value), } -/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`. -pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result { +/// Parse a sidecar / XMP packet. `target` selects how a `crs:` white balance is read +/// ([`crate::crs::Target`]: absolute Kelvin for raws with a measured illuminant, a shift from the +/// as-shot white for everything developed relative to it). +pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Result { let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?; let m = &d.metadata; let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned(); @@ -162,7 +164,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result Some(DevelopPatch::Full(Box::new(s))), None if crate::crs::has_adjustments(&d.properties) => { - Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0)) + Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0)) } None => None, }; @@ -475,7 +477,7 @@ impl Session { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; let Some(orig) = file_path(p) else { return Ok(None) }; let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) }; - let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw) + let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)) .map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))? .resolve_label(&self.catalog); let mut q = (**p).clone(); @@ -553,7 +555,7 @@ mod tests { fn packet_roundtrip_restores_everything() { let p = photo(); let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new()); - let sc = parse_sidecar(&x, false).unwrap(); + let sc = parse_sidecar(&x, crate::crs::Target::Rendered).unwrap(); assert_eq!(sc.rating, Some(4)); assert_eq!(sc.flag, Some(Flag::Pick)); assert_eq!(sc.label, Some(Some(ColorLabel::Purple))); @@ -572,7 +574,7 @@ mod tests { let x = r#" "#; - let sc = parse_sidecar(x, true).unwrap(); + let sc = parse_sidecar(x, crate::crs::Target::RawAbsolute).unwrap(); assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green)))); let mut p = photo(); let before = p.develop.clone(); diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 5d107f65d..b35167c1b 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -355,9 +355,9 @@ fn label_sets_and_xmp_label_names() { p.label = Some(ColorLabel::Green); let x = crate::sidecar::sidecar_packet(&p, &s.catalog); assert!(x.contains("Approved"), "{x}"); - let sc = crate::sidecar::parse_sidecar(&x, false).unwrap().resolve_label(&s.catalog); + let sc = crate::sidecar::parse_sidecar(&x, crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog); assert_eq!(sc.label, Some(Some(ColorLabel::Green))); - let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), false).unwrap().resolve_label(&s.catalog); + let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog); assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work"); s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap(); assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay"); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index ab801e237..e3137da00 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -94,7 +94,8 @@ fn auto_write_and_naming_preference() { // a slider drag writes once, at the end s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap(); - let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), false).unwrap().develop { + let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().develop + { Some(crate::sidecar::DevelopPatch::Full(d)) => d.light.exposure, other => panic!("{other:?}"), }; diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index d3d1940e9..31a0183d9 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -41,6 +41,11 @@ models, some masking/retouch fields and process-version algorithms are not repro fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots and original smart-collection rules. Smart collections become regular albums with current membership. Archived history and snapshots are source data, not native LightCraft history yet. +A Custom white balance on a raw LightCraft develops relative to its as-shot look (ARW, NEF, RW2, +RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvin: the catalog keeps no +as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported +(issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is +shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings; it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone is not evaluated by the importer; LightCraft's Auto control remains available after migration. diff --git a/docs/parity.md b/docs/parity.md index e6480f304..1e9f1a419 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -228,7 +228,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -739,7 +739,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 735432312..ece7c6c81 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -107,8 +107,8 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `Exposure2012` | `light.exposure` | EV, 1:1 | | `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 | | `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom | -| `Temperature`, `Tint` | `wb.temp`, `wb.tint` | Kelvin / tint for raw files (and presets) | -| `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider) | +| `Temperature`, `Tint` | `wb.temp`, `wb.tint` | Kelvin / tint as written for raws with a measured illuminant (DNG) and for presets. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510) | +| `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider); a rendered file with only `Temperature` shifts from `AsShotTemperature` like the raws above when it is present | | `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 | | `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 | | `HueAdjustment`, `SaturationAdjustment`, `LuminanceAdjustment` | `mixer..hue/sat/lum` | bands Red, Orange, Yellow, Green, Aqua, Blue, Purple, Magenta |