LR-IMP-PROFILES: carry masks over from presets, .lrtemplate and XMP sidecars

Local corrections stored as crs: structures (MaskGroupBasedCorrections and the older
gradient / circular gradient / paint containers) become our masks: linear and radial
gradients, brushes (dab runs → strokes), Subject / Sky, luminance and depth ranges, with
add / subtract / intersect and invert, and all local sliders. lightcraft-meta's XmpData gains
`values`, a structured view of every property (arrays of structs survive); .lrtemplate Lua
tables convert into the same shape.
Applying a preset adds its masks to the photo's own (once; Amount scales them); sidecar masks
replace them and radial masks are fitted to the photo's aspect. Unsupported components
(colour range, other AI selections) are reported as "Mask: <kind>".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 20:31:40 -04:00
1 parent 1dc501bc3a
commit b7d497d049
10 files changed
+536 -18

No files matched your search

+22 -1
View File
@@ -208,8 +208,29 @@ impl Preset {
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
}
/// Apply at `amount` (1.0 = 100 %). Masks in the preset are added to the photo's own (ones it
/// already has are skipped), their effect scaled by the amount.
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
apply_partial(s, &self.settings, amount)
let Some(masks) = self.settings.get("masks").and_then(Value::as_array).filter(|m| !m.is_empty()) else {
return apply_partial(s, &self.settings, amount);
};
let mut rest = self.settings.clone();
if let Some(o) = rest.as_object_mut() {
o.remove("masks");
}
let mut out = apply_partial(s, &rest, amount);
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
for m in masks {
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
continue;
}
next += 1;
m.id = next;
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
out.masks.push(m);
}
out
}
}
+19 -3
View File
@@ -354,10 +354,25 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
/// [`to_partial`], plus the adjustments in the packet it could not carry over (field names
/// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…).
pub fn to_partial_report(props: &Props, raw: Option<bool>) -> (Value, Vec<String>) {
///
/// 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>) {
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
let out = to_partial(props, raw);
let read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
let mut out = to_partial(props, raw);
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
let mut mask_skips = Vec::new();
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 = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
}
}
// fields that only switch a panel on/off or name things: not adjustments by themselves
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
let mut unmapped: Vec<String> = props
@@ -369,6 +384,7 @@ pub fn to_partial_report(props: &Props, raw: Option<bool>) -> (Value, Vec<String
.filter(|k| !quiet(k))
.collect();
unmapped.dedup();
unmapped.extend(mask_skips);
(out, unmapped)
}
+379
View File
@@ -0,0 +1,379 @@
//! Interchange: local corrections (masks) stored as `crs:` structures in XMP sidecars, XMP
//! presets and `.lrtemplate` files → our [`Mask`](lightcraft_develop::Mask)s.
//!
//! Four containers hold them: `MaskGroupBasedCorrections` (current: each correction has a list
//! of mask components) and the older `GradientBasedCorrections`, `CircularGradientBasedCorrections`
//! and `PaintBasedCorrections`. Every correction carries its local sliders (`Local*`, stored as
//! −1..1 fractions; exposure ×4 EV) and its components (`crs:What` = `Mask/Gradient`,
//! `Mask/CircularGradient`, `Mask/Paint`, `Mask/Image`, `Mask/RangeMask`).
//!
//! Implemented from the public XMP data model and black-box observation; best effort — the
//! pipelines differ, and the component combine modes and AI mask kinds are mapped from what the
//! fields evidently mean. Unsupported components are reported, never guessed.
use std::collections::BTreeMap;
use lightcraft_meta::XmpValue;
use serde_json::{Value, json};
/// Top-level properties keyed `prefix:name`, with structure.
pub type Values = BTreeMap<String, XmpValue>;
/// The containers this module reads.
pub const CONTAINERS: [&str; 4] =
["crs:MaskGroupBasedCorrections", "crs:GradientBasedCorrections", "crs:CircularGradientBasedCorrections", "crs:PaintBasedCorrections"];
fn text<'a>(v: &'a XmpValue, k: &str) -> Option<&'a str> {
v.field(k).and_then(XmpValue::text).filter(|s| !s.is_empty())
}
fn num(v: &XmpValue, k: &str) -> Option<f64> {
let s = text(v, k)?;
let s = s.strip_prefix('+').unwrap_or(s);
s.parse::<f64>().ok().filter(|x| x.is_finite())
}
fn flag(v: &XmpValue, k: &str) -> Option<bool> {
text(v, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
}
/// Local slider fields → (our `adjust` field, scale from the stored fraction).
const LOCAL: &[(&str, &str, f64)] = &[
("LocalExposure2012", "exposure", 4.0),
("LocalContrast2012", "contrast", 100.0),
("LocalHighlights2012", "highlights", 100.0),
("LocalShadows2012", "shadows", 100.0),
("LocalWhites2012", "whites", 100.0),
("LocalBlacks2012", "blacks", 100.0),
("LocalClarity2012", "clarity", 100.0),
("LocalTexture", "texture", 100.0),
("LocalDehaze", "dehaze", 100.0),
("LocalTemperature", "temp", 100.0),
("LocalTint", "tint", 100.0),
("LocalSaturation", "saturation", 100.0),
("LocalHue", "hue", 100.0),
("LocalSharpness", "sharpness", 100.0),
("LocalLuminanceNoise", "noise", 100.0),
("LocalMoire", "moire", 100.0),
("LocalDefringe", "defringe", 100.0),
("LocalToningSaturation", "color_sat", 100.0),
("LocalToningHue", "color_hue", 1.0),
// older process versions
("LocalExposure", "exposure", 4.0),
("LocalContrast", "contrast", 100.0),
("LocalClarity", "clarity", 100.0),
];
fn adjust(c: &XmpValue) -> Value {
let mut a = serde_json::Map::new();
for (crs, ours, k) in LOCAL {
if a.contains_key(*ours) {
continue;
}
if let Some(v) = num(c, &format!("crs:{crs}")) {
let lim = if *ours == "exposure" {
4.0
} else if *ours == "color_hue" {
360.0
} else {
100.0
};
let v = (v * k).clamp(if *ours == "color_hue" { 0.0 } else { -lim }, lim);
a.insert((*ours).into(), json!(v));
}
}
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
Value::Object(a)
}
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
let y = l.clamp(1e-4, 1.0).powf(2.2);
((y.log2() + 8.0) / 12.0).clamp(0.0, 1.0)
}
fn pt(x: f64, y: f64) -> Value {
json!({"x": x, "y": y})
}
/// One mask component → our component JSON; `Err(kind)` names an unsupported one.
fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
let what = text(m, "crs:What").unwrap_or("");
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
1 => "subtract",
2 => "intersect",
_ => "add",
};
let invert = flag(m, "crs:MaskInverted").unwrap_or(false);
// positions are fractions of the image's width / height; our radii are in long-edge units
let (sx, sy) = if aspect >= 1.0 { (1.0, 1.0 / aspect) } else { (aspect, 1.0) };
let shape = match what {
"Mask/Gradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
json!({"kind": "linear", "start": pt(g("FullX", 0.5), g("FullY", 0.0)), "end": pt(g("ZeroX", 0.5), g("ZeroY", 1.0))})
}
"Mask/CircularGradient" => {
let g = |k: &str, d: f64| num(m, &format!("crs:{k}")).unwrap_or(d);
let (l, r, t, b) = (g("Left", 0.25), g("Right", 0.75), g("Top", 0.25), g("Bottom", 0.75));
json!({
"kind": "radial",
"center": pt((l + r) / 2.0, (t + b) / 2.0),
"rx": ((r - l).abs() / 2.0 * sx).max(1e-3),
"ry": ((b - t).abs() / 2.0 * sy).max(1e-3),
"angle": g("Angle", 0.0),
"feather": g("Feather", 50.0).clamp(0.0, 100.0),
"invert": flag(m, "crs:Flipped").unwrap_or(false),
})
}
"Mask/Paint" => {
let pts: Vec<Value> = m
.field("crs:Dabs")
.map(XmpValue::items)
.unwrap_or_default()
.iter()
.filter_map(XmpValue::text)
.filter_map(|d| {
let mut it = d.split_whitespace().skip(1).filter_map(|v| v.parse::<f64>().ok());
Some(pt(it.next()?, it.next()?))
})
.collect();
if pts.is_empty() {
return Err("empty brush".into());
}
let erase = num(m, "crs:MaskValue").is_some_and(|v| v <= 0.0);
json!({"kind": "brush", "strokes": [{
"points": pts,
"size": num(m, "crs:Radius").unwrap_or(0.03).clamp(0.001, 1.0),
"feather": (num(m, "crs:CenterWeight").unwrap_or(0.5) * 100.0).clamp(0.0, 100.0),
"flow": (num(m, "crs:Flow").unwrap_or(1.0) * 100.0).clamp(0.0, 100.0),
"density": (num(m, "crs:MaskValue").unwrap_or(1.0).abs() * 100.0).clamp(0.0, 100.0),
"erase": erase,
}]})
}
"Mask/Image" => match text(m, "crs:MaskSubType") {
Some("1") => json!({"kind": "subject"}),
Some("2") => json!({"kind": "sky"}),
other => return Err(format!("AI mask ({})", other.unwrap_or("?"))),
},
"Mask/RangeMask" => {
let r = m.field("crs:CorrectionRangeMask").unwrap_or(m);
match text(r, "crs:Type") {
Some("2") => {
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
}
Some("3") => {
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, _] = q[..] else { return Err("depth range".into()) };
json!({"kind": "depthRange", "lo": b, "hi": c, "feather": (b - a).max(0.0)})
}
_ => return Err("colour range".into()),
}
}
other => return Err(if other.is_empty() { "unknown mask".into() } else { other.trim_start_matches("Mask/").to_string() }),
};
Ok(json!({"op": op, "invert": invert, "shape": shape}))
}
/// Masks in `values` (JSON for `DevelopSettings::masks`), plus the kinds of component that
/// couldn't be carried over. `aspect` = width / height of the target (presets: assume 3:2).
pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut skipped = Vec::new();
for container in CONTAINERS {
let Some(list) = values.get(container) else { continue };
for c in list.items() {
if flag(c, "crs:CorrectionActive") == Some(false) {
continue;
}
let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default();
let mut parts = Vec::new();
for m in comps {
match component(m, aspect) {
Ok(v) => parts.push(v),
Err(kind) => skipped.push(kind),
}
}
if parts.is_empty() {
continue;
}
// brush dabs of one correction are one brush component
let mut merged: Vec<Value> = Vec::new();
for p in parts {
let is_brush = p["shape"]["kind"] == "brush";
if is_brush
&& let Some(prev) = merged.iter_mut().find(|q| q["shape"]["kind"] == "brush" && q["op"] == p["op"])
&& let (Some(a), Some(b)) = (prev["shape"]["strokes"].as_array_mut(), p["shape"]["strokes"].as_array())
{
a.extend(b.iter().cloned());
continue;
}
merged.push(p);
}
let n = out.len() + 1;
let name = text(c, "crs:CorrectionName").map(str::to_string).unwrap_or_else(|| format!("Mask {n}"));
out.push(json!({"id": n, "name": name, "visible": true, "invert": false, "components": merged, "adjust": adjust(c)}));
}
}
skipped.sort();
skipped.dedup();
(out, skipped)
}
/// A `.lrtemplate` Lua value as an XMP value (field names get the `crs:` prefix).
pub fn from_lua(v: &crate::preset_import::Lua) -> XmpValue {
use crate::preset_import::Lua;
match v {
Lua::Nil => XmpValue::Text(String::new()),
Lua::Bool(b) => XmpValue::Text(if *b { "true" } else { "false" }.into()),
Lua::Num(n) => XmpValue::Text(format!("{n}")),
Lua::Str(s) => XmpValue::Text(s.clone()),
Lua::Table(arr, map) if map.is_empty() => XmpValue::Array(arr.iter().map(from_lua).collect()),
Lua::Table(_, map) => XmpValue::Struct(map.iter().map(|(k, v)| (format!("crs:{k}"), from_lua(v))).collect()),
}
}
/// The aspect radial masks are computed for when the target's shape isn't known yet.
pub const DEFAULT_ASPECT: f64 = 1.5;
/// Re-fit radial masks in `partial` (computed for aspect `from`) to a target of aspect `to`.
pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
let scale = |a: f64| if a >= 1.0 { (1.0, 1.0 / a) } else { (a, 1.0) };
let ((fx, fy), (tx, ty)) = (scale(from), scale(to));
let Some(masks) = partial.get_mut("masks").and_then(Value::as_array_mut) else { return };
for m in masks {
for c in m["components"].as_array_mut().into_iter().flatten() {
let s = &mut c["shape"];
if s["kind"] == "radial" {
if let Some(rx) = s["rx"].as_f64() {
s["rx"] = json!(rx / fx * tx);
}
if let Some(ry) = s["ry"].as_f64() {
s["ry"] = json!(ry / fy * ty);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
/// A packet in the shape local corrections take (values written for this test).
const PACKET: &str = 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:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.2">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/>
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
<crs:PaintBasedCorrections><rdf:Seq>
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
</rdf:Seq></crs:CorrectionMasks></rdf:li>
</rdf:Seq></crs:PaintBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
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);
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
}
#[test]
fn corrections_become_masks() {
let (s, unmapped) = settings();
assert_eq!(s.light.exposure, 0.2, "global settings still map");
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
let sky = &s.masks[0];
assert_eq!(sky.name, "Darker sky");
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
assert_eq!(sky.components[0].shape, MaskShape::Sky);
assert_eq!(sky.components[1].op, MaskOp::Intersect);
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
let rad = &s.masks[1];
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true));
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
assert!(lo < hi && *lo_feather > 0.0);
let brush = &s.masks[2];
assert_eq!(brush.adjust.clarity, 40.0);
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
}
#[test]
fn masks_render() {
let (s, _) = settings();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(s.masks.len(), 1);
assert_eq!(s.masks[0].adjust.exposure, -2.0);
}
#[test]
fn presets_add_masks_once() {
let (s, _) = settings();
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
let p =
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
let mut mine = DevelopSettings::default();
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
let once = p.apply(&mine, 1.0);
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
assert_eq!(once.masks[1].id, 8, "fresh ids");
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
let half = p.apply(&mine, 0.5);
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
}
#[test]
fn sidecar_radials_fit_the_photo() {
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
refit_radials(&mut v, 1.5, 1.0);
let s = &v["masks"][0]["components"][0]["shape"];
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
}
}
+1
View File
@@ -12,6 +12,7 @@
pub mod cmd;
pub mod crs;
pub mod crs_masks;
pub mod demo;
pub mod devices;
pub mod export;
+14 -9
View File
@@ -340,15 +340,20 @@ fn delocalize(s: &str) -> String {
}
}
/// The develop settings of an `.lrtemplate` as `crs:` properties, plus its title.
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props), String> {
/// The develop settings of an `.lrtemplate` as `crs:` properties (flat, and structured for local
/// corrections), plus its title.
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props, crate::crs_masks::Values), String> {
let root = parse_lua(text)?;
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
let mut props = Props::new();
let mut values = crate::crs_masks::Values::new();
for (k, v) in fields {
let key = format!("crs:{k}");
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
values.insert(key.clone(), crate::crs_masks::from_lua(v));
}
let vals: Vec<String> = match v {
Lua::Num(n) => vec![format!("{n}")],
Lua::Bool(b) => vec![if *b { "True" } else { "False" }.to_string()],
@@ -368,7 +373,7 @@ pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props), String> {
};
props.insert(key, vals);
}
Ok((title, props))
Ok((title, props, values))
}
// ------------------------------------------------------------------------------- XMP in files
@@ -429,8 +434,8 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p)).map(str::to_string)
}
fn build(props: &Props, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
let (mut settings, unmapped) = crate::crs::to_partial_report(props, None);
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);
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");
@@ -483,8 +488,8 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<V
}
"lrtemplate" => {
let text = String::from_utf8_lossy(bytes);
let (title, props) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
build(&props, title.unwrap_or(stem), group, false)
let (title, props, values) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
build(&props, &values, title.unwrap_or(stem), group, false)
.map(|i| vec![i])
.ok_or_else(|| "no develop settings we can use in this template".into())
}
@@ -493,13 +498,13 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<V
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
let props = &d.properties;
let name = props.get("crs:Name").and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or(stem);
build(props, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
build(props, &d.values, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
}
// a photo carrying its edits (mobile "DNG presets", edited JPEG / TIFF)
"dng" | "jpg" | "jpeg" | "tif" | "tiff" => {
let xmp = embedded_xmp(bytes).ok_or("this photo carries no edits (no XMP)")?;
let d = lightcraft_meta::parse_xmp(&xmp).map_err(|e| e.to_string())?;
build(&d.properties, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
build(&d.properties, &d.values, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
}
_ => crate::presets::parse_preset_file(name, bytes).map(|v| {
v.into_iter()
+10 -2
View File
@@ -125,7 +125,9 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
let full = d.lc_settings.as_deref().and_then(|j| serde_json::from_str::<Value>(j).ok()).and_then(|v| DevelopSettings::from_json(&v).ok());
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(&d.properties, Some(raw)))),
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))
}
None => None,
};
Ok(out)
@@ -164,7 +166,13 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
}
let develop = match &sc.develop {
Some(DevelopPatch::Full(s)) => (**s).clone(),
Some(DevelopPatch::Partial(v)) => lightcraft_develop::apply_partial(&p.develop, v, 1.0),
Some(DevelopPatch::Partial(v)) => {
let mut v = v.clone();
if p.width > 0 && p.height > 0 {
crate::crs_masks::refit_radials(&mut v, crate::crs_masks::DEFAULT_ASPECT, p.width as f64 / p.height as f64);
}
lightcraft_develop::apply_partial(&p.develop, &v, 1.0)
}
None => return false,
};
if develop == *p.develop {
+1 -1
View File
@@ -20,7 +20,7 @@ pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use iptc::parse_iptc;
pub use lightcraft_geom::Orientation;
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, parse_xmp, write_xmp, write_xmp_lc};
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc};
use serde::{Deserialize, Serialize};
+66 -1
View File
@@ -65,6 +65,67 @@ pub struct XmpData {
/// Every property found, keyed `prefix:name` (struct fields as `prefix:name/prefix:field`); arrays keep
/// item order, language alternatives put `x-default` first.
pub properties: BTreeMap<String, Vec<String>>,
/// Every top-level property with its full structure (arrays of structs such as local
/// corrections, which [`XmpData::properties`] flattens).
pub values: BTreeMap<String, XmpValue>,
}
/// A structured XMP value.
#[derive(Clone, Debug, PartialEq)]
pub enum XmpValue {
Text(String),
Array(Vec<XmpValue>),
Struct(BTreeMap<String, XmpValue>),
}
impl XmpValue {
/// A struct field (`prefix:name`).
pub fn field(&self, k: &str) -> Option<&XmpValue> {
match self {
XmpValue::Struct(m) => m.get(k),
_ => None,
}
}
pub fn text(&self) -> Option<&str> {
match self {
XmpValue::Text(s) => Some(s.trim()),
XmpValue::Array(a) => a.first().and_then(XmpValue::text),
XmpValue::Struct(_) => None,
}
}
/// Array items (a lone value is a one-item array).
pub fn items(&self) -> &[XmpValue] {
match self {
XmpValue::Array(a) => a,
other => std::slice::from_ref(other),
}
}
}
fn node_struct(n: &Node) -> XmpValue {
let mut m = BTreeMap::new();
for (k, v) in &n.attrs {
if !is_rdf_meta_attr(k) {
m.insert(k.clone(), XmpValue::Text(v.clone()));
}
}
for c in &n.children {
m.insert(c.name.clone(), node_value(c));
}
XmpValue::Struct(m)
}
fn node_value(n: &Node) -> XmpValue {
if let Some(arr) = n.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
return XmpValue::Array(arr.children.iter().filter(|li| li.name == "rdf:li").map(node_value).collect());
}
if let Some(d) = n.children.iter().find(|c| c.name == "rdf:Description") {
return node_struct(d);
}
if !n.children.is_empty() || n.attrs.iter().any(|(k, _)| !is_rdf_meta_attr(k)) {
return node_struct(n);
}
XmpValue::Text(n.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| n.text.trim().to_string()))
}
#[derive(Debug, Default)]
@@ -322,8 +383,12 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
let mut descs = Vec::new();
find_descriptions(&tree, &mut descs);
let mut props = BTreeMap::new();
let mut values = BTreeMap::new();
for d in descs {
collect_description(d, "", &mut props);
if let XmpValue::Struct(m) = node_struct(d) {
values.extend(m);
}
}
let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let num = |k: &str| first(k).and_then(|s| parse_number(&s));
@@ -394,7 +459,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
}
let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned();
Ok(XmpData { metadata: m, lc_settings, properties: props })
Ok(XmpData { metadata: m, lc_settings, properties: props, values })
}
fn frac(v: f64) -> String {
+1 -1
View File
@@ -106,7 +106,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import`, `crates/engine/src/preset_import.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), .zip bundles, folders (folder → group), drag & drop; unmapped settings reported; no profile import (Adobe profile formats deliberately unsupported) |
| LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import`, `crates/engine/src/preset_import.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), .zip bundles, folders (folder → group), drag & drop; masks (gradients, radial, brush, subject/sky, luminance/depth range) carried over; unmapped settings reported; no profile import (Adobe profile formats deliberately unsupported) |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode |
+23
View File
@@ -83,6 +83,29 @@ Values pass through our control specs, so anything outside our slider ranges get
**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), local adjustments
(masks, gradients, brushes), spot removal, red eye, lens blur, process-version 2010 field names, and AI features.
## Local corrections (masks)
Masks stored as `crs:` structures are read from sidecars, DNG-embedded XMP, XMP presets and `.lrtemplate` files
(`crates/engine/src/crs_masks.rs`). Four containers hold them: `MaskGroupBasedCorrections` (current) and the older
`GradientBasedCorrections`, `CircularGradientBasedCorrections` and `PaintBasedCorrections`. Each correction becomes one
mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive corrections are skipped.
| Field | Ours | Notes |
|---|---|---|
| `LocalExposure2012` | `adjust.exposure` | stored as a fraction, ×4 EV |
| `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 |
| `LocalToningHue` | `adjust.color_hue` | degrees |
| `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point |
| `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape); `Flipped` → invert |
| `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases |
| `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed |
| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported |
| `MaskBlendMode` 0 / 1 / 2, `MaskInverted` | add / subtract / intersect, invert | |
Applying a preset adds its masks to the photo's own (masks the photo already has are not added twice), and the Amount
slider scales them. A sidecar's masks replace the photo's, because a sidecar holds the whole edit. Components we can't
carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`.
## Preset files
| | |