mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
Merge PR #1510
# Conflicts: # crates/ui-egui/locales/de-formats.json # crates/ui-egui/locales/es-formats.json # crates/ui-egui/locales/fr-formats.json # crates/ui-egui/locales/ja-formats.json # crates/ui-egui/locales/pt-br-formats.json # crates/ui-egui/locales/ru-formats.json # crates/ui-egui/locales/uk-formats.json # crates/ui-egui/locales/zh-hans-formats.json # crates/ui-egui/locales/zh-hant-formats.json # crates/ui-egui/src/lib.rs # docs/parity-checklist.md
This commit is contained in:
commit
f8d1585c6f
18 files changed
+514
-20
No files matched your search
@@ -1,10 +1,11 @@
|
||||
//! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed
|
||||
//! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every
|
||||
//! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is
|
||||
//! what teaches the suggestions).
|
||||
//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two
|
||||
//! people when asked.
|
||||
//!
|
||||
//! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is
|
||||
//! catalog-only (no sidecar is written) and `faces.setName` is undoable.
|
||||
//! catalog-only (no sidecar is written) and naming is undoable.
|
||||
|
||||
use lightcraft_catalog::Op;
|
||||
use serde_json::{Value, json};
|
||||
@@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "index": index, "name": name}))
|
||||
}
|
||||
|
||||
/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo
|
||||
/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused
|
||||
/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing.
|
||||
fn rename_person(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.renamePerson";
|
||||
let from = match p.get("from") {
|
||||
Some(Value::String(v)) => {
|
||||
let t = v.trim();
|
||||
if t.is_empty() {
|
||||
return Err(bad(C, "missing `from`"));
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`from` must be text")),
|
||||
None => return Err(bad(C, "missing `from`")),
|
||||
};
|
||||
let to = match p.get("to") {
|
||||
Some(Value::String(v)) => {
|
||||
let t = v.trim();
|
||||
if t.is_empty() {
|
||||
return Err(bad(C, "`to` must be a name"));
|
||||
}
|
||||
if t.chars().count() > 200 || t.chars().any(char::is_control) {
|
||||
return Err(bad(C, "a name is at most 200 characters, without control characters"));
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`to` must be text")),
|
||||
None => return Err(bad(C, "missing `to`")),
|
||||
};
|
||||
let merge = super::bool_or(p, "merge", false);
|
||||
let from_key = from.to_lowercase();
|
||||
let to_key = to.to_lowercase();
|
||||
let mut matched = 0usize;
|
||||
let mut other = false;
|
||||
let mut faces = 0usize;
|
||||
let mut ops = Vec::new();
|
||||
let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
let mut indexes = Vec::new();
|
||||
for (i, r) in ph.meta.regions.iter().enumerate() {
|
||||
if r.kind != lightcraft_meta::RegionKind::Face {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
let key = n.to_lowercase();
|
||||
if key == from_key {
|
||||
matched += 1;
|
||||
if r.name.as_deref() != Some(to.as_str()) {
|
||||
indexes.push(i);
|
||||
}
|
||||
} else if key == to_key {
|
||||
other = true;
|
||||
}
|
||||
}
|
||||
if indexes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut meta = ph.meta.clone();
|
||||
for i in indexes {
|
||||
if let Some(region) = meta.regions.get_mut(i) {
|
||||
region.name = Some(to.clone());
|
||||
faces += 1;
|
||||
}
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
if matched == 0 {
|
||||
return Err(bad(C, format!("no person named {from}")));
|
||||
}
|
||||
if faces == 0 {
|
||||
return Err(bad(C, format!("{to} is already their name")));
|
||||
}
|
||||
if other && !merge {
|
||||
return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them")));
|
||||
}
|
||||
let photos = ops.len();
|
||||
s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?;
|
||||
s.skip_auto_write = true;
|
||||
if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) {
|
||||
s.filter.person = Some(to.clone());
|
||||
}
|
||||
Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other}))
|
||||
}
|
||||
|
||||
/// Most faces `faces.unnamed` lists.
|
||||
const MAX_UNNAMED: usize = 3000;
|
||||
|
||||
@@ -1079,5 +1166,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::always,
|
||||
set_name
|
||||
),
|
||||
cmd!(
|
||||
"faces.renamePerson",
|
||||
"Rename Person",
|
||||
[],
|
||||
None,
|
||||
"{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched",
|
||||
super::always,
|
||||
rename_person
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -523,3 +523,98 @@ fn face_scan_row_follows_pending_photos() {
|
||||
assert!(faces_rows(&s).is_empty(), "the row goes when the scan is done");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
fn face_names(s: &Session, id: PhotoId) -> Vec<Option<String>> {
|
||||
s.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Every in-library spelling of a person becomes the exact new name, including a change of case only,
|
||||
/// and a person filter follows it. One undo step puts the old names back.
|
||||
#[test]
|
||||
fn rename_person_rewrites_every_spelling_and_undoes() {
|
||||
let mut s = Session::with_demo();
|
||||
let (a, b) = two_photos(&s);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, Some(" ANN ")), region(0.7, Some("Rex"))]);
|
||||
let mut pet = region(0.2, Some("Ann"));
|
||||
pet.kind = RegionKind::Pet;
|
||||
set_regions(&mut s, b, vec![region(0.3, Some("ann")), pet]);
|
||||
s.execute("library.filter", &json!({"person": "ANN"})).unwrap();
|
||||
let undo = s.undo.len();
|
||||
let r = s.execute("faces.renamePerson", &json!({"from": " ann ", "to": " Ada "})).unwrap();
|
||||
assert_eq!((r["faces"].clone(), r["photos"].clone(), r["to"].clone(), r["merged"].clone()), (json!(3), json!(2), json!("Ada"), json!(false)));
|
||||
assert_eq!(face_names(&s, a), vec![Some("Ada".into()), Some("Ada".into()), Some("Rex".into())]);
|
||||
assert_eq!(face_names(&s, b)[0], Some("Ada".into()));
|
||||
assert_eq!(face_names(&s, b)[1], Some("Ann".into()), "a pet is not a person");
|
||||
assert_eq!(s.catalog.people().iter().find(|p| p.name.eq_ignore_ascii_case("ada")).map(|p| p.name.as_str()), Some("Ada"));
|
||||
assert_eq!(s.filter.person.as_deref(), Some("Ada"));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Rename Person"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(face_names(&s, a), vec![Some("Ann".into()), Some(" ANN ".into()), Some("Rex".into())]);
|
||||
assert_eq!(face_names(&s, b)[0], Some("ann".into()));
|
||||
}
|
||||
|
||||
/// Renaming onto someone else is refused until merge is asked for; then one undo step restores both people.
|
||||
#[test]
|
||||
fn rename_person_refuses_a_merge_until_it_is_asked_for() {
|
||||
let mut s = Session::with_demo();
|
||||
let (a, b) = two_photos(&s);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]);
|
||||
set_regions(&mut s, b, vec![region(0.3, Some("Bea"))]);
|
||||
let before = (face_names(&s, a), face_names(&s, b), s.undo.len());
|
||||
let err = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea"})).unwrap_err();
|
||||
assert!(err.to_string().contains("already another person"), "{err}");
|
||||
assert_eq!((face_names(&s, a), face_names(&s, b), s.undo.len()), before, "a refusal changes nothing");
|
||||
let r = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea", "merge": true})).unwrap();
|
||||
assert_eq!(r["merged"], true);
|
||||
assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Merge People"));
|
||||
let names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(names, vec!["Bea".to_string()]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let mut names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["Ann".to_string(), "Bea".to_string()]);
|
||||
}
|
||||
|
||||
/// More faces than `faces.person` lists are still renamed. A bad call, a missing person, or a name that
|
||||
/// is already exact changes nothing, and deleted or only-browsed photos are not part of the person.
|
||||
#[test]
|
||||
fn rename_person_covers_more_than_the_person_query_and_rejects_bad_input() {
|
||||
let mut s = Session::with_demo();
|
||||
let (a, b) = two_photos(&s);
|
||||
set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]);
|
||||
let undo = s.undo.len();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"from": "Ann"}),
|
||||
json!({"from": " ", "to": "Ada"}),
|
||||
json!({"from": 1, "to": "Ada"}),
|
||||
json!({"from": "Ann", "to": ""}),
|
||||
json!({"from": "Ann", "to": 5}),
|
||||
json!({"from": "Ann", "to": "a\u{7}b"}),
|
||||
json!({"from": "Ann", "to": "x".repeat(201)}),
|
||||
json!({"from": "Nobody", "to": "Ada"}),
|
||||
json!({"from": "Ann", "to": "Ann"}),
|
||||
] {
|
||||
let err = s.execute("faces.renamePerson", &p).unwrap_err();
|
||||
assert!(s.undo.len() == undo, "{p} changed the catalog: {err}");
|
||||
}
|
||||
assert_eq!(face_names(&s, a), vec![Some("Ann".into())]);
|
||||
let regions: Vec<_> = (0..5001).map(|i| region(0.1, Some(if i == 0 { "ANN" } else { "Ann" }))).collect();
|
||||
set_regions(&mut s, a, regions);
|
||||
set_regions(&mut s, b, vec![region(0.2, Some("Ann"))]);
|
||||
s.commit("delete", Op::SetDeleted { id: b, deleted: true }).unwrap();
|
||||
let listed = s.execute("faces.person", &json!({"name": "Ann"})).unwrap();
|
||||
assert_eq!((listed["total"].clone(), listed["confirmed"].as_array().unwrap().len()), (json!(5001), 5000));
|
||||
let r = s.execute("faces.renamePerson", &json!({"from": "ann", "to": "Ada"})).unwrap();
|
||||
assert_eq!((r["faces"].clone(), r["photos"].clone()), (json!(5001), json!(1)), "the deleted photo is left alone");
|
||||
assert!(s.catalog.photo(a).unwrap().meta.regions.iter().all(|r| r.name.as_deref() == Some("Ada")));
|
||||
assert_eq!(s.catalog.photo(b).unwrap().meta.regions[0].name.as_deref(), Some("Ann"));
|
||||
let local = s.catalog.photos().map(|p| p.id).find(|id| *id != a && *id != b).unwrap();
|
||||
set_regions(&mut s, local, vec![region(0.1, Some("Ada"))]);
|
||||
s.commit("browse", Op::SetLocal { id: local, local: true }).unwrap();
|
||||
let r = s.execute("faces.renamePerson", &json!({"from": "Ada", "to": "Bea"})).unwrap();
|
||||
assert_eq!(r["photos"], 1, "the browsed photo is not another person and is not renamed");
|
||||
assert_eq!(s.catalog.photo(a).unwrap().meta.regions[0].name.as_deref(), Some("Bea"));
|
||||
assert_eq!(s.catalog.photo(local).unwrap().meta.regions[0].name.as_deref(), Some("Ada"));
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · {n} übersprungen (nicht darstellbar)",
|
||||
"{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder."
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · {n} omitidas (no se pudieron representar)",
|
||||
"{n} raws can now be developed from their raw data": "{n} RAW ya se pueden revelar a partir de sus datos RAW",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se detuvo inesperadamente: {e}\n\nLos detalles están en el archivo de registro: {path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft se detuvo inesperadamente: {e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft se detuvo inesperadamente: {e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo."
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · {n} ignorées (rendu impossible)",
|
||||
"{n} raws can now be developed from their raw data": "{n} fichiers RAW peuvent désormais être développés à partir de leurs données RAW",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft s’est arrêté de façon inattendue : {e}\n\nConsultez le fichier journal pour plus de détails : {path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft s’est arrêté de façon inattendue : {e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft s’est arrêté de façon inattendue : {e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau."
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · {n} 枚をスキップ(レンダリングできません)",
|
||||
"{n} raws can now be developed from their raw data": "{n}枚のRAWをRAWデータから現像できるようになりました",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraftが予期せず終了しました: {e}\n\n詳しくはログファイルをご覧ください: {path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraftが予期せず終了しました: {e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraftが予期せず終了しました: {e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "ライブラリ全体で{n}件の顔の名前を変更します。ファイルは変更されません。{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。"
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · {n} ignoradas (não foi possível renderizar)",
|
||||
"{n} raws can now be developed from their raw data": "{n} RAW agora podem ser revelados a partir dos dados RAW",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "O LightCraft parou inesperadamente: {e}\n\nO arquivo de log tem os detalhes: {path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "O LightCraft parou inesperadamente: {e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "O LightCraft parou inesperadamente: {e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Renomeia {n} rosto{} na biblioteca. Os arquivos não são alterados.",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo."
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · пропущено: {n} (не удалось отрисовать)",
|
||||
"{n} raws can now be developed from their raw data": "RAW-файлов, которые теперь можно проявлять из RAW-данных: {n}",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft неожиданно остановился: {e}\n\nПодробности в файле журнала: {path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft неожиданно остановился: {e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft неожиданно остановился: {e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их."
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · пропущено: {n} (не вдалося відтворити)",
|
||||
"{n} raws can now be developed from their raw data": "RAW-файлів, які тепер можна проявляти з RAW-даних: {n}",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft несподівано зупинився: {e}\n\nПодробиці у файлі журналу: {path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft несподівано зупинився: {e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft несподівано зупинився: {e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх."
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · 已跳过 {n} 张(无法渲染)",
|
||||
"{n} raws can now be developed from their raw data": "{n} 个 RAW 现在可以从 RAW 数据冲印",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n详细信息见日志文件:{path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "在整个图库中重命名 {n} 张面孔。不会改动文件。{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。"
|
||||
}
|
||||
@@ -285,5 +285,7 @@
|
||||
" · {n} skipped (could not be rendered)": " · 已略過 {n} 張(無法算繪)",
|
||||
"{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印",
|
||||
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}",
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}"
|
||||
"LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}",
|
||||
"Renames {n} face{} across the library. The files are not changed.": "在整個圖庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。"
|
||||
}
|
||||
@@ -128,6 +128,8 @@ mod tests_recheck;
|
||||
#[cfg(test)]
|
||||
mod tests_sam_setup;
|
||||
#[cfg(test)]
|
||||
mod tests_rename_person;
|
||||
#[cfg(test)]
|
||||
mod tests_scroll;
|
||||
#[cfg(test)]
|
||||
mod tests_slideshow;
|
||||
@@ -551,6 +553,22 @@ impl LightcraftApp {
|
||||
self
|
||||
}
|
||||
|
||||
/// The open person page, the way back to it, and the photo it was opened from follow a rename.
|
||||
fn follow_renamed_person(&mut self, from: &str, to: &str) {
|
||||
let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase();
|
||||
if self.ui.person_page.as_deref().is_some_and(same) {
|
||||
self.ui.person_page = Some(to.to_string());
|
||||
}
|
||||
if self.ui.last_person.as_deref().is_some_and(same) {
|
||||
self.ui.last_person = Some(to.to_string());
|
||||
}
|
||||
if let Some((name, photo)) = self.ui.person_from.clone()
|
||||
&& same(&name)
|
||||
{
|
||||
self.ui.person_from = Some((to.to_string(), photo));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a UI or engine command by id. The single entry point for every frontend path.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
self.run_typed(id, params).map_err(|e| e.message)
|
||||
@@ -637,6 +655,13 @@ impl LightcraftApp {
|
||||
}
|
||||
}
|
||||
Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v),
|
||||
Ok(v) if id == "faces.renamePerson" => {
|
||||
let to = v["to"].as_str().unwrap_or("").to_string();
|
||||
let from = params.get("from").and_then(Value::as_str).unwrap_or("");
|
||||
if !to.is_empty() {
|
||||
self.follow_renamed_person(from, &to);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
r
|
||||
|
||||
@@ -230,6 +230,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::LabelNames { .. } => "Edit Color Label Names",
|
||||
Dialog::CaptureTime { .. } => "Edit Capture Time",
|
||||
Dialog::RenameKeyword { .. } => "Rename Keyword",
|
||||
Dialog::RenamePerson { .. } => "Rename Person",
|
||||
Dialog::KeywordTag { editing: None, .. } => "Create Keyword Tag",
|
||||
Dialog::KeywordTag { .. } => "Edit Keyword Tag",
|
||||
Dialog::DeleteKeyword { .. } => "Delete Keyword",
|
||||
@@ -605,6 +606,36 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
Dialog::RenamePerson { from, to, merge } => {
|
||||
let submitted = dialog_field(ui, "field:personName", to, "Name");
|
||||
let plan = person_rename_plan(&app.session.catalog, from, to);
|
||||
if !plan.needs_merge {
|
||||
*merge = false;
|
||||
}
|
||||
if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) {
|
||||
confirm = true;
|
||||
}
|
||||
if let Some(why) = plan.block {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution));
|
||||
} else {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr_format!(
|
||||
"Renames {n} face{} across the library. The files are not changed.",
|
||||
if plan.faces == 1 { "" } else { "s" },
|
||||
n = plan.faces
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
if plan.needs_merge {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr_format!("“{to}” is already a person. Combining them makes one person; Undo separates them again.", to = to.trim()))
|
||||
.color(t.caution),
|
||||
);
|
||||
let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person"));
|
||||
crate::widgets::register(ui.ctx(), "check:personMerge", r.rect);
|
||||
}
|
||||
}
|
||||
Dialog::RenameKeyword { from, to } => {
|
||||
let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count();
|
||||
if dialog_field(ui, "field:keywordName", to, "New name") {
|
||||
@@ -1319,18 +1350,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "",
|
||||
Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again",
|
||||
Dialog::SamModel { .. } => "Download",
|
||||
Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge",
|
||||
_ if informational => "Close",
|
||||
_ => "OK",
|
||||
};
|
||||
// installing waits for the licence to be accepted
|
||||
// (and Synchronize waits for the scan)
|
||||
let can_confirm = informational
|
||||
|| !matches!(
|
||||
&dlg,
|
||||
Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. }
|
||||
)
|
||||
// smart-album rules that can't mean anything wait to be fixed
|
||||
&& !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty());
|
||||
let rename_blocked = match &dlg {
|
||||
Dialog::RenamePerson { from, to, merge } => {
|
||||
let plan = person_rename_plan(&app.session.catalog, from, to);
|
||||
plan.block.is_some() || (plan.needs_merge && !*merge)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let can_confirm = !rename_blocked
|
||||
&& (informational
|
||||
|| !matches!(
|
||||
&dlg,
|
||||
Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. }
|
||||
)
|
||||
// smart-album rules that can't mean anything wait to be fixed
|
||||
&& !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()));
|
||||
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);
|
||||
@@ -1689,6 +1729,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
}
|
||||
Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})),
|
||||
Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})),
|
||||
Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})),
|
||||
Dialog::KeywordTag {
|
||||
editing,
|
||||
name,
|
||||
@@ -2081,6 +2122,54 @@ fn return_confirms(dlg: &Dialog) -> bool {
|
||||
matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. })
|
||||
}
|
||||
|
||||
struct PersonRenamePlan {
|
||||
faces: usize,
|
||||
needs_merge: bool,
|
||||
/// Why Rename stays disabled. `None` when the typed name can be applied.
|
||||
block: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`.
|
||||
fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan {
|
||||
let to_trim = to.trim();
|
||||
if to_trim.is_empty() {
|
||||
return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") };
|
||||
}
|
||||
if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) {
|
||||
return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") };
|
||||
}
|
||||
let from_key = from.trim().to_lowercase();
|
||||
let to_key = to_trim.to_lowercase();
|
||||
let mut matched = 0usize;
|
||||
let mut faces = 0usize;
|
||||
let mut needs_merge = false;
|
||||
if !from_key.is_empty() {
|
||||
for photo in cat.photos().filter(|p| p.in_library()) {
|
||||
for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
|
||||
let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
let key = name.to_lowercase();
|
||||
if key == from_key {
|
||||
matched += 1;
|
||||
if region.name.as_deref() != Some(to_trim) {
|
||||
faces += 1;
|
||||
}
|
||||
} else if key == to_key {
|
||||
needs_merge = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let block = if matched == 0 {
|
||||
Some("No person by that name")
|
||||
} else if faces == 0 {
|
||||
Some("That is already their name")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// a case-only change is the same person, not a merge
|
||||
PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block }
|
||||
}
|
||||
|
||||
/// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens
|
||||
/// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc
|
||||
/// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the
|
||||
|
||||
@@ -183,11 +183,25 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed
|
||||
p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text);
|
||||
let photos = crate::i18n::tr_format!("{n} photo{}", if person.count == 1 { "" } else { "s" }, n = person.count);
|
||||
p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim);
|
||||
if resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)).clicked() {
|
||||
let resp = resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name));
|
||||
let full = person.name.clone();
|
||||
resp.context_menu(|ui| {
|
||||
let r = ui.button(crate::i18n::tr("Rename Person…"));
|
||||
register(ui.ctx(), "personMenu:rename", r.rect);
|
||||
if r.clicked() {
|
||||
open_rename(app, &full);
|
||||
}
|
||||
});
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.person", json!({"name": person.name}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Rename Person dialog, prefilled with the name the card shows.
|
||||
pub(crate) fn open_rename(app: &mut LightcraftApp, name: &str) {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenamePerson { from: name.to_string(), to: name.to_string(), merge: false });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -153,6 +153,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) {
|
||||
let _ = app.run("view.people", json!({}));
|
||||
}
|
||||
ui.label(RichText::new(&shown_name).font(t.semibold(15.0)).color(t.text));
|
||||
let rename = ui.button(crate::i18n::tr("Rename Person…"));
|
||||
register(ui.ctx(), "person:rename", rename.rect);
|
||||
if rename.clicked() {
|
||||
super::people::open_rename(app, &shown_name);
|
||||
}
|
||||
let count = if page.total == 1 { "1 face".to_string() } else { format!("{} faces", page.total) };
|
||||
ui.label(RichText::new(count).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
|
||||
@@ -997,6 +997,13 @@ pub enum Dialog {
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
/// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person
|
||||
/// `to` already names.
|
||||
RenamePerson {
|
||||
from: String,
|
||||
to: String,
|
||||
merge: bool,
|
||||
},
|
||||
/// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag.
|
||||
KeywordTag {
|
||||
/// The keyword being edited; `None`: a new one.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Rename Person from the People overview and from a person's page.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::Op;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn demo() -> Headless {
|
||||
let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
h.settle(T);
|
||||
h
|
||||
}
|
||||
|
||||
fn face(x: f64, name: &str) -> lightcraft_meta::Region {
|
||||
lightcraft_meta::Region {
|
||||
rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 },
|
||||
kind: lightcraft_meta::RegionKind::Face,
|
||||
name: Some(name.to_string()),
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn name_faces(h: &mut Headless, id: lightcraft_catalog::PhotoId, regions: Vec<lightcraft_meta::Region>) {
|
||||
let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone();
|
||||
meta.regions = regions;
|
||||
h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap();
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> egui::Rect {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
|
||||
}
|
||||
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id}), T);
|
||||
assert_eq!(r["ok"], true, "{id}: {r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Headless, id: &str) {
|
||||
let c = widget(h, id).center();
|
||||
let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn open_people(h: &mut Headless) {
|
||||
let r = h.request("engine.execute", json!({"command": "view.people"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(T);
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn stored(h: &Headless, id: lightcraft_catalog::PhotoId) -> Vec<Option<String>> {
|
||||
h.app.session.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Both entry points open the dialog. Cancel and an empty name change nothing; a real rename updates
|
||||
/// the person page, the way back to it, and a person filter.
|
||||
#[test]
|
||||
fn rename_person_from_the_card_and_the_page() {
|
||||
let mut h = demo();
|
||||
let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect();
|
||||
name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]);
|
||||
name_faces(&mut h, ids[1], vec![face(0.3, "Jane Doe")]);
|
||||
open_people(&mut h);
|
||||
right_click(&mut h, "person:Jane Doe");
|
||||
click(&mut h, "personMenu:rename");
|
||||
assert!(matches!(&h.app.ui.dialog, Some(Dialog::RenamePerson { from, to, .. }) if from == "Jane Doe" && to == "Jane Doe"));
|
||||
click(&mut h, "button:dialogCancel");
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]);
|
||||
|
||||
h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T);
|
||||
h.step();
|
||||
click(&mut h, "person:rename");
|
||||
let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) };
|
||||
to.clear();
|
||||
h.step();
|
||||
click(&mut h, "button:dialogOk");
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "an empty name does not rename");
|
||||
assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe"));
|
||||
|
||||
let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("dialog closed") };
|
||||
*to = "Ada Lovelace".into();
|
||||
h.app.ui.last_person = Some("Jane Doe".into());
|
||||
h.app.ui.person_from = Some(("Jane Doe".into(), ids[0].0));
|
||||
h.app.session.execute("library.filter", &json!({"person": "jane doe"})).unwrap();
|
||||
h.step();
|
||||
click(&mut h, "button:dialogOk");
|
||||
assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog);
|
||||
assert!(stored(&h, ids[0]).iter().all(|n| n.as_deref() == Some("Ada Lovelace")));
|
||||
assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("Ada Lovelace"));
|
||||
assert_eq!(h.app.ui.person_page.as_deref(), Some("Ada Lovelace"));
|
||||
assert_eq!(h.app.ui.last_person.as_deref(), Some("Ada Lovelace"));
|
||||
assert_eq!(h.app.ui.person_from.as_ref().map(|(n, _)| n.as_str()), Some("Ada Lovelace"));
|
||||
assert_eq!(h.app.session.filter.person.as_deref(), Some("Ada Lovelace"));
|
||||
h.app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]);
|
||||
}
|
||||
|
||||
/// Renaming onto someone else waits for Combine, then one undo step restores both people.
|
||||
#[test]
|
||||
fn rename_person_asks_before_merging() {
|
||||
let mut h = demo();
|
||||
let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect();
|
||||
name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe")]);
|
||||
name_faces(&mut h, ids[1], vec![face(0.3, "John Roe")]);
|
||||
open_people(&mut h);
|
||||
h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T);
|
||||
h.step();
|
||||
click(&mut h, "person:rename");
|
||||
let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) };
|
||||
*to = "John Roe".into();
|
||||
h.step();
|
||||
click(&mut h, "button:dialogOk");
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "a merge is not silent");
|
||||
assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe"));
|
||||
click(&mut h, "check:personMerge");
|
||||
click(&mut h, "button:dialogOk");
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("John Roe"));
|
||||
assert_eq!(h.app.ui.person_page.as_deref(), Some("John Roe"));
|
||||
let names: Vec<String> = h.app.session.catalog.people().into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(names, vec!["John Roe".to_string()]);
|
||||
h.app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe"));
|
||||
assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("John Roe"));
|
||||
}
|
||||
+8
-1
@@ -268,6 +268,13 @@ To name a group: click faces to select them (Shift-click selects a range, **Sele
|
||||
name in the bar that appears (people already named complete as you type), press Enter: all of them are named at once, as
|
||||
one undo step. Selecting names nothing.
|
||||
|
||||
Right-click a person's card, or use **Rename Person…** on their page, to rename every face of that person in the library
|
||||
at once (`faces.renamePerson`). The name is matched the way the cards are grouped (trimmed, ignoring capitalization) and
|
||||
every matching face is stored with the exact new spelling, so a change of capitalization alone updates them all. The
|
||||
dialog says how many faces that is. If the new name is already someone else, it refuses until **Combine with the existing
|
||||
person** is ticked; that makes one person, and Undo separates them again. Cancelling changes nothing. A person filter,
|
||||
the open person page and the way back to it follow the new name. Nothing is written back to the files.
|
||||
|
||||
The thumbnail-size slider in the bottom bar sizes the faces here too, with limits of their own. Every face is shown by the
|
||||
same kind of box: the detector's own box once the scan has looked at it (kept beside its embedding), so a loosely drawn box
|
||||
from another tool does not make one face look farther away than the next; before the scan has looked at a face it is shown
|
||||
@@ -332,7 +339,7 @@ reuses the pictures of its step instead of rendering every face again.
|
||||
|
||||
All of this is reachable from the control channel, the CLI and MCP: `faces.models.list`, `faces.models.inspect {path}`,
|
||||
`faces.models.install {path, acknowledged: true, activate?}`, `faces.models.download {id, acknowledged: true}` (then `faces.models.downloads`, which also installs what has arrived, and `faces.models.downloadCancel {id}`), `faces.models.remove {id}`, `faces.models.select {id}`,
|
||||
`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`.
|
||||
`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step), `faces.renamePerson {from, to, merge?}` (rename every in-library face of one person, or merge two when `merge` is true, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`.
|
||||
|
||||
## Find Blemishes
|
||||
|
||||
|
||||
Reference in new issue
Block a user