Start face indexing from Settings

Beside the thumbnail sweep, because it is the same kind of thing: a job
that runs for an hour, is asked for once, and reports into the activity
list above it. It is also downstream of that sweep -- detection reads the
proxies it builds -- so the two belong in that order, and the coverage
line says how many images are waiting on a proxy rather than only how
many are left to index.

The button drives the Identity Manager's own state rather than a second
copy, so it cannot disagree with that screen about whether a pass is
running, and either place can start or stop it.

The pass now opens an activity row. The caption promises progress will
appear in the list above, and without a row it would not: the button
would be the only sign anything was happening, invisible from every
other screen.

Coverage is read when the Settings page opens. The figures live in the
catalog and this page deliberately holds no session, so they arrive
through a closure rather than being kept current -- they are only ever
looked at while the page is on screen, and the check is two counts and an
indexed scan.

Also adds DARKROOM_NO_SYNC. Redirecting XDG_DATA_HOME isolates a test
launch's catalog and thumbnails but not its server, and I found that out
by pushing a test catalog over the live one. The guard sits in
start_derived_sync rather than at its three call sites, because the sweep
firing a sync is correct and a flag checked in three places is one that
gets missed in a fourth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 09:22:43 +02:00
co-authored by Claude Opus 5
parent f00b92a0e6
commit 25c88d9dbd
7 changed files with 172 additions and 34 deletions
+24
View File
@@ -50,6 +50,13 @@ pub struct IdentityController {
/// grid when they came from an open photograph loses their place for no
/// reason.
came_from_library: std::cell::Cell<bool>,
/// The activity row for the running sweep.
///
/// Face indexing is an hours-long background pass, and the Settings page
/// promises its progress will appear in the list above the button. Without
/// a row it would not, and the button would be the only sign it was
/// running — invisible from every other screen.
activity: RefCell<Option<crate::activity::Activity>>,
/// Rail portraits, kept between refreshes.
///
/// Every mutating action reloads the whole screen, and cutting a portrait
@@ -248,6 +255,7 @@ pub fn wire<S, M, P>(
window: &AppWindow,
ctl: Rc<IdentityController>,
catalog: Rc<RefCell<Option<Catalog>>>,
activity: Rc<crate::activity::ActivityLog>,
store: S,
models: M,
paths: P,
@@ -480,6 +488,10 @@ pub fn wire<S, M, P>(
ctl.progress.set((0, 0));
ctl.faces_found.set(0);
*ctl.activity.borrow_mut() = Some(activity.begin(
crate::activity::Kind::Index,
"Indexing faces",
));
*ctl.sweep.borrow_mut() = Some(crate::faces::spawn_face_sweep(
catalog_path,
store_dir,
@@ -536,9 +548,18 @@ pub fn wire<S, M, P>(
)
.into(),
);
if let Some(a) = ctl_tick.activity.borrow().as_ref() {
a.progress(seen, total);
}
if done {
*ctl_tick.sweep.borrow_mut() = None;
if let Some(a) = ctl_tick.activity.borrow_mut().take() {
a.finish(format!(
"{} face(s) in {seen} image(s)",
ctl_tick.faces_found.get()
));
}
w.set_identity_indexing(false);
// Newly indexed faces belong to nobody until they are
// grouped, so a sweep ending with an unchanged people
@@ -565,6 +586,9 @@ pub fn wire<S, M, P>(
// send fails and it returns, leaving everything already written
// written. No flag to fall out of step with the thread.
*ctl.sweep.borrow_mut() = None;
if let Some(a) = ctl.activity.borrow_mut().take() {
a.finish("stopped");
}
w.set_identity_indexing(false);
reload!(w, ctl, catalog, store);
});
+23
View File
@@ -963,6 +963,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
&window,
identity.clone(),
library.catalog(),
activity.clone(),
move || {
let (_, session) = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(
@@ -1276,6 +1277,25 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if let Some(w) = weak.upgrade() {
settings_ui::render(&w, &ctl);
}
},
{
// Face coverage, read when the page opens. The figures live in the
// catalog and the settings page holds no session, so they arrive
// through here rather than being kept up to date continuously —
// they are only ever looked at while this page is on screen.
let lib = library.clone();
let catalog = library.catalog();
move |w: &AppWindow| {
let store = lib.session().and_then(|(_, s)| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&s.server, &s.user_id)).ok()
});
identity_ui::refresh_coverage(w, &catalog, store.as_ref());
w.set_identity_model_missing(
lib.session()
.and_then(|(_, s)| library::face_models(&s.server, &s.user_id))
.is_none(),
);
}
});
}
@@ -2037,6 +2057,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
if std::env::var_os("DARKROOM_START_IDENTITY").is_some() {
window.invoke_identity_open();
}
if std::env::var_os("DARKROOM_START_SETTINGS").is_some() {
window.invoke_settings_open();
}
{
let weak = window.as_weak();
+16
View File
@@ -3317,6 +3317,22 @@ fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
/// Fired after the sweep completes, when there is a finished index worth
/// sharing, and from the Sync button for an explicit exchange.
fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
// An escape hatch for running the app against a *copied* library without
// touching the account's real server.
//
// Added after doing exactly that by accident: a local test launch
// completed its thumbnail sweep, which fires this, which pushed a test
// catalog over the live one. Redirecting `XDG_DATA_HOME` isolates the
// catalog and the thumbnails but not the server, and nothing said so.
//
// The guard belongs here rather than at the call sites: the sweep firing a
// sync is correct — the point of building thumbnails is sharing them — and
// a flag checked in three places would eventually be missed in a fourth.
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
log::info!("derived sync suppressed by DARKROOM_NO_SYNC");
return;
}
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
+14 -2
View File
@@ -281,15 +281,26 @@ fn index_of<T: PartialEq>(all: &[T], value: &T) -> i32 {
/// `on_budget_changed` runs when a cache ceiling moves, so the caller can
/// enforce it against the catalog — this module has no connection and must not
/// grow one.
pub fn wire<F>(window: &AppWindow, controller: Rc<SettingsController>, on_budget_changed: F)
where
pub fn wire<F, G>(
window: &AppWindow,
controller: Rc<SettingsController>,
on_budget_changed: F,
on_open: G,
) where
F: Fn(&Settings) + 'static,
G: Fn(&AppWindow) + 'static,
{
let on_budget_changed = Rc::new(on_budget_changed);
// Run when the page opens, to fill in figures this module does not own —
// the face-indexing coverage, which lives in the catalog. Read here rather
// than kept up to date continuously, because it is only ever looked at
// when this page is on screen and it is two counts and an indexed scan.
let on_open: Rc<dyn Fn(&AppWindow)> = Rc::new(on_open);
// --- opening and closing -------------------------------------------
{
let weak = window.as_weak();
let on_open = on_open.clone();
let ctl = controller.clone();
window.on_settings_open(move || {
let Some(w) = weak.upgrade() else { return };
@@ -299,6 +310,7 @@ where
// back over the newer one.
*ctl.settings.borrow_mut() = ctl.store.load();
render(&w, &ctl);
on_open(&w);
w.set_show_settings(true);
});
}
+8
View File
@@ -1279,8 +1279,16 @@ in property <bool> panel-visible: true;
activity-kept: root.activity-kept;
clear-finished() => { root.activity-clear-finished(); }
thumbnailing: root.library-thumbnailing;
// The face pass reuses the Identity Manager's own state rather
// than duplicating it: one job, startable from either place, and
// a button that cannot disagree with the screen about whether it
// is running.
face-indexing: root.identity-indexing;
face-coverage: root.identity-coverage;
face-model-missing: root.identity-model-missing;
library-open: root.library-open;
thumbnail-library() => { root.library-thumbnail-all(); }
index-faces() => { root.identity-index(); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
+55
View File
@@ -78,6 +78,19 @@ export component SettingsPage inherits Rectangle {
in property <bool> library-open: false;
callback thumbnail-library();
/// TRACES: FR-CULL-8
/// The face-indexing pass, and what a coverage check last found.
///
/// Beside the thumbnail sweep because it is the same kind of thing: a job
/// that runs for an hour, is asked for once, and reports into the list
/// above. It is also *downstream* of that sweep — face detection reads the
/// proxies it builds — so the two belong in the same place, in that order.
in property <bool> face-indexing: false;
in property <string> face-coverage;
/// No model on disk, so the pass cannot run at all.
in property <bool> face-model-missing: false;
callback index-faces();
// --- cache ---------------------------------------------------------
in-out property <string> original-budget;
in property <bool> original-unlimited: false;
@@ -369,6 +382,48 @@ export component SettingsPage inherits Rectangle {
}
}
// TRACES: FR-CULL-8
// Face indexing, directly under the sweep that feeds
// it. Detection runs on the proxies the pass above
// builds, so a library that has not been thumbnailed
// has nothing here to index — which is why the
// coverage line says how many images are waiting on a
// proxy rather than only how many are left.
if root.library-open: Rectangle {
height: Theme.gap-sm;
}
if root.library-open: Caption {
text: "Face indexing looks for people in the "
+ "photographs already thumbnailed above. It "
+ "runs once per library and syncs, so your "
+ "other devices never repeat the work.";
wrap: word-wrap;
}
if root.library-open && root.face-coverage != "": Caption {
text: root.face-coverage;
wrap: word-wrap;
}
if root.library-open && root.face-model-missing: Caption {
text: "No face model is installed, so this cannot run.";
wrap: word-wrap;
}
if root.library-open: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: root.face-indexing
? "Indexing faces…"
: "Index faces in the whole library";
enabled: !root.face-indexing && !root.face-model-missing;
clicked => { root.index-faces(); }
}
}
if root.activity-kept > 0: Rectangle {
height: Theme.control-height;