diff --git a/Cargo.lock b/Cargo.lock index 4a8498ea..be24a88c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3096,6 +3096,7 @@ dependencies = [ "lightcraft-color", "lightcraft-develop", "lightcraft-engine", + "lightcraft-faces", "lightcraft-geom", "lightcraft-meta", "lightcraft-pipeline", diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 6c5e6e44..0f393706 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -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(); diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 9ffbf170..a1c9d76d 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -209,6 +209,8 @@ fn services(originals: Originals, backend: Option, 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, diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 4c7bb424..39034e22 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -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 (`/models`). +fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { + 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, seed_demo: bool) -> (Session, Option) { +fn open_library_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { if in_memory { return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None); } diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs new file mode 100644 index 00000000..933dc22d --- /dev/null +++ b/crates/engine/src/config.rs @@ -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 { + 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 `/models`. +pub fn default_face_models_dir() -> Option { + 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 + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index acbfa814..a368678e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index bdebec60..7347d33b 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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 { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 49bdfc90..96779e31 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -26,6 +26,7 @@ log = { workspace = true } [dev-dependencies] lightcraft-codecs = { workspace = true } +lightcraft-faces = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 206d75b1..d7e5e480 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -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] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aeab2794..36b72f31 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -106,6 +106,8 @@ pub struct Services { pub picker: Option, /// Show an open dialog for photos; returns paths. pub pick_files: Option, + /// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…). + pub pick_model_file: Option, /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). pub pick_preset_files: Option, /// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…). @@ -761,6 +763,13 @@ impl LightcraftApp { let dropped: Vec = 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, Vec) = 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, Vec) = 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>)>, /// 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). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5ca01cb4..ddc33f5a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { 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 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" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index cd087776..fa879359 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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 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}); diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs new file mode 100644 index 00000000..ab321aff --- /dev/null +++ b/crates/ui-egui/src/panels/faces.rs @@ -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) -> 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 = 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 { + 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 { + 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 +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 30075648..cd2ac1a3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index db5ca5a1..822d2127 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -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(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() diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 17f5c58b..397e341f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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)>, }, diff --git a/docs/parity.md b/docs/parity.md index 90dc8a74..baddd9dd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 | ⬜ | | |