Faces: Settings > Faces tab and the add-a-model licence dialog
An off-by-default "Recognise faces" toggle and the face model list in Settings, with a one-drop install: pick a .onnx (Add a model file…) or drop one on the window. The engine inspects it (recognised by hash, or described from its shape), a dialog shows its licence, whether commercial use is allowed, what it was trained on and what LightCraft had to assume, and Install stays disabled until "I accept" is ticked. "Get…" opens a model's own page in the browser: LightCraft never fetches a model itself. A file it cannot use says why. The models folder is shared by the app, the CLI and the MCP server (`engine::config`, `$LIGHTCRAFT_FACE_MODELS` overrides `<config>/models`), so a model installed from one is there for the others. A session has none until a host asks (`with_default_face_models`), which keeps tests hermetic. Nothing runs a model yet, and the tab says so. Headless tests cover the whole flow (the dialog, refusing until accepted, installing, a file it cannot use). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7ca7113b32
commit
cb34bb4842
|
|
@ -3096,6 +3096,7 @@ dependencies = [
|
|||
"lightcraft-color",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
|
|
|
|||
|
|
@ -734,12 +734,12 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
|||
let t0 = Instant::now();
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs();
|
||||
let mut s = Session::new().with_fs().with_default_face_models();
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs(),
|
||||
None => Session::new().with_fs(),
|
||||
None if files.is_empty() => Session::with_demo().with_fs().with_default_face_models(),
|
||||
None => Session::new().with_fs().with_default_face_models(),
|
||||
};
|
||||
if !files.is_empty() {
|
||||
let ti = Instant::now();
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
|||
pick_preset_files: None,
|
||||
pick_tracklog: None,
|
||||
pick_lightroom_catalog: None,
|
||||
// Face models need a folder to live in; the web has none.
|
||||
pick_model_file: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
|
|
|
|||
|
|
@ -400,6 +400,14 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
|||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
pick_model_file: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Add a Face Recognition Model")
|
||||
.add_filter("Face models (ONNX)", &["onnx"])
|
||||
.pick_file()
|
||||
.map(|p| vec![p.to_string_lossy().to_string()])
|
||||
.unwrap_or_default()
|
||||
})),
|
||||
pick_tracklog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
|
||||
|
|
@ -446,12 +454,19 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
|||
}
|
||||
}
|
||||
|
||||
/// The library session with the folder face models are kept in (`<config>/models`).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
|
||||
s.face_models_dir = lightcraft_engine::config::default_face_models_dir();
|
||||
(s, problem)
|
||||
}
|
||||
|
||||
/// The persistent library session, or with `--memory` an in-memory one (demo photos).
|
||||
///
|
||||
/// If the library can't be opened the session is empty and in memory — never seeded with the
|
||||
/// demo photos, never written anywhere — and the problem is returned: the window then says so
|
||||
/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100).
|
||||
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
if in_memory {
|
||||
return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, face models).
|
||||
//!
|
||||
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
|
||||
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
|
||||
//! face-models folder until a host asks for one ([`Session::with_default_face_models`]), so tests stay
|
||||
//! hermetic.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("lightcraft"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where face models are kept: `$LIGHTCRAFT_FACE_MODELS` if set, else `<config>/models`.
|
||||
pub fn default_face_models_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models")))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Keep face models in the shared default folder ([`default_face_models_dir`]).
|
||||
pub fn with_default_face_models(mut self) -> Self {
|
||||
self.face_models_dir = default_face_models_dir();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ pub mod availability;
|
|||
mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
pub mod cmd;
|
||||
pub mod config;
|
||||
pub mod crs;
|
||||
pub mod crs_masks;
|
||||
pub mod demo;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ impl Drop for Headless {
|
|||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
Self::new(Session::new().with_fs())
|
||||
Self::new(Session::new().with_fs().with_default_face_models())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ impl Headless {
|
|||
|
||||
/// A headless session with the procedurally generated demo library.
|
||||
pub fn demo() -> Self {
|
||||
Self::new(Session::with_demo().with_fs())
|
||||
Self::new(Session::with_demo().with_fs().with_default_face_models())
|
||||
}
|
||||
|
||||
fn photo_or_active(&self, p: &Value) -> Result<PhotoId, String> {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ log = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -401,6 +401,60 @@ mod tests {
|
|||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// Adding a face model: the dialog shows the file's terms, the model is installed only once they are
|
||||
/// accepted, and a file LightCraft cannot use only says why.
|
||||
#[test]
|
||||
fn adding_a_face_model_shows_its_terms_and_installs_only_once_accepted() {
|
||||
use crate::state::Dialog;
|
||||
let mut h = demo([1400.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-facemodel-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.app.session.face_models_dir = Some(dir.join("models"));
|
||||
let model = dir.join("Mine.onnx");
|
||||
std::fs::write(&model, lightcraft_faces::synthetic::embedder_model(512)).unwrap();
|
||||
let open = |h: &mut Headless, path: &std::path::Path| {
|
||||
h.request("engine.execute", json!({"command": "dialog.faceModel", "params": {"path": path.to_string_lossy()}}), t)
|
||||
};
|
||||
|
||||
// the dialog opens for the chosen file, with nothing accepted and nothing installed
|
||||
assert_eq!(open(&mut h, &model)["ok"], true);
|
||||
h.settle(SETTLE);
|
||||
h.step();
|
||||
let Some(dlg) = h.app.ui.dialog.clone() else { panic!("no dialog") };
|
||||
let Dialog::FaceModel { info, accepted, .. } = &dlg else { panic!("{dlg:?}") };
|
||||
assert!(!accepted);
|
||||
assert_eq!(info["kind"], "draft");
|
||||
assert!(!dir.join("models").exists());
|
||||
// OK does nothing yet
|
||||
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_err());
|
||||
assert!(!dir.join("models").exists());
|
||||
// ticking the box and confirming installs it
|
||||
let r = h.request("ui.clickWidget", json!({"id": "check:faceModel.accept"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert!(matches!(&h.app.ui.dialog, Some(Dialog::FaceModel { accepted: true, .. })));
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let listed = h.app.run("faces.models.list", json!({})).unwrap();
|
||||
assert!(listed["models"].as_array().unwrap().iter().any(|m| m["installed"] == true && m["known"] == false), "{listed}");
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
|
||||
// a file that is not a usable model says why and offers no install
|
||||
let junk = dir.join("junk.onnx");
|
||||
std::fs::write(&junk, b"not a model").unwrap();
|
||||
assert_eq!(open(&mut h, &junk)["ok"], true);
|
||||
h.step();
|
||||
let Some(Dialog::FaceModel { info, .. }) = h.app.ui.dialog.clone() else { panic!("no dialog") };
|
||||
assert_eq!(info["kind"], "unsupported");
|
||||
// a path that does not exist is an error, not a dialog
|
||||
h.app.ui.dialog = None;
|
||||
assert_eq!(open(&mut h, &dir.join("nope.onnx"))["ok"], false);
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Profile browser: live variant thumbnails, hover previews in the loupe without touching the
|
||||
/// photo or its history, click applies, the star toggles the favourite.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ pub struct Services {
|
|||
pub picker: Option<pick::Picker>,
|
||||
/// Show an open dialog for photos; returns paths.
|
||||
pub pick_files: Option<PickFiles>,
|
||||
/// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…).
|
||||
pub pick_model_file: Option<PickFiles>,
|
||||
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
|
||||
pub pick_preset_files: Option<PickFiles>,
|
||||
/// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…).
|
||||
|
|
@ -761,6 +763,13 @@ impl LightcraftApp {
|
|||
let dropped: Vec<String> =
|
||||
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
|
||||
// preset files import as presets, everything else as photos
|
||||
// a dropped face model opens its licence dialog; presets and photos as before
|
||||
let (models, dropped): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_model_file(p));
|
||||
if let Some(model) = models.first()
|
||||
&& let Err(e) = panels::faces::open_dialog(self, model)
|
||||
{
|
||||
self.toast(ctx, e);
|
||||
}
|
||||
let (presets, photos): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_preset_file(p));
|
||||
if !presets.is_empty() {
|
||||
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
|
||||
|
|
@ -956,6 +965,11 @@ pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool {
|
|||
d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.")
|
||||
}
|
||||
|
||||
/// A dropped face model file (`.onnx`).
|
||||
pub fn is_model_file(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
|
||||
}
|
||||
|
||||
/// Files dropped on the window that are presets rather than photos.
|
||||
pub fn is_preset_file(path: &str) -> bool {
|
||||
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
|
|
@ -964,6 +978,16 @@ pub fn is_preset_file(path: &str) -> bool {
|
|||
|
||||
#[cfg(test)]
|
||||
mod drop_tests {
|
||||
#[test]
|
||||
fn dropped_face_models_are_told_apart() {
|
||||
for p in ["/a/model.onnx", "/a/dir/M.ONNX"] {
|
||||
assert!(super::is_model_file(p), "{p}");
|
||||
}
|
||||
for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] {
|
||||
assert!(!super::is_model_file(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_presets_are_told_apart_from_photos() {
|
||||
for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] {
|
||||
|
|
@ -988,6 +1012,8 @@ pub struct Caches {
|
|||
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
|
||||
/// How often the album counts were recomputed (tests check that unchanged frames don't).
|
||||
pub album_count_scans: usize,
|
||||
/// Bumped when a face model is installed, removed or chosen, so Settings re-reads the list at once.
|
||||
pub faces_epoch: u64,
|
||||
/// The grid's date runs, layout and indexes (by the visible list's generation).
|
||||
pub grid: panels::grid::GridCache,
|
||||
/// What the grid did on its frames (benchmarks and tests check unchanged frames stay cheap).
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("tool.keywordPainter", "Keyword Painter", None, ""),
|
||||
("view.gridInfo", "Grid Info", None, ""),
|
||||
("dialog.allMetadata", "All Metadata…", None, "Photo"),
|
||||
("dialog.faceModel", "Add Face Model…", None, ""),
|
||||
("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"),
|
||||
("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"),
|
||||
("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"),
|
||||
|
|
@ -433,7 +434,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
"app.settings" => {
|
||||
let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general");
|
||||
if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) {
|
||||
return Some(Err(format!("unknown settings tab `{tab}` (general|import|performance|interface)")));
|
||||
return Some(Err(format!("unknown settings tab `{tab}` (general|import|performance|interface|faces)")));
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::Settings { tab: tab.into() });
|
||||
Ok(Value::Null)
|
||||
|
|
@ -906,6 +907,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
"merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"),
|
||||
"merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"),
|
||||
"dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"),
|
||||
"dialog.faceModel" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => app.services.pick_model_file.as_mut().and_then(|f| f().into_iter().next()),
|
||||
};
|
||||
match path {
|
||||
Some(path) => crate::panels::faces::open_dialog(app, &path),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"),
|
||||
"dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"),
|
||||
"app.about" => {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
Dialog::NewSmartAlbum { .. } => "Create Smart Album",
|
||||
Dialog::AllMetadata { .. } => "All Metadata",
|
||||
Dialog::SystemInfo { .. } => "System Info",
|
||||
Dialog::FaceModel { .. } => "Add Face Model",
|
||||
Dialog::WhatsNew => "What's New",
|
||||
Dialog::Cull { .. } => "Assisted Culling",
|
||||
Dialog::SmartRules { id: None, .. } => "New Smart Album",
|
||||
|
|
@ -123,6 +124,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
Dialog::Import { .. } => crate::import::DIALOG_SIZE[0],
|
||||
Dialog::SmartRules { .. } => 680.0,
|
||||
Dialog::AllMetadata { .. } => 620.0,
|
||||
Dialog::FaceModel { .. } => 460.0,
|
||||
_ => 380.0,
|
||||
})
|
||||
.show(ctx, |ui| {
|
||||
|
|
@ -174,6 +176,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
}
|
||||
Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted),
|
||||
Dialog::SystemInfo { rows } => {
|
||||
egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| {
|
||||
for (k, v) in rows.iter() {
|
||||
|
|
@ -784,7 +787,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
|
||||
// a model file LightCraft cannot use has nothing to confirm
|
||||
let unusable_model = matches!(&dlg, Dialog::FaceModel { info, .. } if info["kind"] == "unsupported");
|
||||
let informational = unusable_model || matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. });
|
||||
let sam = &app.session.segmenter;
|
||||
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
|
||||
// no download location in this build: nothing to offer but the manual install
|
||||
|
|
@ -810,6 +815,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
add_label.as_str()
|
||||
}
|
||||
Dialog::Merge { .. } => "Merge",
|
||||
Dialog::FaceModel { .. } if !informational => "Install",
|
||||
Dialog::ConfirmDelete { .. } => "Delete",
|
||||
Dialog::RemoveFolder { .. } => "Remove",
|
||||
Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue",
|
||||
|
|
@ -819,7 +825,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
_ if informational => "Close",
|
||||
_ => "OK",
|
||||
};
|
||||
let r = (!ok.is_empty()).then(|| ui.button(crate::i18n::tr(ok)));
|
||||
// installing waits for the licence to be accepted
|
||||
let can_confirm = informational || !matches!(&dlg, Dialog::FaceModel { accepted: false, .. });
|
||||
let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok))));
|
||||
if let Some(r) = &r {
|
||||
crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect);
|
||||
}
|
||||
|
|
@ -1025,6 +1033,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
|||
Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})),
|
||||
Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})),
|
||||
Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
|
||||
Dialog::FaceModel { path, accepted, .. } => crate::panels::faces::install(app, path, *accepted),
|
||||
Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null),
|
||||
Dialog::Cull { reject_below, pick_best } => {
|
||||
let mut p = json!({"pickBest": pick_best});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
//! 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 never fetches a model by itself: "Get…" opens the model's own page in the browser.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::settings::{check, heading, hint};
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// 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::new("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, ui: &egui::Ui) -> Value {
|
||||
let now = ui.input(|i| i.time);
|
||||
let epoch = app.caches.faces_epoch;
|
||||
if let Some((e, at, v)) = ui.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);
|
||||
ui.data_mut(|d| d.insert_temp(list_id(), (epoch, now, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
/// 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 c = s.chars();
|
||||
let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default();
|
||||
out.push_str(c.as_str());
|
||||
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);
|
||||
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);
|
||||
if check(ui, "settings.facesEnabled", &mut on, "Recognise faces (experimental)") {
|
||||
let _ = app.run("faces.enable", json!({"enabled": on}));
|
||||
app.caches.faces_epoch += 1;
|
||||
}
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
"Off by default. LightCraft already reads the face names other apps wrote into your photos; this is for models that suggest who is in a photo. Everything stays on your computer.",
|
||||
);
|
||||
if list["runtime"].as_bool() != Some(true) {
|
||||
hint(ui, t, "Models can be added and chosen now. Running them arrives in a later update.");
|
||||
}
|
||||
let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default();
|
||||
heading(ui, t, "Face detector");
|
||||
for m in all.iter().filter(|m| m["role"] == "detector") {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(m["name"].as_str().unwrap_or("")).color(t.text));
|
||||
ui.label(RichText::new(format!("{} · included", mb(m["sizeBytes"].as_u64()))).color(t.text_dim));
|
||||
});
|
||||
}
|
||||
heading(ui, t, "Recognition models");
|
||||
for m in all.iter().filter(|m| m["role"] == "embedder") {
|
||||
model_row(app, ui, t, m);
|
||||
}
|
||||
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("Add a model file…"));
|
||||
register(ui.ctx(), "faces:addModel", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("dialog.faceModel", json!({}));
|
||||
}
|
||||
ui.label(RichText::new("or drop a .onnx file on the window").color(t.text_dim));
|
||||
});
|
||||
}
|
||||
|
||||
fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value) {
|
||||
let id = m["id"].as_str().unwrap_or("").to_string();
|
||||
let (installed, selected) = (m["installed"].as_bool() == Some(true), m["selected"].as_bool() == Some(true));
|
||||
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("in use").color(t.accent));
|
||||
} else if installed {
|
||||
ui.label(RichText::new("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
|
||||
}));
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if installed {
|
||||
if m["bundled"] != true {
|
||||
let r = ui.button("Remove");
|
||||
register(ui.ctx(), 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 {
|
||||
let r = ui.button("Use");
|
||||
register(ui.ctx(), format!("faces:use:{id}"), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("faces.models.select", json!({"id": id}));
|
||||
app.caches.faces_epoch += 1;
|
||||
}
|
||||
}
|
||||
} else if let Some(url) = m["source"].as_str() {
|
||||
let r = ui
|
||||
.button("Get…")
|
||||
.on_hover_text("Opens the model's own page in your browser. Download the .onnx file there, then add it here.");
|
||||
register(ui.ctx(), format!("faces:get:{id}"), r.rect);
|
||||
if r.clicked() {
|
||||
open_page(app, url);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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("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("Already installed. Installing again is harmless.").color(t.text_dim));
|
||||
}
|
||||
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(format!("Trained on: {provenance}")).color(t.text_dim)).wrap());
|
||||
}
|
||||
if kind == "draft" {
|
||||
ui.add_space(2.0);
|
||||
ui.label(RichText::new("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.ctx(), "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");
|
||||
ui.label(RichText::new("The model is kept on this computer only. LightCraft never uploads or shares it.").font(t.font(11.5)).color(t.text_dim));
|
||||
}
|
||||
|
||||
/// 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: install the model (the engine refuses without the acceptance).
|
||||
pub fn install(app: &mut LightcraftApp, path: &str, accepted: bool) -> Result<Value, String> {
|
||||
if !accepted {
|
||||
return Err("Tick the box to accept the model's terms first".into());
|
||||
}
|
||||
let r = app.run("faces.models.install", json!({"path": path, "acknowledged": true}));
|
||||
app.caches.faces_epoch += 1;
|
||||
r
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ pub mod crop_overlay;
|
|||
pub mod detail;
|
||||
pub mod dialogs;
|
||||
pub mod edit;
|
||||
pub mod faces;
|
||||
pub mod filterbar;
|
||||
pub mod grid;
|
||||
pub mod keymap;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! The Settings dialog (⌘,): General, Import, Performance, Interface.
|
||||
//! The Settings dialog (⌘,): General, Import, Performance, Interface, Faces.
|
||||
//!
|
||||
//! Changes apply immediately (no OK/Cancel). Where they are stored:
|
||||
//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU,
|
||||
|
|
@ -17,7 +17,8 @@ use crate::theme::Tokens;
|
|||
use crate::widgets::register;
|
||||
|
||||
/// (id, label) of the tabs, in order.
|
||||
pub const TABS: &[(&str, &str)] = &[("general", "General"), ("import", "Import"), ("performance", "Performance"), ("interface", "Interface")];
|
||||
pub const TABS: &[(&str, &str)] =
|
||||
&[("general", "General"), ("import", "Import"), ("performance", "Performance"), ("interface", "Interface"), ("faces", "Faces")];
|
||||
|
||||
/// Thumbnail cache sizes offered (MB).
|
||||
const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192];
|
||||
|
|
@ -43,11 +44,12 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
|
|||
"import" => import_tab(app, ui, &t),
|
||||
"performance" => performance_tab(app, ui, &t),
|
||||
"interface" => interface_tab(app, ui, &t),
|
||||
"faces" => super::faces::settings_tab(app, ui, &t),
|
||||
_ => general_tab(app, ui, &t),
|
||||
}
|
||||
}
|
||||
|
||||
fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text));
|
||||
}
|
||||
|
|
@ -63,12 +65,12 @@ fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui
|
|||
.inner
|
||||
}
|
||||
|
||||
fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
pub(super) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim));
|
||||
}
|
||||
|
||||
/// A checkbox addressable as `check:{id}`; true when toggled.
|
||||
fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool {
|
||||
pub(super) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool {
|
||||
let r = ui.checkbox(value, crate::i18n::tr(label));
|
||||
register(ui.ctx(), format!("check:{id}"), r.rect);
|
||||
r.changed()
|
||||
|
|
|
|||
|
|
@ -535,6 +535,13 @@ pub enum Dialog {
|
|||
pick_best: bool,
|
||||
},
|
||||
/// Help ▸ System Info: (label, value) rows.
|
||||
/// A face model file the user chose: what it is, its licence terms, and the "I accept" box.
|
||||
FaceModel {
|
||||
path: String,
|
||||
/// The engine's `faces.models.inspect` answer.
|
||||
info: serde_json::Value,
|
||||
accepted: bool,
|
||||
},
|
||||
SystemInfo {
|
||||
rows: Vec<(String, String)>,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -482,7 +482,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
|
||||
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
|
||||
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings |
|
||||
| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | |
|
||||
| LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/panels/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled (the YuNet detector, 233 KB, is the one exception, not wired yet): add one by picking or dropping a `.onnx`, which is recognised by hash or described from its shape, shows its licence terms and training-data note, and installs only after an "I accept" box. Nothing runs a model yet (no inference runtime), so nothing recognises faces yet |
|
||||
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets |
|
||||
| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command |
|
||||
| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue