mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge pull request #39 from storytold/fix/issue-9-luminar
Import Luminar looks (.lmp, .mplumpack) (fixes #9)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) + ¶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("<true/>", "<false/>"),
|
||||
]
|
||||
.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, "<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&b</key><string>x <yA</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) + ¶m("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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user