Files
lightcraft/crates/ui-egui/src/dialogs/faces.rs
T

851 lines
40 KiB
Rust

//! Faces: the Settings ▸ Faces tab (the on/off toggle and the list of face models) and the dialog that
//! shows a model file's licence terms before it is installed.
//!
//! Adding a model is: pick or drop a `.onnx` file → the engine inspects it (`faces.models.inspect`) →
//! this dialog shows what it is and its terms → the user ticks "I accept" → `faces.models.install`.
//! LightCraft fetches a model only when the user presses Download on a model it has a pinned address for: the
//! same dialog shows its terms first, and accepting them downloads it (`faces.models.download`: checked against its
//! size and SHA-256), installs it, starts using it and switches recognition on, with no further question. "Open
//! page" opens the model's own page in the browser for anything else.
use egui::RichText;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::panels::settings::{check, heading, hint};
use crate::theme::Tokens;
use crate::widgets::register;
/// How hard the background scan may work (the engine's `faces.pump` pace), from what the user is doing: nothing new
/// while they drag, type or scroll; one photo at a time while they are around or the window is minimized; half the
/// machine once they have been idle for a few seconds, or while another app has the keyboard but this window is still
/// on screen; most of it while they are looking at the scan's progress.
fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static str, bool) {
let (focused, minimized) = ctx.input(|i| (i.raw.focused, i.viewport().minimized.unwrap_or(false)));
let watching =
matches!(&app.ui.dialog, Some(crate::state::Dialog::Settings { tab }) if tab == "faces") || app.ui.view == crate::state::ViewMode::People;
(pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching), focused && !minimized)
}
/// The pace for other background work that reads the library's files (the focus-point reader): the face scan's, without
/// the "watching" speed-up.
pub(crate) fn background_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> &'static str {
let (focused, minimized) = ctx.input(|i| (i.raw.focused, i.viewport().minimized.unwrap_or(false)));
pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, false)
}
/// The pace for a window that has the keyboard (or not) and is minimized (or not), `worked` seconds after the user last
/// dragged, typed or scrolled and `moved` seconds after they last moved the pointer, while they are (or are not) looking
/// at the scan's progress.
fn pace_for(focused: bool, minimized: bool, worked: f64, moved: f64, watching: bool) -> &'static str {
if minimized {
"light"
} else if !focused {
// still on screen, but the user is working in another app: no input here to get in the way of
"normal"
} else if worked < 0.4 {
"pause"
} else if moved < 3.0 {
"light"
} else if watching {
"full"
} else {
"normal"
}
}
/// How long a read of the model list is reused (it is a few small files, but not for every frame).
const REFRESH_SECS: f64 = 1.5;
fn list_id() -> egui::Id {
egui::Id::unique("faces-model-list")
}
/// The engine's model list, re-read at most every [`REFRESH_SECS`], or at once after an action changed it.
fn models(app: &mut LightcraftApp, ctx: &egui::Context) -> Value {
let now = app.tick_now();
let epoch = app.caches.faces_epoch;
if let Some((e, at, v)) = ctx.data(|d| d.get_temp::<(u64, f64, Value)>(list_id()))
&& e == epoch
&& now - at < REFRESH_SECS
{
return v;
}
let v = app.run("faces.models.list", json!({})).unwrap_or(Value::Null);
ctx.data_mut(|d| d.insert_temp(list_id(), (epoch, now, v.clone())));
v
}
/// What stands between the user and name suggestions, for the prompts where faces are shown or named.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Setup {
/// Recognition is on (or about to start): nothing to offer.
Running,
/// A model is installed and chosen; only the switch is off.
TurnOn,
/// No model to use yet: Settings ▸ Faces has the download.
GetModel,
/// This build cannot keep or run models: nothing to offer.
Unavailable,
}
/// Where the user stands with face recognition. Cheap: the model list is read at most every 1.5 s, and not at all while
/// recognition runs.
pub fn setup(app: &mut LightcraftApp, ctx: &egui::Context) -> Setup {
if app.caches.faces_active {
return Setup::Running;
}
let list = models(app, ctx);
if list["dir"].is_null() || list["runtime"].as_bool() != Some(true) {
return Setup::Unavailable;
}
let chosen = list["embedder"].as_str();
let usable = chosen.is_some_and(|id| list["models"].as_array().into_iter().flatten().any(|m| m["id"] == id && m["installed"] == true));
match (usable, list["enabled"].as_bool() == Some(true)) {
(true, true) => Setup::Running,
(true, false) => Setup::TurnOn,
(false, _) => Setup::GetModel,
}
}
impl Setup {
/// The sentence and the button label that offer the next step; none when there is nothing to do.
pub fn prompt(self) -> Option<(&'static str, &'static str)> {
match self {
Setup::TurnOn => Some(("Face recognition is off. Turn it on to get name suggestions and see look-alike faces together.", "Turn on")),
Setup::GetModel => {
Some(("Face recognition needs a model to suggest names: a one-time download, in Settings.", "Set up face recognition"))
}
Setup::Running | Setup::Unavailable => None,
}
}
/// The same offer as one line of a small box (the name box in the loupe).
pub fn short(self) -> Option<&'static str> {
match self {
Setup::TurnOn => Some("Turn on name suggestions"),
Setup::GetModel => Some("Set up name suggestions…"),
Setup::Running | Setup::Unavailable => None,
}
}
}
/// The prompt's button: switch recognition on, or open Settings ▸ Faces, where the model is one click away.
pub fn take_step(app: &mut LightcraftApp, ctx: &egui::Context, step: Setup) {
match step {
Setup::TurnOn => {
if app.run("faces.enable", json!({"enabled": true})).is_ok() {
app.caches.faces_epoch += 1;
app.ui.status = "Face recognition is on".into();
app.toast(ctx, "Face recognition is on");
}
}
Setup::GetModel => {
let _ = app.run("app.settings", json!({"tab": "faces"}));
}
Setup::Running | Setup::Unavailable => {}
}
}
/// A slim line under a view's heading offering to set recognition up, shown only while it is not set up.
pub fn setup_banner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let step = setup(app, ui.ctx());
let Some((message, button)) = step.prompt() else { return };
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(12, 6)).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(message).font(t.font(12.5)).color(t.text_dim));
let r = ui.button(button);
register(ui, "faces:setup", r.rect);
if r.clicked() {
take_step(app, ui.ctx(), step);
}
});
});
}
/// Where the face models run (the GPU only once a check shows it gives the CPU's results faster), and the switch that
/// keeps them on the CPU.
fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Value) {
heading(ui, t, "Processor");
let mut cpu = gpu["cpuOnly"].as_bool().unwrap_or(false);
let r = ui.checkbox(&mut cpu, crate::i18n::tr("Run face models on the CPU only"));
register(ui, "check:settings.facesCpuOnly", r.rect);
if r.changed() {
match app.run("faces.gpu", json!({"cpuOnly": cpu})) {
Ok(_) => app.caches.faces_epoch += 1,
Err(e) => app.toast(ui.ctx(), e),
}
}
for (what, label) in [("detection", "Detection"), ("recognition", "Recognition")] {
let st = &gpu[what];
let (text, why) = if st.is_null() {
(crate::i18n::tr("Not loaded yet").to_string(), None)
} else if st["checking"] == true {
(crate::i18n::tr("Checking the GPU…").to_string(), None)
} else if let Some(device) = st["device"].as_str() {
(device.to_string(), None)
} else {
(crate::i18n::tr("CPU").to_string(), st["why"].as_str())
};
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim));
if st["checking"] == true {
crate::progress::spinner(ui, 12.0);
}
let r = ui.label(text);
register(ui, format!("faces:device:{what}"), r.rect);
if let Some(why) = why {
r.on_hover_text(sentence(why));
}
});
}
hint(ui, t, "A face model uses the GPU only when a check shows it gives the same results faster.");
}
/// A download-style size: decimal megabytes, as file managers and model pages show them.
fn mb(bytes: Option<u64>) -> String {
match bytes {
Some(b) if b >= 10_000_000 => format!("{} MB", (b as f64 / 1e6).round() as u64),
Some(b) if b >= 1_000_000 => format!("{:.1} MB", b as f64 / 1e6),
Some(b) => format!("{} KB", (b as f64 / 1e3).round().max(1.0) as u64),
None => String::new(),
}
}
/// "its input is not an image" → "Its input is not an image."
fn sentence(s: &str) -> String {
let s = s.trim();
let mut out = crate::i18n::upper_first(s);
if !out.is_empty() && !out.ends_with(['.', '!', '?']) {
out.push('.');
}
out
}
/// "Apache-2.0 · commercial use allowed"
fn licence_line(m: &Value) -> String {
let name = m["licence"]["name"].as_str().filter(|s| !s.is_empty()).unwrap_or("Unknown licence");
let terms = match m["licence"]["commercial"].as_str() {
Some("yes") => "commercial use allowed",
Some("no") => "non-commercial use only",
_ => "terms unclear",
};
format!("{name} · {terms}")
}
fn open_page(app: &mut LightcraftApp, url: &str) {
if let Some(f) = app.services.open_url.as_mut() {
let _ = f(url);
}
}
/// The Settings ▸ Faces tab.
pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let list = models(app, ui.ctx());
heading(ui, t, "Face recognition");
if list["dir"].is_null() {
hint(ui, t, "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)");
return;
}
let mut on = list["enabled"].as_bool().unwrap_or(false);
let runtime = list["runtime"].as_bool() == Some(true);
// a chosen recognition model that is installed: without one the switch has nothing to run
// (issue #678), so it can only be switched off, with the hint below saying what to do first
let usable =
list["embedder"].as_str().is_some_and(|id| list["models"].as_array().into_iter().flatten().any(|m| m["id"] == id && m["installed"] == true));
let r = ui
.add_enabled(on || (runtime && usable), egui::Checkbox::new(&mut on, crate::i18n::tr("Recognise faces (experimental)")))
.on_disabled_hover_text(crate::i18n::tr("Choose a model below (Use) to start."));
register(ui, "check:settings.facesEnabled", r.rect);
if r.changed() {
match app.run("faces.enable", json!({"enabled": on})) {
Ok(_) => app.caches.faces_epoch += 1,
Err(e) => app.toast(ui.ctx(), e),
}
}
hint(ui, t, "Suggests who is in a photo from the faces you have named. Everything stays on your computer.");
if !runtime {
hint(ui, t, "This build cannot run recognition models: they can be added and chosen, not used.");
} else if !usable {
hint(ui, t, "Choose a model below (Use) to start.");
} else if on && app.caches.faces_active {
scan_progress(app, ui, t);
}
let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default();
let downloads =
app.session.execute("faces.models.downloads", &json!({})).map(|v| v["downloads"].as_array().cloned().unwrap_or_default()).unwrap_or_default();
gpu_section(app, ui, t, &list["gpu"]);
heading(ui, t, "Face detector");
for m in all.iter().filter(|m| m["role"] == "detector") {
model_row(app, ui, t, m, downloads.iter().find(|d| d["id"] == m["id"]), runtime);
}
if !all.iter().any(|m| m["role"] == "detector" && m["installed"] == true) {
hint(ui, t, "Detect Faces (Photo menu) needs it: a small one-time download.");
}
heading(ui, t, "Recognition models");
for m in all.iter().filter(|m| m["role"] == "embedder") {
model_row(app, ui, t, m, downloads.iter().find(|d| d["id"] == m["id"]), runtime);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let can = app.services.pick_model_file.is_some();
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Add a model file…")));
register(ui, "faces:addModel", r.rect);
if r.clicked() {
let _ = app.run("dialog.faceModel", json!({}));
}
ui.label(RichText::new(crate::i18n::tr("or drop a .onnx file on the window")).color(t.text_dim));
});
// what is wrong with the user's own catalog (catalog.json in the models folder), if anything
for e in list["catalog"]["errors"].as_array().into_iter().flatten().filter_map(Value::as_str).take(3) {
ui.add(egui::Label::new(RichText::new(format!("catalog.json: {e}")).font(t.font(11.5)).color(t.caution)).wrap());
}
if let Some(d) = all.iter().find(|m| m["role"] == "detector") {
ui.add_space(4.0);
hint(ui, t, &format!("Faces are found by {} ({}, included).", d["name"].as_str().unwrap_or("the detector"), mb(d["sizeBytes"].as_u64())));
}
}
/// How the scan is going: a bar while photos are left, then how many faces it learned.
fn scan_progress(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let (left, peak, faces) = (app.caches.faces_pending, app.caches.faces_peak.max(1), app.caches.faces_indexed);
if left == 0 {
hint(ui, t, &format!("{faces} faces learned."));
return;
}
let text = format!("Scanning photos for faces: {left} left · {faces} faces so far");
crate::progress::Progress::new(Some(1.0 - left as f32 / peak as f32)).label(text).widget("faces:scanProgress").show(ui);
}
fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, dl: Option<&Value>, can_run: bool) {
let id = m["id"].as_str().unwrap_or("").to_string();
let detector = m["role"] == "detector";
let (installed, selected) = (m["installed"].as_bool() == Some(true), m["selected"].as_bool() == Some(true));
let dl_state = if installed { None } else { dl.and_then(|d| d["state"].as_str()) };
// a build that cannot run recognition models has no use for a download
let host = if can_run { m["downloadHost"].as_str().unwrap_or("").to_string() } else { String::new() };
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 3)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(m["name"].as_str().unwrap_or("")).color(t.text));
let size = mb(m["sizeBytes"].as_u64());
if !size.is_empty() {
ui.label(RichText::new(size).color(t.text_dim));
}
if selected {
ui.label(RichText::new(crate::i18n::tr("in use")).color(t.accent));
} else if installed {
ui.label(RichText::new(crate::i18n::tr("installed")).color(t.text_dim));
}
});
ui.label(RichText::new(licence_line(m)).font(t.font(11.5)).color(if m["licence"]["commercial"] == "yes" {
t.text_dim
} else {
t.caution
}));
// how fast it is, as times faster than a ResNet-100 model (milliseconds would depend on the computer),
// with whether it passed its test when it has been installed
let speed = m["speedText"].as_str().filter(|s| !s.is_empty());
let (line, ok) = match m["accepted"]["selfTest"].as_object() {
Some(test) if test.get("ok").and_then(Value::as_bool) == Some(true) => {
(Some(speed.map_or("Works".to_string(), |s| format!("Works · {s}"))), true)
}
Some(_) => (Some("Failed its last test".to_string()), false),
None => (speed.map(str::to_string), true),
};
if let Some(line) = line {
ui.label(RichText::new(line).font(t.font(11.5)).color(if ok { t.text_dim } else { t.caution }));
}
match (dl_state, dl) {
(Some("running"), Some(d)) => {
let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0));
let text = if total > 0 && got >= total {
"Checking…".to_string()
} else {
format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)))
};
crate::progress::Progress::new(crate::progress::fraction_of(got, total))
.label(text)
.width(crate::progress::ROW_WIDTH.min(ui.available_width()))
.widget(format!("faces:progress:{id}"))
.show(ui);
}
(Some("done"), _) => {
ui.horizontal(|ui| {
crate::progress::spinner(ui, 12.0);
ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim));
});
}
(Some("failed"), Some(d)) => {
let why = sentence(d["error"].as_str().unwrap_or("The download failed"));
ui.add(egui::Label::new(RichText::new(why).font(t.font(11.5)).color(t.caution)).wrap());
}
_ => {}
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if installed {
let r = ui.button(crate::i18n::tr("Remove"));
register(ui, format!("faces:remove:{id}"), r.rect);
if r.clicked() {
let _ = app.run("faces.models.remove", json!({"id": id}));
app.caches.faces_epoch += 1;
}
if !selected && !detector {
let r = ui.button("Use");
register(ui, format!("faces:use:{id}"), r.rect);
if r.clicked() {
let _ = app.run("faces.models.select", json!({"id": id}));
app.caches.faces_epoch += 1;
}
}
} else {
match dl_state {
Some("running") => {
let r = ui.button(crate::i18n::tr("Cancel"));
register(ui, format!("faces:cancelDownload:{id}"), r.rect);
if r.clicked() {
let _ = app.run("faces.models.downloadCancel", json!({"id": id}));
app.caches.faces_dl_watch.retain(|w| w != &id);
}
}
// the engine installs it within a moment; nothing to press
Some("done") => {}
_ => {
if let Some(url) = m["source"].as_str() {
let r = ui.button(crate::i18n::tr("Open page")).on_hover_text(crate::i18n::tr(
"Opens the model's own page in your browser, to read about it or get the file yourself.",
));
register(ui, format!("faces:get:{id}"), r.rect);
if r.clicked() {
open_page(app, url);
}
}
if !host.is_empty() {
let label = if dl_state == Some("failed") { "Try again" } else { "Download" };
let tip = format!(
"Shows the model's terms, then downloads {} from {host} and installs it{}. Nothing else is sent.",
mb(m["sizeBytes"].as_u64()),
if detector { "" } else { ", starts using it" }
);
let r = ui.button(label).on_hover_text(tip);
register(ui, format!("faces:download:{id}"), r.rect);
if r.clicked() {
open_download_dialog(app, m);
}
}
}
}
}
});
});
});
}
/// The body of the "Add Face Model" dialog: what the file is, its terms, and the accept box.
/// `info` is the engine's `faces.models.inspect` answer.
pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info: &Value, accepted: &mut bool) {
let kind = info["kind"].as_str().unwrap_or("unsupported");
let file = info["fileName"].as_str().unwrap_or("");
if kind == "unsupported" {
ui.label(RichText::new(crate::i18n::tr("This file cannot be used yet")).font(t.semibold(13.5)).color(t.caution));
ui.add(
egui::Label::new(
RichText::new(sentence(info["reason"].as_str().unwrap_or("It is not a face recognition model LightCraft understands")))
.color(t.text_label),
)
.wrap(),
);
ui.label(RichText::new(format!("{file} · {}", mb(info["sizeBytes"].as_u64()))).color(t.text_dim));
return;
}
let m = &info["model"];
ui.horizontal(|ui| {
ui.label(RichText::new(m["name"].as_str().unwrap_or(file)).font(t.semibold(14.0)).color(t.text));
ui.label(RichText::new(mb(info["sizeBytes"].as_u64())).color(t.text_dim));
});
if info["alreadyInstalled"] == true {
ui.label(RichText::new(crate::i18n::tr("Already installed. Installing again is harmless.")).color(t.text_dim));
}
let download = info["download"].is_string();
let detector = m["role"] == "detector";
if download {
let host = info["host"].as_str().unwrap_or("its own repository");
ui.add(
egui::Label::new(
RichText::new(format!(
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it{}.",
if detector { "" } else { ", starts using it and turns face recognition on" }
))
.color(t.text_label),
)
.wrap(),
);
}
let commercial = m["licence"]["commercial"].as_str().unwrap_or("unknown");
ui.label(RichText::new(licence_line(m)).font(t.semibold(12.5)).color(if commercial == "yes" { t.text } else { t.caution }));
let notice = m["licence"]["notice"].as_str().unwrap_or("");
if !notice.is_empty() {
ui.add(egui::Label::new(RichText::new(notice).color(t.text_label)).wrap());
}
let provenance = m["provenance"].as_str().unwrap_or("");
if !provenance.is_empty() {
ui.add(
egui::Label::new(RichText::new(crate::i18n::tr_format!("Trained on: {provenance}", provenance = provenance)).color(t.text_dim)).wrap(),
);
}
if kind == "draft" {
ui.add_space(2.0);
ui.label(RichText::new(crate::i18n::tr("LightCraft does not know this model, so it assumed:")).color(t.text_label));
for a in info["assumptions"].as_array().into_iter().flatten().filter_map(Value::as_str) {
ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(t.font(12.0)).color(t.text_dim)).wrap());
}
}
if let Some(url) = m["source"].as_str().or(m["licence"]["url"].as_str()) {
let r = ui.link("Open the model's page");
register(ui, "faceModel:page", r.rect);
if r.clicked() {
open_page(app, url);
}
}
ui.add_space(4.0);
check(ui, "faceModel.accept", accepted, "I have read these terms and accept them for my own use");
let closing = if download {
"Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it."
} else {
"The model is kept on this computer only. LightCraft never uploads or shares it."
};
ui.label(RichText::new(closing).font(t.font(11.5)).color(t.text_dim));
}
/// Pressing Download: the model's terms first, in the same dialog as a model file. Accepting them starts the download;
/// nothing is fetched before.
pub fn open_download_dialog(app: &mut LightcraftApp, m: &Value) {
let info = json!({
"kind": "known",
"fileName": m["id"],
"sizeBytes": m["sizeBytes"],
"model": m,
"alreadyInstalled": false,
"download": m["id"],
"host": m["downloadHost"],
"assumptions": [],
"reason": null,
});
app.ui.dialog = Some(crate::state::Dialog::FaceModel { path: String::new(), info, accepted: false });
}
/// Photo ▸ Detect Faces without the detector: offer to download it (its terms first), the same as in Settings.
pub fn offer_detector(app: &mut LightcraftApp) {
let list = app.session.execute("faces.models.list", &json!({})).unwrap_or(Value::Null);
if let Some(m) = list["models"].as_array().and_then(|a| a.iter().find(|m| m["role"] == "detector" && m["installed"] != true)) {
open_download_dialog(app, m);
}
}
/// Inspect `path` and open the dialog for it (also what dropping a `.onnx` file on the window does).
pub fn open_dialog(app: &mut LightcraftApp, path: &str) -> Result<Value, String> {
let info = app.run("faces.models.inspect", json!({"path": path}))?;
app.ui.dialog = Some(crate::state::Dialog::FaceModel { path: path.to_string(), info, accepted: false });
Ok(Value::Null)
}
/// The dialog's OK (the engine refuses without the acceptance): for a download, start it and watch it; for a file,
/// install it. Either way the model ends up installed, in use, with recognition on.
pub fn install(app: &mut LightcraftApp, path: &str, info: &Value, accepted: bool) -> Result<Value, String> {
if !accepted {
return Err("Tick the box to accept the model's terms first".into());
}
app.caches.faces_epoch += 1;
let back_to_settings = |app: &mut LightcraftApp| app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "faces".into() });
if let Some(id) = info["download"].as_str() {
let r = app.run("faces.models.download", json!({"id": id, "acknowledged": true}));
if r.is_ok() {
app.caches.faces_dl_watch.push(id.to_string());
// the progress is in Settings: stay there
back_to_settings(app);
}
return r;
}
let r = app.run("faces.models.install", json!({"path": path, "acknowledged": true}));
if let Ok(v) = &r {
let name = v["installed"]["name"].as_str().unwrap_or("The model");
app.ui.status = if v["installed"]["role"] == "detector" {
format!("{name} is installed")
} else {
format!("{name} is installed and in use: face recognition is on")
};
back_to_settings(app);
}
r
}
// ---------------------------------------------------------------------------------------------------
// Naming faces in the loupe
/// A suggestion for one unnamed face: the best guess (when it passes the bar) and the closest few people.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Hint {
pub suggestion: Option<(String, f32)>,
pub candidates: Vec<(String, f32)>,
}
/// Suggestions for the unnamed faces of one photo, by region index.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Hints {
pub by_index: std::collections::HashMap<usize, Hint>,
}
/// Suggestions for `photo`'s unnamed faces from what is already indexed (nothing is embedded for this: the background
/// indexer does that). `None` while recognition is off. Asked again only when the catalog or the index changed.
pub fn hints_for(app: &mut LightcraftApp, photo: u64) -> Option<std::sync::Arc<Hints>> {
if !app.caches.faces_active {
return None;
}
let (rev, indexed) = (app.session.catalog.revision, app.caches.faces_indexed);
if let Some((p, r, i, h)) = &app.caches.face_hints
&& (*p, *r, *i) == (photo, rev, indexed)
{
return Some(h.clone());
}
let answer = app.session.execute("faces.suggest", &json!({"ids": [photo], "budgetMs": 0})).ok()?;
let pair = |v: &Value| Some((v["name"].as_str()?.to_string(), v["score"].as_f64()? as f32));
let mut hints = Hints::default();
for f in answer["photos"][0]["faces"].as_array().into_iter().flatten() {
let Some(index) = f["index"].as_u64().and_then(|i| usize::try_from(i).ok()) else { continue };
hints.by_index.insert(
index,
Hint { suggestion: pair(&f["suggestion"]), candidates: f["candidates"].as_array().into_iter().flatten().filter_map(pair).collect() },
);
}
let hints = std::sync::Arc::new(hints);
app.caches.face_hints = Some((photo, rev, indexed, hints.clone()));
Some(hints)
}
/// What the name box did this frame.
pub enum Editor {
Open,
Submit(String),
Cancel,
/// The offer to set up recognition was pressed.
Setup,
}
/// The inline name box under a face: type a name (completed from the people already named), pick one of the
/// suggestions, Enter to confirm, Escape to cancel. While recognition is not set up it offers the next step (`setup`).
pub fn name_editor(
ctx: &egui::Context,
at: egui::Pos2,
edit: &mut crate::state::NameEdit,
candidates: &[(String, f32)],
people: &[String],
setup: Setup,
) -> Editor {
let t = Tokens::get(ctx);
let mut outcome = Editor::Open;
let opening = edit.fresh;
let shown = egui::Area::new(egui::Id::unique("face-name-editor")).order(egui::Order::Foreground).fixed_pos(at).constrain(true).show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(230.0);
let r = ui.add(egui::TextEdit::singleline(&mut edit.text).hint_text("Name").desired_width(220.0));
register(ui, "field:faceName", r.rect);
if edit.fresh {
r.request_focus();
edit.fresh = false;
}
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
outcome = Editor::Submit(edit.text.trim().to_string());
}
let typed = edit.text.trim().to_lowercase();
let mut offered: Vec<(String, Option<f32>)> = candidates.iter().take(3).map(|(n, s)| (n.clone(), Some(*s))).collect();
if !typed.is_empty() {
for p in people.iter().filter(|p| p.to_lowercase().starts_with(&typed)) {
if offered.len() >= 7 {
break;
}
if !offered.iter().any(|(n, _)| n.eq_ignore_ascii_case(p)) {
offered.push((p.clone(), None));
}
}
}
for (name, score) in offered {
let label = match score {
Some(s) => format!("{name} {:.0}%", (s * 100.0).max(0.0)),
None => name.clone(),
};
let b = ui.add(egui::Button::new(RichText::new(label).color(t.text_label)).frame(false).min_size(egui::vec2(220.0, 0.0)));
register(ui, format!("faceName:pick:{name}"), b.rect);
if b.clicked() {
outcome = Editor::Submit(name);
}
}
if let Some(label) = setup.short() {
let b = ui.add(egui::Button::new(RichText::new(label).font(t.font(12.0)).color(t.accent)).frame(false));
register(ui, "faces:setup", b.rect);
if b.clicked() {
outcome = Editor::Setup;
}
}
ui.label(RichText::new(crate::i18n::tr("Enter to confirm, Esc to cancel")).font(t.font(11.0)).color(t.text_dim));
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
outcome = Editor::Cancel;
}
// a click anywhere else closes it (not on the frame it opened, which is the click that opened it)
if !opening && ctx.input(|i| i.pointer.any_pressed()) && ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !shown.response.rect.contains(p))
{
outcome = Editor::Cancel;
}
outcome
}
/// Called every frame: keeps the background face indexer going while recognition is on, and notes whether it is
/// running, how many faces it has done and how many photos are left (the loupe's suggestions depend on it).
/// `pump_background` plus the UI-input half; the app's own phases keep them apart.
pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) {
let now = app.tick_now();
pump_ui_input(app, ctx, now);
pump_background(app, ctx, now);
}
/// Fresh pointer/key activity (UI phase only).
pub fn pump_ui_input(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) {
let (working, moving) = ctx.input(|i| {
let key_or_scroll = i.events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::MouseWheel { .. }));
(i.pointer.any_down() || key_or_scroll, i.pointer.is_moving())
});
if working {
app.caches.last_input = now;
}
if moving || working {
app.caches.last_move = now;
}
}
/// Worker results and pacing on monotonic deadlines; safe while hidden.
pub fn pump_background(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) {
watch_downloads(app, ctx);
if now < app.caches.faces_next_pump {
return;
}
let (pace, in_front) = scan_pace(app, ctx, now);
app.caches.faces_pace = pace;
app.caches.faces_in_front = in_front;
let Ok(v) = app.session.execute("faces.pump", &json!({"pace": pace})) else { return };
app.caches.faces_active = v["active"] == true;
app.caches.faces_indexed = v["indexedFaces"].as_u64().unwrap_or(0);
app.caches.faces_pending = v["pendingPhotos"].as_u64().unwrap_or(0);
// the progress bar's whole: the most photos that were left at once since the scan last finished (the engine keeps
// it, so Settings and the activity stack agree)
app.caches.faces_peak = v["peak"].as_u64().unwrap_or(0);
// Frames are drawn only when something asks for one, so nothing here wakes the window needlessly: while there is work it
// asks to be called again in 50 ms (the progress in Settings, the next photos for the workers); with recognition on and
// nothing to do it looks again every few seconds (a few wake-ups a minute); with recognition off it asks for nothing
// (and is called on every frame, which is cheap: whatever makes a frame, such as switching recognition on, is seen at once).
let busy = app.caches.faces_active && (app.caches.faces_pending > 0 || v["inFlight"].as_u64().unwrap_or(0) > 0);
let wait = match (busy, app.caches.faces_active) {
(true, _) => Some(0.05),
(false, true) => Some(5.0),
(false, false) => None,
};
app.caches.faces_next_pump = now
+ if busy {
0.05
} else if app.caches.faces_active {
1.0
} else {
0.0
};
if let Some(secs) = wait {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(secs));
}
}
/// Sort the watched downloads: those that have been installed (to announce), and those still to watch (running, or
/// arrived and being installed). A failed or cancelled one is dropped: the model's row in Settings says what happened.
fn due(rows: &[Value], watched: Vec<String>) -> (Vec<String>, Vec<String>) {
let (mut installed, mut keep) = (Vec::new(), Vec::new());
for id in watched {
match rows.iter().find(|r| r["id"] == id.as_str()).and_then(|r| r["state"].as_str()) {
Some("running" | "done") => keep.push(id),
Some("installed") => installed.push(id),
_ => {}
}
}
(installed, keep)
}
/// Follows the downloads the user started: keeps redrawing while one runs (the progress bar), and says so when a
/// model has been installed, is in use and recognition is on (the engine does all of that by itself).
fn watch_downloads(app: &mut LightcraftApp, ctx: &egui::Context) {
if app.caches.faces_dl_watch.is_empty() {
return;
}
let Ok(v) = app.session.execute("faces.models.downloads", &json!({})) else {
app.caches.faces_dl_watch.clear();
return;
};
let rows: Vec<Value> = v["downloads"].as_array().cloned().unwrap_or_default();
let (installed, keep) = due(&rows, std::mem::take(&mut app.caches.faces_dl_watch));
app.caches.faces_dl_watch = keep;
for id in installed {
let name = app
.session
.execute("faces.models.list", &json!({}))
.ok()
.and_then(|l| {
l["models"].as_array().and_then(|a| a.iter().find(|m| m["id"] == id.as_str()).and_then(|m| m["name"].as_str().map(str::to_string)))
})
.unwrap_or_else(|| id.clone());
let _ = app.session.execute("faces.models.downloadCancel", &json!({"id": id}));
app.caches.faces_epoch += 1;
let detector = app
.session
.execute("faces.models.list", &json!({}))
.ok()
.is_some_and(|l| l["models"].as_array().is_some_and(|a| a.iter().any(|m| m["id"] == id.as_str() && m["role"] == "detector")));
let text = if detector { format!("{name} is installed") } else { format!("{name} is installed and in use: face recognition is on") };
app.ui.status = text.clone();
app.toast(ctx, text);
}
ctx.request_repaint_after(std::time::Duration::from_millis(120));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_scan_works_hard_only_when_nobody_is_in_the_way() {
// (focused, minimized, seconds since a drag/key/scroll, seconds since the pointer moved, watching the progress)
assert_eq!(pace_for(true, false, 0.1, 0.1, true), "pause", "dragging a slider: nothing new, even if watching");
assert_eq!(pace_for(true, false, 2.0, 0.2, false), "light", "the pointer is moving: one photo at a time");
assert_eq!(pace_for(true, false, 5.0, 2.9, true), "light");
assert_eq!(pace_for(true, false, 5.0, 4.0, false), "normal", "idle for a few seconds: half the machine");
assert_eq!(pace_for(true, false, 60.0, 60.0, true), "full", "idle and looking at the progress: most of it");
// minimized: gentle, whatever the user did last
assert_eq!([pace_for(true, true, 0.0, 0.0, true), pace_for(false, true, 99.0, 99.0, true)], ["light", "light"]);
// on screen behind another app: the user is elsewhere, so the scan gets on with it, however recent the last input
assert_eq!([pace_for(false, false, 0.0, 0.0, true), pace_for(false, false, 99.0, 99.0, false)], ["normal", "normal"]);
}
fn row(id: &str, state: &str) -> Value {
json!({"id": id, "state": state})
}
#[test]
fn an_installed_download_is_announced_once_and_the_rest_are_followed() {
let rows = vec![row("a", "running"), row("b", "done"), row("c", "installed"), row("d", "failed"), row("e", "cancelled")];
let watched = ["a", "b", "c", "d", "e", "gone"].map(String::from).to_vec();
let (installed, keep) = due(&rows, watched);
assert_eq!(installed, ["c"]);
// running and just-arrived ones are watched; failed, cancelled and vanished ones are dropped
assert_eq!(keep, ["a", "b"]);
assert_eq!(due(&[], vec!["x".into()]), (vec![], vec![]));
}
}