diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index ce0f74255..2c8a5c820 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -166,7 +166,7 @@ fn services() -> Services { pick_preset_files: Some(Box::new(|| { rfd::FileDialog::new() .set_title("Import Presets") - .add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "cube"]) + .add_filter("Presets & Profiles", &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"]) .pick_files() .unwrap_or_default() .into_iter() diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index d24942a33..fc385f34c 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -1,4 +1,4 @@ -//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset and zip bundles; export `.lcpreset`. +//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset, Luminar looks and zip bundles; export `.lcpreset`. use serde_json::{Value, json}; @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Import Presets", [], None, - "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\") and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}", + "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}", always, import ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6fd399c24..77a7e1daa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod media; pub mod memory; pub mod merge; pub mod preset_import; +pub mod preset_luminar; pub mod presets; pub mod rename; pub mod sidecar; diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index f86ac030e..3f9498cc9 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -1,7 +1,8 @@ //! Importing other editors' presets: XMP preset files, classic `.lrtemplate` files (a Lua table //! literal), photos that carry their edits as an XMP packet ("DNG presets" from mobile apps, //! edited JPEG/TIFF), and zip bundles of any of these. Everything ends up as `crs:` properties -//! mapped by [`crate::crs`]. +//! mapped by [`crate::crs`]. Luminar looks (`.lmp`, `.mplumpack` collections) are read by +//! [`crate::preset_luminar`]. //! //! Written from the formats' public structure (Lua's table syntax, the ZIP application note, //! ISO 16684-1 XMP) and black-box observation; no third-party code or preset content was used. @@ -23,7 +24,7 @@ pub struct Imported { } /// Extensions of files read as presets (beyond our own `.lcpreset`). -pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng"]; +pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack"]; // ------------------------------------------------------------------------------------- zip @@ -426,15 +427,24 @@ pub fn group_from_dir(dir: &str) -> Option { "new", "old", "adobe", + "luminar", + "skylum", + "looks", + "lmp", + "mplumpack", + "contents", + "resources", ]; let generic = |name: &str| { let lower = name.to_ascii_lowercase(); lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).all(|w| FILLER.contains(&w) || w.chars().all(|c| c.is_ascii_digit())) }; - dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p)).map(str::to_string) + // a macOS bundle (`Look.lmp/Contents/…`) is a file, not a group + let bundle = |p: &str| p.to_ascii_lowercase().ends_with(".lmp"); + dir.split(['/', '\\']).rev().find(|p| !p.is_empty() && !generic(p) && !bundle(p)).map(str::to_string) } -fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option, from_photo: bool) -> Option { +pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option, from_photo: bool) -> Option { let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT); 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 @@ -486,6 +496,8 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option) -> Result crate::preset_luminar::read_lmp(name, bytes, group).map(|i| vec![i]), + "mplumpack" => crate::preset_luminar::read_mplumpack(name, bytes), "lrtemplate" => { let text = String::from_utf8_lossy(bytes); let (title, props, values) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?; @@ -522,7 +534,7 @@ pub fn read_presets(name: &str, bytes: &[u8], group: Option) -> Result), + Array(Vec), + Str(String), + Num(f64), + Bool(bool), + /// `` (base64; not decoded) + Data, +} + +impl Plist { + pub fn get(&self, key: &str) -> Option<&Plist> { + match self { + Plist::Dict(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + pub fn str(&self) -> Option<&str> { + match self { + Plist::Str(s) => Some(s), + _ => None, + } + } + pub fn num(&self) -> Option { + match self { + Plist::Num(n) => Some(*n), + _ => None, + } + } + pub fn array(&self) -> &[Plist] { + match self { + Plist::Array(a) => a, + _ => &[], + } + } +} + +struct Parser<'a> { + s: &'a str, + i: usize, + /// Nesting of the element being read (bounded: a hostile file must not overflow the stack). + depth: usize, +} + +/// Deepest `` / `` nesting read (real looks nest a handful of levels). +const MAX_DEPTH: usize = 64; + +fn unescape(t: &str) -> String { + let mut out = String::with_capacity(t.len()); + let mut rest = t; + while let Some(p) = rest.find('&') { + out.push_str(&rest[..p]); + rest = &rest[p..]; + let Some(end) = rest.find(';') else { break }; + let ent = &rest[1..end]; + let c = match ent { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => ent + .strip_prefix("#x") + .or_else(|| ent.strip_prefix("#X")) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + match c { + Some(c) => { + out.push(c); + rest = &rest[end + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +impl Parser<'_> { + fn err(&self, what: &str) -> Result { + Err(format!("property list: {what} at byte {}", self.i)) + } + /// Skip whitespace, the XML declaration, the doctype and comments. + fn skip(&mut self) { + loop { + let r = &self.s[self.i..]; + let t = r.trim_start(); + self.i += r.len() - t.len(); + let close = if t.starts_with("" + } else if t.starts_with("" + } else if t.starts_with("" + } else { + return; + }; + match t.find(close) { + Some(e) => self.i += e + close.len(), + None => { + self.i = self.s.len(); + return; + } + } + } + } + /// The next tag: (name, closing, self-closing). + fn tag(&mut self) -> Result<(String, bool, bool), String> { + self.skip(); + let r = &self.s[self.i..]; + if !r.starts_with('<') { + return self.err("expected a tag"); + } + let Some(end) = r.find('>') else { return self.err("unterminated tag") }; + let inner = &r[1..end]; + self.i += end + 1; + let closing = inner.starts_with('/'); + let selfc = inner.ends_with('/'); + let name = inner.trim_start_matches('/').trim_end_matches('/').split_whitespace().next().unwrap_or("").to_string(); + Ok((name, closing, selfc)) + } + fn peek_close(&mut self, name: &str) -> bool { + self.skip(); + let r = &self.s[self.i..]; + let t = format!(""); + if r.starts_with(&t) { + self.i += t.len(); + true + } else { + false + } + } + fn text(&mut self, name: &str) -> Result { + let r = &self.s[self.i..]; + let t = format!(""); + let Some(end) = r.find(&t) else { return self.err(&format!("missing {t}")) }; + self.i += end + t.len(); + Ok(unescape(&r[..end])) + } + fn value(&mut self) -> Result { + if self.depth >= MAX_DEPTH { + return self.err("nested too deeply"); + } + self.depth += 1; + let v = self.value_inner(); + self.depth -= 1; + v + } + fn value_inner(&mut self) -> Result { + let (name, closing, selfc) = self.tag()?; + if closing { + return self.err(&format!("unexpected ")); + } + let leaf = |p: &mut Self, f: &dyn Fn(String) -> Result| if selfc { f(String::new()) } else { p.text(&name).and_then(f) }; + match name.as_str() { + "plist" => { + if selfc { + return Ok(Plist::Dict(Vec::new())); + } + let v = self.value()?; + self.peek_close("plist"); + Ok(v) + } + "dict" => { + let mut m = Vec::new(); + if !selfc { + while !self.peek_close("dict") { + let (k, c, sc) = self.tag()?; + if k != "key" || c { + return self.err("expected "); + } + let key = if sc { String::new() } else { self.text("key")? }; + m.push((key, self.value()?)); + } + } + Ok(Plist::Dict(m)) + } + "array" => { + let mut a = Vec::new(); + if !selfc { + while !self.peek_close("array") { + a.push(self.value()?); + } + } + Ok(Plist::Array(a)) + } + "string" | "date" => leaf(self, &|t| Ok(Plist::Str(t))), + "real" | "integer" => { + leaf(self, &|t| t.trim().parse::().map(Plist::Num).map_err(|_| format!("property list: bad number {:?}", t.trim()))) + } + "data" => leaf(self, &|_| Ok(Plist::Data)), + "true" | "false" => { + if !selfc { + self.text(&name)?; + } + Ok(Plist::Bool(name == "true")) + } + _ => self.err(&format!("unknown element <{name}>")), + } + } +} + +/// Parse an XML property list. +pub fn parse_plist(bytes: &[u8]) -> Result { + if bytes.starts_with(b"bplist") { + return Err("binary property lists are not supported".into()); + } + let text = String::from_utf8_lossy(bytes); + let mut p = Parser { s: text.trim_start_matches('\u{feff}'), i: 0, depth: 0 }; + p.value() +} + +// ------------------------------------------------------------------------------------ mapping + +/// Luminar's colour bands (`h`/`s`/`l` + band in the HSL tool) — the same eight as `crs:`. +const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"]; + +/// Effects whose parameters carry the basic tone / colour sliders under their own names. +const BASIC: &[&str] = &[ + "MIPLDevelopCommonEffectID", + "MIPLExposureEffect", + "MIPLContrastEffect", + "MIPLHighlightsEffect", + "MIPLBlackWhiteEffect", + "MIPLWhiteBalanceEffect", + "MIPLSaturationEffect", + "MIPLVibranceEffect", + "MIPLClarityEffect", +]; + +/// How one Luminar slider maps: the `crs:` field, a scale, whether it adds up across layers +/// (and fades with layer opacity), and its clamp range. +struct Target { + crs: String, + scale: f64, + additive: bool, + range: (f64, f64), +} + +fn target(effect: &str, param: &str, value: f64) -> Option { + let t = |crs: &str, scale: f64, additive: bool, range: (f64, f64)| Some(Target { crs: crs.to_string(), scale, additive, range }); + let pm = (-100.0, 100.0); + if BASIC.contains(&effect) { + return match param { + "Exposure" => t("Exposure2012", 4.0 / 100.0, true, (-5.0, 5.0)), + "Contrast" => t("Contrast2012", 1.0, true, pm), + "Highlights" => t("Highlights2012", 1.0, true, pm), + "Shadows" => t("Shadows2012", 1.0, true, pm), + "Whites" => t("Whites2012", 1.0, true, pm), + "Blacks" => t("Blacks2012", 1.0, true, pm), + // an absolute colour temperature (raw develop) vs. a relative shift + "Temperature" if value.abs() > 1000.0 => t("Temperature", 1.0, false, (2000.0, 50000.0)), + "Temperature" => t("IncrementalTemperature", 1.0, true, pm), + "Tint" => t("IncrementalTint", 1.0, true, pm), + "Saturation" => t("Saturation", 1.0, true, pm), + "Vibrance" => t("Vibrance", 1.0, true, pm), + "Clarity" => t("Clarity2012", 1.0, true, pm), + "Dehaze" => t("Dehaze", 1.0, true, pm), + _ => None, + }; + } + match (effect, param) { + ("MIPLAIStructureEffect" | "MIPLStructureEffect", "Amount") => t("Clarity2012", 1.0, true, pm), + ("MIPLDehazeEffect", "Amount") => t("Dehaze", 1.0, true, pm), + ("MIPLVignetteEffect", "Amount") => t("PostCropVignetteAmount", 1.0, true, pm), + ("MIPLVignetteEffect", "Vignette Size" | "Size") => t("PostCropVignetteMidpoint", 1.0, false, (0.0, 100.0)), + ("MIPLGrainNewEffect" | "MIPLGrainEffect", "Amount") => t("GrainAmount", 1.0, true, (0.0, 100.0)), + ("MIPLChannelsEffect" | "MIPLHSLEffect", _) => { + let (kind, band) = param.split_at(param.char_indices().nth(1).map_or(param.len(), |(i, _)| i)); + let field = match kind { + "h" => "HueAdjustment", + "s" => "SaturationAdjustment", + "l" => "LuminanceAdjustment", + _ => return None, + }; + BANDS.contains(&band).then(|| Target { crs: format!("{field}{band}"), scale: 1.0, additive: true, range: pm }) + } + _ => None, + } +} + +/// A point curve stored as `OptionalData`: a leading value, then x, y pairs in 0..1 with x +/// increasing. Anything else isn't read. +fn curve_points(param: &Plist) -> Option> { + let data: Vec = param.get("OptionalData")?.array().iter().map(Plist::num).collect::>()?; + if data.len() < 5 || data.len().is_multiple_of(2) { + return None; + } + let pts: Vec<(f64, f64)> = data[1..].chunks(2).map(|c| (c[0], c[1])).collect(); + let ok = pts.iter().all(|&(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)) && pts.windows(2).all(|w| w[1].0 > w[0].0); + ok.then_some(pts) +} + +/// `MIPLSmartContrastEffect` → `SmartContrast`. +fn short(effect: &str) -> &str { + let e = effect.strip_prefix("MIPL").unwrap_or(effect); + ["_v1_Effect", "EffectID", "Effect"].iter().find_map(|s| e.strip_suffix(s)).unwrap_or(e) +} + +#[derive(Default)] +struct Acc { + sums: BTreeMap, + curves: BTreeMap>, + unmapped: Vec, +} + +impl Acc { + fn skip(&mut self, what: String) { + if !self.unmapped.contains(&what) { + self.unmapped.push(what); + } + } + + fn layers(&mut self, layers: &[Plist], weight: f64) { + for layer in layers { + if matches!(layer.get("Enabled"), Some(Plist::Bool(false))) { + continue; + } + let opacity = weight * layer.get("Amount").and_then(Plist::num).unwrap_or(1.0).clamp(0.0, 1.0); + if opacity <= 0.0 { + continue; + } + let blend = layer.get("BlendModeIdentifier").and_then(Plist::str).unwrap_or("Normal"); + // a layer with a mask applies locally: not a global look + let empty = |v: &Plist| { + matches!(v, Plist::Bool(false)) || matches!(v, Plist::Dict(d) if d.is_empty()) || matches!(v, Plist::Array(a) if a.is_empty()) + }; + let masked = matches!(layer, Plist::Dict(m) if m.iter().any(|(k, v)| k.to_ascii_lowercase().contains("mask") && !empty(v))); + let literal = blend.eq_ignore_ascii_case("normal") && !masked; + let mut reported = false; + for effect in layer.get("Effects").map(Plist::array).unwrap_or_default() { + let id = effect.get("Identifier").and_then(Plist::str).unwrap_or("?"); + let Some(Plist::Dict(params)) = effect.get("Parameters") else { continue }; + for (name, pv) in params { + let value = pv.get("Value").and_then(Plist::num).or(pv.num()); + if literal && id == "MIPLCurveEffect" { + let crs = match name.as_str() { + "RGB" => Some("ToneCurvePV2012"), + "Red" => Some("ToneCurvePV2012Red"), + "Green" => Some("ToneCurvePV2012Green"), + "Blue" => Some("ToneCurvePV2012Blue"), + _ => None, + }; + if let (Some(crs), Some(pts)) = (crs, curve_points(pv)) + && !self.curves.contains_key(crs) + { + // a curve at partial opacity: the points fade toward the identity + self.curves.insert(crs.to_string(), pts.into_iter().map(|(x, y)| (x, x + (y - x) * opacity)).collect()); + continue; + } + if pv.get("OptionalData").is_some() { + self.skip(format!("{}.{name}", short(id))); + } + continue; + } + let Some(v) = value.filter(|v| *v != 0.0) else { continue }; + match target(id, name, v).filter(|_| literal) { + Some(t) => { + let x = v * t.scale * if t.additive { opacity } else { 1.0 }; + let e = self.sums.entry(t.crs).or_insert((0.0, t.range)); + e.0 = if t.additive { e.0 + x } else { x }; + } + None => { + reported = true; + self.skip(format!("{}.{name}", short(id))); + } + } + } + } + if !literal && reported { + let lid = layer.get("Identifier").and_then(Plist::str).unwrap_or("layer"); + self.skip(if masked { format!("{lid}: mask") } else { format!("{lid}: blend mode {blend}") }); + } + if let Some(sub) = layer.get("Sublayers").and_then(|s| s.get("AdjustmentLayers")) { + self.layers(sub.array(), opacity); + } + } + } +} + +/// The name of a look stored at `path`: its file name, or the bundle's for +/// `Name.lmp/Contents/preset.lmp`. +fn look_name(path: &str) -> String { + let p = Path::new(path); + let stem = |p: &Path| p.file_stem().map(|s| s.to_string_lossy().to_string()); + let bundle = p.parent().filter(|d| d.file_name().is_some_and(|n| n.eq_ignore_ascii_case("Contents"))).and_then(Path::parent); + match bundle { + Some(b) if b.extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) => stem(b), + _ => stem(p), + } + .unwrap_or_else(|| "Look".into()) +} + +/// Read one Luminar look (`.lmp`) into a preset in `group` (default "Imported Presets"). +pub fn read_lmp(path: &str, bytes: &[u8], group: Option) -> Result { + let root = parse_plist(bytes)?; + let layers = root.get("AdjustmentLayers").ok_or("not a Luminar look (no adjustment layers)")?; + let mut acc = Acc::default(); + acc.layers(layers.array(), 1.0); + let mut props = Props::new(); + for (k, (v, (lo, hi))) in &acc.sums { + props.insert(format!("crs:{k}"), vec![format!("{}", v.clamp(*lo, *hi))]); + } + for (k, pts) in &acc.curves { + props.insert(format!("crs:{k}"), pts.iter().map(|(x, y)| format!("{}, {}", x * 255.0, y * 255.0)).collect()); + } + if let Some(u) = root.get("uuid").and_then(Plist::str).filter(|u| !u.trim().is_empty()) { + props.insert("crs:UUID".into(), vec![u.trim().to_string()]); + } + let name = root.get("Name").and_then(Plist::str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| look_name(path)); + match build(&props, &Default::default(), name, group, false) { + Some(mut i) => { + i.preset.id = i.preset.id.replacen("user.xmp.", "user.lmp.", 1); + i.unmapped.extend(acc.unmapped); + Ok(i) + } + None if acc.unmapped.is_empty() => Err("this look has no adjustments".into()), + None => Err(format!("none of this look's settings have a counterpart here ({})", acc.unmapped.join(", "))), + } +} + +/// Read a Luminar looks collection (`.mplumpack`, a zip): every look, grouped by the pack's +/// `GroupName` (else the pack's file name). +pub fn read_mplumpack(path: &str, bytes: &[u8]) -> Result, String> { + let entries = read_zip(bytes)?; + let info = entries + .iter() + .find(|(n, _)| n.rsplit('/').next().is_some_and(|b| b.eq_ignore_ascii_case("PresetsInfo.plist"))) + .and_then(|(_, b)| parse_plist(b).ok()) + .and_then(|p| p.get("GroupName").and_then(Plist::str).map(str::trim).filter(|g| !g.is_empty()).map(str::to_string)); + let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()); + let pack = info.or_else(|| stem.as_deref().and_then(group_from_dir)).unwrap_or_else(|| "Luminar Looks".into()); + let mut out = Vec::new(); + let mut errors = Vec::new(); + for (inner, data) in &entries { + if !Path::new(inner).extension().is_some_and(|e| e.eq_ignore_ascii_case("lmp")) { + continue; + } + match read_lmp(inner, data, Some(pack.clone())) { + Ok(i) => out.push(i), + Err(e) => errors.push(format!("{inner}: {e}")), + } + } + if out.is_empty() { + return Err(if errors.is_empty() { "no looks in this collection".into() } else { errors.join("; ") }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::preset_import::read_presets; + use serde_json::json; + + /// One effect's parameter as Luminar writes it. + fn param(name: &str, v: f64) -> String { + format!("{name}OptionalDataType0Value{v}") + } + + fn layer(id: &str, amount: f64, blend: &str, effects: &[(&str, String)], sub: &str) -> String { + let fx: String = effects + .iter() + .map(|(e, p)| format!("Identifier{e}Parameters{p}")) + .collect(); + let sub = + if sub.is_empty() { String::new() } else { format!("SublayersAdjustmentLayers{sub}") }; + format!( + "Amount{amount}BlendModeIdentifier{blend}Effects{fx}\ + EnabledIdentifier{id}{sub}" + ) + } + + fn look(layers: &str, extra: &str) -> String { + format!( + "\n\n\ + AdjustmentLayers{layers}{extra}group_identifierCustom" + ) + } + + /// A newer-style look (develop sliders nested in sublayers) written for this test. + fn modern() -> String { + let curve = "RGBOptionalData0.500.10.50.510.9OptionalDataType1Value50"; + let sub = [ + layer( + "DevelopAdjustmentSubLayer", + 1.0, + "Normal", + &[( + "MIPLDevelopCommonEffectID", + param("Exposure", 25.0) + ¶m("Contrast", 12.0) + ¶m("Highlights", -30.0) + ¶m("Temperature", 15.0), + )], + &layer("CurveLayer", 1.0, "Normal", &[("MIPLCurveEffect", curve.to_string())], ""), + ), + layer("Disabled", 1.0, "Normal", &[("MIPLDevelopCommonEffectID", param("Shadows", 50.0))], "").replace("", ""), + ] + .concat(); + let layers = [ + layer("DevelopAdjustmentLayer", 1.0, "Normal", &[], &sub), + layer("AIStructureEffect", 1.0, "Normal", &[("MIPLAIStructureEffect", param("Amount", 20.0) + ¶m("Boost", 10.0))], ""), + layer( + "Half", + 0.5, + "Normal", + &[("MIPLVibranceEffect", param("Vibrance", 30.0)), ("MIPLSaturationEffect", param("Saturation", -10.0))], + "", + ), + layer("Hsl", 1.0, "Normal", &[("MIPLChannelsEffect", param("hOrange", -8.0) + ¶m("sBlue", 12.0) + ¶m("lGreen", 4.0))], ""), + layer("Vignette", 1.0, "Normal", &[("MIPLVignetteEffect", param("Amount", -20.0) + ¶m("Vignette Size", 40.0))], ""), + layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""), + layer("Screened", 1.0, "Screen", &[("MIPLContrastEffect", param("Contrast", 40.0))], ""), + layer("Untouched", 1.0, "Normal", &[("MIPLSkyEnhancerEffect", String::new())], ""), + ] + .concat(); + look(&layers, "uuidAB12-CD34kMPPresetIdentifierKeyx 2.lmp") + } + + #[test] + fn plists_parse() { + let p = parse_plist(b"a&bx <yAn-3etdAAECs").unwrap(); + assert_eq!(p.get("a&b").and_then(Plist::str), Some("x a").is_err()); + assert!(parse_plist(b"bplist00\x01\x02").is_err()); + } + + #[test] + fn hostile_nesting_is_an_error_not_a_crash() { + let deep = format!("{}{}", "".repeat(200_000), "".repeat(200_000)); + assert!(parse_plist(deep.as_bytes()).unwrap_err().contains("nested too deeply")); + let ok = format!("{}{}", "".repeat(20), "".repeat(20)); + assert!(parse_plist(ok.as_bytes()).is_ok()); + } + + #[test] + fn look_sliders_map_and_the_rest_is_reported() { + let i = read_presets("/x/Soft Contrast.lmp", modern().as_bytes(), None).unwrap().remove(0); + let p = &i.preset; + assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Soft Contrast", "Imported Presets", "user.lmp.ab12-cd34")); + let s = &p.settings; + assert_eq!(s["light"]["exposure"], json!(1.0), "±100 ↔ ±4 EV"); + assert_eq!(s["light"]["contrast"], json!(12.0)); + assert_eq!(s["light"]["highlights"], json!(-30.0)); + assert!(s["light"].get("shadows").is_none(), "disabled layers are ignored"); + assert_eq!(s["effects"]["clarity"], json!(20.0), "Structure → clarity"); + assert_eq!(s["color"]["vibrance"], json!(15.0), "layer opacity scales the slider"); + assert_eq!(s["color"]["saturation"], json!(-5.0)); + assert_eq!(s["mixer"]["orange"]["hue"], json!(-8.0)); + assert_eq!(s["mixer"]["blue"]["sat"], json!(12.0)); + assert_eq!(s["mixer"]["green"]["lum"], json!(4.0)); + assert_eq!(s["vignette"]["amount"], json!(-20.0)); + assert_eq!(s["vignette"]["midpoint"], json!(40.0)); + assert!(s["wb"]["temp"].as_f64().unwrap() > 6500.0, "a warm shift: {}", s["wb"]); + let c = s["curve"]["master"].as_array().unwrap(); + assert_eq!(c.len(), 3); + assert!((c[0]["y"].as_f64().unwrap() - 0.1).abs() < 1e-9 && (c[2]["y"].as_f64().unwrap() - 0.9).abs() < 1e-9); + assert_eq!(i.unmapped, ["AIStructure.Boost", "OrtonFilter.Amount", "Contrast.Contrast", "Screened: blend mode Screen"]); + assert!(s["light"]["contrast"] == json!(12.0), "the screen-blended layer isn't folded in"); + lightcraft_develop::DevelopSettings::default().merged(s).expect("valid develop settings"); + } + + #[test] + fn classic_looks_and_bundles() { + // the older flat layout: one layer per tool, the look's name inside + let layers = [ + layer( + "ToneLayer", + 1.0, + "Normal", + &[("MIPLExposureEffect", param("Exposure", 0.0)), ("MIPLHighlightsEffect", param("Shadows", 10.0))], + "", + ), + layer("ClarityLayer", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""), + layer("Wb", 1.0, "Normal", &[("MIPLWhiteBalanceEffect", param("Temperature", -15.0) + ¶m("Tint", 10.0))], ""), + ] + .concat(); + let lmp = look(&layers, "NameBlue Hour"); + let i = read_presets("whatever.lmp", lmp.as_bytes(), Some("Mine".into())).unwrap().remove(0); + assert_eq!((i.preset.name.as_str(), i.preset.group.as_str()), ("Blue Hour", "Mine")); + assert_eq!(i.preset.settings["light"], json!({"shadows": 10.0})); + assert_eq!(i.preset.settings["effects"]["clarity"], json!(20.0)); + assert_eq!(i.preset.settings["wb"]["tint"], json!(10.0)); + assert!(i.preset.settings["wb"]["temp"].as_f64().unwrap() < 6500.0); + assert!(i.unmapped.is_empty(), "{:?}", i.unmapped); + // a bundle's inner file is named after the bundle + let b = read_presets("/p/Vintage.lmp/Contents/preset.lmp", modern().as_bytes(), None).unwrap(); + assert_eq!(b[0].preset.name, "Vintage"); + // nothing we can carry over: an error that says what was there + let only = look(&layer("Orton", 1.0, "Normal", &[("MIPLOrtonFilterEffect", param("Amount", 15.0))], ""), ""); + let e = read_presets("Glow.lmp", only.as_bytes(), None).unwrap_err(); + assert!(e.contains("OrtonFilter.Amount"), "{e}"); + assert!(read_presets("x.lmp", b"", None).is_err()); + } + + #[test] + fn packs_group_by_their_name() { + use crate::preset_import::tests::zip; + let a = look(&layer("C", 1.0, "Normal", &[("MIPLClarityEffect", param("Clarity", 20.0))], ""), "NamePop 1"); + let b = look(&layer("S", 1.0, "Normal", &[("MIPLSaturationEffect", param("Saturation", 25.0))], ""), ""); + let info = b"GroupNameMagic Light"; + let pack = zip( + &[ + ("Pop 1.lmp", a.as_bytes()), + ("__MACOSX/._Pop 1.lmp", b"junk"), + ("Pop 2.lmp", b.as_bytes()), + ("icon@2x.png", b"\x89PNG"), + ("PresetsInfo.plist", info), + ], + true, + ); + let v = read_presets("/d/Magic Light-2.mplumpack", &pack, None).unwrap(); + let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect(); + assert_eq!(got, [("Pop 1", "Magic Light"), ("Pop 2", "Magic Light")]); + // without PresetsInfo.plist the pack's file name is the group + let v = read_presets("Seaside Looks.mplumpack", &zip(&[("Pop 2.lmp", b.as_bytes())], false), None).unwrap(); + assert_eq!(v[0].preset.group, "Seaside Looks"); + // a pack inside a downloaded zip, and bundle-style looks in a zip folder + let outer = zip(&[("Archive/Magic Light-2.mplumpack", &pack), ("Archive/Magic Light.plist", b"")], false); + assert_eq!(read_presets("download.zip", &outer, None).unwrap().len(), 2); + let bundles = zip( + &[ + ("Wild Pack/Vintage.lmp/Contents/preset.lmp", modern().as_bytes()), + ("Wild Pack/Vintage.lmp/Contents/Info.plist", b""), + ("Wild Pack/Flat.lmp", a.as_bytes()), + ], + true, + ); + let v = read_presets("wild.zip", &bundles, None).unwrap(); + let got: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect(); + assert_eq!(got, [("Vintage", "Wild Pack"), ("Pop 1", "Wild Pack")]); + assert!(read_presets("empty.mplumpack", &zip(&[("icon.png", b"x")], false), None).is_err()); + } + + #[test] + fn import_command_reads_looks_and_bundle_folders() { + let dir = std::env::temp_dir().join(format!("lc-luminar-import-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Wild/Vintage.lmp/Contents/resources")).unwrap(); + std::fs::write(dir.join("Wild/Vintage.lmp/Contents/preset.lmp"), modern()).unwrap(); + std::fs::write(dir.join("Wild/Vintage.lmp/Contents/Info.plist"), "").unwrap(); + std::fs::write(dir.join("Wild/Vintage.lmp/Contents/PkgInfo"), "LMP?????").unwrap(); + let mut s = crate::Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild").to_string_lossy()]})).unwrap(); + let im = &r["imported"][0]; + assert_eq!((im["name"].as_str(), im["group"].as_str()), (Some("Vintage"), Some("Wild")), "{r}"); + assert!(im["unmapped"].as_array().unwrap().iter().any(|u| u == "OrtonFilter.Amount")); + // a dropped bundle folder on its own + let r = s.execute("preset.import", &json!({"paths": [dir.join("Wild/Vintage.lmp").to_string_lossy()], "dryRun": true})).unwrap(); + assert_eq!(r["imported"][0]["group"], json!("Imported Presets"), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 5b0a6871e..57a82ac52 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -97,7 +97,7 @@ pub fn expand_preset_paths(paths: &[String]) -> Vec { } } else { let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip"].contains(&ext.as_str()) { + if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) { out.push(p.to_string_lossy().to_string()); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 13a0e09d9..6ec391b44 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,7 +56,7 @@ pub type OpenWithFn = Box Result<(), String>>; pub struct Services { /// Show an open dialog for photos; returns paths. pub pick_files: Option, - /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`). + /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). pub pick_preset_files: Option, /// Save dialog for an exported `.lcpreset` file. pub save_preset_file: Option, @@ -627,14 +627,14 @@ pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { /// Files dropped on the window that are presets rather than photos. pub fn is_preset_file(path: &str) -> bool { let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ["lcpreset", "lrtemplate", "xmp", "zip", "cube"].contains(&ext.as_str()) + ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str()) } #[cfg(test)] mod drop_tests { #[test] fn dropped_presets_are_told_apart_from_photos() { - for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset"] { + for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] { assert!(super::is_preset_file(p), "{p}"); } for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] { diff --git a/docs/parity.md b/docs/parity.md index be341962d..eeea2d3e5 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`, `cmd:profile.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 carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; 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 (incl. lossy / Smart Preview 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/whats-new.md b/docs/whats-new.md index 6cccbc102..0e64cd434 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -5,6 +5,8 @@ ### Presets and profiles - Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip` bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along. +- Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders + with a counterpart here come along, the rest is listed. - 23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners. ### Library diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index bf8a059f9..eea43e2a6 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -117,6 +117,30 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: `. LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`). +## Luminar looks (`.lmp`, `.mplumpack`) + +`preset.import` (and drag & drop, File ▸ Import Profiles & Presets…) also reads Luminar looks: an `.lmp` file is an +XML property list of adjustment layers (each with effects and named sliders, mostly on a −100..100 scale); newer looks +may be a bundle folder `Name.lmp/Contents/preset.lmp`. An `.mplumpack` collection is a zip of `.lmp` files whose +`PresetsInfo.plist` names the group (`GroupName`); the pack's file name is the fallback. Looks get their own name +(`Name`) or their file / bundle name. The sliders with a clear counterpart are carried over; everything else (AI tools, +Orton, glow, LUT layers, colour balance…) is listed in `unmapped` as `Tool.Slider` (e.g. `OrtonFilter.Amount`). +Disabled layers are ignored, layer opacity scales the sliders (and fades curves toward a straight line), and layers +with a blend mode other than Normal or with a mask are reported, not applied. Binary property lists are not read. + +| Luminar tool.slider | Ours | +|---|---| +| Develop / Light / Exposure `Exposure` (±100) | `light.exposure` (±4 EV — the scale is our reading, not documented) | +| `Contrast`, `Highlights`, `Shadows`, `Whites`, `Blacks` | `light.*` (same scale) | +| `Temperature`, `Tint` (relative) | white balance shift as `crs:IncrementalTemperature` / `IncrementalTint`; values above 1000 as Kelvin | +| `Saturation`, `Vibrance` | `color.saturation`, `color.vibrance` | +| Clarity `Clarity`, Structure / AI Structure `Amount` | `effects.clarity` (summed) | +| Dehaze `Amount` | `effects.dehaze` | +| HSL (`MIPLChannelsEffect`) `h`/`s`/`l` + Red…Magenta | `mixer..hue / sat / lum` | +| Curves `RGB` / `Red` / `Green` / `Blue` (x, y points in 0..1) | `curve.master / red / green / blue` | +| Vignette `Amount`, `Vignette Size` | `vignette.amount`, `vignette.midpoint` | +| Grain `Amount` | `grain.amount` | + ## Profiles: 3D LUTs (`.cube`) `profile.import {paths}` (File ▸ Import Profiles & Presets…, drag & drop) reads `.cube` 3D LUTs — single files, folders