Contrast2012 and the four recovery sliders were imported one to one. They do not mean the same thing here: fitted on the library's Lightroom 6 exports and their raws — each photograph's sliders carried across as slider × factor, one factor per slider, on about 90 exports with no look applied, on the Camera Raw default rendering — ours needed contrast at about a tenth (Lightroom's −100 imported as ours flattens a frame to grey), highlights ×1.4, shadows ×1.9 and blacks ×1.25. Whites fitted below 1 every time without agreeing where; 0.5 is a hedge, and says so. Vibrance stays one to one: the op itself is now calibrated to Lightroom's.
1011 lines
37 KiB
Rust
1011 lines
37 KiB
Rust
//! 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, 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, Reach};
|
||
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,
|
||
},
|
||
// The five tone sliders do not mean the same thing in the two applications,
|
||
// whatever their shared ±100 suggests. The factors were fitted against the
|
||
// library's own Lightroom 6 exports and their raws (darkroom-lrfit, 2026-10):
|
||
// each photograph's sliders carried across as `slider × factor`, one factor
|
||
// per slider, on ~90 exports with no look applied. Contrast is ours at a
|
||
// tenth — at −100 ours flattens a frame to grey — and our shadows need
|
||
// nearly twice Lightroom's number. Whites barely appears in those exports;
|
||
// every fit put it under 1 but none agreed where, so 0.5 is a hedge.
|
||
Mapping {
|
||
crs: "Contrast2012",
|
||
op: "contrast",
|
||
param: "contrast",
|
||
convert: Convert::Scale(0.1),
|
||
},
|
||
Mapping {
|
||
crs: "Highlights2012",
|
||
op: "highlights_shadows",
|
||
param: "highlights",
|
||
convert: Convert::Scale(1.4),
|
||
},
|
||
Mapping {
|
||
crs: "Shadows2012",
|
||
op: "highlights_shadows",
|
||
param: "shadows",
|
||
convert: Convert::Scale(1.9),
|
||
},
|
||
Mapping {
|
||
crs: "Whites2012",
|
||
op: "blacks_whites",
|
||
param: "whites",
|
||
convert: Convert::Scale(0.5),
|
||
},
|
||
Mapping {
|
||
crs: "Blacks2012",
|
||
op: "blacks_whites",
|
||
param: "blacks",
|
||
convert: Convert::Scale(1.25),
|
||
},
|
||
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,
|
||
},
|
||
// TRACES: FR-DEV-6
|
||
// Lightroom's HSL panel: eight bands, each ±100 for hue, saturation and
|
||
// luminance, onto the colour mixer's twelve. Lightroom's bands sit where
|
||
// ours do except two, matched to the nearest of ours by hue: Aqua (180°)
|
||
// is our cyan, Purple (270°) our violet; chartreuse, spring, azure and
|
||
// rose have no Lightroom counterpart and are left alone. One for one, as
|
||
// a first translation — the band widths differ, and `lr-fit`'s
|
||
// measurement against Lightroom's own output may yet scale these.
|
||
Mapping {
|
||
crs: "HueAdjustmentRed",
|
||
op: "colour_mixer",
|
||
param: "red_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentRed",
|
||
op: "colour_mixer",
|
||
param: "red_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentRed",
|
||
op: "colour_mixer",
|
||
param: "red_lum",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "HueAdjustmentOrange",
|
||
op: "colour_mixer",
|
||
param: "orange_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentOrange",
|
||
op: "colour_mixer",
|
||
param: "orange_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentOrange",
|
||
op: "colour_mixer",
|
||
param: "orange_lum",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "HueAdjustmentYellow",
|
||
op: "colour_mixer",
|
||
param: "yellow_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentYellow",
|
||
op: "colour_mixer",
|
||
param: "yellow_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentYellow",
|
||
op: "colour_mixer",
|
||
param: "yellow_lum",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "HueAdjustmentGreen",
|
||
op: "colour_mixer",
|
||
param: "green_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentGreen",
|
||
op: "colour_mixer",
|
||
param: "green_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentGreen",
|
||
op: "colour_mixer",
|
||
param: "green_lum",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "HueAdjustmentAqua",
|
||
op: "colour_mixer",
|
||
param: "cyan_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentAqua",
|
||
op: "colour_mixer",
|
||
param: "cyan_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentAqua",
|
||
op: "colour_mixer",
|
||
param: "cyan_lum",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "HueAdjustmentBlue",
|
||
op: "colour_mixer",
|
||
param: "blue_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentBlue",
|
||
op: "colour_mixer",
|
||
param: "blue_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentBlue",
|
||
op: "colour_mixer",
|
||
param: "blue_lum",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "HueAdjustmentPurple",
|
||
op: "colour_mixer",
|
||
param: "violet_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentPurple",
|
||
op: "colour_mixer",
|
||
param: "violet_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentPurple",
|
||
op: "colour_mixer",
|
||
param: "violet_lum",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "HueAdjustmentMagenta",
|
||
op: "colour_mixer",
|
||
param: "magenta_hue",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "SaturationAdjustmentMagenta",
|
||
op: "colour_mixer",
|
||
param: "magenta_sat",
|
||
convert: Convert::Direct,
|
||
},
|
||
Mapping {
|
||
crs: "LuminanceAdjustmentMagenta",
|
||
op: "colour_mixer",
|
||
param: "magenta_lum",
|
||
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,
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// The Lightroom edit stored inside a photograph — the XMP packet Lightroom
|
||
/// writes into a DNG — translated as a preset is.
|
||
///
|
||
/// `None` where the file carries no packet, or one with no Camera Raw
|
||
/// settings in it (darktable's sidecars, a camera's own XMP). The packet is
|
||
/// found by its delimiters rather than by walking the TIFF structure: it is
|
||
/// plain text by specification, and the same search serves any container.
|
||
pub fn read_embedded(bytes: &[u8]) -> Option<Import> {
|
||
const OPEN: &[u8] = b"<x:xmpmeta";
|
||
const CLOSE: &[u8] = b"</x:xmpmeta>";
|
||
let start = find(bytes, OPEN)?;
|
||
let end = start + find(&bytes[start..], CLOSE)? + CLOSE.len();
|
||
let text = std::str::from_utf8(&bytes[start..end]).ok()?;
|
||
read_xmp(text).ok().filter(|i| !i.preset.is_empty())
|
||
}
|
||
|
||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||
haystack.windows(needle.len()).position(|w| w == needle)
|
||
}
|
||
|
||
/// 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,
|
||
// A look, because that is what a Lightroom preset is: it changes the
|
||
// settings it was saved with and leaves every other one where the
|
||
// photograph had it. Applied as a whole edit instead, a preset
|
||
// holding only a grade would reset the exposure it was put on top of.
|
||
preset: Preset::from_params(params).with_reach(Reach::Named),
|
||
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 a_dngs_embedded_lightroom_edit_comes_across_with_its_hsl() {
|
||
// TRACES: FR-DEV-6
|
||
// The shape Lightroom 6 writes into a DNG, trimmed: the library's
|
||
// house look, as camera-profiles.md's D21 note records it.
|
||
let mut file = b"II*\0 binary header bytes ".to_vec();
|
||
file.extend_from_slice(
|
||
br#"<?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:ProcessVersion="6.7" crs:Exposure2012="0.00" crs:Highlights2012="-40" crs:Blacks2012="-20" crs:SaturationAdjustmentBlue="+58" crs:SaturationAdjustmentAqua="+50" crs:SaturationAdjustmentPurple="+23" crs:HueAdjustmentRed="-5" crs:LuminanceAdjustmentGreen="+7"/></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#,
|
||
);
|
||
file.extend_from_slice(b"\0 more binary");
|
||
let import = read_embedded(&file).expect("an edit");
|
||
let p = import.preset.params();
|
||
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
|
||
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
|
||
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
|
||
assert_eq!(get("colour_mixer", "violet_sat"), Some(23.0));
|
||
assert_eq!(get("colour_mixer", "red_hue"), Some(-5.0));
|
||
assert_eq!(get("colour_mixer", "green_lum"), Some(7.0));
|
||
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0 * 1.4));
|
||
assert_eq!(get("blacks_whites", "blacks"), Some(-20.0 * 1.25));
|
||
}
|
||
|
||
#[test]
|
||
fn the_libraries_dngs_carry_the_house_look() {
|
||
// TRACES: FR-DEV-6
|
||
// The library's own Lightroom 6 DNG, where it is on this machine.
|
||
let Some(home) = std::env::var_os("HOME") else {
|
||
return;
|
||
};
|
||
let path =
|
||
std::path::Path::new(&home).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng");
|
||
let Ok(bytes) = std::fs::read(&path) else {
|
||
eprintln!("skipped: no sample DNG at {}", path.display());
|
||
return;
|
||
};
|
||
let import = read_embedded(&bytes).expect("Lightroom's edit");
|
||
let p = import.preset.params();
|
||
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
|
||
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
|
||
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
|
||
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0 * 1.4));
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_with_no_camera_raw_settings_has_no_edit() {
|
||
assert!(read_embedded(b"no packet at all").is_none());
|
||
let darktable = br#"<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/" xmp:Rating="3"/></rdf:RDF></x:xmpmeta>"#;
|
||
assert!(read_embedded(darktable).is_none());
|
||
}
|
||
|
||
#[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 * 0.1));
|
||
assert_eq!(
|
||
value(&import, "highlights_shadows", "highlights"),
|
||
Some(-40.0 * 1.4)
|
||
);
|
||
assert_eq!(
|
||
value(&import, "highlights_shadows", "shadows"),
|
||
Some(30.0 * 1.9)
|
||
);
|
||
assert_eq!(value(&import, "blacks_whites", "whites"), Some(10.0 * 0.5));
|
||
assert_eq!(
|
||
value(&import, "blacks_whites", "blacks"),
|
||
Some(-15.0 * 1.25)
|
||
);
|
||
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 * 0.1));
|
||
}
|
||
|
||
#[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())
|
||
.expect_no_film();
|
||
assert_eq!(
|
||
graph.param(
|
||
dr_pipeline::ops::exposure::ID,
|
||
dr_pipeline::ops::exposure::EXPOSURE
|
||
),
|
||
Some(0.75)
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Lightroom's own rule: a preset changes what it was saved with and
|
||
/// nothing else, so a grade lands on top of the photograph's correction.
|
||
#[test]
|
||
fn an_imported_preset_leaves_what_it_does_not_set_alone() {
|
||
use dr_pipeline::ops::dehaze;
|
||
|
||
let import = read_xmp(ATTRIBUTE_FORM).unwrap();
|
||
assert_eq!(import.preset.reach(), Reach::Named);
|
||
assert!(value(&import, "dehaze", "amount").is_none());
|
||
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.set_param(dehaze::ID, dehaze::AMOUNT, 30.0);
|
||
import
|
||
.preset
|
||
.apply(&mut graph, Scope::adjustments())
|
||
.expect_no_film();
|
||
assert_eq!(graph.param(dehaze::ID, dehaze::AMOUNT), Some(30.0));
|
||
}
|
||
|
||
#[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_subfolder_becomes_the_category_of_what_it_holds() {
|
||
// The folder picked is the root and names nothing; each folder under
|
||
// it is a level of category, as Lightroom's groups were.
|
||
let dir = tempdir("categories");
|
||
std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
|
||
let nested = dir.join("Film").join("Colour");
|
||
std::fs::create_dir_all(&nested).unwrap();
|
||
std::fs::write(nested.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
|
||
|
||
let names: Vec<_> = read_path(&dir).presets.into_iter().map(|p| p.0).collect();
|
||
assert_eq!(names, ["Film/Colour/Golden Hour", "Golden Hour"]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_slash_in_a_displayed_name_is_not_a_category() {
|
||
let dir = tempdir("slash");
|
||
std::fs::write(
|
||
dir.join("p.xmp"),
|
||
ATTRIBUTE_FORM.replace("Warm Portrait", "Warm / Cool"),
|
||
)
|
||
.unwrap();
|
||
let report = read_path(&dir);
|
||
assert_eq!(report.presets[0].0, "Warm \u{2215} Cool");
|
||
}
|
||
|
||
#[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, and each one below `path` becomes a
|
||
/// category: a preset in `Portraits/` is named `Portraits/Warm skin`, which is
|
||
/// how the preset menu files it (see `PresetLibrary`'s note on categories).
|
||
///
|
||
/// 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. A `/` inside that name would read as a
|
||
/// category it never had, so it becomes `∕`, which looks the same and
|
||
/// separates nothing.
|
||
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
|
||
}
|
||
|
||
/// The category a folder below the import root files its presets under.
|
||
fn category_of(parent: &str, folder: &std::path::Path) -> String {
|
||
let Some(name) = folder.file_name() else {
|
||
return parent.to_string();
|
||
};
|
||
let name = name.to_string_lossy().replace('/', "\u{2215}");
|
||
if parent.is_empty() {
|
||
name
|
||
} else {
|
||
format!("{parent}/{name}")
|
||
}
|
||
}
|
||
|
||
fn read_into(path: &std::path::Path, category: &str, 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 {
|
||
let category = if path.is_dir() {
|
||
category_of(category, &path)
|
||
} else {
|
||
category.to_string()
|
||
};
|
||
read_into(&path, &category, 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);
|
||
let name = name.replace('/', "\u{2215}");
|
||
let name = if category.is_empty() {
|
||
name
|
||
} else {
|
||
format!("{category}/{name}")
|
||
};
|
||
report.presets.push((name, import.preset));
|
||
}
|
||
Err(e) => {
|
||
log::debug!("preset import: {}: {e}", path.display());
|
||
report.failed += 1;
|
||
}
|
||
}
|
||
}
|