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
+331 -10
View File
@@ -18,12 +18,14 @@ mod activity;
mod collections_ui;
mod derived_sync;
mod develop;
mod export;
mod labels;
mod library;
mod library_ui;
#[cfg(live_style)]
mod live_style;
mod net_runtime;
mod presets;
mod settings_store;
mod settings_ui;
mod trash;
@@ -286,6 +288,109 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
window.set_crop_h(crop.height);
}
/// TRACES: FR-EXP-6 | FR-EXP-9
/// Render the open image at full resolution and place the result.
///
/// Synchronous, on the UI thread, and that is a known compromise rather than
/// an oversight. A full-resolution render plus a Lanczos reduction plus an
/// encode is hundreds of milliseconds on a 24 MP frame, and the window is
/// unresponsive for all of it. It is done this way because the alternative —
/// moving a `DevelopSession` and its GPU pass onto a worker — is a larger
/// change than the button is worth before batch export exists, and a batch is
/// what makes the wait intolerable rather than merely noticeable. The status
/// line says what is happening in the meantime.
fn export_now(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
settings: &Rc<settings_ui::SettingsController>,
library: &Rc<library_ui::LibraryController>,
) -> Result<export::Placed, String> {
let mut borrowed = session.borrow_mut();
let Some(session) = borrowed.as_mut() else {
return Err("nothing is open".into());
};
let stored = settings.snapshot();
let frame = session.render_for_export()?;
// The size has to be resolved before the name, because `{dimensions}` is
// one of the tokens the template can carry.
let (tw, th) = dr_export::target_size(
frame.width,
frame.height,
stored.export.sizing,
stored.export.allow_upscaling,
);
let filename = window.get_filename().to_string();
let stem = std::path::Path::new(&filename)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
let outbox = match library.session() {
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
// No account, so no outbox — a device export still works, and a
// remote one is refused below by `place` rather than here, so the
// message names the setting rather than the plumbing.
None => std::env::temp_dir().join("darkroom-outbox"),
};
let ctx = dr_export::NameContext {
source_stem: &stem,
sequence: 1,
date: "",
width: tw,
height: th,
preset: "",
};
// What counts as "taken" depends on where this is going. A device export
// can look at the folder; a queued one is checked against the outbox,
// since the server cannot be reached from here and may not be reachable
// at all — see `export::stage` for why two queued exports of one name
// both survive regardless.
let target_dir = std::path::PathBuf::from(&stored.export.destination);
let taken = |name: &str| -> bool {
match stored.export.target {
dr_types::ExportTarget::Device => target_dir.join(name).exists(),
dr_types::ExportTarget::Remote => false,
}
};
let name = dr_export::resolve_name(
&stored.export.filename_template,
&ctx,
stored.export.format,
stored.export.collision,
&taken,
)
.ok_or("a file of that name is already there, and the collision setting is Skip")?;
let encoded = dr_export::export(&frame, &stored.export, name).map_err(|e| e.to_string())?;
export::place(
&encoded,
stored.export.target,
&stored.export.destination,
&outbox,
)
}
/// What the export button should say, given where an export would go.
///
/// The label carries the destination because the button is the only place the
/// distinction is visible from: "Export" alone gives no hint whether the file
/// lands on this device or is queued for a server that may be unreachable.
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
let stored = settings.snapshot();
let label = match stored.export.target {
dr_types::ExportTarget::Device => "Export".to_string(),
dr_types::ExportTarget::Remote => "Export to Nextcloud".to_string(),
};
window.set_export_label(label.into());
}
/// Push current parameter values back to the interface.
///
/// The controls are not self-updating: the core clamps values, so what the
@@ -297,7 +402,55 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
/// progress. The symptom is a slider that jumps on click but cannot be
/// dragged, because each move event destroys the thing that would deliver
/// the next one.
fn sync_rows(
/// TRACES: FR-CAT-8
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
///
/// The sidecar fetch is started beside the image fetch and is three orders of
/// magnitude smaller, so it has almost always landed by the time there is a
/// session to apply it to — and this takes it straight from the channel. The
/// timer covers the case where it has not, which is why this is not simply a
/// blocking receive: a slow or stalled sidecar request must not freeze the
/// window with the photograph already decoded and on screen.
///
/// A late arrival redraws, so the image is correct either way; the only
/// difference is whether it was ever briefly shown at its defaults.
fn apply_when_ready(
window: &AppWindow,
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
) {
// Already here — the overwhelmingly common case.
if let Ok(got) = rx.try_recv() {
if let Some(sidecar) = got {
presets::apply_stored_edit(window, &sidecar, session, rows);
}
return;
}
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(50),
move || {
let Ok(got) = rx.try_recv() else { return };
held.stop();
let Some(w) = weak.upgrade() else { return };
let Some(sidecar) = got else { return };
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
redraw(&w);
}
},
);
}
pub(crate) fn sync_rows(
window: &AppWindow,
rows: &Rc<slint::VecModel<ParamRow>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
@@ -451,6 +604,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
#[allow(clippy::type_complexity)]
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = Rc::new(RefCell::new(None));
// TRACES: FR-CAT-8
// The same knot, for the other direction: leaving develop has to persist
// the edit, and the grid's "‹ Library" button is wired before the develop
// session exists to save from. Empty until then, and calling it is a no-op
// rather than a panic — there is nothing open to lose.
#[allow(clippy::type_complexity)]
let leave_develop: Rc<RefCell<Option<Rc<dyn Fn()>>>> = Rc::new(RefCell::new(None));
// Before anything binds to a token: the compiled palette is already in
// place, so this only overwrites what style.yaml currently says.
#[cfg(live_style)]
@@ -463,6 +624,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// develop side reads `paths` to rebuild its browsing list when an image is
// opened from the grid.
let library = library_ui::LibraryController::new(activity.clone());
// Hoisted out of the launch block below because the settings clipboard
// needs it too: a paste onto "the selection" reads the selection from
// here, and that wiring happens once the develop session exists.
let collections = collections_ui::CollectionsController::new(activity.clone());
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
@@ -473,7 +638,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
let collections = collections_ui::CollectionsController::new(activity.clone());
let collections = collections.clone();
// The click handler needs `show`, which is built further down because
// it captures the develop session and the GPU context. This cell is
@@ -481,13 +646,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// A click before then is a no-op rather than a panic — the grid cannot
// be reached until the window is running, by which point it is set.
let open_from_library = open_from_library.clone();
library_ui::wire(&window, library.clone(), collections.clone(), move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
});
let leave_develop = leave_develop.clone();
library_ui::wire(
&window,
library.clone(),
collections.clone(),
move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
},
Rc::new(move || {
if let Some(f) = leave_develop.borrow().clone() {
f();
}
}),
);
// The collections sidebar shares the library's catalog handle rather
// than opening its own: one SQLite connection, so an edit here is
@@ -567,14 +743,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Wired independently of every view above. It reads no library and holds no
// session, so it has nothing to be sequenced against — which is the reason
// it is a page reachable from anywhere rather than a panel inside one view.
// Hoisted out of the block below: the export action needs the same record
// the settings page edits, and a controller scoped to the wiring block
// would be gone by the time that callback is built.
let settings = settings_ui::SettingsController::new();
{
let settings = settings_ui::SettingsController::new();
// What the cache actually holds, so the ceiling above it is a figure
// the user can judge rather than an abstract one.
settings.set_usage_label(describe_cache_usage(&library));
// Rendered once up front so the page is correct the first time it is
// opened, rather than on the second open after a callback has run.
settings_ui::render(&window, &settings);
// The export button carries its destination, so it has to be correct
// before the first click rather than after the first settings edit.
refresh_export_label(&window, &settings);
// Apply what is on disk before anything can use it. Without this the
// controller's defaults stand until the user happens to open the
@@ -595,6 +777,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// cache would sit over budget indefinitely.
lib.set_cache_budget(s.cache.original_budget_bytes);
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
if let Some(w) = weak.upgrade() {
refresh_export_label(&w, &ctl);
}
// Lowering the ceiling evicts, so the figure beside it has just
// changed — leaving the old one would show the cache still over a
@@ -627,6 +812,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// The current develop session, if the file yielded sensor data.
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
// TRACES: FR-DEV-6 | FR-CAT-8
// The settings clipboard, and where the open image's edit is stored.
//
// Both live for the life of the window rather than the view: a copy is
// taken in develop and may be pasted onto a selection back in the grid, so
// a clipboard owned by the develop view would be emptied by the very
// navigation that carries it to its destination.
let clipboard = presets::Clipboard::new();
let open_image: presets::OpenImage = Rc::new(RefCell::new(presets::Stored::Nowhere));
// One model for the lifetime of the window. Rows are mutated in place;
// see `sync_rows` for why replacing it breaks dragging.
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
@@ -793,7 +988,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let gpu = gpu.clone();
let rows = rows.clone();
let open_image = open_image.clone();
let library_for_show = library.clone();
Rc::new(move |window: &AppWindow| {
// The image about to be replaced is the last chance to persist its
// edit — stepping to the next frame is as much a departure as
// going back to the grid.
presets::save_open_edit(window, &open_image.borrow(), &session, &library_for_show);
let i = *index.borrow();
// Cloned rather than held: `load` below is slow, and keeping the
// list borrowed across it would panic the moment anything else
@@ -825,6 +1027,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
Some(s) => {
window.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
// A local file stores its edit beside itself. Read
// *before* the first render, so an edited
// photograph never flashes up at its defaults.
*open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf());
if let Some(sidecar) = presets::load_local(path) {
presets::apply_stored_edit(window, &sidecar, &session, &rows);
}
// Through `sync_rows` rather than setting rows
// directly, so the curve's drawn shape is
// refreshed by the same path that refreshes the
@@ -837,6 +1048,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// the controls rather than offering sliders that
// would do nothing.
*session.borrow_mut() = None;
*open_image.borrow_mut() = presets::Stored::Nowhere;
rows.set_vec(Vec::<ParamRow>::new());
window.set_adjust_enabled(false);
if let Some(image) = l.fallback {
@@ -874,9 +1086,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let rows = rows.clone();
let gpu = gpu.clone();
let activity = activity.clone();
let open_image = open_image.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
// Whatever was open before is being replaced; persist its edit
// before the identity below is overwritten.
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
reset_view_state(&w);
w.set_filename(name.clone().into());
@@ -894,6 +1111,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
return;
};
// TRACES: FR-CAT-8 | FR-NC-8
// Where this image's edit belongs. The uuid comes from the catalog
// so every device names the same version; without one there is
// nowhere to save to, and the image opens read-only as far as
// persistence is concerned rather than writing to an invented
// identity that would never merge.
*open_image.borrow_mut() = match library.version_uuid_for_path(&path) {
Some(version_uuid) => presets::Stored::Remote {
path: path.clone(),
version_uuid,
},
None => {
log::debug!("no catalog version for {path}; edits will not persist");
presets::Stored::Nowhere
}
};
// The sidecar is fetched alongside the image rather than after it.
// It is a few kilobytes against tens of megabytes, so it costs
// nothing to have in hand by the time there is a session to apply
// it to — and starting it here means the edit is ready when the
// photograph is, instead of the image appearing at its defaults
// and visibly changing a moment later.
let sidecar_rx =
library::spawn_sidecar_fetch(creds.clone(), user_id.clone(), path.clone());
// TRACES: FR-NC-6a
// The cache is consulted first, so a second open of the same
// photograph is a disk read rather than a second download of tens
@@ -924,6 +1167,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
let sidecar_rx = Rc::new(sidecar_rx);
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
@@ -967,6 +1211,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
Some(s) => {
w.set_adjust_enabled(true);
*session.borrow_mut() = Some(s);
// TRACES: FR-CAT-8
// The stored edit, if it has landed. It
// was started before the download of a
// file thousands of times its size, so in
// practice it has; `apply_when_ready`
// covers the case where it has not rather
// than blocking the UI thread on a socket.
apply_when_ready(
&w,
sidecar_rx.clone(),
&session,
&rows,
&redraw,
);
sync_rows(&w, &rows, &session);
redraw(&w);
}
@@ -993,10 +1251,73 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}));
}
// ---- copying settings between photographs (FR-DEV-6) ----------------
//
// Wired after the develop session and the library both exist, because a
// paste reaches both: onto the image on screen, or onto the grid's
// selection through its sidecars.
{
presets::wire(
&window,
clipboard.clone(),
presets::Develop {
session: session.clone(),
rows: rows.clone(),
redraw: redraw.clone(),
open: open_image.clone(),
},
settings.clone(),
library.clone(),
collections.clone(),
);
// Close the knot left open beside `open_from_library`: the grid's
// "‹ Library" button was wired before there was a session to save.
let weak = window.as_weak();
let open_image = open_image.clone();
let session = session.clone();
let library = library.clone();
*leave_develop.borrow_mut() = Some(Rc::new(move || {
let Some(w) = weak.upgrade() else { return };
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
}));
presets::render(&window, &clipboard, &settings);
}
// ---- Adjustment callbacks ------------------------------------------
//
// Generic by construction: they carry indices into the capability list,
// so adding an operation needs no change here (FR-DEV-3c).
// --- export ---------------------------------------------------------
//
// Renders its own frame at full resolution rather than encoding what is
// on screen: the display render is deliberately viewport-sized
// (FR-DSP-1), and exporting that would hand the user a soft, screen-sized
// file with no indication anything had been lost (FR-EXP-9).
{
let weak = window.as_weak();
let session = session.clone();
let settings = settings.clone();
let library = library.clone();
window.on_export_image(move || {
let Some(w) = weak.upgrade() else { return };
w.set_export_busy(true);
// Pushed before the work rather than after: the render blocks the
// UI thread, so a label set afterwards would never be drawn in
// the "Exporting…" state at all.
w.set_export_status("Rendering…".into());
let result = export_now(&w, &session, &settings, &library);
match result {
Ok(placed) => w.set_export_status(placed.describe().into()),
Err(e) => w.set_export_status(format!("Export failed: {e}").into()),
}
w.set_export_busy(false);
refresh_export_label(&w, &settings);
});
}
{
let weak = window.as_weak();
let session = session.clone();