Export a whole selection, on a thread that is not the interface's

The export button rendered, resampled and encoded a 24 MP frame on the UI
thread and the window was dead for all of it. That was written down as a known
compromise, on the grounds that a batch is what makes the wait intolerable
rather than merely noticeable. This is the batch, so the compromise comes due.

The grid's selection now exports (FR-EXP-7). A worker thread takes a clone of
the `GpuContext` — an `Arc` pair over a device and a queue — and opens each
photograph for itself: fetch, sidecar, decode, demosaic, render at full size,
resample, sharpen, encode, write. Nothing of that touches the interface, which
keeps drawing throughout, and the progress goes where every other background
job's does: one row in the activity register, with a count and a bar.

Why the worker does not borrow the session it could have had. A
`DevelopSession` owns the `AdjustPass` the canvas renders from, so handing it
to a worker would stop the develop view drawing for the length of the batch —
the same freeze, moved. Opening a session per image instead costs a
`Demosaicer` and an `AdjustPass` each time round, and the pipeline cache is
per-pass so the composed shader is recompiled per image rather than once for
the run. Against a full-resolution decode, render and encode that is a few
percent, and it keeps this file out of the pipeline `develop` owns. A reusable
export pass is the obvious next economy if a profile ever says so.

The open image is the exception, and it is why the develop button is not simply
a one-image batch. Its edit lives in the interface's session and may not have
reached a sidecar yet, so a worker that re-opened the file would export the
saved version rather than the one on screen. That frame is therefore rendered
by the caller and handed over as `Source::Rendered`; everything after the
render — the Lanczos reduction, the encode, the write, which is the larger half
of the wait and all of its variance — still leaves the UI thread. So the
develop export is no longer synchronous, but it is not fully off-thread either,
and the doc comment says so rather than claiming otherwise.

Cancellation (NFR-ARCH-3) is an `AtomicBool` read between stages, and the
export button becomes the cancel button while a run is live — a batch that
could only be stopped by not touching the selection would be a trap. Waits on
another worker use `recv_timeout` rather than `recv`, so a cancelled batch
sitting on a forty-megabyte download gives up within 100 ms instead of when the
transfer finishes. The honest bound is worse than that: a frame already in
render has no interior stopping point, so the worst case is one image. Closing
that needs the render itself to become interruptible, which is NFR-ARCH-2's
scheduler and not a finer poll here.

Failures are per image and typed (NFR-ARCH-4). One unreadable body, one folder
that cannot be written, one server that went away — each is a message on the
channel, a line in the log, and a count in the summary, and the batch carries
on. A run with any failure keeps its row until it is cleared, because that is
the row somebody came to the list to find; a cancelled run does not, because
they asked for it.

Two collisions that look alike and are not. `CollisionPolicy` is the user's
answer to "a file of this name was already there", and Overwrite is a fine
answer to that. It is not an answer to "the frame I exported four seconds ago
was also called this" — two folders in a library each holding an IMG_0001 is
ordinary — so a name the run has already issued is always stepped past whatever
the policy says about the folder. Both halves are held by tests.

Supporting changes, each smaller than it sounds. `open_session` comes out of
`load_bytes` so the worker shares the JPEG-versus-RAW routing rather than
carrying a copy that would drift; the half that builds a `slint::Image` stays
behind, where it belongs. `LibraryController::selected_image_paths` answers
from the catalog rather than from the loaded window, because selection is by id
and survives a scrub — a selection made before scrolling routinely names
photographs no row holds. `cache_context_for` takes an id for the same reason,
so a batch reads the originals cache instead of re-downloading three hundred
files. `format_date` is shared so `{date}` and the timeline agree about what
day a photograph was taken.

