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
+61 -1
View File
@@ -13,7 +13,8 @@ use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::ops::curve;
use dr_pipeline::{
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Unit, WidgetKind,
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Preset, Scope, Unit,
WidgetKind,
};
use crate::labels;
@@ -538,6 +539,35 @@ impl DevelopSession {
self.graph.output_size(w, h)
}
/// TRACES: FR-EXP-9
/// Render at full resolution and hand back the pixels, for an export.
///
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
/// viewport size, which is what keeps a slider inside the frame budget on
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
/// screen-sized file. This renders the framed output size instead, so the
/// export is the full-quality path FR-EXP-9 requires.
///
/// The readback here is `export_pixels`, not the display bridge: a file
/// is made of bytes on the CPU and there is no path to one that avoids
/// the transfer. See the note on that method for why the two are separate.
///
/// Leaves the pass holding a full-resolution target, so the caller should
/// expect the next display render to reallocate. Cheaper than keeping a
/// second pass alive for the exports a session rarely performs.
pub fn render_for_export(&mut self) -> Result<dr_export::Frame, String> {
let (sw, sh) = self.demosaiced.size();
let (w, h) = self.graph.output_size(sw, sh);
let shader = self.graph.compose();
self.adjust
.render(&self.demosaiced, &shader, w, h)
.map_err(|e| e.to_string())?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
dr_export::Frame::new(rw, rh, pixels).map_err(|e| e.to_string())
}
/// The sensor's own dimensions, before framing.
///
/// What a crop overlay needs: its handles are placed against the full
@@ -767,6 +797,36 @@ impl DevelopSession {
pub fn is_neutral(&self) -> bool {
self.graph.is_neutral()
}
/// TRACES: FR-DEV-6
/// Lift this session's edit onto the clipboard.
///
/// Captured at full scope — framing included — because the decision about
/// what travels is made when the preset is *applied*. Copying, then
/// changing one's mind about the crop, must not mean copying again.
pub fn copy_settings(&self) -> Preset {
Preset::capture(&self.graph)
}
/// TRACES: FR-DEV-6
/// Replace this session's edit within `scope`.
///
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
/// values the sliders are showing, and nothing here pushes them.
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
preset.apply(&mut self.graph, scope);
}
/// TRACES: FR-CAT-8
/// Load a stored edit, as read from this image's sidecar.
///
/// A replacement rather than an overlay — [`Version::apply`] resets first —
/// so a version that stores nothing opens the photograph at its defaults
/// rather than leaving the previous image's exposure standing. The file's
/// orientation survives it, since that was never an edit.
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
version.apply(&mut self.graph);
}
}
/// Re-express a crop rect after the frame it is measured against turns.