Merge pull request #39 from storytold/fix/issue-9-luminar

Import Luminar looks (.lmp, .mplumpack) (fixes #9)
This commit is contained in:
Brandon Thomas
2026-10-04 03:04:50 -04:00
committed by GitHub
10 changed files with 740 additions and 13 deletions
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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<CommandSpec> {
"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
),
+1
View File
@@ -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;
+17 -5
View File
@@ -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<String> {
"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<String>, from_photo: bool) -> Option<Imported> {
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
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<String>) -> Result<V
}
Ok(out)
}
"lmp" => 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<String>) -> Result<V
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use serde_json::json;
+688
View File
@@ -0,0 +1,688 @@
//! Luminar looks: `.lmp` files and `.mplumpack` collections.
//!
//! An `.lmp` look is an Apple XML property list: a dict whose `AdjustmentLayers` array holds layers
//! (`Identifier`, `Enabled`, `Amount` = opacity 0..1, `BlendModeIdentifier`, optional nested
//! `Sublayers.AdjustmentLayers`), each with `Effects` (`Identifier` such as
//! `MIPLDevelopCommonEffectID`, and `Parameters`: name → `{Value: number}`; parameters at their
//! default are left out). Newer looks may be a macOS bundle directory `Name.lmp/Contents/preset.lmp`
//! (plus `Info.plist` and LUT resources). An `.mplumpack` is a zip of `.lmp` files with a
//! `PresetsInfo.plist` (`GroupName`) and icons.
//!
//! The look's sliders with a clear counterpart become `crs:` fields (mapped by [`crate::crs`]);
//! everything else (AI tools, Orton, glow, LUTs…) is reported as unmapped. Sliders are on a
//! −100..100 scale; Exposure is read as ±100 ↔ ±4 EV. Layer opacity scales additive sliders.
//!
//! Written from black-box inspection of freely distributed looks and the public property-list
//! format; no Skylum code or look content was used.
use std::collections::BTreeMap;
use std::path::Path;
use crate::crs::Props;
use crate::preset_import::{Imported, build, group_from_dir, read_zip};
// ----------------------------------------------------------------------------- property lists
/// A value of an XML property list.
#[derive(Clone, Debug, PartialEq)]
pub enum Plist {
Dict(Vec<(String, Plist)>),
Array(Vec<Plist>),
Str(String),
Num(f64),
Bool(bool),
/// `<data>` (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<f64> {
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 `<dict>` / `<array>` 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<T>(&self, what: &str) -> Result<T, String> {
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!("</{name}>");
if r.starts_with(&t) {
self.i += t.len();
true
} else {
false
}
}
fn text(&mut self, name: &str) -> Result<String, String> {
let r = &self.s[self.i..];
let t = format!("</{name}>");
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<Plist, String> {
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<Plist, String> {
let (name, closing, selfc) = self.tag()?;
if closing {
return self.err(&format!("unexpected </{name}>"));
}
let leaf = |p: &mut Self, f: &dyn Fn(String) -> Result<Plist, String>| 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 <key>");
}
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::<f64>().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<Plist, String> {
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<Target> {
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<Vec<(f64, f64)>> {
let data: Vec<f64> = param.get("OptionalData")?.array().iter().map(Plist::num).collect::<Option<_>>()?;
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<String, (f64, (f64, f64))>,
curves: BTreeMap<String, Vec<(f64, f64)>>,
unmapped: Vec<String>,
}
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<String>) -> Result<Imported, String> {
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<Vec<Imported>, 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!("<key>{name}</key><dict><key>OptionalDataType</key><integer>0</integer><key>Value</key><real>{v}</real></dict>")
}
fn layer(id: &str, amount: f64, blend: &str, effects: &[(&str, String)], sub: &str) -> String {
let fx: String = effects
.iter()
.map(|(e, p)| format!("<dict><key>Identifier</key><string>{e}</string><key>Parameters</key><dict>{p}</dict></dict>"))
.collect();
let sub =
if sub.is_empty() { String::new() } else { format!("<key>Sublayers</key><dict><key>AdjustmentLayers</key><array>{sub}</array></dict>") };
format!(
"<dict><key>Amount</key><real>{amount}</real><key>BlendModeIdentifier</key><string>{blend}</string><key>Effects</key><array>{fx}</array>\
<key>Enabled</key><true/><key>Identifier</key><string>{id}</string>{sub}</dict>"
)
}
fn look(layers: &str, extra: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\"><dict><key>AdjustmentLayers</key><array>{layers}</array>{extra}<key>group_identifier</key><string>Custom</string></dict></plist>"
)
}
/// A newer-style look (develop sliders nested in sublayers) written for this test.
fn modern() -> String {
let curve = "<key>RGB</key><dict><key>OptionalData</key><array><real>0.5</real><real>0</real><real>0.1</real><real>0.5</real><real>0.5</real><real>1</real><real>0.9</real></array><key>OptionalDataType</key><integer>1</integer><key>Value</key><real>50</real></dict>";
let sub = [
layer(
"DevelopAdjustmentSubLayer",
1.0,
"Normal",
&[(
"MIPLDevelopCommonEffectID",
param("Exposure", 25.0) + &param("Contrast", 12.0) + &param("Highlights", -30.0) + &param("Temperature", 15.0),
)],
&layer("CurveLayer", 1.0, "Normal", &[("MIPLCurveEffect", curve.to_string())], ""),
),
layer("Disabled", 1.0, "Normal", &[("MIPLDevelopCommonEffectID", param("Shadows", 50.0))], "").replace("<true/>", "<false/>"),
]
.concat();
let layers = [
layer("DevelopAdjustmentLayer", 1.0, "Normal", &[], &sub),
layer("AIStructureEffect", 1.0, "Normal", &[("MIPLAIStructureEffect", param("Amount", 20.0) + &param("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) + &param("sBlue", 12.0) + &param("lGreen", 4.0))], ""),
layer("Vignette", 1.0, "Normal", &[("MIPLVignetteEffect", param("Amount", -20.0) + &param("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, "<key>uuid</key><string>AB12-CD34</string><key>kMPPresetIdentifierKey</key><string>x 2.lmp</string>")
}
#[test]
fn plists_parse() {
let p = parse_plist(b"<?xml version=\"1.0\"?><!-- c --><plist><dict><key>a&amp;b</key><string>x &lt;y&#x41;</string><key>n</key><integer>-3</integer><key>e</key><dict/><key>t</key><true/><key>d</key><data>AAEC</data><key>s</key><string/></dict></plist>").unwrap();
assert_eq!(p.get("a&b").and_then(Plist::str), Some("x <yA"));
assert_eq!(p.get("n").and_then(Plist::num), Some(-3.0));
assert_eq!(p.get("e"), Some(&Plist::Dict(vec![])));
assert_eq!(p.get("t"), Some(&Plist::Bool(true)));
assert_eq!(p.get("d"), Some(&Plist::Data));
assert_eq!(p.get("s").and_then(Plist::str), Some(""));
assert!(parse_plist(b"<plist><dict><key>a</key>").is_err());
assert!(parse_plist(b"bplist00\x01\x02").is_err());
}
#[test]
fn hostile_nesting_is_an_error_not_a_crash() {
let deep = format!("<plist>{}{}</plist>", "<array>".repeat(200_000), "</array>".repeat(200_000));
assert!(parse_plist(deep.as_bytes()).unwrap_err().contains("nested too deeply"));
let ok = format!("<plist>{}<true/>{}</plist>", "<array>".repeat(20), "</array>".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) + &param("Tint", 10.0))], ""),
]
.concat();
let lmp = look(&layers, "<key>Name</key><string>Blue Hour</string>");
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"<plist><dict/></plist>", 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))], ""), "<key>Name</key><string>Pop 1</string>");
let b = look(&layer("S", 1.0, "Normal", &[("MIPLSaturationEffect", param("Saturation", 25.0))], ""), "");
let info = b"<?xml version=\"1.0\"?><plist version=\"1.0\"><dict><key>GroupName</key><string>Magic Light</string></dict></plist>";
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"<plist/>")], 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"<plist><dict/></plist>"),
("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"), "<plist><dict/></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);
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
}
} 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());
}
}
+3 -3
View File
@@ -56,7 +56,7 @@ pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
pub struct Services {
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// 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<PickFiles>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
@@ -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"] {
+1 -1
View File
@@ -106,7 +106,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry |
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `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 |
+2
View File
@@ -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
+24
View File
@@ -117,6 +117,30 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`.
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.<band>.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