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:
@@ -456,11 +456,17 @@ component HeaderActions inherits HorizontalLayout {
|
||||
/// Whether settings have been copied, and what pasting them would apply.
|
||||
in property <bool> settings-armed: false;
|
||||
in property <string> settings-summary;
|
||||
/// TRACES: FR-EXP-7
|
||||
/// Whether a batch is running, and where its files are going.
|
||||
in property <bool> exporting: false;
|
||||
in property <bool> export-to-server: false;
|
||||
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||||
/// is sized to its content.
|
||||
in property <bool> centred: true;
|
||||
in property <length> row-height: 44px;
|
||||
|
||||
callback export-selection();
|
||||
callback cancel-export();
|
||||
callback paste-settings-to-selection();
|
||||
callback remove-from-collection();
|
||||
callback change-library();
|
||||
@@ -486,6 +492,37 @@ component HeaderActions inherits HorizontalLayout {
|
||||
clicked => { root.paste-settings-to-selection(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||||
// Export the selection, and stop the batch that is running.
|
||||
//
|
||||
// One button doing both, because they are the same thought a moment apart
|
||||
// and a separate cancel would have to appear from somewhere — shifting the
|
||||
// row under the pointer at the exact moment the user is reaching for it.
|
||||
//
|
||||
// It stays while a batch runs whatever the selection has since become: the
|
||||
// run is what the button now refers to, and a batch of three hundred that
|
||||
// could only be stopped by not changing the selection would be a trap.
|
||||
//
|
||||
// The count is in the label rather than behind a confirmation, exactly as
|
||||
// the paste above puts it there: "Export 40" read before the click is worth
|
||||
// more than a dialogue asking the same question after it.
|
||||
if root.selected-count > 0 || root.exporting: Button {
|
||||
text: root.exporting
|
||||
? "Cancel export"
|
||||
: (root.export-to-server
|
||||
? "Export " + root.selected-count + " to Nextcloud"
|
||||
: "Export " + root.selected-count);
|
||||
active: root.exporting;
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
clicked => {
|
||||
if (root.exporting) {
|
||||
root.cancel-export();
|
||||
} else {
|
||||
root.export-selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removing from a collection is only meaningful while the grid is scoped
|
||||
// to one. Offering it unscoped would invite the reading "remove from the
|
||||
// library", which nothing here does.
|
||||
@@ -753,6 +790,15 @@ export component LibraryGrid inherits Rectangle {
|
||||
in property <string> settings-summary;
|
||||
callback paste-settings-to-selection();
|
||||
|
||||
// TRACES: FR-EXP-7
|
||||
// Exporting the selection. The grid owns neither the settings that decide
|
||||
// where the files go nor the worker that writes them — it reports what is
|
||||
// selected and asks, exactly as it does for a paste.
|
||||
in property <bool> exporting: false;
|
||||
in property <bool> export-to-server: false;
|
||||
callback export-selection();
|
||||
callback cancel-export();
|
||||
|
||||
// --- the keyboard cursor ------------------------------------------------
|
||||
//
|
||||
// Where the keyboard is in the library, as an **image ordinal** — not a
|
||||
@@ -891,6 +937,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
scope-pinned: root.scope-pinned;
|
||||
settings-armed: root.settings-armed;
|
||||
settings-summary: root.settings-summary;
|
||||
exporting: root.exporting;
|
||||
export-to-server: root.export-to-server;
|
||||
export-selection => { root.export-selection(); }
|
||||
cancel-export => { root.cancel-export(); }
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
change-library => { root.change-library(); }
|
||||
@@ -938,6 +988,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
scope-pinned: root.scope-pinned;
|
||||
settings-armed: root.settings-armed;
|
||||
settings-summary: root.settings-summary;
|
||||
exporting: root.exporting;
|
||||
export-to-server: root.export-to-server;
|
||||
export-selection => { root.export-selection(); }
|
||||
cancel-export => { root.cancel-export(); }
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
change-library => { root.change-library(); }
|
||||
|
||||
Reference in New Issue
Block a user