Files
lightcraft/docs/background-tasks.md
T
Jason Baldwin fdad5a4ad4 LR-VIEW-FOCUSPOINT: read focus points for photos already in the library, header-only in the background (#1480)
Photos imported before focus points were read got one only through Reload from Disk, which
hashes whole files on the UI thread. Now:

- lightcraft_meta::focus::read_header reads only what the decoder looks at: IFD0 (Make,
  Model), the Exif IFD, the maker note's IFD and the focus tags' values (a JPEG: its segments
  up to the Exif one), walked round by round and decoded with the whole-file code from a
  buffer of the file's length holding just those ranges. Bounded rounds, bytes (2 MB) and
  size (1 GB). One 32 KB read per α1 / A7S III ARW; on 109 real files (83 with a point)
  it gives the whole-file read's point for every one.
- A background job (crates/engine/src/focus_scan.rs) reads photos with no point from a
  supported body (by the stored camera; nothing else is opened), ARW or JPEG, one at a
  time in path order, with a "Reading focus points" activity row (Cancel::Flag). The UI's
  per-frame focus.pump gives it the face scan's pace (paused while dragging or typing) and
  starts it once per library opening. Commits every 200 photos or 3 s as system ops (no
  undo step, no sidecar write), only meta.focus, only on photos that still have none at the
  same path. "Looked, none" goes to focus-scanned.bin beside the library, headed by the
  decoder's tag so a decoder knowing more bodies starts afresh; unreadable files are retried
  at the next opening.
- photo.readFocusPoints {ids?, wait?} (Photo > Read Focus Points) runs it on demand.

Measured cold on a 5400 rpm USB drive: 200 α1 / A7S III ARWs in 1.9 s (~100 photos/s,
32 KB each), so 58,796 raws take about 10 minutes instead of re-reading 2.25 TB.
2026-10-13 16:06:06 +02:00

4.6 KiB

Long-running tasks and the activity stack

Everything that takes a while runs off the UI thread and shows up in one place: the activity stack in the top-left corner under the top bar (issue #345). Each task has a row with its name, a progress bar (a sweep while the amount of work isn't known), the count and what it's working on, and ✕ if it can stop. Quitting asks first while a task that can stop is running. Agents see the same list through activity.list / activity.cancel, and ui.inspect reports it as activity.

The registry is lightcraft_engine::activity (crates/engine/src/activity.rs, Session::activity). The stack is crates/ui-egui/src/panels/activity.rs. Every job that can last more than a moment uses it; don't add a progress window, panel or progress toast of your own. A dialog may still show progress inline (the Synchronize Folder dialog counts files while it scans), and a toast at the end saying what happened is fine. A view saying its pixels aren't final isn't progress either: the loupe's "Rendering…" pill (loupe:rendering) marks a stand-in or an out-of-date render on the photo itself, and the render behind it holds no row (a slider drag replaces it many times a second).

Adding a task

use lightcraft_engine::activity::{Cancel, Unit};

// when the job starts, with the cancel flag the worker already checks between files
let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
guard.progress(0, total as u64);

// every frame (or from the worker, through `guard.handle()`)
guard.progress(done as u64, total as u64);
guard.detail(&file_name);
  • Keep the guard for exactly as long as the job runs. Put it in the struct the UI polls (ExportTask, ScanTask, SyncRun…), or move it into the worker thread. Dropping it removes the row, also when the worker panics, so a dead job never leaves a stuck bar. A worker that only reports progress takes a TaskHandle (guard.handle(), Clone + Send).
  • kind is a stable camelCase id that agents filter by (export, import, scan, sync, previews, download, faces, focus…). label is a short English name in sentence case ("Building previews"). The stack shows it translated, so add it to every catalog in crates/ui-egui/locales/.
  • Progress: progress(done, total). A total of 0 means the amount of work isn't known yet, and the bar sweeps. set_unit(Unit::Bytes) makes the count read "12 of 340 MB"; Unit::Percent reads "40 %". The default Unit::Count reads "3 of 25".
  • Detail: what the job is on (a file name, a model's name). Detail set by engine code is a name, never a sentence. UI code may set translated text.
  • Cancel:
    • Cancel::Flag(Arc<AtomicBool>) adopts the flag the job already checks. The row's ✕, activity.cancel and the job's own Cancel then all set the same atomic.
    • Cancel::Yes: the job polls guard.is_cancelled() itself.
    • Cancel::No: the job can't stop; it gets no ✕ and doesn't make Quit ask.
    • set_cancellable(false) for a phase that has to finish once started (a Lightroom import adding its photos).
  • Quiet periodic work shows no row: the auto-import folder listing runs every few seconds through tasks::spawn(.., None, ..).

Examples in the code

Job Where Shows how to
Export, contact sheet crates/ui-egui/src/export_task.rs adopt the job's flag; update the row each frame
Build Previews crates/engine/src/cmd/previews.rs move the guard into the worker thread
Model downloads crates/engine/src/face_download.rs report progress through a TaskHandle in shared state
Lightroom catalog import crates/ui-egui/src/lightroom_import.rs stop being cancellable for a phase that must finish
Face scan, AI Denoise crates/engine/src/cmd/face_recognize.rs, crates/engine/src/denoise.rs a continuous job with no ✕, kept in session state
Find Missing Photos crates/ui-egui/src/tasks.rs a generic background task with a row (kind: Some(..))
Reading focus points crates/engine/src/focus_scan.rs a worker paced by the UI's per-frame pump (focus.pump {pace}); Cancel::Flag; commits in batches without undo steps

Testing

Assert the row (session.activity.list(): kind, label, total, cancellable), that activity.cancel(id) stops the job, and that the row is gone once the job ends. crates/ui-egui/src/tests_activity.rs has the helpers, and the job-specific tests show the pattern (tests_sync.rs, export_task.rs, tests_denoise.rs…). A row only appears on screen once its task is 0.5 s old, so tests that click its ✕ wait for activity:cancel:<id> first.