Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ pub mod graph;
|
||||
pub mod lens;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
pub mod preset;
|
||||
pub mod sidecar;
|
||||
|
||||
pub use descriptor::{
|
||||
@@ -50,6 +51,7 @@ pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
|
||||
RESERVED_UNIFORM_FIELDS,
|
||||
};
|
||||
pub use preset::{Preset, Scope};
|
||||
pub use sidecar::{Sidecar, Version};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,8 @@ use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::descriptor::{OpId, ParamId};
|
||||
use crate::graph::EditGraph;
|
||||
use crate::preset::{resolve, Preset};
|
||||
|
||||
/// Format version of the document itself.
|
||||
///
|
||||
@@ -348,27 +348,13 @@ fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 {
|
||||
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
|
||||
/// from — so an operation is persisted by virtue of being in the chain, with
|
||||
/// nothing to register and nothing to forget.
|
||||
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
|
||||
let mut out = BTreeMap::new();
|
||||
for cap in graph.capabilities() {
|
||||
for p in &cap.params {
|
||||
if p.is_modified() {
|
||||
out.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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 file's strings
|
||||
/// is what bounds memory: an unrecognised name never becomes a `'static`.
|
||||
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))
|
||||
/// Delegated to [`Preset::capture`] rather than reimplemented: a version's
|
||||
/// parameters and a copied preset are the same values taken from the same
|
||||
/// list, and two routines building the same map would be two places for the
|
||||
/// non-default rule to drift.
|
||||
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
|
||||
Preset::capture(graph).into_params()
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
|
||||
Reference in New Issue
Block a user