Fetch the photographs around the open one ahead of the step to them
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 0s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / windows-image (push) Canceled after 0s
🐳 Windows image / Build and push (push) Canceled after 0s
Build and test / Windows (x86_64, cross) (push) Canceled after 0s
Build and test / Layer separation (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 16m26s
Traceability / Requirement traces (push) Canceled after 0s

Walking the photo roll was one download per frame: every step showed
"Downloading…" over an empty canvas while tens of megabytes came down,
and moving between a pair of near-identical frames paid that a dozen
times. Now, once the opened photograph has landed, the ones around it
are fetched into the originals cache while it is being looked at, so
the next step is a disk read.

A single worker serves the latest wish only, closest first and working
outwards — next, previous, next-but-one, previous-but-one… — one file
at a time. Each open replaces the wish, so a fast walk never leaves a
trail of stale downloads competing with the one being waited on. A
process-wide in-flight registry makes a click on a photograph that is
still being fetched ahead wait for that transfer and read it from disk,
rather than start a second download of the same file.

How far each side is a setting under STORAGE — Off, 2, 5, 10 or 20,
defaulting to 5 — and it is moot while "keep originals after opening"
is off, since a fetch the cache would discard on arrival is transfer
for nothing. Nothing is fetched ahead while offline. The transfers show
in the activity list while they run and are removed when they end.
This commit is contained in:
2026-09-19 10:36:50 +02:00
parent 2917b7427d
commit 78cb00634e
9 changed files with 775 additions and 154 deletions
+98
View File
@@ -1315,6 +1315,12 @@ 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());
// TRACES: FR-NC-6a | FR-UI-4
// The photographs either side of the open one, fetched into the cache
// while it is being looked at, so a step along the roll is a disk read.
// Fed from the develop open below; its transfers are shown here.
let prefetch = Rc::new(library::Prefetcher::new());
show_prefetches(&prefetch, &activity);
// 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.
@@ -1597,6 +1603,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let stored = settings.snapshot();
library.set_cache_budget(stored.cache.original_budget_bytes);
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
library.set_fetch_ahead(stored.cache.fetch_ahead);
library.set_write_xmp_sidecars(stored.library.write_xmp_sidecars);
library.set_timeline_bars(stored.library.timeline_bars);
library.set_face_model_id(stored.faces.detector.model_id());
@@ -1729,6 +1736,7 @@ 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);
lib.set_fetch_ahead(s.cache.fetch_ahead);
lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars);
if let Some(w) = weak.upgrade() {
refresh_export_label(&w, &ctl);
@@ -2359,6 +2367,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let open_image = open_image.clone();
let viewport = viewport.clone();
let inspection = inspection.clone();
let prefetch = prefetch.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
@@ -2447,6 +2456,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Cloned for the timer closure: the outer callback is an `Fn` and
// may run again for the next photograph.
let library = library.clone();
let prefetch = prefetch.clone();
let path = path.clone();
let viewport = viewport.clone();
let inspection = inspection.clone();
@@ -2484,6 +2494,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
job.finish(activity::describe_bytes(bytes.len() as u64));
log::info!("{name}: {} bytes fetched", bytes.len());
// TRACES: FR-NC-6a | FR-UI-4
// Now, and not when the click happened: started earlier
// they would share the connection with the download the
// user is watching, and the photograph on screen is the
// one that matters. Before the decode below, so the
// transfers are under way while the UI thread is busy.
prefetch_neighbours(&library, &prefetch, &path);
match load_bytes(gpu.as_ref(), &bytes) {
Ok(l) => {
w.set_load_error("".into());
@@ -3773,6 +3791,86 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
/// TRACES: FR-NC-6a
/// TRACES: FR-NC-6a | FR-UI-4
/// Ask for the photographs around `path` — as many each side as the settings
/// say — to be fetched into the cache.
///
/// Every gate the prefetcher documents is applied here, because this is the
/// side that can see the settings and the network: nothing while offline, and
/// nothing into a cache that would not keep the bytes. A neighbour the cache
/// cannot place — a row that has scrolled out of the window — is left out
/// rather than fetched to nowhere.
///
/// Called on every landing, including one with no neighbours: an empty wish
/// cancels whatever the previous photograph asked for, which is right — those
/// were *its* neighbours.
fn prefetch_neighbours(
library: &Rc<library_ui::LibraryController>,
prefetch: &library::Prefetcher,
path: &str,
) {
let Some(conn) = library.credentials() else {
return;
};
let jobs: Vec<library::PrefetchJob> = if library.is_offline() {
Vec::new()
} else {
library
.neighbours_of(path, library.fetch_ahead())
.into_iter()
.filter_map(|path| {
let cache = library.cache_context(&path).filter(|c| c.store)?;
Some(library::PrefetchJob { path, cache })
})
.collect()
};
if !jobs.is_empty() {
log::debug!("fetching {} neighbour(s) of {path} ahead", jobs.len());
}
prefetch.want(conn, jobs);
}
/// TRACES: FR-NC-6a
/// Show the prefetcher's transfers in the activity list while they run.
///
/// A row each, like the download the user is waiting on, so a transfer that
/// is using the connection is never invisible. Removed rather than kept when
/// it ends, on the same grounds as a thumbnail batch: routine work the user
/// never asked for by name would push a failed transfer out of the list. A
/// prefetch that fails says nothing here — the click that wants the
/// photograph will try again and report in its own row.
fn show_prefetches(prefetch: &Rc<library::Prefetcher>, activity: &Rc<activity::ActivityLog>) {
let prefetch = prefetch.clone();
let activity = activity.clone();
let mut live: std::collections::HashMap<String, activity::Activity> = Default::default();
// Held by its own closure for the life of the window, like every other
// drain here.
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
move || {
let _keep = &held;
for event in prefetch.poll() {
match event {
library::PrefetchEvent::Started(path) => {
let name = path.rsplit('/').next().unwrap_or(&path);
let job = activity
.begin(activity::Kind::Download, format!("Fetching {name} ahead"));
live.insert(path, job);
}
library::PrefetchEvent::Ended(path) => {
if let Some(job) = live.remove(&path) {
job.finish_quietly();
}
}
}
}
},
);
}
/// What the originals cache is holding, for the settings page.
///
/// Pinned and passive are reported separately because they answer different