Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between images, and batch-apply to a selection. The last two have been here for a while; this is the first. The format is the sidecar's, deliberately. A preset *is* the non-default half of a version, so the lines are the same lines keyed the same way, which makes the two files diffable against each other and lets someone debugging an edit paste a block from one into the other. One file rather than one per preset: a preset per file makes the name a path, and every name then has to survive a filesystem — a `/` becomes a directory, a name differing only in case collides on one platform and not another, and renaming becomes two operations that can half-fail. As a key in a document it is none of those. Unknown *parameters* needed no machinery. `Preset` already holds whatever keys it is given and resolves them against the descriptors only at apply time, so one written by a newer build survives by being stored. Only lines that are not `op.param = float` at all are preserved verbatim, which is the sidecar's version-skew promise made here too. Applying is the paste path with a different source, so a preset reaches a selection through the sidecar read-modify-write that was already there: no graph, no decode, no GPU, forty files or one. Two smaller decisions worth the record. A library that fails to parse is held empty in memory and *not* written back over — settings regenerate themselves and this is work, so a parse failure must not be the moment it is destroyed. And every save persists immediately and rolls the in-memory copy back if the write fails, so the sheet never lists a preset the file does not have. The grid's "Presets" button is gated on the selection alone, unlike the "Paste to 40" beside it. That button needs a clipboard armed this session; the preset list is whatever was saved last month, and hiding it behind an unrelated action is what makes a feature only its author knows about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,7 @@ pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
||||
};
|
||||
pub use preset::{Preset, Scope};
|
||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
||||
pub use sidecar::{Sidecar, Version};
|
||||
pub use spot::{Spot, SpotMode, SpotSet};
|
||||
pub use state::{EditState, FilmRebake, FilmRef};
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
//! is the whole claim the action makes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::descriptor::{OpId, ParamId};
|
||||
use crate::graph::EditGraph;
|
||||
@@ -240,6 +242,328 @@ pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId,
|
||||
Some((cap.id, p.id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Named presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Format version of a preset library file.
|
||||
///
|
||||
/// Present where `settings.json` has no version field, and the difference is
|
||||
/// not an inconsistency. Settings are a flat bag of `#[serde(default)]`
|
||||
/// fields, so an older file is *missing* keys rather than wrong about them and
|
||||
/// additive change needs no version. This file has structure — blocks, and a
|
||||
/// name carried in a block header — and a change to what a block *means* is
|
||||
/// not something a reader can detect by noticing an absent key.
|
||||
pub const LIBRARY_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// The file extension for a DarkRoom preset library.
|
||||
pub const LIBRARY_EXTENSION: &str = "drpl";
|
||||
|
||||
/// Why a name was refused.
|
||||
///
|
||||
/// A closed set rather than a string, so the interface can say something
|
||||
/// specific about each and the message is not written here — this crate
|
||||
/// depends on nothing and has no business holding user-facing prose.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NameError {
|
||||
/// Empty, or nothing but whitespace.
|
||||
Empty,
|
||||
/// Contains a character the block header cannot carry: `[`, `]`, or a
|
||||
/// line break.
|
||||
///
|
||||
/// A round-trip constraint rather than a matter of taste. The header is
|
||||
/// `[preset <name>]`, so a `]` inside the name would make the file parse
|
||||
/// back as a *different* library, and a newline would make it parse back
|
||||
/// as two.
|
||||
Unrepresentable,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// A set of named presets, as stored.
|
||||
///
|
||||
/// # Why one file rather than one file per preset
|
||||
///
|
||||
/// A preset per file makes the name a *path*, and every name then has to
|
||||
/// survive a filesystem: a `/` becomes a directory, a name that differs only
|
||||
/// in case collides on one platform and not another, and renaming becomes two
|
||||
/// operations that can half-fail. Here the name is a key in a document, so
|
||||
/// renaming is a map operation, deleting cannot leave an orphan, and the whole
|
||||
/// library is written atomically by the same tmp-and-rename the settings store
|
||||
/// uses.
|
||||
///
|
||||
/// The cost is that the file is rewritten whole on every change. A preset is a
|
||||
/// few dozen floats and a photographer has tens of them, not thousands, so the
|
||||
/// file is kilobytes; the trade would look different at a scale this is not.
|
||||
///
|
||||
/// # Why the sidecar's shape rather than JSON
|
||||
///
|
||||
/// A preset *is* the non-default half of a version (see the module note), so
|
||||
/// the lines here are the lines a sidecar carries, keyed the same way. That
|
||||
/// makes the two files diffable against each other and lets someone debugging
|
||||
/// an edit paste a block from one into the other. It also keeps this crate
|
||||
/// dependency-free, which is the property that lets it be tested without a
|
||||
/// device (ARCH §6.5a).
|
||||
///
|
||||
/// # Ordering
|
||||
///
|
||||
/// By name, so the same library always writes the same bytes and a caller may
|
||||
/// compare content to decide whether a write is needed — the same
|
||||
/// determinism [`Sidecar::to_text`](crate::Sidecar::to_text) offers, for the
|
||||
/// same reason.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct PresetLibrary {
|
||||
presets: BTreeMap<String, Preset>,
|
||||
/// Lines inside a `[preset]` block that were not `op.param = float`.
|
||||
///
|
||||
/// Keyed by preset name and written back verbatim, so a build that
|
||||
/// predates whatever wrote them round-trips the file without discarding
|
||||
/// it. Unknown *parameters* need no such machinery: [`Preset`] holds
|
||||
/// whatever keys it was given and resolves them against the descriptors
|
||||
/// only at apply time, so a parameter this build has never heard of
|
||||
/// survives simply by being stored.
|
||||
unknown: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl PresetLibrary {
|
||||
/// Whether a name is storable, and why not if it is not.
|
||||
///
|
||||
/// Leading and trailing whitespace is trimmed rather than refused — it is
|
||||
/// almost always a stray keystroke, and refusing it would mean a dialogue
|
||||
/// about a space.
|
||||
pub fn check_name(name: &str) -> Result<String, NameError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(NameError::Empty);
|
||||
}
|
||||
if name.contains([']', '[', '\n', '\r']) {
|
||||
return Err(NameError::Unrepresentable);
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Store `preset` under `name`, replacing any preset already there.
|
||||
///
|
||||
/// Returns whether something was replaced.
|
||||
///
|
||||
/// Replacing rather than refusing, with [`Self::contains`] beside it for a
|
||||
/// caller that wants to ask first: whether overwriting needs a
|
||||
/// confirmation is a question about the interface, and answering it here
|
||||
/// would force every caller into the same answer.
|
||||
///
|
||||
/// An *empty* preset is stored like any other. A neutral edit is a real
|
||||
/// thing to save — applying it returns an image to default, which is the
|
||||
/// fastest "undo everything on these forty frames" there is — and the
|
||||
/// module note above is the same argument made about the clipboard.
|
||||
pub fn insert(&mut self, name: &str, preset: Preset) -> Result<bool, NameError> {
|
||||
let name = Self::check_name(name)?;
|
||||
let replaced = self.presets.insert(name, preset).is_some();
|
||||
Ok(replaced)
|
||||
}
|
||||
|
||||
/// The preset stored under `name`.
|
||||
pub fn get(&self, name: &str) -> Option<&Preset> {
|
||||
self.presets.get(name)
|
||||
}
|
||||
|
||||
/// Whether a preset is stored under `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.presets.contains_key(name)
|
||||
}
|
||||
|
||||
/// Remove the preset stored under `name`, reporting whether there was one.
|
||||
pub fn remove(&mut self, name: &str) -> bool {
|
||||
self.unknown.remove(name);
|
||||
self.presets.remove(name).is_some()
|
||||
}
|
||||
|
||||
/// Rename `from` to `to`.
|
||||
///
|
||||
/// `Ok(false)` means there was nothing called `from` — not an error, since
|
||||
/// the caller may be acting on a list another window has already changed.
|
||||
/// Renaming onto an existing name replaces it, for the same reason
|
||||
/// [`Self::insert`] does.
|
||||
pub fn rename(&mut self, from: &str, to: &str) -> Result<bool, NameError> {
|
||||
let to = Self::check_name(to)?;
|
||||
let Some(preset) = self.presets.remove(from) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if let Some(unknown) = self.unknown.remove(from) {
|
||||
self.unknown.insert(to.clone(), unknown);
|
||||
}
|
||||
self.presets.insert(to, preset);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Every stored name, in the order they are written.
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.presets.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Every stored preset with its name, in the order they are written.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &Preset)> {
|
||||
self.presets.iter().map(|(n, p)| (n.as_str(), p))
|
||||
}
|
||||
|
||||
/// How many presets are stored.
|
||||
pub fn len(&self) -> usize {
|
||||
self.presets.len()
|
||||
}
|
||||
|
||||
/// Whether nothing is stored.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.presets.is_empty()
|
||||
}
|
||||
|
||||
/// Serialise to the on-disk form.
|
||||
///
|
||||
/// Deterministic, like the sidecar's: the same library always produces the
|
||||
/// same bytes.
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut out = format!("drpl {LIBRARY_FORMAT_VERSION}\n");
|
||||
for (name, preset) in &self.presets {
|
||||
let _ = write!(out, "\n[preset {name}]\n");
|
||||
for ((op, param), value) in preset.params() {
|
||||
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
|
||||
}
|
||||
for line in self.unknown.get(name).into_iter().flatten() {
|
||||
let _ = writeln!(out, "{line}");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the on-disk form.
|
||||
///
|
||||
/// Tolerant on the same terms as the sidecar's parser, and for a weaker
|
||||
/// version of the same reason: a preset library is not the authoritative
|
||||
/// store an edit lives in, but it is still work the user did by hand, and
|
||||
/// one bad line must cost that line rather than the collection. The only
|
||||
/// hard failures are a file that is not a preset library at all and one
|
||||
/// written by a newer build, where continuing would mean guessing.
|
||||
pub fn parse(text: &str) -> Result<Self, LibraryParseError> {
|
||||
let mut lines = text.lines();
|
||||
let header = lines.next().unwrap_or_default().trim();
|
||||
let Some(version) = header.strip_prefix("drpl ") else {
|
||||
return Err(LibraryParseError::NotALibrary);
|
||||
};
|
||||
match version.trim().parse::<u32>() {
|
||||
Ok(v) if v <= LIBRARY_FORMAT_VERSION => {}
|
||||
Ok(v) => return Err(LibraryParseError::UnsupportedVersion(v)),
|
||||
Err(_) => return Err(LibraryParseError::NotALibrary),
|
||||
}
|
||||
|
||||
let mut library = Self::default();
|
||||
let mut current: Option<String> = None;
|
||||
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||
|
||||
for line in lines {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(head) = line
|
||||
.strip_prefix("[preset ")
|
||||
.and_then(|l| l.strip_suffix(']'))
|
||||
{
|
||||
if let Some(name) = current.take() {
|
||||
library.presets.insert(name, Preset::from_params(params));
|
||||
params = BTreeMap::new();
|
||||
}
|
||||
// A name the writer should never have produced is dropped
|
||||
// rather than taken: accepting it would mean writing a file
|
||||
// back out that no longer parses as this one.
|
||||
match Self::check_name(head) {
|
||||
Ok(name) => current = Some(name),
|
||||
Err(_) => {
|
||||
log::warn!("preset library: unusable preset name {head:?}; skipping");
|
||||
current = None;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(name) = current.clone() else {
|
||||
log::warn!("preset library: line outside any preset: {line}");
|
||||
continue;
|
||||
};
|
||||
|
||||
match line.split_once('=') {
|
||||
Some((key, value)) => {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
match (key.split_once('.'), value.parse::<f32>()) {
|
||||
(Some((op, param)), Ok(v)) if !op.is_empty() && !param.is_empty() => {
|
||||
params.insert((op.to_string(), param.to_string()), v);
|
||||
}
|
||||
_ => library
|
||||
.unknown
|
||||
.entry(name)
|
||||
.or_default()
|
||||
.push(line.to_string()),
|
||||
}
|
||||
}
|
||||
None => library
|
||||
.unknown
|
||||
.entry(name)
|
||||
.or_default()
|
||||
.push(line.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = current {
|
||||
library.presets.insert(name, Preset::from_params(params));
|
||||
}
|
||||
|
||||
// A block whose every line was unreadable still produced a preset, and
|
||||
// an unknown block belonging to no preset would be written back into
|
||||
// whichever one happened to sort first. Drop the orphans.
|
||||
library
|
||||
.unknown
|
||||
.retain(|k, _| library.presets.contains_key(k));
|
||||
|
||||
Ok(library)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a value the way the sidecar does — no trailing `.0`, no exponent —
|
||||
/// so the two files stay comparable line for line.
|
||||
fn format_value(v: f32) -> String {
|
||||
let mut s = format!("{v:.6}");
|
||||
if s.contains('.') {
|
||||
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
}
|
||||
if s == "-0" {
|
||||
s = "0".to_string();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LibraryParseError {
|
||||
/// The header line was missing or not a `drpl` header.
|
||||
NotALibrary,
|
||||
/// Written by a newer build, in a format this one cannot read.
|
||||
UnsupportedVersion(u32),
|
||||
}
|
||||
|
||||
impl fmt::Display for LibraryParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotALibrary => f.write_str("not a DarkRoom preset library"),
|
||||
Self::UnsupportedVersion(v) => {
|
||||
write!(
|
||||
f,
|
||||
"preset library format version {v} is newer than this build"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LibraryParseError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -563,4 +887,196 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(excluded, vec![framing::ID.0]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Named presets
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn named() -> PresetLibrary {
|
||||
let mut lib = PresetLibrary::default();
|
||||
lib.insert("Warm portrait", Preset::capture(&edited()))
|
||||
.unwrap();
|
||||
lib.insert("Neutral", Preset::default()).unwrap();
|
||||
lib
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_round_trips_through_its_text_form() {
|
||||
let lib = named();
|
||||
let back = PresetLibrary::parse(&lib.to_text()).unwrap();
|
||||
assert_eq!(back, lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_library_always_writes_the_same_bytes() {
|
||||
// What lets a caller skip a write by comparing content. Built in the
|
||||
// opposite order to `named()` so insertion order cannot be what makes
|
||||
// this pass.
|
||||
let mut other = PresetLibrary::default();
|
||||
other.insert("Neutral", Preset::default()).unwrap();
|
||||
other
|
||||
.insert("Warm portrait", Preset::capture(&edited()))
|
||||
.unwrap();
|
||||
assert_eq!(other.to_text(), named().to_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_preset_is_storable_and_survives_the_round_trip() {
|
||||
// The empty preset is the "clear these forty frames" action, so it has
|
||||
// to be a real entry rather than an absence — and a block with no
|
||||
// lines under it has to parse back as a preset rather than vanish.
|
||||
let back = PresetLibrary::parse(&named().to_text()).unwrap();
|
||||
assert_eq!(back.get("Neutral"), Some(&Preset::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_line_costs_that_line_and_not_the_library() {
|
||||
let text = format!(
|
||||
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
|
||||
this line is not a setting\nsaturation.amount = not a number\n"
|
||||
);
|
||||
let lib = PresetLibrary::parse(&text).unwrap();
|
||||
let preset = lib.get("Keep").expect("the preset survived");
|
||||
assert_eq!(
|
||||
preset.params().get(&("exposure".into(), "exposure".into())),
|
||||
Some(&0.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_this_build_cannot_read_are_written_back_untouched() {
|
||||
// The sidecar's version-skew promise, applied here: a build running
|
||||
// behind must not silently strip what a newer one wrote.
|
||||
let text = format!(
|
||||
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
|
||||
something_new_entirely\n"
|
||||
);
|
||||
let out = PresetLibrary::parse(&text).unwrap().to_text();
|
||||
assert!(out.contains("something_new_entirely"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_parameter_survives_without_any_machinery_for_it() {
|
||||
// `Preset` stores whatever keys it is given and resolves them against
|
||||
// the descriptors only at apply time, so a parameter from a newer
|
||||
// build needs no preservation path of its own.
|
||||
let text = format!(
|
||||
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nnot_an_op.not_a_param = 0.25\n"
|
||||
);
|
||||
let out = PresetLibrary::parse(&text).unwrap().to_text();
|
||||
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
|
||||
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
|
||||
assert_eq!(
|
||||
PresetLibrary::parse(&text),
|
||||
Err(LibraryParseError::UnsupportedVersion(
|
||||
LIBRARY_FORMAT_VERSION + 1
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn something_that_is_not_a_preset_library_is_refused() {
|
||||
assert_eq!(
|
||||
PresetLibrary::parse("drsc 1\n\n[version abc]\n"),
|
||||
Err(LibraryParseError::NotALibrary)
|
||||
);
|
||||
assert_eq!(
|
||||
PresetLibrary::parse(""),
|
||||
Err(LibraryParseError::NotALibrary)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_would_not_parse_back_is_refused() {
|
||||
// Round-trip safety, not taste: a `]` would close the header early and
|
||||
// the file would read back as a different library.
|
||||
let mut lib = PresetLibrary::default();
|
||||
assert_eq!(
|
||||
lib.insert("bracket] inside", Preset::default()),
|
||||
Err(NameError::Unrepresentable)
|
||||
);
|
||||
assert_eq!(
|
||||
lib.insert("two\nlines", Preset::default()),
|
||||
Err(NameError::Unrepresentable)
|
||||
);
|
||||
assert_eq!(lib.insert(" ", Preset::default()), Err(NameError::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrounding_whitespace_is_trimmed_rather_than_refused() {
|
||||
let mut lib = PresetLibrary::default();
|
||||
lib.insert(" Warm ", Preset::default()).unwrap();
|
||||
assert!(lib.contains("Warm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_over_a_name_replaces_it_and_says_so() {
|
||||
let mut lib = named();
|
||||
assert_eq!(lib.insert("Warm portrait", Preset::default()), Ok(true));
|
||||
assert_eq!(lib.insert("Brand new", Preset::default()), Ok(false));
|
||||
assert_eq!(lib.get("Warm portrait"), Some(&Preset::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_moves_the_preset_and_leaves_nothing_behind() {
|
||||
let mut lib = named();
|
||||
let before = lib.get("Warm portrait").cloned().unwrap();
|
||||
assert_eq!(lib.rename("Warm portrait", "Cool portrait"), Ok(true));
|
||||
assert!(!lib.contains("Warm portrait"));
|
||||
assert_eq!(lib.get("Cool portrait"), Some(&before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_something_that_is_gone_is_not_an_error() {
|
||||
// Another window may have deleted it since this list was drawn.
|
||||
let mut lib = named();
|
||||
assert_eq!(lib.rename("Never existed", "Whatever"), Ok(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_reports_whether_there_was_anything_to_delete() {
|
||||
let mut lib = named();
|
||||
assert!(lib.remove("Neutral"));
|
||||
assert!(!lib.remove("Neutral"));
|
||||
assert_eq!(lib.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_preset_applies_exactly_as_a_pasted_one_does() {
|
||||
// The whole point of sharing one representation: a named preset is not
|
||||
// a second kind of thing with a second apply path.
|
||||
let lib = named();
|
||||
let stored = PresetLibrary::parse(&lib.to_text()).unwrap();
|
||||
let preset = stored.get("Warm portrait").unwrap();
|
||||
|
||||
let mut target = EditGraph::default_chain();
|
||||
preset.apply(&mut target, Scope::Adjustments);
|
||||
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
||||
// The target keeps its own framing on the default scope.
|
||||
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_preset_amends_a_sidecar_without_a_graph() {
|
||||
// The batch path: applying to forty images must not build forty
|
||||
// graphs, so this is the call the library's batch apply makes.
|
||||
let lib = named();
|
||||
let preset = lib.get("Warm portrait").unwrap();
|
||||
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||
params.insert(("framing".into(), "angle".into()), 5.0);
|
||||
params.insert(("exposure".into(), "exposure".into()), -1.0);
|
||||
|
||||
preset.amend(&mut params, Scope::Adjustments);
|
||||
|
||||
assert_eq!(
|
||||
params.get(&("exposure".into(), "exposure".into())),
|
||||
Some(&0.75)
|
||||
);
|
||||
// Out of scope, so the target's own crop is untouched.
|
||||
assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user