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:
2026-08-16 23:58:17 +02:00
co-authored by Claude Opus 5
parent 23f0c4b76a
commit e00c99b864
19 changed files with 2845 additions and 85 deletions
+2
View File
@@ -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)]
+566
View File
@@ -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]);
}
}
+7 -21
View File
@@ -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 {