diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index f00e7bca..64bb116c 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -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::(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 } } diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index ec56ebaa..dce623d0 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -354,10 +354,25 @@ pub fn to_partial(props: &Props, raw: Option) -> 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) -> (Value, Vec) { +/// +/// 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) { 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 = props @@ -369,6 +384,7 @@ pub fn to_partial_report(props: &Props, raw: Option) -> (Value, Vec; + +/// 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 { + let s = text(v, k)?; + let s = s.strip_prefix('+').unwrap_or(s); + s.parse::().ok().filter(|x| x.is_finite()) +} + +fn flag(v: &XmpValue, k: &str) -> Option { + 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 { + 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 = 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::().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 = 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 = 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, Vec) { + 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 = 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#" + + + + + + + + + + + + + + + + + + + + + + + d 0.1 0.2d 0.15 0.22 + d 0.5 0.5 + + +"#; + + 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); + (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"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b3cac0a6..5bae1b70 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod cmd; pub mod crs; +pub mod crs_masks; pub mod demo; pub mod devices; pub mod export; diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index ec24c6b2..f86ac030 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -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, 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, 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 = 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, 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 { dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p)).map(str::to_string) } -fn build(props: &Props, name: String, group: Option, from_photo: bool) -> Option { - let (mut settings, unmapped) = crate::crs::to_partial_report(props, None); +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); 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) -> Result { 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) -> Result { 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() diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 0a54b8e6..ac101580 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -125,7 +125,9 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result(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 { diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index fe283d95..052cbd6c 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -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}; diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2643532f..0c686d89 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -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>, + /// Every top-level property with its full structure (arrays of structs such as local + /// corrections, which [`XmpData::properties`] flattens). + pub values: BTreeMap, +} + +/// A structured XMP value. +#[derive(Clone, Debug, PartialEq)] +pub enum XmpValue { + Text(String), + Array(Vec), + Struct(BTreeMap), +} + +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 { 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 { 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 { diff --git a/docs/parity.md b/docs/parity.md index dff3abe6..51f07aa2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index a51f3af5..dab57443 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -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: `. + ## Preset files | | |