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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user