This commit is contained in:
Brandon Thomas
2026-10-09 20:36:10 -04:00
12 changed files with 234 additions and 57 deletions
+9 -2
View File
@@ -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`]).
+149 -23
View File
@@ -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<String, Vec<String>>;
/// 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<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
/// Adjustments [`to_partial`] read but deliberately left out, for the report.
static SKIPPED: std::cell::RefCell<Option<Vec<String>>> = 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<Value> {
/// 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<bool>) -> 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<bool>) -> 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<bool>) -> 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<bool>) -> 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: <kind>`.
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, target: Target, aspect: f64) -> (Value, Vec<String>) {
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<Preset> {
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
xmlns:c="http://ns.adobe.com/camera-raw-settings/1.0/" c:Vibrance="+30" c:GrainAmount="10"/></rdf:RDF>"#;
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 {
<crs:Temperature>4800</crs:Temperature>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
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#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Daylight" crs:Temperature="5500" crs:Tint="10" crs:AlreadyApplied="True"/>"#;
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#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="1" crs:HasSettings="True"/>"#;
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#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:AsShotTemperature="3650" crs:AsShotTint="-2"/>"#;
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#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Custom" crs:Temperature="3578" crs:Tint="-5" crs:Exposure2012="+0.3"/>"#;
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 {
</rdf:Description></rdf:RDF></x:xmpmeta>"#
)
};
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 = "<crs:ToneCurvePV2012><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012>";
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<String> {
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);
}
}
+1 -1
View File
@@ -298,7 +298,7 @@ mod tests {
fn settings() -> (DevelopSettings, Vec<String>) {
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)
}
+2 -1
View File
@@ -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}");
+51 -16
View File
@@ -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>), String> {
fn mapped_settings(
text: &str,
target: crate::crs::Target,
aspect: f64,
orientation: Orientation,
stored_aspect: f64,
) -> Result<(Value, Vec<String>), 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);
}
+1 -1
View File
@@ -464,7 +464,7 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
}
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
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");
+8 -6
View File
@@ -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<SidecarData, String> {
/// 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<SidecarData, String> {
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<SidecarData, S
out.develop = match full {
Some(s) => 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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
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();
+2 -2
View File
@@ -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");
+2 -1
View File
@@ -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:?}"),
};
+5
View File
@@ -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.
+2 -2
View File
@@ -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 (`<file>.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 (`<file>.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/<host>`), NFS (`/Network/Servers/<host>`), 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 |
+2 -2
View File
@@ -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<Band>`, `SaturationAdjustment<Band>`, `LuminanceAdjustment<Band>` | `mixer.<band>.hue/sat/lum` | bands Red, Orange, Yellow, Green, Aqua, Blue, Purple, Magenta |