|
|
|
@@ -0,0 +1,566 @@
|
|
|
|
|
//! TRACES: FR-DEV-6
|
|
|
|
|
//! An edit lifted off one photograph and dropped onto another.
|
|
|
|
|
//!
|
|
|
|
|
//! # This is the same data a sidecar already stores
|
|
|
|
|
//!
|
|
|
|
|
//! A [`Preset`] is a map of `(op, param) -> value` holding only what differs
|
|
|
|
|
//! from default — which is precisely [`Version::params`](crate::Version). That
|
|
|
|
|
//! is not a coincidence to be tidied away later: copying settings between two
|
|
|
|
|
//! images and persisting one image's settings are the same operation seen from
|
|
|
|
|
//! two ends, so they share one representation and one capture routine
|
|
|
|
|
//! ([`Preset::capture`], which `sidecar` calls). A second, parallel notion of
|
|
|
|
|
//! "a bundle of parameter values" would be a second thing to keep in step with
|
|
|
|
|
//! the descriptors.
|
|
|
|
|
//!
|
|
|
|
|
//! It follows that this module names no operation either, with the single
|
|
|
|
|
//! exception of framing — for the reason below, which is a statement about
|
|
|
|
|
//! photographs rather than about code.
|
|
|
|
|
//!
|
|
|
|
|
//! # Why framing is its own scope
|
|
|
|
|
//!
|
|
|
|
|
//! A crop is a decision about *this* photograph's composition. Copying colour
|
|
|
|
|
//! from one frame to the next is what a photographer means by "make these
|
|
|
|
|
//! match"; copying the crop as well re-frames every one of them to a rectangle
|
|
|
|
|
//! chosen while looking at a different picture, and on a batch of forty that is
|
|
|
|
|
//! forty compositions destroyed by one action.
|
|
|
|
|
//!
|
|
|
|
|
//! So the default [`Scope::Adjustments`] leaves the target's framing where it
|
|
|
|
|
//! is, and [`Scope::Everything`] is available for the case the exclusion exists
|
|
|
|
|
//! to protect against being impossible otherwise — applying one aspect ratio
|
|
|
|
|
//! across a shoot. The choice is the caller's; neither is hardcoded here.
|
|
|
|
|
//!
|
|
|
|
|
//! # Why applying replaces rather than overlays
|
|
|
|
|
//!
|
|
|
|
|
//! Within its scope, [`Preset::apply`] resets first. A parameter absent from
|
|
|
|
|
//! the preset means *default*, exactly as absence means default in a sidecar
|
|
|
|
|
//! — so pasting a neutral edit clears the target rather than leaving the
|
|
|
|
|
//! target's own exposure standing underneath. Overlaying would make the result
|
|
|
|
|
//! depend on what the target happened to hold, and "these two images now match"
|
|
|
|
|
//! is the whole claim the action makes.
|
|
|
|
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
use crate::descriptor::{OpId, ParamId};
|
|
|
|
|
use crate::graph::EditGraph;
|
|
|
|
|
|
|
|
|
|
/// Which part of an edit a copy carries.
|
|
|
|
|
///
|
|
|
|
|
/// Two variants rather than a per-operation mask because the distinction being
|
|
|
|
|
/// drawn is not "which operations" but "is this about the picture's colour or
|
|
|
|
|
/// about its shape". Everything in the chain but framing answers the first.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
|
|
|
pub enum Scope {
|
|
|
|
|
/// Colour and tone. The target keeps its own crop, straightening,
|
|
|
|
|
/// rotation and flips.
|
|
|
|
|
#[default]
|
|
|
|
|
Adjustments,
|
|
|
|
|
/// The whole edit, framing included.
|
|
|
|
|
Everything,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Scope {
|
|
|
|
|
/// Whether this scope reaches the named operation.
|
|
|
|
|
///
|
|
|
|
|
/// Takes a `&str` rather than an [`OpId`] because the caller may be
|
|
|
|
|
/// holding a name read from a file, which has no `'static` lifetime to
|
|
|
|
|
/// offer — the sidecar path amends a parameter map without ever building
|
|
|
|
|
/// a graph.
|
|
|
|
|
pub fn covers(self, op: &str) -> bool {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Everything => true,
|
|
|
|
|
Self::Adjustments => op != crate::framing::ID.0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A set of non-default parameter values, ready to apply elsewhere.
|
|
|
|
|
///
|
|
|
|
|
/// Ordered, so two captures of the same edit compare equal and a caller can
|
|
|
|
|
/// tell whether the clipboard actually changed.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
|
|
|
pub struct Preset {
|
|
|
|
|
params: BTreeMap<(String, String), f32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Preset {
|
|
|
|
|
/// Every non-default parameter in `graph`.
|
|
|
|
|
///
|
|
|
|
|
/// Walks [`EditGraph::capabilities`] — the same list the panel builds
|
|
|
|
|
/// controls from and the sidecar persists — so an operation is copyable by
|
|
|
|
|
/// virtue of being in the chain, with nothing to register (FR-DEV-3c).
|
|
|
|
|
///
|
|
|
|
|
/// Captured at full [`Scope::Everything`]: filtering happens when the
|
|
|
|
|
/// preset is *applied*, not when it is taken. Otherwise the clipboard would
|
|
|
|
|
/// have to be re-copied to change one's mind about framing, and a preset
|
|
|
|
|
/// that had already discarded the crop could never grow it back.
|
|
|
|
|
pub fn capture(graph: &EditGraph) -> Self {
|
|
|
|
|
let mut params = BTreeMap::new();
|
|
|
|
|
for cap in graph.capabilities() {
|
|
|
|
|
for p in &cap.params {
|
|
|
|
|
if p.is_modified() {
|
|
|
|
|
params.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Self { params }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build from an already-captured parameter map — a sidecar's, typically.
|
|
|
|
|
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
|
|
|
|
|
Self { params }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The parameters, for a caller that stores them.
|
|
|
|
|
pub fn params(&self) -> &BTreeMap<(String, String), f32> {
|
|
|
|
|
&self.params
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Consume into the parameter map.
|
|
|
|
|
pub fn into_params(self) -> BTreeMap<(String, String), f32> {
|
|
|
|
|
self.params
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether this preset carries anything at all.
|
|
|
|
|
///
|
|
|
|
|
/// An empty preset is a *neutral* edit rather than a missing one, and
|
|
|
|
|
/// applying it is meaningful: it returns the target to default. What this
|
|
|
|
|
/// answers is whether there is a clipboard to offer, which is why the UI
|
|
|
|
|
/// asks it before enabling a paste.
|
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
self.params.is_empty()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How many parameters were captured.
|
|
|
|
|
pub fn len(&self) -> usize {
|
|
|
|
|
self.params.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether this preset carries any framing — a crop, a rotation, a flip or
|
|
|
|
|
/// a straightening angle.
|
|
|
|
|
///
|
|
|
|
|
/// What the interface asks to decide whether offering "include crop and
|
|
|
|
|
/// rotation" would change anything for *this* clipboard. Offering it on a
|
|
|
|
|
/// copy that has no framing in it promises an effect that cannot happen.
|
|
|
|
|
pub fn touches_framing(&self) -> bool {
|
|
|
|
|
self.params
|
|
|
|
|
.keys()
|
|
|
|
|
.any(|(op, _)| !Scope::Adjustments.covers(op))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How many operations this preset touches, in scope.
|
|
|
|
|
///
|
|
|
|
|
/// For the interface's "3 adjustments" readout. Counted over operations
|
|
|
|
|
/// rather than parameters because thirty-six mixer sliders is a number
|
|
|
|
|
/// about the mixer's shape, not about how much was copied.
|
|
|
|
|
pub fn op_count(&self, scope: Scope) -> usize {
|
|
|
|
|
let mut ops: Vec<&str> = self
|
|
|
|
|
.params
|
|
|
|
|
.keys()
|
|
|
|
|
.map(|(op, _)| op.as_str())
|
|
|
|
|
.filter(|op| scope.covers(op))
|
|
|
|
|
.collect();
|
|
|
|
|
ops.sort_unstable();
|
|
|
|
|
ops.dedup();
|
|
|
|
|
ops.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Apply to a graph, replacing whatever it held within `scope`.
|
|
|
|
|
///
|
|
|
|
|
/// Parameters this build does not recognise are skipped with a warning by
|
|
|
|
|
/// the same route a sidecar's are — a preset may have been captured by a
|
|
|
|
|
/// newer build, and an unknown name must cost its own line rather than the
|
|
|
|
|
/// paste.
|
|
|
|
|
///
|
|
|
|
|
/// The **view is preserved**. Zoom and pan say where the user is looking,
|
|
|
|
|
/// not what the photograph is; resetting the framing would throw them back
|
|
|
|
|
/// to a fitted view mid-comparison, which reads as the paste having
|
|
|
|
|
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
|
|
|
|
|
/// lives here so every caller inherits it rather than each remembering.
|
|
|
|
|
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) {
|
|
|
|
|
let view = graph.framing().view();
|
|
|
|
|
|
|
|
|
|
// Clear the scope first, so absence means default (see the module
|
|
|
|
|
// note). Collected before writing because `capabilities` borrows the
|
|
|
|
|
// graph and `set_param` needs it mutably.
|
|
|
|
|
let clears: Vec<(OpId, ParamId, f32)> = graph
|
|
|
|
|
.capabilities()
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|cap| scope.covers(cap.id.0))
|
|
|
|
|
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
|
|
|
|
|
.collect();
|
|
|
|
|
for (op, param, default) in clears {
|
|
|
|
|
graph.set_param(op, param, default);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for ((op, param), value) in &self.params {
|
|
|
|
|
if !scope.covers(op) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some((op, param)) = resolve(graph, op, param) else {
|
|
|
|
|
log::warn!("preset: unknown parameter {op}.{param}; ignoring");
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
graph.set_param(op, param, *value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
graph.framing_mut().set_view(view);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Apply to a parameter map — the sidecar of an image that is not open.
|
|
|
|
|
///
|
|
|
|
|
/// The batch path. Applying to forty images by loading forty edit graphs
|
|
|
|
|
/// would mean instantiating the whole chain forty times to move some
|
|
|
|
|
/// numbers between two maps; the graph adds nothing here because there is
|
|
|
|
|
/// no rendering to do and clamping happens when the file is next read into
|
|
|
|
|
/// one ([`EditGraph::set_param`] clamps, and `Version::apply` goes through
|
|
|
|
|
/// it).
|
|
|
|
|
///
|
|
|
|
|
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
|
|
|
|
|
/// the preset's arrive, and out-of-scope keys — the target's own crop, on
|
|
|
|
|
/// the default scope — are left exactly as they were.
|
|
|
|
|
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
|
|
|
|
|
target.retain(|(op, _), _| !scope.covers(op));
|
|
|
|
|
for ((op, param), value) in &self.params {
|
|
|
|
|
if scope.covers(op) {
|
|
|
|
|
target.insert((op.clone(), param.clone()), *value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Find the `'static` ids matching these names, or `None` if this build has no
|
|
|
|
|
/// such parameter.
|
|
|
|
|
///
|
|
|
|
|
/// Looking them up in the descriptors rather than leaking the caller's strings
|
|
|
|
|
/// is what bounds memory: a name read from a file or carried on a clipboard
|
|
|
|
|
/// never becomes a `'static`.
|
|
|
|
|
pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> {
|
|
|
|
|
let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?;
|
|
|
|
|
let p = cap.params.iter().find(|p| p.id.0 == param)?;
|
|
|
|
|
Some((cap.id, p.id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::framing;
|
|
|
|
|
use crate::ops::{exposure, saturation, white_balance};
|
|
|
|
|
use crate::CropRect;
|
|
|
|
|
|
|
|
|
|
/// A graph with colour *and* framing moved off default, which is what makes
|
|
|
|
|
/// the scope distinction observable.
|
|
|
|
|
fn edited() -> EditGraph {
|
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
|
|
|
|
|
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
|
|
|
|
|
g.set_crop(CropRect {
|
|
|
|
|
x: 0.1,
|
|
|
|
|
y: 0.1,
|
|
|
|
|
width: 0.5,
|
|
|
|
|
height: 0.5,
|
|
|
|
|
});
|
|
|
|
|
g.set_param(framing::ID, framing::ANGLE, -2.0);
|
|
|
|
|
g
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_copy_carries_only_what_was_changed() {
|
|
|
|
|
// The property the whole format rests on, restated for the clipboard:
|
|
|
|
|
// a neutral operation contributes nothing, so pasting cannot carry a
|
|
|
|
|
// value the source never set.
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
assert!(preset
|
|
|
|
|
.params()
|
|
|
|
|
.contains_key(&("exposure".into(), "exposure".into())));
|
|
|
|
|
assert!(
|
|
|
|
|
!preset.params().keys().any(|(op, _)| op == saturation::ID.0),
|
|
|
|
|
"an untouched operation must not be copied"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_neutral_graph_copies_nothing() {
|
|
|
|
|
assert!(Preset::capture(&EditGraph::default_chain()).is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_copy_captures_framing_even_though_the_default_scope_drops_it() {
|
|
|
|
|
// Capture is deliberately unfiltered: the decision about framing is
|
|
|
|
|
// made at paste time, so a user who ticks "include crop" after copying
|
|
|
|
|
// must not have to copy again.
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
assert!(preset.touches_framing());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pasting_adjustments_leaves_the_targets_composition_alone() {
|
|
|
|
|
// The case the default scope exists for: two photographs framed
|
|
|
|
|
// differently, made to match in colour without either being re-cropped.
|
|
|
|
|
let source = edited();
|
|
|
|
|
let preset = Preset::capture(&source);
|
|
|
|
|
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
let target_crop = CropRect {
|
|
|
|
|
x: 0.0,
|
|
|
|
|
y: 0.25,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
height: 0.5,
|
|
|
|
|
};
|
|
|
|
|
target.set_crop(target_crop);
|
|
|
|
|
|
|
|
|
|
preset.apply(&mut target, Scope::Adjustments);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
target.param(exposure::ID, exposure::EXPOSURE),
|
|
|
|
|
Some(0.75),
|
|
|
|
|
"the colour must arrive"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
(target.crop().width - target_crop.width).abs() < 1e-5
|
|
|
|
|
&& (target.crop().y - target_crop.y).abs() < 1e-5,
|
|
|
|
|
"the target's own crop must survive: {:?}",
|
|
|
|
|
target.crop()
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
target.param(framing::ID, framing::ANGLE),
|
|
|
|
|
Some(0.0),
|
|
|
|
|
"the source's straightening must not travel on this scope"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pasting_everything_carries_the_composition_too() {
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
preset.apply(&mut target, Scope::Everything);
|
|
|
|
|
|
|
|
|
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
|
|
|
|
assert!(
|
|
|
|
|
(target.crop().width - 0.5).abs() < 1e-5,
|
|
|
|
|
"{:?}",
|
|
|
|
|
target.crop()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pasting_replaces_rather_than_overlaying() {
|
|
|
|
|
// Pasting a neutral copy must *clear* the target. Were this an
|
|
|
|
|
// overlay, "make these match" would leave whatever the target already
|
|
|
|
|
// had underneath, and the two images would not in fact match.
|
|
|
|
|
let neutral = Preset::capture(&EditGraph::default_chain());
|
|
|
|
|
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
|
|
|
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
|
|
|
|
|
|
|
|
|
|
neutral.apply(&mut target, Scope::Adjustments);
|
|
|
|
|
|
|
|
|
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
target.param(saturation::ID, saturation::SATURATION),
|
|
|
|
|
Some(0.0)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_paste_that_excludes_framing_does_not_clear_the_targets_framing() {
|
|
|
|
|
// The other half of the replacement rule: "replace" is scoped. A
|
|
|
|
|
// neutral paste must not straighten the target back to zero, or
|
|
|
|
|
// excluding framing would still destroy it — just to a different value.
|
|
|
|
|
let neutral = Preset::capture(&EditGraph::default_chain());
|
|
|
|
|
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
target.set_param(framing::ID, framing::ANGLE, 3.5);
|
|
|
|
|
|
|
|
|
|
neutral.apply(&mut target, Scope::Adjustments);
|
|
|
|
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pasting_leaves_the_view_where_the_user_was_looking() {
|
|
|
|
|
// Zoom is navigation, not an edit. A paste that reset it would throw
|
|
|
|
|
// the user out of a 4x inspection they were making the comparison at.
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
target.framing_mut().set_view(CropRect {
|
|
|
|
|
x: 0.25,
|
|
|
|
|
y: 0.25,
|
|
|
|
|
width: 0.25,
|
|
|
|
|
height: 0.25,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
preset.apply(&mut target, Scope::Everything);
|
|
|
|
|
assert!(
|
|
|
|
|
target.framing().is_zoomed(),
|
|
|
|
|
"the paste threw away the viewport: {:?}",
|
|
|
|
|
target.framing().view()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_paste_does_not_disturb_how_the_file_stored_its_pixels() {
|
|
|
|
|
// Orientation is a fact about the target's own file. A preset copied
|
|
|
|
|
// from a landscape frame must not lay that frame's sensor scan over a
|
|
|
|
|
// portrait one — the image would open on its side.
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
|
|
|
|
|
let mut sideways = EditGraph::default_chain();
|
|
|
|
|
sideways.set_orientation(dr_types::Orientation::from_exif(6));
|
|
|
|
|
preset.apply(&mut sideways, Scope::Everything);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
sideways.framing().baseline(),
|
|
|
|
|
dr_types::Orientation::from_exif(6)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_unknown_parameter_costs_its_own_line_and_not_the_paste() {
|
|
|
|
|
// A clipboard captured by a newer build. The rest must still apply, or
|
|
|
|
|
// one unfamiliar operation would silently discard the whole copy.
|
|
|
|
|
let mut params = BTreeMap::new();
|
|
|
|
|
params.insert(("time_machine".to_string(), "year".to_string()), 1994.0);
|
|
|
|
|
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
|
|
|
|
|
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
Preset::from_params(params).apply(&mut target, Scope::Adjustments);
|
|
|
|
|
|
|
|
|
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
|
|
|
|
|
let mut params = BTreeMap::new();
|
|
|
|
|
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
|
|
|
|
|
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
Preset::from_params(params).apply(&mut target, Scope::Adjustments);
|
|
|
|
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- the batch path, which has no graph ---------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn amending_a_map_replaces_the_scope_and_spares_the_rest() {
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
|
|
|
|
|
// A target that has its own crop and its own, different, exposure.
|
|
|
|
|
let mut target = BTreeMap::new();
|
|
|
|
|
target.insert(("exposure".to_string(), "exposure".to_string()), -1.0);
|
|
|
|
|
target.insert(("saturation".to_string(), "saturation".to_string()), 40.0);
|
|
|
|
|
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
|
|
|
|
|
|
|
|
|
|
preset.amend(&mut target, Scope::Adjustments);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
target.get(&("exposure".to_string(), "exposure".to_string())),
|
|
|
|
|
Some(&0.75),
|
|
|
|
|
"the copied value must land"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!target.contains_key(&("saturation".to_string(), "saturation".to_string())),
|
|
|
|
|
"an in-scope key the preset does not set must be cleared, not kept"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
target.get(&("framing".to_string(), "crop_w".to_string())),
|
|
|
|
|
Some(&0.3),
|
|
|
|
|
"the target's own crop must survive an adjustments-only paste"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn amending_at_full_scope_replaces_the_targets_crop_too() {
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
let mut target = BTreeMap::new();
|
|
|
|
|
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
|
|
|
|
|
|
|
|
|
|
preset.amend(&mut target, Scope::Everything);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
target.get(&("framing".to_string(), "crop_w".to_string())),
|
|
|
|
|
Some(&0.5)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_map_path_and_the_graph_path_agree() {
|
|
|
|
|
// Two routes to the same result — one through a graph, one through a
|
|
|
|
|
// bare map — and a batch apply must not produce a different edit from
|
|
|
|
|
// pasting onto the image with it open. Asserted by round-tripping the
|
|
|
|
|
// amended map back through a graph and comparing every parameter.
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
|
|
|
|
|
for scope in [Scope::Adjustments, Scope::Everything] {
|
|
|
|
|
let mut target_graph = EditGraph::default_chain();
|
|
|
|
|
target_graph.set_param(saturation::ID, saturation::SATURATION, 20.0);
|
|
|
|
|
target_graph.set_param(framing::ID, framing::ANGLE, 4.0);
|
|
|
|
|
|
|
|
|
|
let mut target_map = Preset::capture(&target_graph).into_params();
|
|
|
|
|
|
|
|
|
|
preset.apply(&mut target_graph, scope);
|
|
|
|
|
preset.amend(&mut target_map, scope);
|
|
|
|
|
|
|
|
|
|
let mut rebuilt = EditGraph::default_chain();
|
|
|
|
|
Preset::from_params(target_map).apply(&mut rebuilt, Scope::Everything);
|
|
|
|
|
|
|
|
|
|
for cap in target_graph.capabilities() {
|
|
|
|
|
for p in &cap.params {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rebuilt.param(cap.id, p.id),
|
|
|
|
|
Some(p.value),
|
|
|
|
|
"{scope:?}: {}.{} differs between the graph and map paths",
|
|
|
|
|
cap.id,
|
|
|
|
|
p.id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_copy_round_trips_through_a_paste() {
|
|
|
|
|
// The end-to-end claim the feature makes: after pasting, the target
|
|
|
|
|
// holds the source's edit. Checked over the whole chain rather than a
|
|
|
|
|
// sample, so an operation that needed special handling would fail here.
|
|
|
|
|
let source = edited();
|
|
|
|
|
let preset = Preset::capture(&source);
|
|
|
|
|
|
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
|
|
|
preset.apply(&mut target, Scope::Everything);
|
|
|
|
|
|
|
|
|
|
for cap in source.capabilities() {
|
|
|
|
|
for p in &cap.params {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
target.param(cap.id, p.id),
|
|
|
|
|
Some(p.value),
|
|
|
|
|
"{}.{} did not survive the copy",
|
|
|
|
|
cap.id,
|
|
|
|
|
p.id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn operations_are_counted_in_scope() {
|
|
|
|
|
let preset = Preset::capture(&edited());
|
|
|
|
|
// exposure, white_balance and framing were touched.
|
|
|
|
|
assert_eq!(preset.op_count(Scope::Everything), 3);
|
|
|
|
|
assert_eq!(preset.op_count(Scope::Adjustments), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn scope_names_framing_and_nothing_else() {
|
|
|
|
|
// The one operation this module knows by name. If a second ever
|
|
|
|
|
// appears here, it should be because someone decided it is about the
|
|
|
|
|
// picture's shape rather than because it was convenient.
|
|
|
|
|
let g = EditGraph::default_chain();
|
|
|
|
|
let excluded: Vec<&str> = g
|
|
|
|
|
.capabilities()
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|c| c.id.0)
|
|
|
|
|
.filter(|id| !Scope::Adjustments.covers(id))
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(excluded, vec![framing::ID.0]);
|
|
|
|
|
}
|
|
|
|
|
}
|