# Conflicts:
#	crates/engine/src/cmd/preset_files.rs
#	crates/engine/src/tests_import.rs
#	docs/parity-checklist.md
#	docs/xmp-interop.md
This commit is contained in:
Brandon Thomas
2026-10-15 00:50:08 -04:00
8 changed files with 336 additions and 6 deletions
+3
View File
@@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
imported.push(json!({"id": id, "name": display, "group": group}));
}
s.save_prefs()?;
// presets that name these profiles use them now
crate::profile_refs::refresh(s);
Ok(json!({"imported": imported, "failed": failed}))
}
@@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
lightcraft_pipeline::lut::unregister(&id);
s.save_prefs()?;
crate::profile_refs::refresh(s);
Ok(json!({"deleted": id}))
}),
]
+12 -2
View File
@@ -57,10 +57,20 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(g) = &group {
it.preset.group = g.clone();
}
// the profile it names (issue #1001): one the user imported, or ours for a basic one
let mut profile = Value::Null;
if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) {
let look = it.preset.settings["profile"]["look"].clone();
it.unmapped.retain(|u| u != "Look");
it.unmapped.extend(crate::profile_refs::report(&look, &r));
profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")});
}
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})),
Some(id) => {
imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile}))
}
None => skipped += 1,
}
}
@@ -108,7 +118,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\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
"{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; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups); a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
always,
import
),
+1
View File
@@ -59,6 +59,7 @@ mod pdf;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod profile_refs;
pub mod proof;
pub mod rename;
pub mod segment;
+31
View File
@@ -395,6 +395,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma
let mut values = crate::crs_masks::Values::new();
for (k, v) in fields {
let key = format!("crs:{k}");
// the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…)
if k == "Look"
&& let Lua::Table(_, look) = v
{
for (f, fv) in look {
let val = match fv {
Lua::Num(n) => format!("{n}"),
Lua::Str(s) => delocalize(s),
_ => continue,
};
props.insert(format!("crs:Look/crs:{f}"), vec![val]);
}
continue;
}
if crate::crs_masks::CONTAINERS.contains(&key.as_str()) {
values.insert(key.clone(), crate::crs_masks::from_lua(v));
}
@@ -497,6 +511,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri
o.remove("wb");
}
}
// the profile it names, resolved against the user's imported profiles by `preset.import`
if let Some(look) = crate::profile_refs::look_ref(props)
&& let Some(o) = settings.as_object_mut()
{
o.insert("profile".into(), serde_json::json!({"look": look}));
}
if settings.as_object().is_none_or(Map::is_empty) {
return None;
}
@@ -684,6 +704,17 @@ text]], z = ZSTR "loc" }"#,
assert_eq!(d.light.exposure, 0.35);
}
#[test]
fn lrtemplate_keeps_the_profile_it_names() {
let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2,
Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#;
let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap();
assert_eq!(
v[0].preset.settings["profile"],
serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}})
);
}
#[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 } } } }";
+205
View File
@@ -0,0 +1,205 @@
//! Profiles named by imported presets (issue #1001).
//!
//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group,
//! UUID, amount), and many presets get most of their look from that profile. On import the
//! reference is kept in the preset's settings as `profile.look`, and resolved against the
//! profiles the user has imported (by name, the group breaking ties), so the preset applies the
//! profile like the other app does. The six basic profiles of the other app map to our own basic
//! profiles; its other built-in profiles can't be used here and the import says so.
//!
//! References are resolved again whenever profiles are imported or deleted, so a pack works in
//! either order. Only the user's own files are read; no profile content ships with LightCraft.
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::lut_profiles::LutProfile;
use crate::crs::Props;
/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`):
/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]).
pub fn look_ref(props: &Props) -> Option<Value> {
let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty());
let name = field("Name")?;
// stored as a fraction (1 = 100 %), 0–2
let amount = field("Amount").and_then(|a| a.parse::<f64>().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0));
let mut look = json!({"name": name, "amount": amount});
if let Some(g) = field("Group") {
look["group"] = json!(g);
}
if let Some(u) = field("UUID") {
look["uuid"] = json!(u.to_ascii_lowercase());
}
Some(look)
}
/// What a preset's profile reference resolved to.
#[derive(Clone, Debug, PartialEq)]
pub enum Resolved {
/// A profile the user imported (`lut:…`).
Imported(String),
/// One of our built-in profiles standing in for the other app's basic profile of that name.
BuiltIn(String),
/// One of the other app's own creative or camera profiles: not available here.
OtherAppBuiltIn,
/// A third-party profile the user hasn't imported (yet).
NotImported,
}
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
}
/// The other app's basic profiles and ours that stand in for them.
const BASIC: &[(&str, &str)] = &[
("adobe color", "lc.color"),
("adobe neutral", "lc.neutral"),
("adobe vivid", "lc.vivid"),
("adobe landscape", "lc.landscape"),
("adobe portrait", "lc.portrait"),
("adobe monochrome", "lc.mono"),
];
/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered
/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`).
fn other_app_builtin(name: &str) -> bool {
let n = norm(name);
if n.starts_with("adobe ") || n.starts_with("camera ") {
return true;
}
["artistic ", "b&w ", "modern ", "vintage "]
.iter()
.any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit())))
}
/// Resolve `look` (a `profile.look` value) against the imported profiles.
pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved {
let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default());
let group = look.get("group").and_then(Value::as_str).map(norm);
if !name.is_empty() {
let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect();
let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first());
if let Some(p) = pick {
return Resolved::Imported(p.id.clone());
}
}
if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) {
return Resolved::BuiltIn((*ours).to_string());
}
if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported }
}
/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its
/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile)
/// and whether `settings` changed.
pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> {
let profile = settings.get_mut("profile")?.as_object_mut()?;
let look = profile.get("look")?.clone();
let r = resolve(&look, luts);
let before = (profile.get("id").cloned(), profile.get("amount").cloned());
match &r {
Resolved::Imported(id) | Resolved::BuiltIn(id) => {
profile.insert("id".into(), json!(id));
profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0)));
}
// the photo keeps its own profile
Resolved::OtherAppBuiltIn | Resolved::NotImported => {
profile.remove("id");
profile.remove("amount");
}
}
let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned());
Some((r, changed))
}
/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names).
pub fn report(look: &Value, r: &Resolved) -> Option<String> {
let name = look.get("name").and_then(Value::as_str).unwrap_or_default();
match r {
Resolved::Imported(_) | Resolved::BuiltIn(_) => None,
Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")),
Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")),
}
}
/// Resolve every user preset's profile reference again (after profiles were imported or deleted).
/// Only the presets that change are touched, so an unchanged list isn't saved again.
pub fn refresh(s: &mut Session) {
let luts = s.lut_profiles.clone();
let stale: Vec<usize> = s
.presets
.iter()
.enumerate()
.filter(|(_, p)| !p.builtin)
.filter(|(_, p)| {
let mut settings = p.settings.clone();
apply(&mut settings, &luts).is_some_and(|(_, changed)| changed)
})
.map(|(i, _)| i)
.collect();
if stale.is_empty() {
return;
}
let presets = &mut *s.presets;
for i in stale {
if let Some(p) = presets.get_mut(i) {
apply(&mut p.settings, &luts);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lut(id: &str, name: &str, group: &str) -> LutProfile {
LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() }
}
#[test]
fn resolves_by_name_with_the_group_breaking_ties() {
let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")];
let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0});
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into()));
let look = json!({"name": "kodak portra 400", "amount": 100.0});
assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group");
assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported);
}
#[test]
fn other_app_profiles_map_to_ours_or_are_reported() {
assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into()));
for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] {
assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}");
}
assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported);
// an imported profile wins over the mapping
assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into()));
}
#[test]
fn apply_sets_and_clears_the_profile() {
let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}});
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false)));
let luts = [lut("lut:mb1", "mb1 profile", "Imported")];
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true)));
assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0)));
assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved");
assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted");
assert!(s["profile"].get("id").is_none());
assert_eq!(apply(&mut json!({"light": {}}), &luts), None);
}
#[test]
fn look_ref_reads_name_group_uuid_and_amount() {
let x = 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">
<crs:Look><rdf:Description crs:Name="mb1 profile" crs:Amount="0.75" crs:UUID="0A1B2C">
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Erich McVey B&amp;W Profiles</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></crs:Look>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let props = lightcraft_meta::parse_xmp(x).unwrap().properties;
assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"})));
assert_eq!(look_ref(&Props::new()), None);
}
}
+68
View File
@@ -1680,3 +1680,71 @@ fn raw_sidecar_rating_and_crop_survive_naming_and_encoding_variants() {
let _ = std::fs::remove_dir_all(&src);
}
}
/// Issue #1001: a Lightroom preset that names a profile applies it once the user has imported a
/// profile of that name (before or after the preset), maps the basic Adobe profiles to ours, and
/// reports the ones it can't use.
#[test]
fn presets_apply_the_imported_profile_they_name() {
let src = temp_dir("lookref");
let preset = |file: &str, name: &str, look: &str| {
let x = format!(
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:PresetType="Normal" crs:Name="{name}" crs:UUID="{file}" crs:Exposure2012="0" crs:GrainAmount="20">
<crs:Look><rdf:Description crs:Name="{look}" crs:Amount="0.6" crs:UUID="1234ABCD">
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Film Pack</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></crs:Look>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
);
std::fs::write(src.join(format!("{file}.xmp")), x).unwrap();
};
preset("a1", "Portra Soft", "Portra 400");
preset("a2", "Mono", "Adobe Monochrome");
preset("a3", "Artsy", "Artistic 01");
let mut s = Session::with_demo();
let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let by_name = |r: &serde_json::Value, n: &str| r["imported"].as_array().unwrap().iter().find(|i| i["name"] == n).cloned().unwrap();
assert_eq!(by_name(&r, "Portra Soft")["unmapped"], json!(["Profile “Portra 400” (not imported yet)"]), "{r}");
assert_eq!(by_name(&r, "Mono")["profile"], json!({"name": "Adobe Monochrome", "id": "lc.mono"}));
assert_eq!(by_name(&r, "Artsy")["unmapped"], json!(["Profile “Artistic 01” (Lightroom's own)"]));
let photo = s.catalog.photos().next().unwrap().id;
s.execute("library.select", &json!({"ids": [photo.0]})).unwrap();
let pid = |s: &Session, n: &str| s.presets.iter().find(|p| p.name == n).unwrap().id.clone();
let apply = |s: &mut Session, n: &str| {
let id = pid(s, n);
s.execute("preset.apply", &json!({"id": id})).unwrap();
s.develop_of(photo).unwrap()
};
// not imported yet: only grain comes across, the photo keeps its profile
let d = apply(&mut s, "Portra Soft");
assert_eq!((d.profile.id.as_str(), d.grain.amount), ("lc.color", 20.0));
// the basic Adobe profiles are ours
assert_eq!(apply(&mut s, "Mono").profile.id, "lc.mono");
s.execute("develop.profile", &json!({"id": "lc.color", "amount": 100})).unwrap();
assert_eq!(apply(&mut s, "Artsy").profile.id, "lc.color", "Lightroom's own creative profiles aren't ours");
// importing the profile afterwards makes the preset use it, at the preset's amount
let mut cube = String::from("TITLE \"Portra 400\"\nLUT_3D_SIZE 2\n");
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
cube.push_str(&format!("{r} {g} {b}\n"));
}
}
}
std::fs::create_dir_all(src.join("profiles/Film Pack")).unwrap();
std::fs::write(src.join("profiles/Film Pack/portra.cube"), &cube).unwrap();
let r = s.execute("profile.import", &json!({"paths": [src.join("profiles").to_string_lossy()]})).unwrap();
let lut = r["imported"][0]["id"].as_str().unwrap().to_string();
let d = apply(&mut s, "Portra Soft");
assert_eq!((d.profile.id.as_str(), d.profile.amount, d.grain.amount), (lut.as_str(), 60.0, 20.0));
// a preset imported after the profile resolves straight away
preset("a4", "Portra Hard", "portra 400");
let r = s.execute("preset.import", &json!({"paths": [src.join("a4.xmp").to_string_lossy()]})).unwrap();
assert_eq!((r["imported"][0]["profile"]["id"].as_str(), r["imported"][0]["unmapped"].clone()), (Some(lut.as_str()), json!([])), "{r}");
// deleting the profile leaves the photo's own profile alone again
s.execute("profile.deleteImported", &json!({"id": lut})).unwrap();
s.execute("develop.profile", &json!({"id": "lc.vivid", "amount": 100})).unwrap();
assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid");
let _ = std::fs::remove_dir_all(&src);
}
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -147,7 +147,18 @@ Values pass through our control specs, so anything outside our slider ranges get
`crs:CameraProfile` is kept as the selected name in the photo's import warning and the active profile is left as it is: the name carries no rendering data and never selects an Adobe profile. Import reports such a profile as not applied (colours may differ from Lightroom), except Lightroom's default profiles (`Adobe Standard`, `Adobe Color`), which every sidecar names and which LightCraft's own starting look stands in for.
**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur,
**Profiles named by presets (`Look`, issue #1001).** A preset keeps the profile it names (`crs:Look`: `Name`, `Group`,
`UUID`, `Amount`; in `.lrtemplate` files the `Look` table) as `profile.look` in its settings. When the user has imported
a profile of that name (`profile.import`; the name compared ignoring case and repeated spaces, the group breaking
ties), applying the preset selects it at the look's amount (`Amount` 1 = 100 %). Lightroom's basic profiles map to our
own (`Adobe Color` → Color, `Adobe Neutral`, `Adobe Vivid`, `Adobe Landscape`, `Adobe Portrait`, `Adobe Monochrome`
→ Monochrome). Any other `Adobe …` / `Camera …` profile and the numbered creative ones (`Artistic 01`, `B&W 05`,
`Modern 10`, `Vintage 02`) can't be used here, and a third-party profile not imported yet leaves the photo's profile as it
is; import reports either per preset (`Profile “Name” (Lightroom's own)` / `(not imported yet)` in `unmapped`).
References are resolved again whenever profiles are imported or deleted, so a pack's profiles and presets can come in
either order. The look's embedded `Parameters` are not read.
**Not mapped:** camera profiles (`CameraProfile`; we have our own profile set), red eye, lens blur,
process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below.
## Spot removal
@@ -230,7 +241,7 @@ mask's kind: the next brush / linear / radial / … mask starts with it (`mask.t
|---|---|
| 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, 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. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. 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; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. 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. |
| 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. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. 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`, a profile named by `Look` that isn't imported, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. 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`).