Bring a Lightroom library across, and start with something in the list
Two halves of the same complaint: a preset sheet that opens on "No presets yet" is homework, and a photographer with ten years of presets in Lightroom has no way to bring them. `dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be mostly a rename rather than a conversion, because Adobe and this pipeline already agree: exposure is in stops in both, and contrast, the four recovery controls, clarity, texture, vibrance and saturation are all ±100 in both. That is not imitation, it is the convention raw developers converged on — `highlights_shadows.yaml` cites it in as many words. Only sharpening needed arithmetic, Adobe's 0…150 against our 0…100. The white balance does not come across, and says so rather than guessing. Adobe writes absolute Kelvin for a raw file where ours is a relative nudge from what the camera recorded, so converting needs the *target image's* as-shot white balance — exactly what a preset cannot carry, since the same preset lands on a frame shot at 3200K and one shot at 7000K. A guess would be wrong on most images and invisibly so. A folder is read as readily as a file, nested, because that is the shape an exported preset folder is in and importing ninety files one at a time is asking someone not to bother. `dr_pipeline::starter` is six presets a first run begins with, written against this pipeline in its units and deliberately mild — a starting point, not a caricature. They are seeded when the library *file* does not exist rather than when the library is empty, so deleting all six does not hand them back on the next launch. Both of these name operations, and `ui_names_no_operation` was right to stop them living in `ui/`. That test exists because the failure is silent and cumulative, and it caught exactly what it was written for: a preset called "Punch" is a statement about contrast, clarity and vibrance, and a table mapping Adobe's vocabulary to ours is a statement about the pipeline. Neither is a fact about an interface. So the starter set went into `dr-pipeline`, and the importer into its own crate — between two walls, since `dr-pipeline` depends on nothing on purpose and XMP is real XML not worth hand-rolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+11
@@ -1533,6 +1533,16 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.9.0"
|
||||
@@ -1630,6 +1640,7 @@ dependencies = [
|
||||
"dr-ingest",
|
||||
"dr-pipeline",
|
||||
"dr-plat",
|
||||
"dr-preset-xmp",
|
||||
"dr-segment",
|
||||
"dr-sync",
|
||||
"dr-sync-folder",
|
||||
|
||||
@@ -12,6 +12,7 @@ members = [
|
||||
"core/dr-gpu",
|
||||
"core/dr-lens",
|
||||
"core/dr-pipeline",
|
||||
"core/dr-preset-xmp",
|
||||
"core/dr-segment",
|
||||
"core/dr-sync",
|
||||
"core/dr-sync-folder",
|
||||
@@ -46,6 +47,7 @@ dr-ingest = { path = "core/dr-ingest" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
dr-lens = { path = "core/dr-lens" }
|
||||
dr-pipeline = { path = "core/dr-pipeline" }
|
||||
dr-preset-xmp = { path = "core/dr-preset-xmp" }
|
||||
# `default-features = false` belongs *here*, not on each dependant: a member
|
||||
# inheriting a workspace dependency cannot turn its default features off, so
|
||||
# writing it below would silently do nothing and every crate touching
|
||||
|
||||
@@ -44,6 +44,7 @@ pub mod ops;
|
||||
pub mod preset;
|
||||
pub mod sidecar;
|
||||
pub mod spot;
|
||||
pub mod starter;
|
||||
pub mod state;
|
||||
|
||||
pub use declared::{Declaration, DeclaredOp};
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! TRACES: FR-DEV-6
|
||||
//! The presets a first run starts with.
|
||||
//!
|
||||
//! # Why any at all
|
||||
//!
|
||||
//! A preset sheet that opens on "No presets yet" teaches the photographer that
|
||||
//! the feature is homework. These exist so the first thing the sheet does is
|
||||
//! demonstrate what a preset *is* — and so that applying one to a selection,
|
||||
//! which is the action worth discovering, is available before anybody has
|
||||
//! saved anything.
|
||||
//!
|
||||
//! # Why these are ours and not Adobe's
|
||||
//!
|
||||
//! Lightroom ships a large bundled set, and importing one of *those* files is
|
||||
//! what [`crate::preset_import`] is for — a photographer's own library,
|
||||
//! carried across. Redistributing Adobe's inside this application would be
|
||||
//! shipping their creative work under a licence that does not permit it, which
|
||||
//! is a reason on its own; and their numbers are calibrated against their tone
|
||||
//! curve rather than ours, so the look would not survive the trip even if the
|
||||
//! licence allowed it.
|
||||
//!
|
||||
//! So these are written against this pipeline, in its units, and they are
|
||||
//! deliberately mild. A starter preset is a starting point — a photographer
|
||||
//! who wanted the full effect can push the sliders, where one who is handed a
|
||||
//! caricature learns to distrust the list.
|
||||
//!
|
||||
//! # Why they live in the core rather than in the interface
|
||||
//!
|
||||
//! Because they name operations, and nothing in `ui/` may
|
||||
//! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a
|
||||
//! preset called "Punch" *is* a statement about contrast, clarity and
|
||||
//! vibrance, which makes it a statement in the pipeline's vocabulary rather
|
||||
//! than a fact about any interface. The frontend asks for the set and stores
|
||||
//! it; it never learns what is in it.
|
||||
//!
|
||||
//! # Why they are seeded rather than merged
|
||||
//!
|
||||
//! Written once, on the first run that finds no library at all, and never
|
||||
//! again. Re-adding them on every start would resurrect one the photographer
|
||||
//! deleted on purpose, and updating them in place would silently rewrite an
|
||||
//! edit they had adjusted and kept under the same name. After the first run
|
||||
//! these are ordinary presets: renameable, editable, deletable, and gone for
|
||||
//! good when deleted.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{Preset, PresetLibrary};
|
||||
|
||||
/// One starter preset: a name and the parameters that differ from default.
|
||||
struct Starter {
|
||||
name: &'static str,
|
||||
params: &'static [(&'static str, &'static str, f32)],
|
||||
}
|
||||
|
||||
/// The set. Small on purpose — six a photographer might actually reach for
|
||||
/// beats forty they have to scroll past.
|
||||
const STARTERS: &[Starter] = &[
|
||||
Starter {
|
||||
name: "Punch",
|
||||
params: &[
|
||||
("contrast", "contrast", 18.0),
|
||||
("clarity", "amount", 12.0),
|
||||
("vibrance", "vibrance", 18.0),
|
||||
("blacks_whites", "blacks", -8.0),
|
||||
],
|
||||
},
|
||||
Starter {
|
||||
name: "Soft portrait",
|
||||
params: &[
|
||||
("contrast", "contrast", -8.0),
|
||||
("highlights_shadows", "highlights", -20.0),
|
||||
("highlights_shadows", "shadows", 15.0),
|
||||
("clarity", "amount", -10.0),
|
||||
("vibrance", "vibrance", 10.0),
|
||||
("saturation", "saturation", -5.0),
|
||||
],
|
||||
},
|
||||
Starter {
|
||||
name: "Recover the sky",
|
||||
params: &[
|
||||
// The most common single fix in landscape work: a bright sky and a
|
||||
// dark foreground, both pulled back toward the middle.
|
||||
("highlights_shadows", "highlights", -55.0),
|
||||
("highlights_shadows", "shadows", 35.0),
|
||||
("blacks_whites", "whites", -10.0),
|
||||
],
|
||||
},
|
||||
Starter {
|
||||
name: "Lift the shadows",
|
||||
params: &[
|
||||
("highlights_shadows", "shadows", 40.0),
|
||||
("blacks_whites", "blacks", 12.0),
|
||||
("contrast", "contrast", -5.0),
|
||||
],
|
||||
},
|
||||
Starter {
|
||||
name: "Crisp detail",
|
||||
params: &[
|
||||
("texture", "amount", 20.0),
|
||||
("clarity", "amount", 10.0),
|
||||
("capture_sharpen", "amount", 35.0),
|
||||
],
|
||||
},
|
||||
Starter {
|
||||
name: "Muted",
|
||||
params: &[
|
||||
("saturation", "saturation", -30.0),
|
||||
("vibrance", "vibrance", 10.0),
|
||||
("contrast", "contrast", -10.0),
|
||||
("highlights_shadows", "shadows", 12.0),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// The starter library, for a device that has never had one.
|
||||
pub fn library() -> PresetLibrary {
|
||||
let mut library = PresetLibrary::default();
|
||||
for starter in STARTERS {
|
||||
let params: BTreeMap<(String, String), f32> = starter
|
||||
.params
|
||||
.iter()
|
||||
.map(|(op, param, value)| ((op.to_string(), param.to_string()), *value))
|
||||
.collect();
|
||||
// The name is a literal in this file, so a refusal would be a bug here
|
||||
// rather than bad input — but it still must not take the whole set
|
||||
// down, since the alternative to five presets is not six, it is none.
|
||||
if let Err(e) = library.insert(starter.name, Preset::from_params(params)) {
|
||||
log::warn!(
|
||||
"starter preset {:?} is unusable ({e:?}); skipping",
|
||||
starter.name
|
||||
);
|
||||
}
|
||||
}
|
||||
library
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EditGraph, Scope};
|
||||
|
||||
#[test]
|
||||
fn every_starter_names_parameters_this_build_actually_has() {
|
||||
// The same guard the importer's table has, for the same reason: a
|
||||
// renamed parameter must break the build rather than ship a preset
|
||||
// that quietly does nothing.
|
||||
let graph = EditGraph::default_chain();
|
||||
let capabilities = graph.capabilities();
|
||||
for starter in STARTERS {
|
||||
for (op, param, _) in starter.params {
|
||||
let capability = capabilities
|
||||
.iter()
|
||||
.find(|c| c.id.0 == *op)
|
||||
.unwrap_or_else(|| panic!("{:?}: no operation {op:?}", starter.name));
|
||||
assert!(
|
||||
capability.params.iter().any(|p| p.id.0 == *param),
|
||||
"{:?}: operation {op:?} has no parameter {param:?}",
|
||||
starter.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_starter_actually_changes_something() {
|
||||
// A preset that applies to nothing is worse than one fewer preset: it
|
||||
// teaches the photographer that the list does not work.
|
||||
for (name, preset) in library().iter() {
|
||||
assert!(!preset.is_empty(), "{name} carries nothing");
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
preset.apply(&mut graph, Scope::adjustments());
|
||||
assert_ne!(
|
||||
Preset::capture(&graph),
|
||||
Preset::default(),
|
||||
"{name} left the graph at its defaults"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_starter_carries_a_crop() {
|
||||
// These are looks, not compositions. One that re-framed every image it
|
||||
// was applied to would be the exact accident `Scope`'s default exists
|
||||
// to prevent.
|
||||
for (name, preset) in library().iter() {
|
||||
assert!(!preset.touches_framing(), "{name} carries framing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_names_are_distinct() {
|
||||
assert_eq!(library().len(), STARTERS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_values_stay_inside_what_the_controls_accept() {
|
||||
// Clamping happens on apply, so an out-of-range literal here would be
|
||||
// silently trimmed and the preset would not be the one written.
|
||||
let graph = EditGraph::default_chain();
|
||||
for starter in STARTERS {
|
||||
for (op, param, value) in starter.params {
|
||||
let mut applied = EditGraph::default_chain();
|
||||
let capability = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id.0 == *op)
|
||||
.unwrap();
|
||||
let descriptor = capability.params.iter().find(|p| p.id.0 == *param).unwrap();
|
||||
applied.set_param(capability.id, descriptor.id, *value);
|
||||
assert_eq!(
|
||||
applied.param(capability.id, descriptor.id),
|
||||
Some(*value),
|
||||
"{}: {op}.{param} = {value} was clamped",
|
||||
starter.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "dr-preset-xmp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-pipeline.workspace = true
|
||||
# XMP is real XML with namespaces and is not worth hand-rolling. Already in the
|
||||
# tree for WebDAV, so this is a use rather than a new dependency.
|
||||
quick-xml.workspace = true
|
||||
log.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,693 @@
|
||||
//! TRACES: FR-DEV-6
|
||||
//! Reading a Lightroom `.xmp` preset into one of ours.
|
||||
//!
|
||||
//! # Why this is possible at all
|
||||
//!
|
||||
//! Adobe's Camera Raw settings and ours agree on more than they had any
|
||||
//! obligation to. Exposure is in stops in both; contrast, the four recovery
|
||||
//! controls, clarity, texture, vibrance and saturation are all ±100 in both.
|
||||
//! That is not imitation — it is the convention raw developers converged on,
|
||||
//! and `highlights_shadows.yaml` cites it in as many words ("matching the
|
||||
//! convention every other developer uses"). So most of the table below is a
|
||||
//! rename rather than a conversion.
|
||||
//!
|
||||
//! # Why this is its own crate
|
||||
//!
|
||||
//! Two walls, and it sits between them. It cannot live in `dr-pipeline`,
|
||||
//! which depends on nothing deliberately — that is what lets the descriptor
|
||||
//! and codegen logic be tested without a device (ARCH §6.5a) — and XMP is real
|
||||
//! XML with namespaces, not worth hand-rolling. And it cannot live in `dr-ui`,
|
||||
//! because the table below *names operations* and nothing in the interface may
|
||||
//! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a
|
||||
//! mapping from Adobe's vocabulary to ours is a statement about the pipeline,
|
||||
//! not about any interface.
|
||||
//!
|
||||
//! So: a small crate below the interface and beside the pipeline, which is how
|
||||
//! the rest of `core/` is already organised.
|
||||
//!
|
||||
//! # What is deliberately not imported
|
||||
//!
|
||||
//! **White balance.** Lightroom writes `crs:Temperature` as absolute Kelvin
|
||||
//! for a raw file; ours is a relative ±100 nudge away from what the camera
|
||||
//! recorded. Converting between them needs the *target image's* as-shot white
|
||||
//! balance, which is exactly what a preset does not carry — the same preset
|
||||
//! lands on a frame shot at 3200K and one shot at 7000K. A guess here would be
|
||||
//! wrong on most images and invisibly so, which is worse than an honest gap,
|
||||
//! so these keys are counted as skipped and reported.
|
||||
//!
|
||||
//! **Tone curves, colour mixing, masks, lens profiles and grain.** Each is a
|
||||
//! structure rather than a number, and each would need its own argument about
|
||||
//! whether the two applications mean the same thing. They are skipped by
|
||||
//! omission — a key not in the table is simply not understood — and the
|
||||
//! [`Import::skipped`] count is what tells the user their preset arrived
|
||||
//! partial.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use dr_pipeline::Preset;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::XmlVersion;
|
||||
|
||||
/// How a Lightroom value becomes one of ours.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Convert {
|
||||
/// Same units, same range. Most of the table.
|
||||
Direct,
|
||||
/// Same zero, different full deflection — multiply.
|
||||
///
|
||||
/// Carries the factor rather than the two ranges because that is what the
|
||||
/// arithmetic needs, and a range pair would invite the reader to check it
|
||||
/// against a descriptor that is not the authority for Adobe's half.
|
||||
Scale(f32),
|
||||
}
|
||||
|
||||
/// One key we understand.
|
||||
struct Mapping {
|
||||
/// The attribute name inside the `crs` namespace.
|
||||
crs: &'static str,
|
||||
op: &'static str,
|
||||
param: &'static str,
|
||||
convert: Convert,
|
||||
}
|
||||
|
||||
/// Every Camera Raw key this build can translate.
|
||||
///
|
||||
/// The op and param names are checked against the pipeline's own descriptors
|
||||
/// by a test, so a renamed parameter breaks the build rather than silently
|
||||
/// dropping a setting on import.
|
||||
const MAPPINGS: &[Mapping] = &[
|
||||
// Stops in both. The one mapping that needed no thought at all.
|
||||
Mapping {
|
||||
crs: "Exposure2012",
|
||||
op: "exposure",
|
||||
param: "exposure",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Contrast2012",
|
||||
op: "contrast",
|
||||
param: "contrast",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Highlights2012",
|
||||
op: "highlights_shadows",
|
||||
param: "highlights",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Shadows2012",
|
||||
op: "highlights_shadows",
|
||||
param: "shadows",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Whites2012",
|
||||
op: "blacks_whites",
|
||||
param: "whites",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Blacks2012",
|
||||
op: "blacks_whites",
|
||||
param: "blacks",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Clarity2012",
|
||||
op: "clarity",
|
||||
param: "amount",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Texture",
|
||||
op: "texture",
|
||||
param: "amount",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Vibrance",
|
||||
op: "vibrance",
|
||||
param: "vibrance",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "Saturation",
|
||||
op: "saturation",
|
||||
param: "saturation",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
// Adobe's sharpening runs 0…150 where ours runs 0…100, so a preset asking
|
||||
// for its maximum gets ours rather than being clamped there silently.
|
||||
Mapping {
|
||||
crs: "Sharpness",
|
||||
op: "capture_sharpen",
|
||||
param: "amount",
|
||||
convert: Convert::Scale(100.0 / 150.0),
|
||||
},
|
||||
Mapping {
|
||||
crs: "LuminanceSmoothing",
|
||||
op: "noise_reduction",
|
||||
param: "luminance",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
Mapping {
|
||||
crs: "ColorNoiseReduction",
|
||||
op: "noise_reduction",
|
||||
param: "chroma",
|
||||
convert: Convert::Direct,
|
||||
},
|
||||
];
|
||||
|
||||
/// Keys that are understood well enough to know we are *not* importing them.
|
||||
///
|
||||
/// Separate from silence so the report can say "the white balance did not come
|
||||
/// across" rather than leaving the photographer to notice. See the module note.
|
||||
const KNOWN_UNSUPPORTED: &[&str] = &[
|
||||
"Temperature",
|
||||
"Tint",
|
||||
"ToneCurvePV2012",
|
||||
"ToneCurvePV2012Red",
|
||||
"ToneCurvePV2012Green",
|
||||
"ToneCurvePV2012Blue",
|
||||
];
|
||||
|
||||
/// What came of reading one file.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Import {
|
||||
/// The preset's name, from `crs:Name` where the file carries one.
|
||||
pub name: Option<String>,
|
||||
/// The settings that translated.
|
||||
pub preset: Preset,
|
||||
/// Keys recognised as deliberately unsupported — the white balance, the
|
||||
/// tone curve. Named, so the report can be specific.
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum ImportError {
|
||||
#[error("not readable as XML: {0}")]
|
||||
NotXml(String),
|
||||
#[error("no Camera Raw settings in this file")]
|
||||
NoSettings,
|
||||
}
|
||||
|
||||
/// Read one Lightroom `.xmp` preset.
|
||||
///
|
||||
/// Tolerant in the same direction the sidecar parser is: a value that will not
|
||||
/// parse as a number costs that setting and not the file, because a preset
|
||||
/// that arrives missing its contrast is still worth having and an error
|
||||
/// message is not.
|
||||
///
|
||||
/// Camera Raw writes its settings either as attributes on an
|
||||
/// `rdf:Description` or as child elements of one, depending on version and on
|
||||
/// whether the value is a structure. Both are read, because a photographer's
|
||||
/// preset folder spans a decade of Lightroom versions and the file does not
|
||||
/// say which shape it used.
|
||||
pub fn read_xmp(text: &str) -> Result<Import, ImportError> {
|
||||
let mut reader = quick_xml::Reader::from_str(text);
|
||||
reader.config_mut().trim_text(true);
|
||||
|
||||
let mut found: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut name: Option<String> = None;
|
||||
let mut pending_element: Option<String> = None;
|
||||
// Inside `<crs:Name><rdf:Alt><rdf:li>` the text is the preset's name.
|
||||
let mut in_name = false;
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Err(e) => return Err(ImportError::NotXml(e.to_string())),
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
|
||||
let local = local_name(e.name().as_ref());
|
||||
if local == "Name" {
|
||||
in_name = true;
|
||||
}
|
||||
pending_element = Some(local.clone());
|
||||
|
||||
for attribute in e.attributes().flatten() {
|
||||
let key = local_name(attribute.key.as_ref());
|
||||
// `normalized_value` rather than the deprecated
|
||||
// `unescape_value`. `Implicit1_0` is the right version
|
||||
// here: an XMP packet opens with `<?xpacket?>` rather than
|
||||
// an XML declaration, so the version is unstated and the
|
||||
// specification says to assume 1.0.
|
||||
let Ok(value) = attribute.normalized_value(XmlVersion::Implicit1_0) else {
|
||||
continue;
|
||||
};
|
||||
found.entry(key).or_insert_with(|| value.to_string());
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
if local_name(e.name().as_ref()) == "Name" {
|
||||
in_name = false;
|
||||
}
|
||||
pending_element = None;
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
let Ok(text) = e.xml10_content() else {
|
||||
continue;
|
||||
};
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if in_name && name.is_none() {
|
||||
name = Some(text.clone());
|
||||
}
|
||||
if let Some(key) = pending_element.clone() {
|
||||
found.entry(key).or_insert(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if found.is_empty() {
|
||||
return Err(ImportError::NoSettings);
|
||||
}
|
||||
|
||||
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||
for mapping in MAPPINGS {
|
||||
let Some(raw) = found.get(mapping.crs) else {
|
||||
continue;
|
||||
};
|
||||
// Adobe writes a leading `+` on positive numbers, which Rust's float
|
||||
// parser accepts, and occasionally a trailing `%`, which it does not.
|
||||
let Ok(value) = raw.trim().trim_end_matches('%').parse::<f32>() else {
|
||||
log::debug!("xmp: {} is not a number: {raw:?}", mapping.crs);
|
||||
continue;
|
||||
};
|
||||
let value = match mapping.convert {
|
||||
Convert::Direct => value,
|
||||
Convert::Scale(by) => value * by,
|
||||
};
|
||||
// Out-of-range values are left as they are: `EditGraph::set_param`
|
||||
// clamps when the preset is applied, and clamping here as well would
|
||||
// mean two places to be wrong about a range.
|
||||
params.insert((mapping.op.to_string(), mapping.param.to_string()), value);
|
||||
}
|
||||
|
||||
let skipped = KNOWN_UNSUPPORTED
|
||||
.iter()
|
||||
.filter(|k| found.contains_key(**k))
|
||||
.map(|k| k.to_string())
|
||||
.collect();
|
||||
|
||||
if params.is_empty() && name.is_none() {
|
||||
return Err(ImportError::NoSettings);
|
||||
}
|
||||
|
||||
Ok(Import {
|
||||
name,
|
||||
preset: Preset::from_params(params),
|
||||
skipped,
|
||||
})
|
||||
}
|
||||
|
||||
/// The part of `ns:Local` after the colon.
|
||||
///
|
||||
/// Namespace prefixes are declared per document and Camera Raw is not the only
|
||||
/// thing that writes XMP, so matching on the prefix would be matching on a
|
||||
/// convention rather than on the standard. Comparing local names is what makes
|
||||
/// a file written by a different tool with a different prefix still readable.
|
||||
fn local_name(raw: &[u8]) -> String {
|
||||
let name = String::from_utf8_lossy(raw);
|
||||
match name.split_once(':') {
|
||||
Some((_, local)) => local.to_string(),
|
||||
None => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_pipeline::{EditGraph, Scope};
|
||||
|
||||
/// The attribute form: settings as attributes on `rdf:Description`.
|
||||
const ATTRIBUTE_FORM: &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:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:PresetType="Normal"
|
||||
crs:Exposure2012="+0.75"
|
||||
crs:Contrast2012="+25"
|
||||
crs:Highlights2012="-40"
|
||||
crs:Shadows2012="+30"
|
||||
crs:Whites2012="+10"
|
||||
crs:Blacks2012="-15"
|
||||
crs:Clarity2012="+12"
|
||||
crs:Texture="+8"
|
||||
crs:Vibrance="+20"
|
||||
crs:Saturation="-5"
|
||||
crs:Sharpness="75"
|
||||
crs:Temperature="5400"
|
||||
crs:Tint="+12">
|
||||
<crs:Name>
|
||||
<rdf:Alt>
|
||||
<rdf:li xml:lang="x-default">Warm Portrait</rdf:li>
|
||||
</rdf:Alt>
|
||||
</crs:Name>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"#;
|
||||
|
||||
/// The element form: the same settings as child elements.
|
||||
const ELEMENT_FORM: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
|
||||
<crs:Exposure2012>+0.75</crs:Exposure2012>
|
||||
<crs:Contrast2012>+25</crs:Contrast2012>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"#;
|
||||
|
||||
fn value(import: &Import, op: &str, param: &str) -> Option<f32> {
|
||||
import
|
||||
.preset
|
||||
.params()
|
||||
.get(&(op.to_string(), param.to_string()))
|
||||
.copied()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mapping_names_a_parameter_this_build_actually_has() {
|
||||
// The test that keeps the table honest. Adobe's half cannot be checked
|
||||
// from here, but ours can: a renamed operation or parameter must break
|
||||
// this rather than silently drop a setting on every import.
|
||||
let graph = EditGraph::default_chain();
|
||||
let capabilities = graph.capabilities();
|
||||
for mapping in MAPPINGS {
|
||||
let op = capabilities
|
||||
.iter()
|
||||
.find(|c| c.id.0 == mapping.op)
|
||||
.unwrap_or_else(|| panic!("no operation {:?}", mapping.op));
|
||||
assert!(
|
||||
op.params.iter().any(|p| p.id.0 == mapping.param),
|
||||
"operation {:?} has no parameter {:?}",
|
||||
mapping.op,
|
||||
mapping.param
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_two_mappings_claim_the_same_key_or_the_same_target() {
|
||||
let mut keys: Vec<&str> = MAPPINGS.iter().map(|m| m.crs).collect();
|
||||
keys.sort_unstable();
|
||||
let before = keys.len();
|
||||
keys.dedup();
|
||||
assert_eq!(before, keys.len(), "two mappings read the same crs key");
|
||||
|
||||
let mut targets: Vec<(&str, &str)> = MAPPINGS.iter().map(|m| (m.op, m.param)).collect();
|
||||
targets.sort_unstable();
|
||||
let before = targets.len();
|
||||
targets.dedup();
|
||||
assert_eq!(
|
||||
before,
|
||||
targets.len(),
|
||||
"two mappings write the same parameter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_settings_that_share_a_convention_come_across_unchanged() {
|
||||
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
|
||||
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
|
||||
assert_eq!(value(&import, "contrast", "contrast"), Some(25.0));
|
||||
assert_eq!(
|
||||
value(&import, "highlights_shadows", "highlights"),
|
||||
Some(-40.0)
|
||||
);
|
||||
assert_eq!(value(&import, "highlights_shadows", "shadows"), Some(30.0));
|
||||
assert_eq!(value(&import, "blacks_whites", "whites"), Some(10.0));
|
||||
assert_eq!(value(&import, "blacks_whites", "blacks"), Some(-15.0));
|
||||
assert_eq!(value(&import, "clarity", "amount"), Some(12.0));
|
||||
assert_eq!(value(&import, "texture", "amount"), Some(8.0));
|
||||
assert_eq!(value(&import, "vibrance", "vibrance"), Some(20.0));
|
||||
assert_eq!(value(&import, "saturation", "saturation"), Some(-5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_that_differs_is_rescaled_rather_than_clamped() {
|
||||
// Adobe's sharpening runs to 150. A preset asking for half of its
|
||||
// range should ask for half of ours, not for two thirds of it.
|
||||
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
|
||||
assert_eq!(value(&import, "capture_sharpen", "amount"), Some(50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_white_balance_is_reported_as_skipped_rather_than_guessed_at() {
|
||||
// The honest gap. Adobe's Kelvin cannot become our relative nudge
|
||||
// without the target image's as-shot white balance, which a preset
|
||||
// does not carry.
|
||||
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
|
||||
assert!(value(&import, "white_balance", "temperature").is_none());
|
||||
assert!(value(&import, "white_balance", "tint").is_none());
|
||||
assert!(import.skipped.contains(&"Temperature".to_string()));
|
||||
assert!(import.skipped.contains(&"Tint".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preset_carries_its_name() {
|
||||
assert_eq!(
|
||||
read_xmp(ATTRIBUTE_FORM).unwrap().name.as_deref(),
|
||||
Some("Warm Portrait")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_written_as_elements_read_the_same_as_settings_written_as_attributes() {
|
||||
// A preset folder spans a decade of Lightroom versions and the file
|
||||
// does not say which shape it used.
|
||||
let import = read_xmp(ELEMENT_FORM).unwrap();
|
||||
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
|
||||
assert_eq!(value(&import, "contrast", "contrast"), Some(25.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_preset_applies_like_any_other() {
|
||||
// The point of the exercise: what comes out is a `Preset`, not a
|
||||
// second kind of thing with a second apply path.
|
||||
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
|
||||
let mut graph = EditGraph::default_chain();
|
||||
import.preset.apply(&mut graph, Scope::adjustments());
|
||||
assert_eq!(
|
||||
graph.param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE
|
||||
),
|
||||
Some(0.75)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_that_is_not_a_number_costs_that_setting_and_not_the_file() {
|
||||
let text =
|
||||
ATTRIBUTE_FORM.replace(r#"crs:Contrast2012="+25""#, r#"crs:Contrast2012="lots""#);
|
||||
let import = read_xmp(&text).unwrap();
|
||||
assert_eq!(value(&import, "contrast", "contrast"), None);
|
||||
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_xml_is_refused() {
|
||||
assert!(matches!(
|
||||
read_xmp("<x:xmpmeta><unclosed>"),
|
||||
Err(ImportError::NotXml(_)) | Err(ImportError::NoSettings)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_no_camera_raw_settings_is_refused() {
|
||||
let text = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"></x:xmpmeta>"#;
|
||||
assert_eq!(read_xmp(text), Err(ImportError::NoSettings));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefix_other_than_crs_is_still_read() {
|
||||
// Namespace prefixes are declared per document. Matching on `crs:`
|
||||
// would be matching on a convention rather than on the standard.
|
||||
let text = ELEMENT_FORM.replace("crs:", "cameraraw:");
|
||||
let import = read_xmp(&text).unwrap();
|
||||
assert_eq!(value(&import, "exposure", "exposure"), Some(0.75));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reading a folder
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn tempdir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dr-preset-import-{name}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_of_presets_is_read_including_its_subfolders() {
|
||||
// The shape a photographer's presets are actually in: an exported
|
||||
// Lightroom folder, nested one level per group.
|
||||
let dir = tempdir("folder");
|
||||
std::fs::write(dir.join("one.xmp"), ATTRIBUTE_FORM).unwrap();
|
||||
let group = dir.join("Portraits");
|
||||
std::fs::create_dir_all(&group).unwrap();
|
||||
std::fs::write(group.join("two.xmp"), ELEMENT_FORM).unwrap();
|
||||
// Not a preset, and not an error either.
|
||||
std::fs::write(dir.join("notes.txt"), "ignore me").unwrap();
|
||||
|
||||
let report = read_path(&dir);
|
||||
assert_eq!(report.presets.len(), 2, "{report:?}");
|
||||
assert_eq!(report.failed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_without_a_name_is_called_after_its_file() {
|
||||
// Lightroom writes the name it displays, which is not always the file
|
||||
// name — but a file with no `crs:Name` still has to arrive callable.
|
||||
let dir = tempdir("unnamed");
|
||||
std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
|
||||
let report = read_path(&dir);
|
||||
assert_eq!(report.presets[0].0, "Golden Hour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_displayed_name_wins_over_the_file_name() {
|
||||
let dir = tempdir("named");
|
||||
std::fs::write(dir.join("preset_04b.xmp"), ATTRIBUTE_FORM).unwrap();
|
||||
let report = read_path(&dir);
|
||||
assert_eq!(report.presets[0].0, "Warm Portrait");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_counted_rather_than_stopping_the_import() {
|
||||
// Ninety presets and one bad file should import eighty-nine.
|
||||
let dir = tempdir("partial");
|
||||
std::fs::write(dir.join("good.xmp"), ATTRIBUTE_FORM).unwrap();
|
||||
std::fs::write(dir.join("bad.xmp"), "not xml at all <<<").unwrap();
|
||||
let report = read_path(&dir);
|
||||
assert_eq!(report.presets.len(), 1);
|
||||
assert_eq!(report.failed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_unsupported_keys_are_gathered_once_and_not_once_per_file() {
|
||||
// After forty presets, "the white balance did not come across" is the
|
||||
// useful sentence; forty repetitions of it is not.
|
||||
let dir = tempdir("gathered");
|
||||
std::fs::write(dir.join("a.xmp"), ATTRIBUTE_FORM).unwrap();
|
||||
std::fs::write(dir.join("b.xmp"), ATTRIBUTE_FORM).unwrap();
|
||||
let report = read_path(&dir);
|
||||
assert_eq!(report.presets.len(), 2);
|
||||
assert!(report.unsupported.contains("Temperature"));
|
||||
assert_eq!(
|
||||
report
|
||||
.unsupported
|
||||
.iter()
|
||||
.filter(|k| *k == "Temperature")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_file_can_be_imported_on_its_own() {
|
||||
let dir = tempdir("single");
|
||||
let file = dir.join("one.xmp");
|
||||
std::fs::write(&file, ATTRIBUTE_FORM).unwrap();
|
||||
assert_eq!(read_path(&file).presets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_is_not_there_reports_nothing_rather_than_failing() {
|
||||
let report = read_path(std::path::Path::new("/definitely/not/here"));
|
||||
assert_eq!(report, Report::default());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading a folder of them
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What came of reading a path.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Report {
|
||||
/// Presets read and named, ready to be stored.
|
||||
pub presets: Vec<(String, Preset)>,
|
||||
/// Files that were `.xmp` but did not yield a preset.
|
||||
pub failed: usize,
|
||||
/// Every deliberately-unsupported key seen across the whole run.
|
||||
///
|
||||
/// Gathered rather than counted: after importing forty presets, "the white
|
||||
/// balance did not come across" is the useful sentence, and forty
|
||||
/// repetitions of it is not.
|
||||
pub unsupported: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Read one `.xmp` file, or every `.xmp` under a folder.
|
||||
///
|
||||
/// A folder because that is the shape a photographer's presets are in — an
|
||||
/// exported Lightroom preset folder, nested one level per group — and asking
|
||||
/// them to import ninety files one at a time would be asking them not to
|
||||
/// bother. Nested folders are walked, which is what makes the group structure
|
||||
/// available to whatever wants it later.
|
||||
///
|
||||
/// The *name* comes from `crs:Name` where the file carries one and from the
|
||||
/// file stem where it does not. Lightroom writes the name it displays, which
|
||||
/// is not always the file name, and the displayed name is the one the
|
||||
/// photographer will look for.
|
||||
pub fn read_path(path: &std::path::Path) -> Report {
|
||||
let mut report = Report::default();
|
||||
read_into(path, &mut report);
|
||||
report.presets.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
report
|
||||
}
|
||||
|
||||
fn read_into(path: &std::path::Path, report: &mut Report) {
|
||||
if path.is_dir() {
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
log::warn!("preset import: cannot read {}", path.display());
|
||||
return;
|
||||
};
|
||||
// Sorted, so importing the same folder twice reports the same order
|
||||
// and a name collision resolves the same way both times.
|
||||
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
read_into(&path, report);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let is_xmp = path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("xmp"));
|
||||
if !is_xmp {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
report.failed += 1;
|
||||
return;
|
||||
};
|
||||
match read_xmp(&text) {
|
||||
Ok(import) => {
|
||||
let name = import.name.unwrap_or_else(|| {
|
||||
path.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
report.unsupported.extend(import.skipped);
|
||||
report.presets.push((name, import.preset));
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("preset import: {}: {e}", path.display());
|
||||
report.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -7,6 +7,10 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
# TRACES: FR-DEV-6
|
||||
# Reading Lightroom `.xmp` presets. Its own crate because the translation names
|
||||
# operations, which the interface may not — see `ui_names_no_operation.rs`.
|
||||
dr-preset-xmp.workspace = true
|
||||
# No `readback`. S1 wired Slint's texture import, so the develop view hands
|
||||
# the compositor the texture itself and there is no display round-trip left to
|
||||
# gate (ARCH §6.1, AC-8). The export path reads pixels back through
|
||||
|
||||
+213
-5
@@ -688,8 +688,74 @@ impl NamedPresets {
|
||||
}
|
||||
|
||||
fn at(store: PresetStore) -> Rc<Self> {
|
||||
let library = RefCell::new(store.load());
|
||||
Rc::new(Self { store, library })
|
||||
// TRACES: FR-DEV-6
|
||||
// Seed the starter set on a device that has never had a library, and
|
||||
// only then. See `preset_starter` for why this is not a merge:
|
||||
// re-adding on every start would resurrect one deleted on purpose.
|
||||
//
|
||||
// "Never had one" is the *file* not existing rather than the library
|
||||
// being empty — a photographer who deleted all six must not be handed
|
||||
// them again on the next launch.
|
||||
let seeding = !store.path().exists();
|
||||
let library = if seeding {
|
||||
let starters = dr_pipeline::starter::library();
|
||||
if let Err(e) = store.save(&starters) {
|
||||
// Not fatal. The presets are in memory and usable this
|
||||
// session; what is lost is their persistence, and refusing to
|
||||
// start over it would be absurd.
|
||||
log::warn!("could not write the starter presets: {e}");
|
||||
}
|
||||
starters
|
||||
} else {
|
||||
store.load()
|
||||
};
|
||||
Rc::new(Self {
|
||||
store,
|
||||
library: RefCell::new(library),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read presets from a file or a folder and store them.
|
||||
///
|
||||
/// Returns what to tell the user. Names already in the library are
|
||||
/// replaced, the same rule a save follows — importing the same folder
|
||||
/// twice leaves one copy of each rather than `Warm Portrait 2`.
|
||||
fn import(&self, path: &Path) -> String {
|
||||
let report = dr_preset_xmp::read_path(path);
|
||||
if report.presets.is_empty() {
|
||||
return if report.failed > 0 {
|
||||
format!("Found {} file(s), but none could be read.", report.failed)
|
||||
} else {
|
||||
"No .xmp presets there.".to_string()
|
||||
};
|
||||
}
|
||||
|
||||
let added = report.presets.len();
|
||||
{
|
||||
let mut library = self.library.borrow_mut();
|
||||
for (name, preset) in report.presets {
|
||||
if let Err(e) = library.insert(&name, preset) {
|
||||
log::warn!("imported preset {name:?} is unusable ({e:?}); skipping");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.persist(|_| {}) {
|
||||
return e.message();
|
||||
}
|
||||
|
||||
let mut message = format!(
|
||||
"Imported {added} preset{}.",
|
||||
if added == 1 { "" } else { "s" }
|
||||
);
|
||||
if report.failed > 0 {
|
||||
message.push_str(&format!(" {} could not be read.", report.failed));
|
||||
}
|
||||
// Said once, however many files carried it: after forty presets the
|
||||
// useful sentence is that the white balance did not come across.
|
||||
if !report.unsupported.is_empty() {
|
||||
message.push_str(" White balance and tone curves do not carry across.");
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
/// The stored names, in the order they are written.
|
||||
@@ -702,6 +768,12 @@ impl NamedPresets {
|
||||
self.library.borrow().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Whether a preset is stored under `name`.
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.library.borrow().contains(name)
|
||||
}
|
||||
|
||||
/// Store `preset` under `name` and persist.
|
||||
///
|
||||
/// The in-memory library is updated first and rolled back if the write
|
||||
@@ -726,6 +798,13 @@ impl NamedPresets {
|
||||
}
|
||||
|
||||
/// Save the library, undoing the in-memory change if the write fails.
|
||||
///
|
||||
/// The rollback is the caller's because only the caller knows what the
|
||||
/// previous state was. An import passes an empty one deliberately: it may
|
||||
/// have replaced any number of names, restoring them all would mean
|
||||
/// snapshotting the whole library, and the honest failure there is to say
|
||||
/// the write failed and leave what is on screen matching what is in
|
||||
/// memory until the next successful save.
|
||||
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
|
||||
let result = self.store.save(&self.library.borrow());
|
||||
match result {
|
||||
@@ -745,6 +824,7 @@ impl NamedPresets {
|
||||
}
|
||||
|
||||
/// Why a preset could not be saved.
|
||||
#[derive(Debug)]
|
||||
enum SaveError {
|
||||
/// The name itself was refused.
|
||||
Name(NameError),
|
||||
@@ -899,6 +979,25 @@ pub fn wire_named(
|
||||
});
|
||||
}
|
||||
|
||||
// --- import from Lightroom --------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let named = named.clone();
|
||||
window.on_import_presets(move |path| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Synchronous, deliberately. A preset folder is a few hundred
|
||||
// small text files and the read is milliseconds; a worker and a
|
||||
// progress bar would be machinery around a wait nobody sees.
|
||||
let report = named.import(Path::new(path));
|
||||
w.set_preset_import_report(report.into());
|
||||
render_named(&w, &named);
|
||||
});
|
||||
}
|
||||
|
||||
// --- rename -----------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
@@ -1275,11 +1374,115 @@ mod tests {
|
||||
// Named presets
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// A library that has *already been started*, so the starter presets are
|
||||
/// not in the way.
|
||||
///
|
||||
/// Writing an empty library first is what makes the file exist, which is
|
||||
/// the condition seeding tests — see `NamedPresets::at`. The seeding
|
||||
/// itself has its own tests below rather than being switched off with a
|
||||
/// flag that only tests would set.
|
||||
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
|
||||
let dir = tempdir(name);
|
||||
let path = dir.join("presets.drpl");
|
||||
PresetStore::open_at(path.clone())
|
||||
.save(&PresetLibrary::default())
|
||||
.unwrap();
|
||||
(NamedPresets::open_at(path), dir)
|
||||
}
|
||||
|
||||
/// A device that has never had a preset library.
|
||||
fn fresh(name: &str) -> (Rc<NamedPresets>, PathBuf) {
|
||||
let dir = tempdir(name);
|
||||
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_run_is_handed_the_starter_presets() {
|
||||
// A sheet that opens on "No presets yet" teaches the photographer that
|
||||
// the feature is homework.
|
||||
let (presets, _dir) = fresh("first-run");
|
||||
assert_eq!(presets.names().len(), dr_pipeline::starter::library().len());
|
||||
assert!(!presets.names().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_starters_are_written_out_so_they_survive_a_restart() {
|
||||
let (_presets, dir) = fresh("starters-persist");
|
||||
let path = dir.join("presets.drpl");
|
||||
assert!(path.exists(), "the starter library was never written");
|
||||
assert!(!PresetStore::open_at(path).load().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_starter_the_photographer_deleted_stays_deleted() {
|
||||
// The reason seeding keys on the file existing rather than on the
|
||||
// library being empty: deleting all six must not hand them back on the
|
||||
// next launch.
|
||||
let dir = tempdir("deleted-starters");
|
||||
let path = dir.join("presets.drpl");
|
||||
{
|
||||
let presets = NamedPresets::open_at(path.clone());
|
||||
for name in presets.names() {
|
||||
let mut library = presets.library.borrow_mut();
|
||||
library.remove(&name);
|
||||
}
|
||||
presets.persist(|_| {}).unwrap();
|
||||
}
|
||||
|
||||
let again = NamedPresets::open_at(path);
|
||||
assert!(
|
||||
again.names().is_empty(),
|
||||
"the starters came back: {:?}",
|
||||
again.names()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_a_folder_stores_what_it_finds() {
|
||||
let (presets, dir) = named("import-folder");
|
||||
let from = dir.join("from-lightroom");
|
||||
std::fs::create_dir_all(&from).unwrap();
|
||||
std::fs::write(
|
||||
from.join("warm.xmp"),
|
||||
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.5</crs:Exposure2012>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = presets.import(&from);
|
||||
assert!(report.starts_with("Imported 1 preset."), "{report}");
|
||||
assert_eq!(presets.names(), vec!["warm".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_the_same_folder_twice_leaves_one_copy() {
|
||||
let (presets, dir) = named("import-twice");
|
||||
let from = dir.join("from-lightroom");
|
||||
std::fs::create_dir_all(&from).unwrap();
|
||||
std::fs::write(
|
||||
from.join("warm.xmp"),
|
||||
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:Exposure2012="+0.5"/>"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
presets.import(&from);
|
||||
presets.import(&from);
|
||||
assert_eq!(presets.names(), vec!["warm".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_from_nowhere_says_so_rather_than_claiming_success() {
|
||||
let (presets, _dir) = named("import-nothing");
|
||||
let report = presets.import(Path::new("/definitely/not/here"));
|
||||
assert!(report.contains("No .xmp presets"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_preset_is_on_disk_before_the_call_returns() {
|
||||
// Not on the way out, and not on a timer: a preset the user named and
|
||||
@@ -1287,7 +1490,6 @@ mod tests {
|
||||
let (presets, dir) = named("saved-immediately");
|
||||
presets
|
||||
.insert("Warm", Preset::capture(&edited()))
|
||||
.ok()
|
||||
.expect("saved");
|
||||
|
||||
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
|
||||
@@ -1331,9 +1533,15 @@ mod tests {
|
||||
|
||||
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
|
||||
assert!(presets.insert("Warm", Preset::default()).is_err());
|
||||
// Not "the library is empty": the starter set is held in memory even
|
||||
// on a device whose config directory cannot be written, which is the
|
||||
// right behaviour — the presets are usable this session and only their
|
||||
// persistence is lost. What must not survive is the preset whose write
|
||||
// failed.
|
||||
assert!(
|
||||
presets.names().is_empty(),
|
||||
"the failed save left a preset behind"
|
||||
!presets.contains("Warm"),
|
||||
"the failed save left a preset behind: {:?}",
|
||||
presets.names()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -680,6 +680,11 @@ export component AppWindow inherits Window {
|
||||
in property <bool> copy-scope-empty: false;
|
||||
callback copy-scope-toggled(string);
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Reading Lightroom presets in, and what came of the last attempt.
|
||||
in property <string> preset-import-report;
|
||||
callback import-presets(string);
|
||||
|
||||
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
|
||||
//
|
||||
// A page rather than an overlay, and the outermost of the view conditions
|
||||
@@ -2473,6 +2478,8 @@ in property <bool> panel-visible: true;
|
||||
scope-kinds: root.copy-scope-kinds;
|
||||
scope-empty: root.copy-scope-empty;
|
||||
scope-toggled(name) => { root.copy-scope-toggled(name); }
|
||||
import-report: root.preset-import-report;
|
||||
import-presets(path) => { root.import-presets(path); }
|
||||
save(name) => { root.save-preset(name); }
|
||||
apply(name) => { root.apply-preset(name); }
|
||||
rename(from, to) => { root.rename-preset(from, to); }
|
||||
|
||||
@@ -110,6 +110,14 @@ export component PresetSheet inherits Rectangle {
|
||||
in property <bool> scope-empty: false;
|
||||
callback scope-toggled(string);
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Read Lightroom presets from a typed path, and what came of the last
|
||||
/// attempt. A typed path rather than a file dialogue because that is the
|
||||
/// idiom this application already has — the launch screen takes a library
|
||||
/// the same way, and there is no native picker anywhere in it.
|
||||
in property <string> import-report;
|
||||
callback import-presets(string);
|
||||
|
||||
background: #000000CC;
|
||||
|
||||
// Swallows the taps that miss the card, and closes. First, so the card's
|
||||
@@ -265,6 +273,27 @@ export component PresetSheet inherits Rectangle {
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// Bringing a Lightroom library across. A folder rather than a
|
||||
// file is the expected case — an exported preset folder, nested
|
||||
// one level per group — so the field says so.
|
||||
VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
PanelHeading { text: "IMPORT"; }
|
||||
|
||||
Field {
|
||||
placeholder: "Path to a .xmp file or folder";
|
||||
accepted(path) => { root.import-presets(path); }
|
||||
}
|
||||
|
||||
if root.import-report != "": Caption {
|
||||
text: root.import-report;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// What an apply carries. At the bottom, because it qualifies every
|
||||
// action above it rather than being one of them — and because the
|
||||
|
||||
Reference in New Issue
Block a user