LR-IMP-PROFILES: import presets from .lrtemplate, DNG presets, zip bundles and folders
preset.import (File ▸ Import Presets…, Presets panel ⋯, drag & drop, CLI, MCP) now reads
everything people actually download or carry over from other editors:
- classic .lrtemplate files (Lua table literal parser; value.settings uses crs: field names),
including old process-version fields (Exposure, Contrast, FillLight, HighlightRecovery,
Shadows, Brightness, Clarity, ToneCurve) approximated with today's sliders;
- photos carrying their edits in XMP ("DNG presets" from mobile apps, edited JPEG/TIFF): the
look only, without the photo's crop, geometry or custom white balance;
- .zip bundles (stored + deflate) of any of these, skipping __MACOSX / hidden files;
- folders: the pack's folder name becomes the group, skipping app/format container folders
("Lightroom Classic", "XMP", "Mobile (DNG)"…).
Each imported preset reports the settings that had no counterpart (`unmapped`: profiles,
looks, local masks); the app's toast names them. dryRun previews, group overrides.
Dropped .lcpreset/.lrtemplate/.xmp/.zip files import as presets instead of photos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5d6b889e45
commit
1dc501bc3a
|
|
@ -2180,6 +2180,7 @@ dependencies = [
|
|||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ fn services() -> Services {
|
|||
pick_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Presets")
|
||||
.add_filter("Presets", &["lcpreset", "xmp"])
|
||||
.add_filter("Presets", &["lcpreset", "xmp", "lrtemplate", "zip", "dng"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true }
|
|||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
lightcraft-meta = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! Preset files: import `.lcpreset` / XMP presets, export `.lcpreset`.
|
||||
//! Preset files: import `.lcpreset`, XMP, `.lrtemplate`, DNG-preset and zip bundles; export `.lcpreset`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, parse_preset_file, to_lcpreset};
|
||||
use crate::preset_import::{group_from_dir, read_presets};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
||||
|
|
@ -12,23 +13,36 @@ fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
|||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paths = strings(p, "paths").filter(|v| !v.is_empty()).ok_or_else(|| bad("preset.import", "no paths"))?;
|
||||
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()).map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut imported = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
for f in expand_preset_paths(&paths) {
|
||||
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| parse_preset_file(&f, &b));
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
let n = list.len();
|
||||
let ids = s.add_presets(list);
|
||||
skipped += n - ids.len();
|
||||
for id in ids {
|
||||
if let Some(pr) = s.presets.iter().find(|x| x.id == id) {
|
||||
imported.push(json!({"id": pr.id, "name": pr.name, "group": pr.group}));
|
||||
for top in &paths {
|
||||
let from_dir = std::path::Path::new(top).is_dir();
|
||||
for f in expand_preset_paths(std::slice::from_ref(top)) {
|
||||
// presets in a folder go to a group named after it (unless the file names its own)
|
||||
// the path from the imported folder (inclusive) to the file's folder
|
||||
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
|
||||
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
|
||||
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
|
||||
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
for mut it in list {
|
||||
if let Some(g) = &group {
|
||||
it.preset.group = g.clone();
|
||||
}
|
||||
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
|
||||
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
|
||||
match id {
|
||||
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
|
||||
|
|
@ -69,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Import Presets",
|
||||
[],
|
||||
None,
|
||||
"{paths: [.lcpreset / .xmp file or folder]} — groups preserved; XMP presets map crs: fields (docs/xmp-interop.md) → {imported: [{id,name,group}], skipped, failed}",
|
||||
"{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}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
|
|
|||
|
|
@ -55,7 +55,21 @@ pub fn has_adjustments(props: &Props) -> bool {
|
|||
props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
|
||||
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn note(k: &str) {
|
||||
READ.with(|r| {
|
||||
if let Some(s) = r.borrow_mut().as_mut() {
|
||||
s.insert(k.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
|
||||
note(k);
|
||||
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +109,7 @@ pub fn rel_to_kelvin(r: f64) -> f64 {
|
|||
|
||||
/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
|
||||
fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
note(k);
|
||||
let items = props.get(k)?;
|
||||
let mut pts = Vec::new();
|
||||
for it in items {
|
||||
|
|
@ -130,9 +145,30 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
|||
n(o, "Shadows2012", "light.shadows");
|
||||
n(o, "Whites2012", "light.whites");
|
||||
n(o, "Blacks2012", "light.blacks");
|
||||
// older process versions (most `.lrtemplate` presets): approximate the 2012 sliders from the
|
||||
// earlier ones, relative to their defaults (contrast 25, blacks 5, brightness 50)
|
||||
let has = |o: &Value, path: &str| path.split('.').try_fold(o, |v, k| v.get(k)).is_some();
|
||||
// presets written by newer versions keep the old fields at their defaults: ignore them there
|
||||
let pv2012 = props.keys().any(|k| k.ends_with("2012"));
|
||||
let old = |o: &mut Value, crs: &str, path: &str, f: &dyn Fn(f64) -> f64| {
|
||||
let v = num(props, &format!("crs:{crs}"));
|
||||
if !pv2012
|
||||
&& !has(o, path)
|
||||
&& let Some(v) = v
|
||||
{
|
||||
put(o, path, json!(f(v).clamp(-100.0, 100.0)));
|
||||
}
|
||||
};
|
||||
old(o, "Exposure", "light.exposure", &|v| v);
|
||||
old(o, "Contrast", "light.contrast", &|v| v - 25.0);
|
||||
old(o, "HighlightRecovery", "light.highlights", &|v| -v);
|
||||
old(o, "FillLight", "light.shadows", &|v| v);
|
||||
old(o, "Shadows", "light.blacks", &|v| -(v - 5.0) * 4.0);
|
||||
old(o, "Brightness", "light.whites", &|v| (v - 50.0) * 0.6);
|
||||
// ---- Presence
|
||||
n(o, "Texture", "effects.texture");
|
||||
n(o, "Clarity2012", "effects.clarity");
|
||||
old(o, "Clarity", "effects.clarity", &|v| v);
|
||||
n(o, "Dehaze", "effects.dehaze");
|
||||
n(o, "Vibrance", "color.vibrance");
|
||||
n(o, "Saturation", "color.saturation");
|
||||
|
|
@ -191,6 +227,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
|||
n(o, "ParametricMidtoneSplit", "curve.split_mid");
|
||||
n(o, "ParametricHighlightSplit", "curve.split_highlights");
|
||||
n(o, "CurveRefineSaturation", "curve.refine_saturation");
|
||||
if let Some(c) = curve(props, "crs:ToneCurve").filter(|c| c.as_array().is_some_and(|a| !a.is_empty())) {
|
||||
// the older single curve, used when no 2012 curve is present
|
||||
if !props.contains_key("crs:ToneCurvePV2012") {
|
||||
put(o, "curve.master", c);
|
||||
}
|
||||
}
|
||||
for (crs, ch) in
|
||||
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
|
||||
{
|
||||
|
|
@ -310,6 +352,26 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
|||
out
|
||||
}
|
||||
|
||||
/// [`to_partial`], plus the adjustments in the packet it could not carry over (field names
|
||||
/// without the `crs:` prefix, grouped: `CameraProfile`, `Look`, `MaskGroupBasedCorrections`…).
|
||||
pub fn to_partial_report(props: &Props, raw: Option<bool>) -> (Value, Vec<String>) {
|
||||
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
|
||||
let out = to_partial(props, raw);
|
||||
let read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves
|
||||
let quiet = |k: &str| k.starts_with("Enable") || k.starts_with("ToneCurveName") || k == "AutoTone" || k == "AutoGrayscaleMix";
|
||||
let mut unmapped: Vec<String> = props
|
||||
.keys()
|
||||
.filter(|k| k.starts_with("crs:"))
|
||||
.map(|k| k.split('/').next().unwrap_or(k))
|
||||
.filter(|k| !NON_ADJUSTMENT.contains(k) && !read.contains(*k))
|
||||
.map(|k| k.trim_start_matches("crs:").to_string())
|
||||
.filter(|k| !quiet(k))
|
||||
.collect();
|
||||
unmapped.dedup();
|
||||
(out, unmapped)
|
||||
}
|
||||
|
||||
/// Read an XMP preset (`crs:` fields + `crs:Name` / `crs:Group`) into one of our presets.
|
||||
/// Returns `None` when the packet has no adjustments we understand.
|
||||
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub mod library;
|
|||
pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
pub mod preset_import;
|
||||
pub mod presets;
|
||||
pub mod rename;
|
||||
pub mod sidecar;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,727 @@
|
|||
//! 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`].
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_develop::Preset;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::crs::Props;
|
||||
|
||||
/// One preset read from a file, with the settings we could not carry over.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Imported {
|
||||
pub preset: Preset,
|
||||
/// Names of the other editor's settings that have no counterpart here (local masks, its own
|
||||
/// profiles…).
|
||||
pub unmapped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Extensions of files read as presets (beyond our own `.lcpreset`).
|
||||
pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng"];
|
||||
|
||||
// ------------------------------------------------------------------------------------- zip
|
||||
|
||||
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
|
||||
/// directories, macOS resource forks and hidden files are skipped.
|
||||
pub fn read_zip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
|
||||
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
|
||||
let tail = bytes.len().saturating_sub(65_557);
|
||||
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
|
||||
let count = u16_at(eocd + 10).ok_or("bad zip")?;
|
||||
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..count {
|
||||
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
|
||||
return Err("bad zip central directory".into());
|
||||
}
|
||||
let method = u16_at(at + 10).ok_or("bad zip")?;
|
||||
let csize = u32_at(at + 20).ok_or("bad zip")?;
|
||||
let usize_ = u32_at(at + 24).ok_or("bad zip")?;
|
||||
let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
|
||||
let local = u32_at(at + 42).ok_or("bad zip")?;
|
||||
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
|
||||
at += 46 + nlen + xlen + clen;
|
||||
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
|
||||
if skip {
|
||||
continue;
|
||||
}
|
||||
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
|
||||
return Err(format!("{name}: bad local header"));
|
||||
}
|
||||
let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
|
||||
let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
|
||||
let content = match method {
|
||||
0 => data.to_vec(),
|
||||
8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
|
||||
m => return Err(format!("{name}: unsupported compression method {m}")),
|
||||
};
|
||||
out.push((name, content));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- Lua tables
|
||||
|
||||
/// A value of a Lua table literal (what `.lrtemplate` files contain).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Lua {
|
||||
Nil,
|
||||
Bool(bool),
|
||||
Num(f64),
|
||||
Str(String),
|
||||
/// Positional items and named fields, in file order.
|
||||
Table(Vec<Lua>, Vec<(String, Lua)>),
|
||||
}
|
||||
|
||||
impl Lua {
|
||||
pub fn get(&self, key: &str) -> Option<&Lua> {
|
||||
match self {
|
||||
Lua::Table(_, m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Lua::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LuaParser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl LuaParser<'_> {
|
||||
fn err<T>(&self, what: &str) -> Result<T, String> {
|
||||
Err(format!("{what} at byte {}", self.i))
|
||||
}
|
||||
fn skip_ws(&mut self) {
|
||||
loop {
|
||||
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.s[self.i..].starts_with(b"--") {
|
||||
self.i += 2;
|
||||
if let Some(level) = self.long_bracket_level() {
|
||||
let _ = self.long_string(level);
|
||||
} else {
|
||||
while self.i < self.s.len() && self.s[self.i] != b'\n' {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// `[[` / `[==[` at the cursor: its level (number of `=`).
|
||||
fn long_bracket_level(&self) -> Option<usize> {
|
||||
let r = &self.s[self.i..];
|
||||
if r.first() != Some(&b'[') {
|
||||
return None;
|
||||
}
|
||||
let eq = r[1..].iter().take_while(|c| **c == b'=').count();
|
||||
(r.get(1 + eq) == Some(&b'[')).then_some(eq)
|
||||
}
|
||||
fn long_string(&mut self, level: usize) -> Result<String, String> {
|
||||
self.i += level + 2;
|
||||
let close = format!("]{}]", "=".repeat(level));
|
||||
let rest = &self.s[self.i..];
|
||||
let end = rest.windows(close.len()).position(|w| w == close.as_bytes()).ok_or("unterminated long string")?;
|
||||
let mut text = String::from_utf8_lossy(&rest[..end]).to_string();
|
||||
if text.starts_with('\n') {
|
||||
text.remove(0);
|
||||
}
|
||||
self.i += end + close.len();
|
||||
Ok(text)
|
||||
}
|
||||
fn string(&mut self) -> Result<String, String> {
|
||||
let q = self.s[self.i];
|
||||
self.i += 1;
|
||||
let mut out = Vec::new();
|
||||
while self.i < self.s.len() {
|
||||
let c = self.s[self.i];
|
||||
self.i += 1;
|
||||
match c {
|
||||
c if c == q => return Ok(String::from_utf8_lossy(&out).to_string()),
|
||||
b'\\' => {
|
||||
let e = *self.s.get(self.i).ok_or("bad escape")?;
|
||||
self.i += 1;
|
||||
match e {
|
||||
b'n' => out.push(b'\n'),
|
||||
b't' => out.push(b'\t'),
|
||||
b'r' => out.push(b'\r'),
|
||||
b'0'..=b'9' => {
|
||||
let mut v = (e - b'0') as u32;
|
||||
for _ in 0..2 {
|
||||
match self.s.get(self.i) {
|
||||
Some(d @ b'0'..=b'9') => {
|
||||
v = v * 10 + (d - b'0') as u32;
|
||||
self.i += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
out.push(v.min(255) as u8);
|
||||
}
|
||||
b'\n' => out.push(b'\n'),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
self.err("unterminated string")
|
||||
}
|
||||
fn ident(&mut self) -> Option<String> {
|
||||
let start = self.i;
|
||||
while self.i < self.s.len() && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') {
|
||||
self.i += 1;
|
||||
}
|
||||
(self.i > start && !self.s[start].is_ascii_digit()).then(|| String::from_utf8_lossy(&self.s[start..self.i]).to_string())
|
||||
}
|
||||
fn number(&mut self) -> Result<f64, String> {
|
||||
let start = self.i;
|
||||
if self.s[self.i..].starts_with(b"0x") || self.s[self.i..].starts_with(b"0X") {
|
||||
self.i += 2;
|
||||
let h = self.i;
|
||||
while self.i < self.s.len() && self.s[self.i].is_ascii_hexdigit() {
|
||||
self.i += 1;
|
||||
}
|
||||
return i64::from_str_radix(std::str::from_utf8(&self.s[h..self.i]).unwrap_or(""), 16).map(|v| v as f64).map_err(|e| e.to_string());
|
||||
}
|
||||
while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'e' | b'E' | b'+' | b'-')) {
|
||||
if matches!(self.s[self.i], b'+' | b'-') && self.i > start && !matches!(self.s[self.i - 1], b'e' | b'E') {
|
||||
break;
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok().and_then(|t| t.parse().ok()).map_or_else(|| self.err("bad number"), Ok)
|
||||
}
|
||||
fn value(&mut self) -> Result<Lua, String> {
|
||||
self.skip_ws();
|
||||
let Some(&c) = self.s.get(self.i) else { return self.err("unexpected end") };
|
||||
match c {
|
||||
b'{' => self.table(),
|
||||
b'"' | b'\'' => self.string().map(Lua::Str),
|
||||
b'[' if self.long_bracket_level().is_some() => {
|
||||
let level = self.long_bracket_level().unwrap_or(0);
|
||||
self.long_string(level).map(Lua::Str)
|
||||
}
|
||||
b'-' => {
|
||||
self.i += 1;
|
||||
self.skip_ws();
|
||||
match self.value()? {
|
||||
Lua::Num(n) => Ok(Lua::Num(-n)),
|
||||
_ => self.err("bad negation"),
|
||||
}
|
||||
}
|
||||
b'0'..=b'9' | b'.' => self.number().map(Lua::Num),
|
||||
_ => match self.ident().as_deref() {
|
||||
Some("true") => Ok(Lua::Bool(true)),
|
||||
Some("false") => Ok(Lua::Bool(false)),
|
||||
Some("nil") => Ok(Lua::Nil),
|
||||
// a call with one string / table argument, e.g. ZSTR "…" (localisation helpers)
|
||||
Some(_) => {
|
||||
self.skip_ws();
|
||||
match self.s.get(self.i) {
|
||||
Some(b'"' | b'\'' | b'{') => self.value(),
|
||||
Some(b'(') => {
|
||||
self.i += 1;
|
||||
let v = self.value()?;
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b')') {
|
||||
self.i += 1;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
_ => Ok(Lua::Nil),
|
||||
}
|
||||
}
|
||||
None => self.err("unexpected character"),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn table(&mut self) -> Result<Lua, String> {
|
||||
self.i += 1; // {
|
||||
let (mut arr, mut map) = (Vec::new(), Vec::new());
|
||||
loop {
|
||||
self.skip_ws();
|
||||
match self.s.get(self.i) {
|
||||
None => return self.err("unterminated table"),
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Lua::Table(arr, map));
|
||||
}
|
||||
Some(b',' | b';') => {
|
||||
self.i += 1;
|
||||
continue;
|
||||
}
|
||||
Some(b'[') if self.long_bracket_level().is_none() => {
|
||||
self.i += 1;
|
||||
let k = self.value()?;
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) != Some(&b']') {
|
||||
return self.err("expected ]");
|
||||
}
|
||||
self.i += 1;
|
||||
self.expect_eq()?;
|
||||
let v = self.value()?;
|
||||
match k {
|
||||
Lua::Str(s) => map.push((s, v)),
|
||||
Lua::Num(n) => map.push((format!("{n}"), v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// `name = value`, or a positional value
|
||||
let save = self.i;
|
||||
if let Some(name) = self.ident() {
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b'=') && self.s.get(self.i + 1) != Some(&b'=') {
|
||||
self.i += 1;
|
||||
let v = self.value()?;
|
||||
map.push((name, v));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.i = save;
|
||||
arr.push(self.value()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn expect_eq(&mut self) -> Result<(), String> {
|
||||
self.skip_ws();
|
||||
if self.s.get(self.i) == Some(&b'=') {
|
||||
self.i += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
self.err("expected =")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Lua table literal, optionally preceded by `name =` or `return`.
|
||||
pub fn parse_lua(text: &str) -> Result<Lua, String> {
|
||||
let mut p = LuaParser { s: text.as_bytes(), i: 0 };
|
||||
p.skip_ws();
|
||||
let save = p.i;
|
||||
match p.ident().as_deref() {
|
||||
Some("return") => {}
|
||||
Some(_) => {
|
||||
p.skip_ws();
|
||||
if p.s.get(p.i) == Some(&b'=') {
|
||||
p.i += 1;
|
||||
} else {
|
||||
p.i = save;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
p.value()
|
||||
}
|
||||
|
||||
/// A localisable string `"$$$/Key/Path=Default text"` → its default text.
|
||||
fn delocalize(s: &str) -> String {
|
||||
match s.strip_prefix("$$$/") {
|
||||
Some(rest) => rest.split_once('=').map_or(rest, |(_, t)| t).to_string(),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The develop settings of an `.lrtemplate` as `crs:` properties, plus its title.
|
||||
pub fn lrtemplate_props(text: &str) -> Result<(Option<String>, Props), String> {
|
||||
let root = parse_lua(text)?;
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(delocalize).filter(|t| !t.trim().is_empty());
|
||||
let settings = root.get("value").and_then(|v| v.get("settings")).ok_or("no develop settings in this template")?;
|
||||
let Lua::Table(_, fields) = settings else { return Err("no develop settings in this template".into()) };
|
||||
let mut props = Props::new();
|
||||
for (k, v) in fields {
|
||||
let key = format!("crs:{k}");
|
||||
let vals: Vec<String> = match v {
|
||||
Lua::Num(n) => vec![format!("{n}")],
|
||||
Lua::Bool(b) => vec![if *b { "True" } else { "False" }.to_string()],
|
||||
Lua::Str(s) => vec![delocalize(s)],
|
||||
// point curves are flat lists x1, y1, x2, y2, …
|
||||
Lua::Table(arr, map) if map.is_empty() && !arr.is_empty() && arr.iter().all(|a| matches!(a, Lua::Num(_))) => arr
|
||||
.chunks(2)
|
||||
.filter_map(|c| match c {
|
||||
[Lua::Num(x), Lua::Num(y)] => Some(format!("{x}, {y}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
Lua::Table(a, m) if a.is_empty() && m.is_empty() => continue,
|
||||
// anything else (local corrections, retouch…) is only reported
|
||||
Lua::Table(..) => vec!["(table)".into()],
|
||||
Lua::Nil => continue,
|
||||
};
|
||||
props.insert(key, vals);
|
||||
}
|
||||
Ok((title, props))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- XMP in files
|
||||
|
||||
/// The XMP packet embedded in a file of any container (DNG, JPEG, TIFF…): the text from
|
||||
/// `<x:xmpmeta` to `</x:xmpmeta>`.
|
||||
pub fn embedded_xmp(bytes: &[u8]) -> Option<String> {
|
||||
let start = bytes.windows(10).position(|w| w == b"<x:xmpmeta")?;
|
||||
let end = bytes[start..].windows(12).position(|w| w == b"</x:xmpmeta>")? + start + 12;
|
||||
Some(String::from_utf8_lossy(&bytes[start..end]).to_string())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------- files
|
||||
|
||||
/// A preset group named after the innermost folder of `dir` (a path inside the imported folder or
|
||||
/// bundle) that isn't a container named for an app or a format (`Lightroom Classic`, `XMP`,
|
||||
/// `Mobile (DNG)`, `Develop Presets`…).
|
||||
pub fn group_from_dir(dir: &str) -> Option<String> {
|
||||
const FILLER: &[&str] = &[
|
||||
"lightroom",
|
||||
"lr",
|
||||
"lrc",
|
||||
"classic",
|
||||
"cc",
|
||||
"camera",
|
||||
"raw",
|
||||
"acr",
|
||||
"photoshop",
|
||||
"desktop",
|
||||
"mobile",
|
||||
"xmp",
|
||||
"lrtemplate",
|
||||
"lrtemplates",
|
||||
"dng",
|
||||
"dngs",
|
||||
"preset",
|
||||
"presets",
|
||||
"settings",
|
||||
"lcpreset",
|
||||
"lightcraft",
|
||||
"user",
|
||||
"develop",
|
||||
"files",
|
||||
"for",
|
||||
"and",
|
||||
"version",
|
||||
"versions",
|
||||
"format",
|
||||
"formats",
|
||||
"new",
|
||||
"old",
|
||||
"adobe",
|
||||
];
|
||||
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)
|
||||
}
|
||||
|
||||
fn build(props: &Props, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, None);
|
||||
if from_photo && let Some(o) = settings.as_object_mut() {
|
||||
// a photo's own framing and absolute white balance don't belong in a look
|
||||
o.remove("crop");
|
||||
o.remove("geometry");
|
||||
if o.get("wb").and_then(|w| w.get("mode")).and_then(Value::as_str).is_none_or(|m| m == "custom" || m == "asShot") {
|
||||
o.remove("wb");
|
||||
}
|
||||
}
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let group = props
|
||||
.get("crs:Group")
|
||||
.and_then(|v| v.first())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or(group)
|
||||
.unwrap_or_else(|| "Imported Presets".into());
|
||||
let uuid = props.get("crs:UUID").and_then(|v| v.first()).map(|u| u.to_ascii_lowercase());
|
||||
let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
|
||||
Some(Imported { preset: Preset { id, name, group, settings, favorite: false, builtin: false }, unmapped })
|
||||
}
|
||||
|
||||
/// Read the presets in file `name` (`bytes`); `group` names the group when the file doesn't
|
||||
/// (its folder). Zip bundles are read recursively.
|
||||
pub fn read_presets(name: &str, bytes: &[u8], group: Option<String>) -> Result<Vec<Imported>, String> {
|
||||
let path = Path::new(name);
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"zip" => {
|
||||
let mut out = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for (inner, data) in read_zip(bytes)? {
|
||||
let iext = Path::new(&inner).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if !(PRESET_EXTS.contains(&iext.as_str()) || iext == crate::presets::LCPRESET_EXT) || iext == "zip" {
|
||||
continue;
|
||||
}
|
||||
let dir_group =
|
||||
inner.rsplit_once('/').and_then(|(d, _)| group_from_dir(d)).or_else(|| group_from_dir(&stem)).or_else(|| group.clone());
|
||||
match read_presets(&inner, &data, dir_group) {
|
||||
Ok(v) => out.extend(v),
|
||||
Err(e) => errors.push(format!("{inner}: {e}")),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(if errors.is_empty() { "no presets in this archive".into() } else { errors.join("; ") });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"lrtemplate" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let (title, props) = lrtemplate_props(text.trim_start_matches('\u{feff}'))?;
|
||||
build(&props, title.unwrap_or(stem), group, false)
|
||||
.map(|i| vec![i])
|
||||
.ok_or_else(|| "no develop settings we can use in this template".into())
|
||||
}
|
||||
"xmp" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
|
||||
let props = &d.properties;
|
||||
let name = props.get("crs:Name").and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or(stem);
|
||||
build(props, name, group, false).map(|i| vec![i]).ok_or_else(|| "no develop settings in this XMP file".into())
|
||||
}
|
||||
// a photo carrying its edits (mobile "DNG presets", edited JPEG / TIFF)
|
||||
"dng" | "jpg" | "jpeg" | "tif" | "tiff" => {
|
||||
let xmp = embedded_xmp(bytes).ok_or("this photo carries no edits (no XMP)")?;
|
||||
let d = lightcraft_meta::parse_xmp(&xmp).map_err(|e| e.to_string())?;
|
||||
build(&d.properties, stem, group, true).map(|i| vec![i]).ok_or_else(|| "this photo carries no edits we can use".into())
|
||||
}
|
||||
_ => crate::presets::parse_preset_file(name, bytes).map(|v| {
|
||||
v.into_iter()
|
||||
.map(|mut p| {
|
||||
if p.group == "Imported Presets"
|
||||
&& let Some(g) = &group
|
||||
{
|
||||
p.group = g.clone();
|
||||
}
|
||||
Imported { preset: p, unmapped: Vec::new() }
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A zip archive (`deflate`: compress entries) — enough of a writer for the tests.
|
||||
pub(crate) fn zip(entries: &[(&str, &[u8])], deflate: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, data) in entries {
|
||||
let (method, body) = if deflate { (8u16, miniz_oxide::deflate::compress_to_vec(data, 6)) } else { (0, data.to_vec()) };
|
||||
let off = out.len() as u32;
|
||||
let head = |sig: &[u8], out: &mut Vec<u8>, central: bool| {
|
||||
out.extend_from_slice(sig);
|
||||
if central {
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&method.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 8]); // time, date, crc (not checked)
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
};
|
||||
head(b"PK\x03\x04", &mut out, false);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
head(b"PK\x01\x02", &mut central, true);
|
||||
central.extend_from_slice(&[0; 10]); // comment length, disk, internal + external attributes
|
||||
central.extend_from_slice(&off.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let cd = out.len() as u32;
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(b"PK\x05\x06\0\0\0\0");
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&cd.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A template in the classic Lua-table form (written for this test).
|
||||
pub(crate) const TEMPLATE: &str = r#"s = {
|
||||
id = "0D2F9C1E-TEST",
|
||||
internalName = "Warm Fade",
|
||||
title = "$$$/Test/Warm=Warm Fade",
|
||||
type = "Develop",
|
||||
value = {
|
||||
settings = {
|
||||
Exposure2012 = 0.35,
|
||||
Contrast2012 = -20,
|
||||
ConvertToGrayscale = false,
|
||||
ToneCurvePV2012 = { 0, 20, 128, 128, 255, 240, },
|
||||
SplitToningShadowHue = 210,
|
||||
SplitToningShadowSaturation = 15,
|
||||
HueAdjustmentOrange = -8,
|
||||
CameraProfile = "Some Profile",
|
||||
RetouchInfo = {},
|
||||
ProcessVersion = "11.0",
|
||||
-- a comment
|
||||
EnableColorAdjustments = true,
|
||||
},
|
||||
uuid = "0D2F9C1E-TEST",
|
||||
},
|
||||
version = 0,
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn lua_tables() {
|
||||
let v = parse_lua(
|
||||
r#"return { a = 1, ["b c"] = 'x\'y', -2.5e1, 0x10, t = { true, nil }, [[long
|
||||
text]], z = ZSTR "loc" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v.get("a"), Some(&Lua::Num(1.0)));
|
||||
assert_eq!(v.get("b c"), Some(&Lua::Str("x'y".into())));
|
||||
assert_eq!(v.get("z"), Some(&Lua::Str("loc".into())));
|
||||
let Lua::Table(arr, _) = &v else { panic!() };
|
||||
assert_eq!(arr[..2], [Lua::Num(-25.0), Lua::Num(16.0)]);
|
||||
assert_eq!(arr[2], Lua::Str("long\ntext".into()));
|
||||
assert!(parse_lua("s = { a = ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_maps_settings_and_reports_the_rest() {
|
||||
let v = read_presets("Warm Fade.lrtemplate", TEMPLATE.as_bytes(), None).unwrap();
|
||||
assert_eq!(v.len(), 1);
|
||||
let p = &v[0].preset;
|
||||
assert_eq!((p.name.as_str(), p.group.as_str()), ("Warm Fade", "Imported Presets"));
|
||||
assert_eq!(p.settings["light"]["exposure"], json!(0.35));
|
||||
assert_eq!(p.settings["light"]["contrast"], json!(-20.0));
|
||||
assert_eq!(p.settings["treatment"], json!("color"));
|
||||
assert_eq!(p.settings["grading"]["shadows"]["hue"], json!(210.0));
|
||||
assert_eq!(p.settings["mixer"]["orange"]["hue"], json!(-8.0));
|
||||
let c = p.settings["curve"]["master"].as_array().unwrap();
|
||||
assert_eq!(c.len(), 3);
|
||||
assert!((c[0]["y"].as_f64().unwrap() - 20.0 / 255.0).abs() < 1e-9);
|
||||
assert_eq!(v[0].unmapped, vec!["CameraProfile".to_string()], "profiles are ours; the rest is mapped or bookkeeping");
|
||||
// it parses into real develop settings
|
||||
let d = lightcraft_develop::DevelopSettings::default().merged(&p.settings).expect("valid develop settings");
|
||||
assert_eq!(d.light.exposure, 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_process_version_fields_are_approximated() {
|
||||
let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }";
|
||||
let p = &read_presets("Old.lrtemplate", t.as_bytes(), None).unwrap()[0].preset;
|
||||
assert_eq!(p.settings["light"]["exposure"], json!(0.5));
|
||||
assert_eq!(p.settings["light"]["contrast"], json!(25.0), "relative to the old default of 25");
|
||||
assert_eq!(p.settings["light"]["shadows"], json!(20.0));
|
||||
assert_eq!(p.settings["light"]["highlights"], json!(-30.0));
|
||||
assert_eq!(p.settings["effects"]["clarity"], json!(10.0));
|
||||
assert_eq!(p.settings["curve"]["master"].as_array().unwrap().len(), 3);
|
||||
// newer presets carry the old fields at their defaults: those must not shift anything
|
||||
let t2 = "s = { value = { settings = { Contrast2012 = 10, Contrast = 25, Shadows = 5, Brightness = 50 } } }";
|
||||
let p2 = &read_presets("New.lrtemplate", t2.as_bytes(), None).unwrap()[0];
|
||||
assert_eq!(p2.preset.settings, json!({"light": {"contrast": 10.0}}));
|
||||
assert!(p2.unmapped.is_empty(), "{:?}", p2.unmapped);
|
||||
}
|
||||
|
||||
const XMP: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:Exposure2012="+0.50" crs:Vibrance="20" crs:WhiteBalance="As Shot" crs:Temperature="5600" crs:HasCrop="True" crs:CropLeft="0.1" crs:CropRight="0.9" crs:CropTop="0" crs:CropBottom="1"/>
|
||||
</rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn photos_carrying_edits_become_looks() {
|
||||
// a "DNG preset": any container with an XMP packet; framing and white balance stay behind
|
||||
let mut file = b"II*\0 binary junk ".to_vec();
|
||||
file.extend_from_slice(XMP.as_bytes());
|
||||
file.extend_from_slice(b" more junk");
|
||||
let v = read_presets("/x/Moody.dng", &file, None).unwrap();
|
||||
let p = &v[0].preset;
|
||||
assert_eq!(p.name, "Moody");
|
||||
assert_eq!(p.settings, json!({"light": {"exposure": 0.5}, "color": {"vibrance": 20.0}}));
|
||||
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundles_with_folders_as_groups() {
|
||||
for deflate in [false, true] {
|
||||
let z = zip(
|
||||
&[
|
||||
("Pack/Film/Warm Fade.lrtemplate", TEMPLATE.as_bytes()),
|
||||
("Pack/Film/Bright.xmp", XMP.as_bytes()),
|
||||
("__MACOSX/Pack/._Bright.xmp", b"junk"),
|
||||
("Pack/readme.txt", b"thanks for downloading"),
|
||||
],
|
||||
deflate,
|
||||
);
|
||||
let v = read_presets("pack.zip", &z, None).unwrap();
|
||||
let names: Vec<_> = v.iter().map(|i| (i.preset.name.as_str(), i.preset.group.as_str())).collect();
|
||||
assert_eq!(names, [("Warm Fade", "Film"), ("Bright", "Film")], "deflate={deflate}");
|
||||
}
|
||||
assert!(read_presets("empty.zip", &zip(&[("a.txt", b"x")], true), None).is_err());
|
||||
assert!(read_presets("bad.zip", b"not a zip", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_folder_names_are_not_groups() {
|
||||
assert_eq!(group_from_dir("Settings"), None);
|
||||
assert_eq!(group_from_dir("Develop Presets/"), None);
|
||||
assert_eq!(group_from_dir("Looks/My Film Looks"), Some("My Film Looks".into()));
|
||||
// commercial packs nest by format: the pack's name wins
|
||||
assert_eq!(group_from_dir("Film Pack/Lightroom Classic"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Film Pack/Mobile (DNG)/"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Film Pack/XMP Presets for Lightroom CC 7.3+"), Some("Film Pack".into()));
|
||||
assert_eq!(group_from_dir("Sacred Light"), Some("Sacred Light".into()), "whole words only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_command_reads_folders_bundles_and_applies() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-preset-import-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Looks/Film")).unwrap();
|
||||
std::fs::write(dir.join("Looks/Film/Warm Fade.lrtemplate"), TEMPLATE).unwrap();
|
||||
std::fs::write(dir.join("Looks/bundle.zip"), zip(&[("Street/Bright.xmp", XMP.as_bytes())], true)).unwrap();
|
||||
std::fs::write(dir.join("Looks/notes.txt"), "not a preset").unwrap();
|
||||
let mut s = crate::Session::with_demo();
|
||||
let before = s.presets.len();
|
||||
let paths = json!([dir.join("Looks").to_string_lossy()]);
|
||||
// a dry run reports without adding
|
||||
let r = s.execute("preset.import", &json!({"paths": paths, "dryRun": true})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
|
||||
assert_eq!(s.presets.len(), before);
|
||||
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
||||
let got: Vec<(String, String)> =
|
||||
r["imported"].as_array().unwrap().iter().map(|i| (i["name"].as_str().unwrap().into(), i["group"].as_str().unwrap().into())).collect();
|
||||
assert_eq!(got, [("Warm Fade".to_string(), "Film".to_string()), ("Bright".into(), "Street".into())]);
|
||||
assert_eq!(r["imported"][0]["unmapped"], json!(["CameraProfile"]));
|
||||
assert_eq!(s.presets.len(), before + 2);
|
||||
// importing again adds nothing
|
||||
let r = s.execute("preset.import", &json!({"paths": paths})).unwrap();
|
||||
assert_eq!((r["imported"].as_array().unwrap().len(), r["skipped"].as_u64()), (0, Some(2)));
|
||||
// and the imported look applies
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
let pid = s.presets.iter().find(|p| p.name == "Warm Fade").unwrap().id.clone();
|
||||
s.execute("preset.apply", &json!({"id": pid})).unwrap();
|
||||
let d = s.develop_of(id).unwrap();
|
||||
assert_eq!((d.light.exposure, d.light.contrast), (0.35, -20.0));
|
||||
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 || ext == "xmp" {
|
||||
if top || ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip"].contains(&ext.as_str()) {
|
||||
out.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ pub type OpenUrlFn = Box<dyn FnMut(&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`).
|
||||
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`).
|
||||
pub pick_preset_files: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lcpreset` file.
|
||||
pub save_preset_file: Option<SaveFile>,
|
||||
|
|
@ -359,8 +359,13 @@ impl LightcraftApp {
|
|||
{
|
||||
let dropped: Vec<String> =
|
||||
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
|
||||
if !dropped.is_empty() {
|
||||
let _ = self.run("library.import", serde_json::json!({"paths": dropped}));
|
||||
// preset files import as presets, everything else as photos
|
||||
let (presets, photos): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_preset_file(p));
|
||||
if !presets.is_empty() {
|
||||
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
|
||||
}
|
||||
if !photos.is_empty() {
|
||||
let _ = self.run("library.import", serde_json::json!({"paths": photos}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -514,3 +519,22 @@ pub struct HoverPreview {
|
|||
pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool {
|
||||
d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.")
|
||||
}
|
||||
|
||||
/// 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"].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"] {
|
||||
assert!(super::is_preset_file(p), "{p}");
|
||||
}
|
||||
for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] {
|
||||
assert!(!super::is_preset_file(p), "{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -675,11 +675,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
if let Ok(v) = &r {
|
||||
let n = v["imported"].as_array().map_or(0, Vec::len);
|
||||
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
||||
let msg = match (n, failed) {
|
||||
let mut msg = match (n, failed) {
|
||||
(0, 0) => "No new presets".to_string(),
|
||||
(n, 0) => format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }),
|
||||
(n, f) => format!("Imported {n} preset{}, {f} file{} not readable", if n == 1 { "" } else { "s" }, if f == 1 { "" } else { "s" }),
|
||||
};
|
||||
// settings with no counterpart here (the other editor's profiles, masks…)
|
||||
let mut skipped: Vec<&str> = v["imported"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|i| i["unmapped"].as_array().into_iter().flatten())
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
skipped.sort_unstable();
|
||||
skipped.dedup();
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<&str> = skipped.iter().take(3).copied().collect();
|
||||
msg += &format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
|
||||
}
|
||||
app.toast(&ctx, msg);
|
||||
if n > 0 {
|
||||
app.ui.presets = true;
|
||||
|
|
|
|||
|
|
@ -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` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported |
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import`, `crates/engine/src/preset_import.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), .zip bundles, folders (folder → group), drag & drop; unmapped settings reported; no profile import (Adobe profile formats deliberately unsupported) |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode |
|
||||
|
|
@ -510,7 +510,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
|||
| MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | |
|
||||
| MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N |
|
||||
| MENU-FILE-NEWSMART | New Smart Album… | P1 | ✅ | `cmd:dialog.newSmartAlbum` | saves the current view (source + filter) |
|
||||
| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets only |
|
||||
| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets in every common format (see LR-IMP-PROFILES); profiles not |
|
||||
| MENU-FILE-EXPORT | Export… | P0 | ✅ | `cmd:dialog.export` | |
|
||||
| MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E |
|
||||
| MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | `cmd:app.export` (`preset`), `crates/ui-egui/src/menubar.rs` (Export with Preset) | built-ins, then user presets, then Custom… (the dialog); exports to the last folder |
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ Values pass through our control specs, so anything outside our slider ranges get
|
|||
|---|---|
|
||||
| Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. |
|
||||
| Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. |
|
||||
| Import | `preset.import {paths}`: files or folders (recursive), `.lcpreset` and `.xmp`. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. |
|
||||
| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. |
|
||||
| XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. |
|
||||
|
||||
LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`).
|
||||
|
|
|
|||
Loading…
Reference in New Issue