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:
+162
-2
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user