Merge branch 'worktree-agent-af78b5603e6882a3f'

This commit is contained in:
Brandon Thomas
2026-10-01 01:06:29 -04:00
20 changed files with 1745 additions and 9 deletions
+3
View File
@@ -89,6 +89,9 @@ fn services(store: MemStore, ctx: egui::Context) -> Services {
open_picker(store.clone(), ctx.clone());
Vec::new() // files arrive asynchronously and are imported on a later frame
})),
// Preset files: browser pickers are asynchronous; not wired on the web yet.
pick_preset_files: None,
save_preset_file: None,
write: Some(Box::new(download)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
+18
View File
@@ -91,6 +91,24 @@ fn services() -> Services {
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title("Import Presets")
.add_filter("Presets", &["lcpreset", "xmp"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title("Export Presets")
.add_filter("LightCraft Preset", &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
write: Some(Box::new(|p: &str, b: &[u8]| {
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+4
View File
@@ -8,7 +8,9 @@ mod develop;
mod edit;
mod library;
mod masks;
mod preset_files;
mod query;
mod xmp;
use serde::Serialize;
use serde_json::Value;
@@ -101,6 +103,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(develop::specs());
v.extend(masks::specs());
v.extend(query::specs());
v.extend(xmp::specs());
v.extend(preset_files::specs());
v
})
}
+86
View File
@@ -0,0 +1,86 @@
//! Preset files: import `.lcpreset` / XMP presets, 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::{Result, Session};
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
}
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 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}));
}
}
}
Err(e) => failed.push(json!([f, e])),
}
}
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad("preset.export", "missing path"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(LCPRESET_EXT);
}
let ids = strings(p, "ids");
let group = str_param(p, "group");
let chosen: Vec<_> = s
.presets
.iter()
.filter(|x| match (&ids, group) {
(Some(ids), _) => ids.contains(&x.id),
(None, Some(g)) => x.group == g,
(None, None) => !x.builtin,
})
.cloned()
.collect();
if chosen.is_empty() {
return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"preset.import",
"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}",
always,
import
),
cmd!(
"preset.export",
"Export Presets",
[],
None,
"{path, ids?: [presetId], group?: name} (default: all user presets) → {path, count}; writes a .lcpreset JSON file",
always,
export
),
]
}
+93
View File
@@ -0,0 +1,93 @@
//! XMP sidecar commands: save/read metadata to/from files, sidecar preferences.
use lightcraft_catalog::Op;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, has_selection, str_param};
use crate::sidecar::SidecarNaming;
use crate::{Result, Session};
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let mut written = Vec::new();
let mut failed = Vec::new();
for id in s.targets(p) {
match s.save_sidecar(id) {
Ok(path) => written.push(path.display().to_string()),
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
}
}
Ok(json!({"written": written, "failed": failed}))
}
fn read(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut read = Vec::new();
let mut failed = Vec::new();
for id in s.targets(p) {
match s.read_sidecar_op(id) {
Ok(Some((op, from))) => {
ops.push(op);
read.push(json!({"id": id.0, "from": from.display().to_string()}));
}
Ok(None) => failed.push(json!({"id": id.0, "error": "no XMP sidecar or embedded XMP"})),
Err(e) => failed.push(json!({"id": id.0, "error": e.to_string()})),
}
}
if !ops.is_empty() {
s.commit("Read Metadata from File", Op::Batch { ops })?;
}
Ok(json!({"read": read, "failed": failed}))
}
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = p.get("autoWrite").and_then(Value::as_bool) {
s.xmp.auto_write = a;
}
if let Some(n) = str_param(p, "naming") {
s.xmp.naming = match n {
"stem" => SidecarNaming::Stem,
"full" => SidecarNaming::Full,
other => return Err(bad("library.xmpPreferences", format!("unknown naming `{other}` (stem|full)"))),
};
}
if p.as_object().is_some_and(|o| !o.is_empty()) {
s.save_prefs()?;
}
Ok(serde_json::to_value(s.xmp).unwrap_or_default())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"photo.saveMetadataToFile",
"Save Metadata to File",
["Photo"],
Some("Cmd+S"),
"{ids?} — writes each photo's XMP sidecar (metadata + develop settings) next to the original → {written: [path], failed}",
has_selection,
save
),
cmd!(
"photo.readMetadataFromFile",
"Read Metadata from File",
["Photo"],
None,
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed}",
has_selection,
read
),
cmd!(
"library.xmpPreferences",
"XMP Sidecar Preferences",
[],
None,
"{autoWrite?: bool, naming?: stem (IMG_1.xmp, default) | full (IMG_1.CR3.xmp)} → current preferences (saved with the library)",
always,
prefs
),
cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| {
let on = !s.xmp.auto_write;
prefs(s, &json!({"autoWrite": on}))
}),
]
}
+446
View File
@@ -0,0 +1,446 @@
//! Interchange: camera-raw-settings (`crs:`) develop fields from XMP → our develop settings.
//!
//! Other raw developers store their edits as `crs:` properties in XMP sidecars, in DNG-embedded
//! XMP and in XMP preset files. This module maps the commonly used, documented-by-observation
//! fields to a *partial* [`DevelopSettings`](lightcraft_develop::DevelopSettings) JSON object
//! (only the fields present in the packet), which is then merged like a preset. Our pipeline is
//! not theirs, so the result is a best-effort approximation of the look, not a pixel match.
//! The full table is in `docs/xmp-interop.md`.
//!
//! Implemented from the public XMP specification (ISO 16684-1) and black-box observation of what
//! each field means; no third-party code or preset content was used.
use std::collections::BTreeMap;
use lightcraft_develop::{MIXER_BANDS, Preset};
use serde_json::{Map, Value, json};
/// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]).
pub type Props = BTreeMap<String, Vec<String>>;
/// Band names as they appear in `crs:` field names, in our mixer order.
const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
/// `crs:` fields that describe the packet rather than an adjustment.
const NON_ADJUSTMENT: &[&str] = &[
"crs:Version",
"crs:ProcessVersion",
"crs:Name",
"crs:ShortName",
"crs:Group",
"crs:UUID",
"crs:PresetType",
"crs:SupportsAmount",
"crs:SupportsColor",
"crs:SupportsMonochrome",
"crs:SupportsHighDynamicRange",
"crs:SupportsNormalDynamicRange",
"crs:SupportsSceneReferred",
"crs:SupportsOutputReferred",
"crs:CameraModelRestriction",
"crs:Copyright",
"crs:ContactInfo",
"crs:HasSettings",
"crs:AlreadyApplied",
"crs:RawFileName",
"crs:HasCrop",
];
/// True if the packet carries any `crs:` adjustment (not just bookkeeping fields).
pub fn has_adjustments(props: &Props) -> bool {
if props.get("crs:AlreadyApplied").and_then(|v| v.first()).is_some_and(|s| s.eq_ignore_ascii_case("true")) {
// The pixels already contain these settings (e.g. an exported/rendered file).
return false;
}
props.keys().any(|k| k.starts_with("crs:") && !NON_ADJUSTMENT.contains(&k.split('/').next().unwrap_or(k)))
}
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
}
fn num(props: &Props, k: &str) -> Option<f64> {
let s = first(props, k)?;
let s = s.strip_prefix('+').unwrap_or(s);
if let Some((n, d)) = s.split_once('/') {
let (n, d) = (n.trim().parse::<f64>().ok()?, d.trim().parse::<f64>().ok()?);
return (d != 0.0).then_some(n / d).filter(|v| v.is_finite());
}
s.parse::<f64>().ok().filter(|v| v.is_finite())
}
fn boolean(props: &Props, k: &str) -> Option<bool> {
first(props, k).map(|s| s.eq_ignore_ascii_case("true") || s == "1")
}
/// Insert `v` at the dotted `path` of a JSON object, creating intermediate objects.
fn put(out: &mut Value, path: &str, v: Value) {
let mut cur = out;
let mut parts = path.split('.').peekable();
while let Some(p) = parts.next() {
let Value::Object(m) = cur else { return };
if parts.peek().is_none() {
m.insert(p.to_string(), v);
return;
}
cur = m.entry(p.to_string()).or_insert_with(|| Value::Object(Map::new()));
}
}
/// Relative temperature (−100..100, used for rendered files) → Kelvin, matching the UI's scale
/// (a mired shift around 6500 K).
pub fn rel_to_kelvin(r: f64) -> f64 {
1e6 / (1e6 / 6500.0 - r.clamp(-100.0, 100.0) * 0.8)
}
/// Parse a point curve stored as an `rdf:Seq` of `"x, y"` strings in 0..255.
fn curve(props: &Props, k: &str) -> Option<Value> {
let items = props.get(k)?;
let mut pts = Vec::new();
for it in items {
let (x, y) = it.split_once(',')?;
let (x, y) = (x.trim().parse::<f64>().ok()?, y.trim().parse::<f64>().ok()?);
pts.push(json!({"x": (x / 255.0).clamp(0.0, 1.0), "y": (y / 255.0).clamp(0.0, 1.0)}));
}
if pts.len() < 2 {
return None;
}
// A straight 0,0 → 255,255 line is the identity: store it as "no curve".
let identity = pts.len() == 2 && items.iter().all(|it| it.split(',').map(|v| v.trim()).collect::<Vec<_>>().windows(2).all(|w| w[0] == w[1]));
Some(if identity { json!([]) } else { Value::Array(pts) })
}
/// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object.
///
/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the
/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever
/// is present, absolute first.
pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
let mut out = Value::Object(Map::new());
let o = &mut out;
let n = |o: &mut Value, crs: &str, path: &str| {
if let Some(v) = num(props, &format!("crs:{crs}")) {
put(o, path, json!(v));
}
};
// ---- Light (process version 2012+ names)
n(o, "Exposure2012", "light.exposure");
n(o, "Contrast2012", "light.contrast");
n(o, "Highlights2012", "light.highlights");
n(o, "Shadows2012", "light.shadows");
n(o, "Whites2012", "light.whites");
n(o, "Blacks2012", "light.blacks");
// ---- Presence
n(o, "Texture", "effects.texture");
n(o, "Clarity2012", "effects.clarity");
n(o, "Dehaze", "effects.dehaze");
n(o, "Vibrance", "color.vibrance");
n(o, "Saturation", "color.saturation");
// ---- White balance
let mode = first(props, "crs:WhiteBalance").map(|m| match m.to_ascii_lowercase().replace(' ', "").as_str() {
"asshot" => "asShot",
"auto" => "auto",
"daylight" => "daylight",
"cloudy" => "cloudy",
"shade" => "shade",
"tungsten" => "tungsten",
"fluorescent" => "fluorescent",
"flash" => "flash",
_ => "custom",
});
let abs = num(props, "crs:Temperature").map(|t| (t, num(props, "crs:Tint")));
let rel = num(props, "crs:IncrementalTemperature")
.or(num(props, "crs:IncrementalTint").map(|_| 0.0))
.map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint")));
let wb = match raw {
Some(true) => abs,
Some(false) => rel.or(abs),
None => abs.or(rel),
};
match (mode, wb) {
(Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)),
(m, Some((t, tint))) => {
put(o, "wb.mode", json!(m.unwrap_or("custom")));
put(o, "wb.temp", json!(t));
if let Some(tint) = tint {
put(o, "wb.tint", json!(tint));
}
}
(Some(m), None) => put(o, "wb.mode", json!(m)),
(None, None) => {}
}
// ---- Treatment + B&W mix
if let Some(bw) = boolean(props, "crs:ConvertToGrayscale") {
put(o, "treatment", json!(if bw { "bw" } else { "color" }));
}
for (band, crs) in MIXER_BANDS.iter().zip(CRS_BANDS) {
n(o, &format!("HueAdjustment{crs}"), &format!("mixer.{band}.hue"));
n(o, &format!("SaturationAdjustment{crs}"), &format!("mixer.{band}.sat"));
n(o, &format!("LuminanceAdjustment{crs}"), &format!("mixer.{band}.lum"));
n(o, &format!("GrayMixer{crs}"), &format!("bw_mix.{band}"));
}
// ---- Tone curve: parametric regions + point curves
n(o, "ParametricShadows", "curve.shadows");
n(o, "ParametricDarks", "curve.darks");
n(o, "ParametricLights", "curve.lights");
n(o, "ParametricHighlights", "curve.highlights");
n(o, "ParametricShadowSplit", "curve.split_shadows");
n(o, "ParametricMidtoneSplit", "curve.split_mid");
n(o, "ParametricHighlightSplit", "curve.split_highlights");
for (crs, ch) in
[("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")]
{
if let Some(c) = curve(props, &format!("crs:{crs}")) {
put(o, &format!("curve.{ch}"), c);
}
}
// ---- Color grading (split toning fields are shared with the older split-toning panel)
n(o, "SplitToningShadowHue", "grading.shadows.hue");
n(o, "SplitToningShadowSaturation", "grading.shadows.sat");
n(o, "ColorGradeShadowLum", "grading.shadows.lum");
n(o, "SplitToningHighlightHue", "grading.highlights.hue");
n(o, "SplitToningHighlightSaturation", "grading.highlights.sat");
n(o, "ColorGradeHighlightLum", "grading.highlights.lum");
n(o, "ColorGradeMidtoneHue", "grading.midtones.hue");
n(o, "ColorGradeMidtoneSat", "grading.midtones.sat");
n(o, "ColorGradeMidtoneLum", "grading.midtones.lum");
n(o, "ColorGradeGlobalHue", "grading.global.hue");
n(o, "ColorGradeGlobalSat", "grading.global.sat");
n(o, "ColorGradeGlobalLum", "grading.global.lum");
n(o, "ColorGradeBlending", "grading.blending");
n(o, "SplitToningBalance", "grading.balance");
// ---- Detail
n(o, "Sharpness", "detail.sharpen_amount");
n(o, "SharpenRadius", "detail.sharpen_radius");
n(o, "SharpenDetail", "detail.sharpen_detail");
n(o, "SharpenEdgeMasking", "detail.sharpen_masking");
n(o, "LuminanceSmoothing", "detail.nr_luminance");
n(o, "LuminanceNoiseReductionDetail", "detail.nr_detail");
n(o, "LuminanceNoiseReductionContrast", "detail.nr_contrast");
n(o, "ColorNoiseReduction", "detail.nr_color");
n(o, "ColorNoiseReductionDetail", "detail.nr_color_detail");
n(o, "ColorNoiseReductionSmoothness", "detail.nr_color_smoothness");
// ---- Effects: post-crop vignette + grain
n(o, "PostCropVignetteAmount", "vignette.amount");
n(o, "PostCropVignetteMidpoint", "vignette.midpoint");
n(o, "PostCropVignetteRoundness", "vignette.roundness");
n(o, "PostCropVignetteFeather", "vignette.feather");
n(o, "PostCropVignetteHighlightContrast", "vignette.highlights");
if let Some(st) = num(props, "crs:PostCropVignetteStyle") {
let style = match st as i64 {
2 => "colorPriority",
3 => "paintOverlay",
_ => "highlightPriority",
};
put(o, "vignette.style", json!(style));
}
n(o, "GrainAmount", "grain.amount");
n(o, "GrainSize", "grain.size");
n(o, "GrainFrequency", "grain.roughness");
// ---- Optics (manual corrections; lens profiles are ours, only the switch carries over)
if let Some(b) = boolean(props, "crs:LensProfileEnable") {
put(o, "optics.lens_profile", json!(b));
}
if let Some(b) = boolean(props, "crs:AutoLateralCA") {
put(o, "optics.remove_ca", json!(b));
}
n(o, "LensManualDistortionAmount", "optics.distortion");
n(o, "VignetteAmount", "optics.vignetting");
n(o, "VignetteMidpoint", "optics.vignetting_midpoint");
n(o, "DefringePurpleAmount", "optics.defringe_purple_amount");
n(o, "DefringePurpleHueLo", "optics.defringe_purple_hue_lo");
n(o, "DefringePurpleHueHi", "optics.defringe_purple_hue_hi");
n(o, "DefringeGreenAmount", "optics.defringe_green_amount");
n(o, "DefringeGreenHueLo", "optics.defringe_green_hue_lo");
n(o, "DefringeGreenHueHi", "optics.defringe_green_hue_hi");
// ---- Geometry
n(o, "PerspectiveVertical", "geometry.vertical");
n(o, "PerspectiveHorizontal", "geometry.horizontal");
n(o, "PerspectiveRotate", "geometry.rotate");
n(o, "PerspectiveScale", "geometry.scale");
n(o, "PerspectiveAspect", "geometry.aspect");
n(o, "PerspectiveX", "geometry.offset_x");
n(o, "PerspectiveY", "geometry.offset_y");
if let Some(u) = num(props, "crs:PerspectiveUpright") {
let mode = match u as i64 {
1 => "auto",
2 => "level",
3 => "vertical",
4 => "full",
5 => "guided",
_ => "off",
};
put(o, "geometry.upright", json!(mode));
}
// ---- Crop (normalized edges of the unrotated image + straighten angle)
match boolean(props, "crs:HasCrop") {
Some(true) => {
let e = |k: &str, d: f64| num(props, &format!("crs:Crop{k}")).unwrap_or(d).clamp(0.0, 1.0);
let (l, t, r, b) = (e("Left", 0.0), e("Top", 0.0), e("Right", 1.0), e("Bottom", 1.0));
if r > l && b > t {
put(
o,
"crop.geometry",
json!({"rect": {"x0": l, "y0": t, "x1": r, "y1": b}, "angle": num(props, "crs:CropAngle").unwrap_or(0.0).clamp(-45.0, 45.0)}),
);
}
}
Some(false) => put(o, "crop.geometry", json!({"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0})),
None => {}
}
out
}
/// 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> {
let d = lightcraft_meta::parse_xmp(xmp).ok()?;
let props = &d.properties;
let settings = to_partial(props, None);
if settings.as_object().is_none_or(Map::is_empty) {
return None;
}
let name = first(props, "crs:Name").unwrap_or(fallback_name).to_string();
let group = first(props, "crs:Group").unwrap_or("Imported Presets").to_string();
let uuid = first(props, "crs:UUID").map(|u| u.to_ascii_lowercase());
let id = format!("user.xmp.{}", uuid.unwrap_or_else(|| crate::presets::slug(&format!("{group}-{name}"))));
Some(Preset { id, name, group, settings, favorite: false, builtin: false })
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial};
/// A hand-written sidecar in attribute form (as many tools write it).
const SIDECAR: &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:Version="99.0" crs:ProcessVersion="11.0" crs:WhiteBalance="Custom" crs:Temperature="5150" crs:Tint="+12"
crs:Exposure2012="+0.65" crs:Contrast2012="-14" crs:Highlights2012="-58" crs:Shadows2012="+41"
crs:Whites2012="+9" crs:Blacks2012="-17" crs:Texture="+6" crs:Clarity2012="+11" crs:Dehaze="+4"
crs:Vibrance="+19" crs:Saturation="-3" crs:HueAdjustmentOrange="-7" crs:SaturationAdjustmentBlue="-22"
crs:LuminanceAdjustmentAqua="+13" crs:ParametricShadows="+5" crs:ParametricHighlights="-8"
crs:ParametricMidtoneSplit="55" crs:SplitToningShadowHue="210" crs:SplitToningShadowSaturation="14"
crs:SplitToningHighlightHue="40" crs:SplitToningHighlightSaturation="9" crs:SplitToningBalance="+20"
crs:ColorGradeMidtoneHue="120" crs:ColorGradeMidtoneSat="6" crs:ColorGradeBlending="70"
crs:Sharpness="55" crs:SharpenRadius="+1.2" crs:SharpenDetail="30" crs:SharpenEdgeMasking="12"
crs:LuminanceSmoothing="18" crs:ColorNoiseReduction="25" crs:PostCropVignetteAmount="-21"
crs:PostCropVignetteStyle="2" crs:GrainAmount="15" crs:GrainSize="30" crs:GrainFrequency="60"
crs:PerspectiveUpright="2" crs:PerspectiveVertical="-10" crs:AutoLateralCA="1" crs:ConvertToGrayscale="False"
crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.05" crs:CropBottom="0.9" crs:CropRight="0.8" crs:CropAngle="1.5">
<crs:ToneCurvePV2012>
<rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>64, 52</rdf:li><rdf:li>192, 205</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq>
</crs:ToneCurvePV2012>
<crs:ToneCurvePV2012Red>
<rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq>
</crs:ToneCurvePV2012Red>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
fn props(x: &str) -> Props {
lightcraft_meta::parse_xmp(x).unwrap().properties
}
#[test]
fn maps_common_fields() {
let p = props(SIDECAR);
assert!(has_adjustments(&p));
let partial = to_partial(&p, Some(true));
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
assert_eq!(s.wb.mode, WbMode::Custom);
assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
assert_eq!(s.light.exposure, 0.65);
assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0));
assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0));
assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0));
assert_eq!(s.mixer.orange.hue, -7.0);
assert_eq!(s.mixer.blue.sat, -22.0);
assert_eq!(s.mixer.aqua.lum, 13.0);
assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0));
assert_eq!(s.curve.master.len(), 4);
assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9);
assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve");
assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0));
assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0));
assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0));
assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0));
assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0));
assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0));
assert_eq!(s.vignette.amount, -21.0);
assert_eq!(s.vignette.style, VignetteStyle::ColorPriority);
assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0));
assert_eq!(s.geometry.upright, Upright::Level);
assert_eq!(s.geometry.vertical, -10.0);
assert!(s.optics.remove_ca);
assert_eq!(s.treatment, lightcraft_develop::Treatment::Color);
let c = s.crop.geometry;
assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5));
}
#[test]
fn only_present_fields_are_in_the_partial() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
xmlns:c="http://ns.adobe.com/camera-raw-settings/1.0/" c:Vibrance="+30" c:GrainAmount="10"/></rdf:RDF>"#;
let partial = to_partial(&props(x), None);
assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
}
#[test]
fn element_form_bw_and_incremental_wb() {
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:ConvertToGrayscale>True</crs:ConvertToGrayscale>
<crs:GrayMixerBlue>-35</crs:GrayMixerBlue>
<crs:IncrementalTemperature>25</crs:IncrementalTemperature>
<crs:IncrementalTint>-6</crs:IncrementalTint>
<crs:Temperature>4800</crs:Temperature>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let p = props(x);
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0);
assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw);
assert_eq!(rendered.bw_mix.blue, -35.0);
assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0);
assert_eq!(rendered.wb.tint, -6.0);
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(true)), 1.0);
assert_eq!(raw.wb.temp, 4800.0);
}
#[test]
fn named_wb_and_already_applied() {
let x = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Daylight" crs:Temperature="5500" crs:Tint="10" crs:AlreadyApplied="True"/>"#;
let p = props(x);
assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}}));
assert!(!has_adjustments(&p));
let bookkeeping = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Version="1" crs:HasSettings="True"/>"#;
assert!(!has_adjustments(&props(bookkeeping)));
}
#[test]
fn xmp_preset_with_name_and_group() {
// Written for this test: a minimal preset-shaped packet with localized name/group.
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:PresetType="Normal" crs:UUID="0123ABCD" crs:Contrast2012="+25" crs:SupportsAmount="True">
<crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Punchy Test</rdf:li></rdf:Alt></crs:Name>
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">My Looks</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let p = preset_from_xmp(x, "file").unwrap();
assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd"));
assert_eq!(p.settings, json!({"light": {"contrast": 25.0}}));
assert!(!p.builtin);
assert!(preset_from_xmp("<x/>", "f").is_none());
}
}
+2
View File
@@ -82,6 +82,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
meta,
as_shot_wb,
content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp,
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
@@ -109,6 +110,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
meta,
as_shot_wb: None,
content_hash,
xmp: None,
})
}
+19 -1
View File
@@ -8,7 +8,9 @@
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
//! 6. Commit all new photos as one undoable op.
//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for
//! metadata and develop settings are restored (see [`crate::sidecar`]).
//! 7. Commit all new photos as one undoable op.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -51,6 +53,8 @@ pub struct ImportReport {
pub failed: Vec<(String, String)>,
/// Files found after expanding folders.
pub scanned: usize,
/// Photos whose metadata/develop settings were read from an XMP sidecar (or embedded XMP).
pub sidecars: usize,
}
pub fn is_supported(path: &Path) -> bool {
@@ -219,6 +223,20 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
if let Some((t, tint)) = info.as_shot_wb {
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
}
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
if let Some(x) = packet {
match crate::sidecar::parse_sidecar(&x, raw) {
Ok(sc) if sc != crate::sidecar::SidecarData::default() => {
crate::sidecar::merge_into(&mut p, &sc, &now);
report.sidecars += 1;
}
Ok(_) => {}
Err(e) => log::warn!("import {path}: XMP: {e}"),
}
}
report.imported.push(id.0);
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
+11
View File
@@ -11,6 +11,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod crs;
pub mod demo;
pub mod export;
pub mod files;
@@ -18,6 +19,7 @@ pub mod import;
pub mod library;
pub mod media;
pub mod presets;
pub mod sidecar;
mod view;
use std::sync::Arc;
@@ -90,6 +92,8 @@ pub struct Session {
pub active_mask: Option<u32>,
/// The persistent library this session writes to (`None` = in-memory only).
pub library: Option<library::Library>,
/// XMP sidecar preferences (persisted with the library).
pub xmp: sidecar::XmpPrefs,
}
impl Default for Session {
@@ -121,6 +125,7 @@ impl Session {
depth: 0,
active_mask: None,
library: None,
xmp: sidecar::XmpPrefs::default(),
}
}
@@ -137,6 +142,7 @@ impl Session {
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
let log_start = self.pending_log.len();
self.depth += 1;
let r = (spec.run)(self, params);
self.depth -= 1;
@@ -146,6 +152,9 @@ impl Session {
self.journal.drain(..1000);
}
}
if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
self.auto_write_sidecars(&self.pending_log[log_start..]);
}
if self.depth == 0 && self.library.is_some() {
// Make the command durable before reporting success (on failure the ops stay pending,
// are retried after the next command, and `library.info` reports the error).
@@ -311,3 +320,5 @@ mod tests;
mod tests_import;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_xmp;
+17
View File
@@ -5,6 +5,7 @@
//! LightCraft Library/
//! catalog.snap catalog.log (lightcraft-catalog journal)
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
//! prefs.json (library preferences: XMP sidecars)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! Originals/ (photos imported with "copy into library")
//! ```
@@ -72,6 +73,12 @@ impl Library {
}
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct PrefsFile {
xmp: crate::sidecar::XmpPrefs,
}
fn presets_json(presets: &[Preset]) -> String {
let f = PresetsFile {
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
@@ -115,6 +122,9 @@ impl Session {
}
}
}
// preferences
self.xmp =
std::fs::read(dir.join("prefs.json")).ok().and_then(|b| serde_json::from_slice::<PrefsFile>(&b).ok()).map(|p| p.xmp).unwrap_or_default();
// view state
if let Ok(bytes) = std::fs::read(dir.join("view.json"))
&& let Ok(v) = serde_json::from_slice::<ViewFile>(&bytes)
@@ -189,6 +199,13 @@ impl Session {
Ok(())
}
/// Save the library preferences (no-op for in-memory sessions).
pub fn save_prefs(&self) -> Result<()> {
let Some(lib) = &self.library else { return Ok(()) };
let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp }).unwrap_or_default();
write_atomic(&lib.dir, "prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
}
/// Compact the log into a snapshot now.
pub fn compact_library(&mut self) -> Result<()> {
self.persist()?;
+2
View File
@@ -301,6 +301,8 @@ pub struct ProbeInfo {
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection.
pub content_hash: Option<String>,
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
pub xmp: Option<String>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
+225 -3
View File
@@ -1,8 +1,140 @@
//! Built-in presets and profiles. All values are our own (no third-party preset content).
//! Built-in presets and profiles (all values are our own; no third-party preset content), and
//! preset files: our `.lcpreset` JSON (import + export, groups preserved) and XMP presets
//! (`crs:` fields, read only — see [`crate::crs`]).
use std::path::Path;
use lightcraft_develop::Preset;
use serde::Serialize;
use serde_json::json;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::Session;
/// File extension of LightCraft preset files.
pub const LCPRESET_EXT: &str = "lcpreset";
/// The `format` tag of a `.lcpreset` file.
pub const LCPRESET_FORMAT: &str = "lightcraft.preset";
/// A `.lcpreset` file: one or more presets with their groups.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PresetFile {
pub format: String,
pub version: u32,
pub presets: Vec<Preset>,
}
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
pub fn slug(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
}
let out = out.trim_end_matches('-').to_string();
if out.is_empty() { "preset".into() } else { out }
}
/// Serialise presets as a `.lcpreset` file (favourite/built-in flags are not exported).
pub fn to_lcpreset(presets: &[Preset]) -> String {
let presets = presets.iter().map(|p| Preset { favorite: false, builtin: false, ..p.clone() }).collect();
serde_json::to_string_pretty(&PresetFile { format: LCPRESET_FORMAT.into(), version: 1, presets }).unwrap_or_default()
}
/// Read a preset file by name: `.lcpreset` (a [`PresetFile`], a bare preset object or an array of
/// presets) or `.xmp` (a `crs:` preset).
pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String> {
let stem = Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
let ext = Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let text = String::from_utf8_lossy(bytes);
let text = text.trim_start_matches('\u{feff}');
if ext == "xmp" || (ext != LCPRESET_EXT && text.trim_start().starts_with('<')) {
return crate::crs::preset_from_xmp(text, &stem).map(|p| vec![p]).ok_or_else(|| "no develop settings in this XMP file".into());
}
let v: Value = serde_json::from_str(text).map_err(|e| format!("not a preset file: {e}"))?;
let list = match &v {
Value::Object(o) if o.contains_key("presets") => {
let f: PresetFile = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
if f.format != LCPRESET_FORMAT {
return Err(format!("unknown preset format `{}`", f.format));
}
f.presets
}
Value::Array(_) => serde_json::from_value(v).map_err(|e| e.to_string())?,
_ => vec![serde_json::from_value(v).map_err(|e| e.to_string())?],
};
let mut out = Vec::new();
for mut p in list {
if !p.settings.is_object() {
return Err(format!("preset `{}` has no settings object", p.name));
}
p.builtin = false;
p.favorite = false;
if p.name.trim().is_empty() {
p.name = stem.clone();
}
if p.group.trim().is_empty() {
p.group = "Imported Presets".into();
}
out.push(p);
}
Ok(out)
}
/// Expand files/folders (recursively) into preset files (`.lcpreset`, `.xmp`).
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
fn walk(p: &Path, out: &mut Vec<String>, top: bool) {
if p.is_dir() {
let Ok(rd) = std::fs::read_dir(p) else { return };
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
if !c.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
walk(&c, out, false);
}
}
} else {
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if top || ext == LCPRESET_EXT || ext == "xmp" {
out.push(p.to_string_lossy().to_string());
}
}
}
let mut out = Vec::new();
for p in paths {
walk(Path::new(p), &mut out, true);
}
out
}
impl Session {
/// Add imported presets. Identical presets (same name, group and settings) already present are
/// skipped; id clashes get a fresh id. Returns the ids added.
pub fn add_presets(&mut self, presets: Vec<Preset>) -> Vec<String> {
let mut added = Vec::new();
for mut p in presets {
if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
continue;
}
if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
let mut id = base.clone();
let mut n = 2;
while self.presets.iter().any(|q| q.id == id) {
id = format!("{base}-{n}");
n += 1;
}
p.id = id;
}
p.builtin = false;
added.push(p.id.clone());
self.presets.push(p);
}
added
}
}
fn p(group: &str, id: &str, name: &str, settings: serde_json::Value) -> Preset {
Preset { id: format!("lc.{id}"), name: name.into(), group: group.into(), settings, favorite: false, builtin: true }
@@ -147,4 +279,94 @@ mod tests {
assert_ne!(out, base, "{} had no effect", pr.id);
}
}
#[test]
fn slugs() {
assert_eq!(slug("Warm & Soft!"), "warm-soft");
assert_eq!(slug(" "), "preset");
assert_eq!(slug("B&W/Film 2"), "b-w-film-2");
}
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-presets-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn lcpreset_export_import_roundtrip_keeps_groups() {
let d = dir("rt");
let mut s = Session::with_demo();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.6})).unwrap();
s.execute("preset.create", &json!({"name": "Bright", "group": "Travel"})).unwrap();
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 30})).unwrap();
s.execute("preset.create", &json!({"name": "Crisp", "group": "Street"})).unwrap();
let mine: Vec<Preset> = s.presets.iter().filter(|p| !p.builtin).cloned().collect();
assert_eq!(mine.len(), 2);
let path = d.join("mine");
let r = s.execute("preset.export", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(r["count"], 2);
let file = d.join("mine.lcpreset");
assert!(file.is_file());
let one = d.join("travel.lcpreset");
s.execute("preset.export", &json!({"path": one.to_string_lossy(), "group": "Travel"})).unwrap();
assert!(s.execute("preset.export", &json!({"path": one.to_string_lossy(), "group": "Nope"})).is_err());
// a fresh session imports both files from the folder; the duplicate is skipped
let mut t = Session::new();
let r = t.execute("preset.import", &json!({"paths": [d.to_string_lossy()]})).unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(r["skipped"], 1, "{r}");
for p in &mine {
let q = t.presets.iter().find(|q| q.name == p.name).unwrap();
assert_eq!((&q.group, &q.settings, q.builtin), (&p.group, &p.settings, false));
}
// importing into the session that has them: everything is skipped, nothing duplicated
let n = s.presets.len();
let r = s.execute("preset.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
assert_eq!((r["skipped"].as_u64(), s.presets.len()), (Some(2), n));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn id_clashes_get_fresh_ids_and_bad_files_are_reported() {
let d = dir("clash");
let builtin = builtin().remove(0);
let mut clash = builtin.clone();
clash.settings = json!({"light": {"exposure": 1.0}});
std::fs::write(d.join("a.lcpreset"), serde_json::to_string(&clash).unwrap()).unwrap();
std::fs::write(d.join("b.lcpreset"), "{not json").unwrap();
std::fs::write(d.join("c.lcpreset"), r#"{"format": "other", "version": 1, "presets": []}"#).unwrap();
std::fs::write(d.join("notes.txt"), "ignored inside folders").unwrap();
let mut s = Session::new();
let r = s.execute("preset.import", &json!({"paths": [d.to_string_lossy()]})).unwrap();
assert_eq!(r["failed"].as_array().unwrap().len(), 2, "{r}");
let id = r["imported"][0]["id"].as_str().unwrap();
assert_ne!(id, builtin.id);
assert!(id.starts_with("user."));
assert!(s.presets.iter().find(|p| p.id == builtin.id).unwrap().builtin, "built-in untouched");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn xmp_preset_file_imports_and_applies() {
let d = dir("xmp");
// Hand-written for this test (not a third-party preset).
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:PresetType="Normal" crs:Exposure2012="+0.30" crs:Shadows2012="+20" crs:GrainAmount="12">
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Test Group</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
std::fs::write(d.join("Soft Lift.xmp"), x).unwrap();
let mut s = Session::with_demo();
let r = s.execute("preset.import", &json!({"paths": [d.join("Soft Lift.xmp").to_string_lossy()]})).unwrap();
let id = r["imported"][0]["id"].as_str().unwrap().to_string();
assert_eq!(r["imported"][0]["name"], "Soft Lift", "file name when crs:Name is missing");
assert_eq!(r["imported"][0]["group"], "Test Group");
s.execute("preset.apply", &json!({"id": id})).unwrap();
let dv = s.develop_of(s.active().unwrap()).unwrap();
assert_eq!((dv.light.exposure, dv.light.shadows, dv.grain.amount), (0.3, 20.0, 12.0));
let _ = std::fs::remove_dir_all(&d);
}
}
+369
View File
@@ -0,0 +1,369 @@
//! XMP sidecars: `<stem>.xmp` (default) or `<file>.xmp` next to the original.
//!
//! **Write** ([`Session::save_sidecar`], `photo.saveMetadataToFile`, or automatically after every
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
//!
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::{ColorLabel, Flag, MediaKind, Op, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{EngineError, Result, Session};
/// Sidecar file naming.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SidecarNaming {
/// `IMG_0001.xmp` (the common convention; shared by `IMG_0001.CR3` and `IMG_0001.JPG`).
#[default]
Stem,
/// `IMG_0001.CR3.xmp` (unambiguous when raw+JPEG pairs live side by side).
Full,
}
/// Library preferences for XMP sidecars.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct XmpPrefs {
/// Write the sidecar after every change to a photo's metadata or develop settings.
pub auto_write: bool,
pub naming: SidecarNaming,
}
/// The sidecar path for an original, by naming convention.
pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
match naming {
SidecarNaming::Stem => Path::new(original).with_extension("xmp"),
SidecarNaming::Full => PathBuf::from(format!("{original}.xmp")),
}
}
/// An existing sidecar for `original`: the preferred naming first, then the other one (also
/// accepting an upper-case `.XMP`).
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
[naming, other]
.into_iter()
.flat_map(|n| {
let p = sidecar_path(original, n);
let upper = p.with_extension("XMP");
[p, upper]
})
.find(|p| p.is_file())
}
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SidecarData {
pub rating: Option<u8>,
pub flag: Option<Flag>,
pub label: Option<Option<ColorLabel>>,
pub title: Option<String>,
pub caption: Option<String>,
pub copyright: Option<String>,
pub creator: Option<String>,
pub location: Option<String>,
pub keywords: Option<Vec<String>>,
pub develop: Option<DevelopPatch>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DevelopPatch {
/// Our own complete settings (`lc:settings`).
Full(Box<DevelopSettings>),
/// Mapped `crs:` fields (partial JSON, merged like a preset).
Partial(Value),
}
/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`.
pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, String> {
let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?;
let m = &d.metadata;
let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned();
let mut out = SidecarData {
title: m.title.clone(),
caption: m.caption.clone(),
copyright: m.copyright.clone(),
creator: m.artist.clone(),
location: lc("location"),
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
..Default::default()
};
if let Some(r) = m.rating {
if r < 0 {
out.flag = Some(Flag::Reject);
out.rating = Some(0);
} else {
out.rating = Some(r.clamp(0, 5) as u8);
}
}
if let Some(f) = lc("flag").and_then(|f| Flag::parse(&f)) {
out.flag = Some(f);
}
if let Some(l) = &m.label {
out.label = Some(ColorLabel::parse(l.trim()));
}
let full = d.lc_settings.as_deref().and_then(|j| serde_json::from_str::<Value>(j).ok()).and_then(|v| DevelopSettings::from_json(&v).ok());
out.develop = match full {
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
None if crate::crs::has_adjustments(&d.properties) => Some(DevelopPatch::Partial(crate::crs::to_partial(&d.properties, Some(raw)))),
None => None,
};
Ok(out)
}
/// Apply sidecar data to a photo record (the sidecar wins). Returns true if develop changed.
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
if let Some(r) = sc.rating {
p.rating = r;
}
if let Some(f) = sc.flag {
p.flag = f;
}
if let Some(l) = sc.label {
p.label = l;
}
let m = &mut p.meta;
for (src, dst) in [
(&sc.title, &mut m.title),
(&sc.caption, &mut m.caption),
(&sc.copyright, &mut m.copyright),
(&sc.creator, &mut m.creator),
(&sc.location, &mut m.location),
] {
if let Some(v) = src {
*dst = v.clone();
}
}
if let Some(k) = &sc.keywords {
m.keywords = k.clone();
}
let develop = match &sc.develop {
Some(DevelopPatch::Full(s)) => (**s).clone(),
Some(DevelopPatch::Partial(v)) => lightcraft_develop::apply_partial(&p.develop, v, 1.0),
None => return false,
};
if develop == *p.develop {
return false;
}
p.develop = Arc::new(develop);
p.edited = Some(now.to_string());
true
}
fn label_name(l: ColorLabel) -> String {
format!("{l:?}")
}
/// The sidecar packet for a photo.
pub fn sidecar_packet(p: &Photo) -> String {
let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
let meta = lightcraft_meta::Metadata {
software: Some("LightCraft".into()),
title: nz(&p.meta.title),
caption: nz(&p.meta.caption),
copyright: nz(&p.meta.copyright),
artist: nz(&p.meta.creator),
keywords: p.meta.keywords.clone(),
rating: Some(p.rating.min(5) as i8),
label: p.label.map(label_name),
capture_time: p.captured.as_deref().and_then(lightcraft_meta::DateTime::parse_iso),
gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }),
..Default::default()
};
let settings = serde_json::to_string(&*p.develop).unwrap_or_default();
let flag = match p.flag {
Flag::None => "none",
Flag::Pick => "pick",
Flag::Reject => "reject",
};
let mut lc = vec![("settings", settings.as_str()), ("flag", flag)];
if !p.meta.location.trim().is_empty() {
lc.push(("location", p.meta.location.as_str()));
}
lightcraft_meta::write_xmp_lc(&meta, &lc)
}
/// Write `data` to `path` atomically (temp file + rename).
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(format!("xmp.tmp-{}", std::process::id()));
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
fn file_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } => Some(path),
Source::Demo { .. } => None,
}
}
/// The XMP packet that describes a photo on disk: its sidecar, else (raw/DNG) the embedded XMP.
pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Option<(String, PathBuf)> {
if let Some(sc) = find_sidecar(original, naming)
&& let Ok(s) = std::fs::read_to_string(&sc)
{
return Some((s, sc));
}
if kind == MediaKind::Raw {
let bytes = std::fs::read(original).ok()?;
let x = lightcraft_meta::embedded(&bytes).xmp?;
return Some((x, PathBuf::from(original)));
}
None
}
/// Photo ids touched by an op (for auto-write).
pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
match op {
Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } => {
if !out.contains(id) {
out.push(*id);
}
}
Op::Batch { ops } => ops.iter().for_each(|o| op_photos(o, out)),
_ => {}
}
}
impl Session {
/// Write the photo's XMP sidecar; returns its path.
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
let path = sidecar_path(orig, self.xmp.naming);
write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(path)
}
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let Some(orig) = file_path(p) else { return Ok(None) };
let Some((packet, from)) = read_packet(orig, p.kind, self.xmp.naming) else { return Ok(None) };
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
let mut q = (**p).clone();
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
let mut ops = vec![
Op::SetRating { id, rating: q.rating },
Op::SetFlag { id, flag: q.flag },
Op::SetLabel { id, label: q.label },
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
];
if develop_changed {
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
}
Ok(Some((Op::Batch { ops }, from)))
}
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
pub(crate) fn auto_write_sidecars(&self, ops: &[Op]) {
let mut ids = Vec::new();
ops.iter().for_each(|o| op_photos(o, &mut ids));
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some())
&& let Err(e) = self.save_sidecar(id)
{
log::warn!("auto-write XMP: {e}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_catalog::Meta;
fn photo() -> Photo {
let mut p = Photo::new(PhotoId(7), Source::File { path: "/x/IMG_1.jpg".into() }, "IMG_1.jpg", "JPEG", 40, 30, "2026-01-01T00:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Purple);
p.captured = Some("2025-06-07T08:09:10".into());
p.meta = Meta {
title: "Dune & sky".into(),
caption: "Line <one>\nline two".into(),
copyright: "© 2026 Me".into(),
creator: "Ann Lee".into(),
location: "Namib".into(),
keywords: vec!["desert".into(), "red".into()],
gps: Some((-24.75, 15.3)),
..Default::default()
};
let mut d = DevelopSettings::default();
d.light.exposure = 0.75;
d.effects.clarity = 22.0;
d.crop.geometry.angle = 2.5;
d.masks.push(lightcraft_develop::Mask { id: 1, ..Default::default() });
p.develop = Arc::new(d);
p
}
#[test]
fn naming() {
assert_eq!(sidecar_path("/a/b/IMG_1.CR3", SidecarNaming::Stem), PathBuf::from("/a/b/IMG_1.xmp"));
assert_eq!(sidecar_path("/a/b/IMG_1.CR3", SidecarNaming::Full), PathBuf::from("/a/b/IMG_1.CR3.xmp"));
}
#[test]
fn packet_roundtrip_restores_everything() {
let p = photo();
let x = sidecar_packet(&p);
let sc = parse_sidecar(&x, false).unwrap();
assert_eq!(sc.rating, Some(4));
assert_eq!(sc.flag, Some(Flag::Pick));
assert_eq!(sc.label, Some(Some(ColorLabel::Purple)));
let mut q = Photo::new(PhotoId(7), p.source.clone(), "IMG_1.jpg", "JPEG", 40, 30, "2026-01-01T00:00:00");
q.captured = p.captured.clone();
q.meta.gps = p.meta.gps;
assert!(merge_into(&mut q, &sc, "now"));
assert_eq!(q.develop, p.develop);
assert_eq!(q.meta, p.meta);
assert_eq!((q.rating, q.flag, q.label), (p.rating, p.flag, p.label));
assert_eq!(q.edited.as_deref(), Some("now"));
}
#[test]
fn foreign_sidecar_rating_reject_label_and_crs() {
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:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
let sc = parse_sidecar(x, true).unwrap();
assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green))));
let mut p = photo();
let before = p.develop.clone();
assert!(merge_into(&mut p, &sc, "t"));
assert_eq!(p.develop.light.exposure, -0.4);
assert_eq!(p.develop.color.vibrance, 12.0);
assert_eq!(p.develop.effects.clarity, before.effects.clarity, "crs partial keeps unrelated settings");
assert_eq!(p.meta.title, "Dune & sky", "fields the sidecar doesn't state are kept");
}
#[test]
fn op_photo_collection() {
let mut v = Vec::new();
op_photos(
&Op::Batch {
ops: vec![
Op::SetRating { id: PhotoId(1), rating: 2 },
Op::SetFlag { id: PhotoId(1), flag: Flag::Pick },
Op::SetLabel { id: PhotoId(3), label: None },
],
},
&mut v,
);
assert_eq!(v, vec![PhotoId(1), PhotoId(3)]);
}
}
+246
View File
@@ -0,0 +1,246 @@
//! XMP sidecars end to end: save → delete library → re-import restores edits; auto-write; foreign
//! `crs:` sidecars; DNG-embedded XMP; read metadata from file (undoable).
use std::path::{Path, PathBuf};
use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn write_png(path: &Path, seed: u8) {
let (w, h) = (40usize, 24usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 6) as u8, (i / w * 9) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn only(s: &Session) -> PhotoId {
s.catalog.photos().next().unwrap().id
}
#[test]
fn save_delete_library_reimport_restores_edits() {
let src = temp_dir("roundtrip-src");
let lib = temp_dir("roundtrip-lib");
write_png(&src.join("dune.png"), 1);
write_png(&src.join("plain.png"), 2);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.execute("library.import", &json!({"paths": [src.join("dune.png").to_string_lossy()]})).unwrap();
let id = only(&s);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
s.execute("photo.pick", &json!({})).unwrap();
s.execute("photo.label", &json!({"label": "blue"})).unwrap();
s.execute("photo.setMeta", &json!({"title": "Dune", "caption": "Evening light", "copyright": "© me", "keywords": ["sand", "dusk"]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.8})).unwrap();
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 25})).unwrap();
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
let edited = s.catalog.photo(id).unwrap().clone();
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
assert_eq!(r["written"][0], src.join("dune.xmp").to_string_lossy().as_ref(), "{r}");
assert!(src.join("dune.xmp").is_file());
assert!(!src.join("plain.xmp").exists());
drop(s);
// delete the library; a fresh library re-imports the folder
std::fs::remove_dir_all(&lib).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(r["sidecars"], 1, "{r}");
let p = s.catalog.photos().find(|p| p.file_name == "dune.png").unwrap().clone();
assert_eq!(p.develop, edited.develop, "develop settings restored exactly");
assert_eq!(p.develop.masks.len(), 1);
assert_eq!((p.rating, p.flag, p.label), (4, Flag::Pick, Some(ColorLabel::Blue)));
assert_eq!(p.meta.title, "Dune");
assert_eq!(p.meta.caption, "Evening light");
assert_eq!(p.meta.copyright, "© me");
assert_eq!(p.meta.keywords, vec!["sand".to_string(), "dusk".to_string()]);
assert!(p.edited.is_some());
let plain = s.catalog.photos().find(|p| p.file_name == "plain.png").unwrap();
assert!(plain.develop.is_unedited() && plain.rating == 0);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn auto_write_and_naming_preference() {
let src = temp_dir("auto");
let lib = temp_dir("auto-lib");
write_png(&src.join("a.png"), 3);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
assert!(!src.join("a.xmp").exists(), "auto-write is off by default");
let v = s.execute("library.xmpPreferences", &json!({"autoWrite": true, "naming": "full"})).unwrap();
assert_eq!(v, json!({"autoWrite": true, "naming": "full"}));
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
let sidecar = src.join("a.png.xmp");
assert!(sidecar.is_file());
assert!(std::fs::read_to_string(&sidecar).unwrap().contains("<xmp:Rating>5</xmp:Rating>"));
// a slider drag writes once, at the end
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap();
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), false).unwrap().develop {
Some(crate::sidecar::DevelopPatch::Full(d)) => d.light.exposure,
other => panic!("{other:?}"),
};
assert_eq!(exposure(&sidecar), 0.0, "not written mid-drag");
s.execute("develop.endInteraction", &json!({})).unwrap();
assert_eq!(exposure(&sidecar), 1.5);
// undo rewrites too
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(exposure(&sidecar), 0.0);
// the preference persists with the library
drop(s);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert!(s.xmp.auto_write);
assert_eq!(s.xmp.naming, crate::sidecar::SidecarNaming::Full);
let v = s.execute("library.toggleAutoWriteXmp", &json!({})).unwrap();
assert_eq!(v["autoWrite"], false);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&lib);
}
/// Hand-written sidecar in the style other raw developers use (attribute form, `crs:` fields).
const FOREIGN: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<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:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="3" xmp:Label="Red"
crs:ProcessVersion="11.0" crs:IncrementalTemperature="+20" crs:IncrementalTint="-5"
crs:Exposure2012="+1.10" crs:Shadows2012="+35" crs:Vibrance="+15" crs:Clarity2012="+10"
crs:SplitToningShadowHue="220" crs:SplitToningShadowSaturation="18"
crs:HasCrop="True" crs:CropLeft="0.1" crs:CropTop="0" crs:CropRight="0.9" crs:CropBottom="1" crs:CropAngle="0">
<dc:subject><rdf:Bag><rdf:li>harbour</rdf:li></rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[test]
fn foreign_crs_sidecar_on_import_and_read_from_file() {
let src = temp_dir("foreign");
write_png(&src.join("harbour.png"), 4);
std::fs::write(src.join("harbour.xmp"), FOREIGN).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["sidecars"], 1);
let p = s.catalog.photos().next().unwrap().clone();
assert_eq!((p.rating, p.label), (3, Some(ColorLabel::Red)));
assert_eq!(p.meta.keywords, vec!["harbour".to_string()]);
let d = &p.develop;
assert_eq!((d.light.exposure, d.light.shadows, d.color.vibrance, d.effects.clarity), (1.1, 35.0, 15.0, 10.0));
assert!(d.wb.temp > 6500.0 && d.wb.tint == -5.0, "relative WB for a rendered file: {:?}", d.wb);
assert_eq!((d.grading.shadows.hue, d.grading.shadows.sat), (220.0, 18.0));
assert_eq!((d.crop.geometry.rect.x0, d.crop.geometry.rect.x1), (0.1, 0.9));
assert!(s.render_now(p.id, 32, 32).is_ok());
// change things, then read the sidecar again: one undo step restores the edits
s.execute("library.select", &json!({"ids": [p.id.0]})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
s.execute("photo.rate", &json!({"rating": 1})).unwrap();
let r = s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}");
let q = s.catalog.photo(p.id).unwrap();
assert_eq!(q.rating, 3);
assert_eq!(q.develop.light.exposure, 1.1);
s.execute("edit.undo", &json!({})).unwrap();
let q = s.catalog.photo(p.id).unwrap();
assert_eq!((q.rating, q.develop.light.exposure), (1, 0.0));
// no sidecar → reported, not an error
std::fs::remove_file(src.join("harbour.xmp")).unwrap();
let r = s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&src);
}
fn synthetic_dng(xmp: &str) -> Vec<u8> {
use lightcraft_raw::*;
let (w, h) = (32usize, 24usize);
let cfa = Cfa::bayer("RGGB").unwrap();
let data: Vec<u16> = (0..w * h).map(|i| 256 + ((i % w) * 300 + (i / w) * 200) as u16).collect();
let raw = RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp: 1,
data: RawData::U16(data),
cfa: Some(cfa),
bits: 14,
black: BlackLevel::uniform(256.0),
white: vec![16000.0],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, w, h),
orientation: Orientation::Normal,
color: ColorData {
illuminant: [17, 21],
color_matrix: [
Some(lightcraft_color::Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])),
Some(lightcraft_color::Mat3([[0.7, 0.3, -0.1], [-0.35, 1.3, 0.1], [0.05, -0.2, 1.0]])),
],
as_shot_neutral: Some([0.5, 1.0, 0.7]),
..Default::default()
},
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata::default(),
};
write_dng(&raw, &DngWriteOptions { xmp: Some(xmp.to_string()), ..Default::default() }).unwrap()
}
#[test]
fn dng_embedded_crs_settings_are_read_on_import() {
let src = temp_dir("dng");
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:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="2" crs:WhiteBalance="Custom" crs:Temperature="4300" crs:Tint="+7" crs:Contrast2012="+30" crs:Dehaze="+12"/>
</rdf:RDF></x:xmpmeta>"#;
std::fs::write(src.join("synth.dng"), synthetic_dng(x)).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!((r["imported"].as_array().unwrap().len(), r["sidecars"].as_u64()), (1, Some(1)), "{r}");
let p = s.catalog.photos().next().unwrap();
assert_eq!(p.kind, lightcraft_catalog::MediaKind::Raw);
assert_eq!((p.develop.wb.temp, p.develop.wb.tint), (4300.0, 7.0));
assert_eq!((p.develop.light.contrast, p.develop.effects.dehaze), (30.0, 12.0));
assert_eq!(p.develop.detail.sharpen_amount, 40.0, "raw defaults kept for fields the XMP doesn't set");
assert_eq!(p.rating, 2);
// a sidecar next to the DNG takes precedence over the embedded XMP
let id = p.id;
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.contrast", "value": -10})).unwrap();
s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
let mut s2 = Session::new().with_fs();
s2.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(s2.catalog.photos().next().unwrap().develop.light.contrast, -10.0);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn demo_photos_have_no_sidecar() {
let mut s = Session::with_demo();
let r = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
assert_eq!(r["written"].as_array().unwrap().len(), 0);
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use iptc::parse_iptc;
pub use lightcraft_geom::Orientation;
pub use xmp::{LC_NS, XmpData, XmpError, parse_xmp, write_xmp};
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, parse_xmp, write_xmp, write_xmp_lc};
use serde::{Deserialize, Serialize};
+32 -3
View File
@@ -9,7 +9,7 @@
//! LightCraft's full develop settings as an opaque JSON string in `lc:settings`.
use crate::{DateTime, Flash, Gps, Metadata, Orientation, parse_number};
use quick_xml::escape::escape;
use quick_xml::escape::{escape, partial_escape};
use quick_xml::events::Event;
use std::collections::BTreeMap;
@@ -30,8 +30,13 @@ const NAMESPACES: &[(&str, &str)] = &[
("lr", "http://ns.adobe.com/lightroom/1.0/"),
("Iptc4xmpCore", "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"),
("lc", LC_NS),
// Read-only: develop settings written by other raw developers (interchange; see docs/xmp-interop.md).
("crs", CRS_NS),
];
/// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only.
pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/";
/// XMP read failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XmpError {
@@ -405,6 +410,12 @@ fn sfrac(v: f64) -> String {
/// Serialise the interchange subset of `meta` (plus `lc_settings`, an opaque JSON string, in `lc:settings`)
/// as a complete XMP packet.
pub fn write_xmp(meta: &Metadata, lc_settings: Option<&str>) -> String {
let lc: Vec<(&str, &str)> = lc_settings.map(|s| ("settings", s)).into_iter().collect();
write_xmp_lc(meta, &lc)
}
/// Like [`write_xmp`], with any number of simple `lc:<name>` properties (e.g. `settings`, `flag`).
pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
let mut simple: Vec<(&str, String)> = Vec::new();
let mut push = |k: &'static str, v: Option<String>| {
if let Some(v) = v {
@@ -441,20 +452,25 @@ pub fn write_xmp(meta: &Metadata, lc_settings: Option<&str>) -> String {
push("exif:GPSAltitudeRef", Some(if a < 0.0 { "1" } else { "0" }.into()));
}
}
push("lc:settings", lc_settings.map(str::to_string));
let mut x = String::new();
x.push_str("<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n");
x.push_str("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"LightCraft\">\n");
x.push_str(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n");
x.push_str(" <rdf:Description rdf:about=\"\"");
for (p, u) in NAMESPACES.iter().skip(2) {
for (p, u) in NAMESPACES.iter().skip(2).filter(|(p, _)| *p != "crs") {
x.push_str(&format!("\n xmlns:{p}=\"{u}\""));
}
x.push_str(">\n");
for (k, v) in &simple {
x.push_str(&format!(" <{k}>{}</{k}>\n", escape(v.as_str())));
}
for (k, v) in lc {
if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
// element content: quotes needn't be escaped, which keeps the JSON readable
x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(*v)));
}
}
let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| {
if items.is_empty() {
return;
@@ -599,6 +615,19 @@ mod tests {
}
}
#[test]
fn lc_extras_and_crs_prefix() {
let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]);
assert!(!x.contains("xmlns:crs"), "we never write crs:");
let d = parse_xmp(&x).unwrap();
assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}"));
assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]);
assert!(!x.contains("bad name"));
let foreign = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cr="http://ns.adobe.com/camera-raw-settings/1.0/" cr:Exposure2012="+0.5"/>"#;
assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]);
}
#[test]
fn gps_coords() {
assert_eq!(parse_gps_coord("45,30N"), Some(45.5));
+6
View File
@@ -24,6 +24,8 @@ pub use control::{ControlRequest, ControlResponse};
pub use state::UiState;
pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
/// A save dialog: suggested file name → chosen path (`None` = cancelled).
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
@@ -32,6 +34,10 @@ pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
pub struct Services {
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`).
pub pick_preset_files: Option<PickFiles>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
pub write: Option<WriteFn>,
/// PNG encoder (the host links an image encoder; the UI crate stays codec-free).
pub png: Option<PngEncode>,
+56
View File
@@ -56,6 +56,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"),
("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"),
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
("file.importPresets", "Import Presets…", None, "File"),
("file.exportPresets", "Export Presets…", None, "File"),
("app.about", "About LightCraft", None, "LightCraft"),
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
("app.export", "Export Now", None, ""),
@@ -297,6 +299,59 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
return Some(app.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string()));
}
"file.importPresets" => {
let paths = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
None => match app.services.pick_preset_files.as_mut() {
Some(f) => f(),
None => return Some(Err("no file dialog on this platform".into())),
},
};
if paths.is_empty() {
return Some(Ok(Value::Null));
}
let r = app.session.execute("preset.import", &json!({"paths": paths})).map_err(|e| e.to_string());
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) {
(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" }),
};
app.toast(&ctx, msg);
if n > 0 {
app.ui.presets = true;
}
}
return Some(r);
}
"file.exportPresets" => {
let group = p.get("group").and_then(Value::as_str).map(str::to_string);
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => Some(x.to_string()),
None => {
let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets"));
match app.services.save_preset_file.as_mut() {
Some(f) => f(&name),
None => return Some(Err("no file dialog on this platform".into())),
}
}
};
let Some(path) = path else { return Some(Ok(Value::Null)) };
let mut params = json!({"path": path});
if let Some(g) = group {
params["group"] = json!(g);
}
if let Some(ids) = p.get("ids") {
params["ids"] = ids.clone();
}
let r = app.session.execute("preset.export", &params).map_err(|e| e.to_string());
if let Ok(v) = &r {
app.toast(&ctx, format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
}
return Some(r);
}
"app.export" => crate::control::export_active(app, p),
_ => return None,
};
@@ -307,6 +362,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
match id {
s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close",
"app.export" | "dialog.export" | "dialog.createPreset" | "dialog.copySettings" => app.session.active().is_some(),
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
_ => true,
}
}
+16 -1
View File
@@ -1,5 +1,5 @@
//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview,
//! amount slider, create/favourite.
//! amount slider, create/favourite, import/export of preset files.
use std::collections::BTreeMap;
@@ -31,6 +31,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
{
app.ui.dialog = Some(crate::state::Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
}
let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options");
egui::Popup::menu(&more).show(|ui| {
if ui.button("Import Presets…").clicked() {
let _ = app.run("file.importPresets", json!({}));
}
let any_user = app.session.presets.iter().any(|p| !p.builtin);
if ui.add_enabled(any_user, egui::Button::new("Export User Presets…")).clicked() {
let _ = app.run("file.exportPresets", json!({}));
}
});
divider(ui);
// amount slider (applies to the last applied preset)
let amt_id = egui::Id::new("preset-amount");
@@ -76,6 +86,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
resp.context_menu(|ui| {
if ui.button("Export Group…").clicked() {
let _ = app.run("file.exportPresets", json!({"group": g}));
}
});
if !open {
continue;
}
+93
View File
@@ -0,0 +1,93 @@
# XMP sidecars and interchange
LightCraft keeps its catalog as the source of truth, and can also store each photo's metadata and develop settings in
an XMP sidecar next to the original. Sidecars let edits travel with the files (backups, another LightCraft library,
other tools), and let LightCraft pick up edits made elsewhere.
## Sidecar files
| | |
|---|---|
| Name | `<stem>.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `<file>.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. |
| Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. Written atomically (temp file + rename). |
| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. |
| Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. |
What we write (standard namespaces, so other tools can read the metadata):
| Field | XMP property |
|---|---|
| Rating 0–5 | `xmp:Rating` |
| Colour label | `xmp:Label` (`Red`, `Yellow`, `Green`, `Blue`, `Purple`) |
| Title / caption / copyright / creator | `dc:title` / `dc:description` / `dc:rights` / `dc:creator` |
| Keywords | `dc:subject` |
| Capture time, GPS | `exif:DateTimeOriginal`, `photoshop:DateCreated`, `exif:GPSLatitude`/`GPSLongitude` |
| Pick / reject flag | `lc:flag` (`pick`, `reject`, `none`) |
| Location | `lc:location` |
| Develop settings | `lc:settings` — our complete `DevelopSettings` as JSON (exact round trip, incl. masks, spots, crop) |
`lc:` is `http://ns.lightcraft.app/lc/1.0/`.
Reading merges into the catalog with the **sidecar winning** for every field it states; fields it doesn't state are
kept. `xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from
`lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate).
## Reading `crs:` develop fields
Many raw developers store edits as `crs:` properties (`http://ns.adobe.com/camera-raw-settings/1.0/`) in sidecars, in
DNG files and in XMP presets. LightCraft reads the common ones and maps them to its own controls. We implemented this
from the public XMP specification and by observing what each field does; no third-party code or preset files were used.
Our pipeline renders differently, so **values carry over but the look is approximate**.
We read these fields; we never write them. Only fields in the packet are applied: the result is a partial settings
object that gets merged like a preset, so everything else keeps its current or default value. Packets marked
`crs:AlreadyApplied="True"` are skipped, because those pixels already contain the edit. Only process-version 2012+ field
names are read (e.g. `Exposure2012`, not the older `Exposure`).
| `crs:` field(s) | LightCraft control | Notes |
|---|---|---|
| `Exposure2012` | `light.exposure` | EV, 1:1 |
| `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 |
| `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom |
| `Temperature`, `Tint` | `wb.temp`, `wb.tint` | Kelvin / tint for raw files (and presets) |
| `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider) |
| `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 |
| `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 |
| `HueAdjustment<Band>`, `SaturationAdjustment<Band>`, `LuminanceAdjustment<Band>` | `mixer.<band>.hue/sat/lum` | bands Red, Orange, Yellow, Green, Aqua, Blue, Purple, Magenta |
| `ConvertToGrayscale` | `treatment` | `True` → B&W |
| `GrayMixer<Band>` | `bw_mix.<band>` | |
| `ParametricShadows`, `ParametricDarks`, `ParametricLights`, `ParametricHighlights` | `curve.shadows/darks/lights/highlights` | |
| `ParametricShadowSplit`, `ParametricMidtoneSplit`, `ParametricHighlightSplit` | `curve.split_shadows/split_mid/split_highlights` | |
| `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve |
| `SplitToningShadowHue/Saturation`, `SplitToningHighlightHue/Saturation` | `grading.shadows/highlights.hue/sat` | |
| `ColorGradeShadowLum`, `ColorGradeHighlightLum` | `grading.shadows/highlights.lum` | |
| `ColorGradeMidtoneHue/Sat/Lum`, `ColorGradeGlobalHue/Sat/Lum` | `grading.midtones/global.*` | |
| `ColorGradeBlending`, `SplitToningBalance` | `grading.blending`, `grading.balance` | |
| `Sharpness`, `SharpenRadius`, `SharpenDetail`, `SharpenEdgeMasking` | `detail.sharpen_*` | |
| `LuminanceSmoothing`, `LuminanceNoiseReductionDetail`, `LuminanceNoiseReductionContrast` | `detail.nr_luminance/nr_detail/nr_contrast` | |
| `ColorNoiseReduction`, `ColorNoiseReductionDetail`, `ColorNoiseReductionSmoothness` | `detail.nr_color/nr_color_detail/nr_color_smoothness` | |
| `PostCropVignetteAmount/Midpoint/Roundness/Feather/HighlightContrast` | `vignette.amount/midpoint/roundness/feather/highlights` | |
| `PostCropVignetteStyle` | `vignette.style` | 1 highlight priority, 2 colour priority, 3 paint overlay |
| `GrainAmount`, `GrainSize`, `GrainFrequency` | `grain.amount`, `grain.size`, `grain.roughness` | |
| `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | the switch only; lens profiles are our own |
| `LensManualDistortionAmount`, `VignetteAmount`, `VignetteMidpoint` | `optics.distortion`, `optics.vignetting`, `optics.vignetting_midpoint` | |
| `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | |
| `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | |
| `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided |
| `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop |
Values pass through our control specs, so anything outside our slider ranges gets clamped.
**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), local adjustments
(masks, gradients, brushes), spot removal, red eye, lens blur, process-version 2010 field names, and AI features.
## Preset files
| | |
|---|---|
| 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. |
| 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`).