mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
1 parent
1dc501bc3a
commit
b7d497d049
10 files changed
+536
-18
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
pub mod demo;
|
||||
pub mod devices;
|
||||
pub mod export;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
| | |
|
||||
|
||||
Reference in new issue
Block a user