Left undone, deliberately: the batch is sequential, where FR-EXP-7 asks for all
available cores. Four full-resolution frames in flight is tens of megabytes
each and a straightforward way to exhaust a tablet, and the GPU is shared with
the interface in any case. Also undone: exporting with a chosen preset rather
than the current export settings — that is FR-EXP-5's machinery, which does not
exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:59:57 +02:00
co-authored by Claude Opus 5
parent 2330ed25e9
commit 7d3c8c521f
6 changed files with 1410 additions and 141 deletions
+162 -2
View File
@@ -418,6 +418,18 @@ impl LibraryController {
/// grid row that has scrolled away, or no library open — and the fetch
/// then simply goes to the network.
pub fn cache_context(&self, path: &str) -> Option<library::CacheContext> {
self.cache_context_for(self.image_id_for_path(path)?)
}
/// TRACES: FR-EXP-7 | FR-NC-6a
/// The same, for an image named by id rather than by path.
///
/// A batch export needs this one: its selection is by catalog id and may
/// include photographs that have scrolled out of the loaded window, where
/// [`Self::image_id_for_path`] has nothing to match against. Going through
/// the path would quietly hand those images no cache at all, and a batch of
/// three hundred would re-download every one of them.
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
let borrow = self.session.borrow();
let (_, session, _) = borrow.as_ref()?;
let catalog_path = library::catalog_path(&session.server, &session.user_id);
@@ -427,7 +439,7 @@ impl LibraryController {
Some(library::CacheContext {
dir,
catalog_path,
image: self.image_id_for_path(path)?,
image,
// The user's ceiling, not the catalog's floor: read at each fetch
// so a budget changed mid-session takes effect on the next one.
budget: self.cache_budget.get(),
@@ -480,6 +492,78 @@ impl LibraryController {
.collect()
}
/// TRACES: FR-EXP-7
/// Remote paths for a set of selected images, in the order they were given.
///
/// Answered from the catalog rather than from `paths`, which is only the
/// loaded window. Selection is by id precisely so that it survives a scrub
/// (see [`crate::collections_ui`]), so a selection made before scrolling
/// routinely names photographs no row currently holds — and an export that
/// silently dropped those would be worse than one that refused.
///
/// An id the catalog has never heard of is skipped rather than reported: it
/// can only mean the image was deleted between the selection and the click,
/// and there is nothing to export and nothing to fix.
///
/// Each path comes back beside the id it belongs to, because that skipping
/// means the ids that come out are not the ids that went in — and the caller
/// still has to find each photograph's cache, which is keyed on the id.
pub fn selected_image_paths(
&self,
images: &[dr_types::ImageId],
) -> Vec<(dr_types::ImageId, String)> {
if images.is_empty() {
return Vec::new();
}
let borrow = self.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return Vec::new();
};
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})");
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
}) else {
return Vec::new();
};
// `IN` returns rows in whatever order suits SQLite, and the export's
// `{seq}` token counts through the batch — so the answer is put back
// into the order the caller asked in rather than the one it arrived in.
let found: std::collections::HashMap<i64, String> = rows.flatten().collect();
images
.iter()
.filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone())))
.collect()
}
/// TRACES: FR-EXP-7
/// The selection, addressed the way an export worker can fetch it.
///
/// Assembled here because a worker thread can reach neither the catalog nor
/// the session, and both are needed to say where a cached original lives.
pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> {
self.selected_image_paths(images)
.into_iter()
.map(|(id, path)| crate::export::Source::Library {
path,
cache: self.cache_context_for(id),
})
.collect()
}
/// Credentials and account for the open library, if one is open.
///
/// What a full-file fetch needs: the grid's paths are remote, so opening
@@ -2629,7 +2713,12 @@ fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
}
}
fn format_date(t: i64) -> String {
/// A capture instant as `YYYY-MM-DD`.
///
/// Shared with the exporter, which resolves the `{date}` token from the same
/// reading so a filename and the timeline cannot disagree about what day a
/// photograph was taken.
pub fn format_date(t: i64) -> String {
let (y, m, d, _) = civil_from_unix(t);
format!("{y}-{m:02}-{d:02}")
}
@@ -3721,4 +3810,75 @@ mod tests {
assert_eq!(seen, vec![0, 1, 2, 3]);
}
/// A controller holding a catalog of four named images.
fn with_catalog() -> Rc<LibraryController> {
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
let catalog = Catalog::in_memory().expect("in-memory catalog");
let c = catalog.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.expect("root");
for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
rusqlite::params![id, name],
)
.expect("image");
}
*ctl.catalog.borrow_mut() = Some(catalog);
ctl
}
/// Just the names, for assertions that are about order rather than ids.
fn named(ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) -> Vec<String> {
ctl.selected_image_paths(images)
.into_iter()
.map(|(_, path)| path)
.collect()
}
/// TRACES: FR-EXP-7
#[test]
fn a_selection_resolves_to_paths_in_the_order_it_was_given() {
// `IN (…)` returns rows in whatever order suits SQLite, and an export's
// `{seq}` token counts through the batch — so a lookup that handed back
// the database's order would number the photographs in an order the
// user never saw.
let ctl = with_catalog();
let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]);
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
}
/// TRACES: FR-EXP-7
#[test]
fn a_selection_outside_the_loaded_window_still_resolves() {
// The property the catalog lookup exists for. Selection is by id and
// survives a scrub, so a selection made before scrolling routinely
// names photographs no row holds — and `paths`, the loaded window, is
// empty here precisely to prove nothing is being read from it.
let ctl = with_catalog();
assert!(ctl.paths.borrow().is_empty());
assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]);
}
/// TRACES: FR-EXP-7
#[test]
fn an_image_that_vanished_under_the_selection_is_skipped() {
// Deleted between the selection and the click. There is nothing to
// export and nothing to fix, so it drops out rather than becoming a
// failure row the user can do nothing about.
let ctl = with_catalog();
let paths = named(
&ctl,
&[
dr_types::ImageId(1),
dr_types::ImageId(99),
dr_types::ImageId(2),
],
);
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
}
}