Show a face beside each name in the people rail

The rail was drawing an empty square for every person: cover was
hardcoded to a default image. That is the one place a portrait matters
most, because the rail is how the user decides which unnamed group to
open first, and a list of "Unnamed (24 faces)" rows tells them nothing.

The portrait is the person's confirmed face with the largest crop_px --
the most source pixels the face actually occupied, so the one they have
the best chance of recognising -- falling back to a suggestion so a
freshly clustered group still has a face beside it.

Cached in the controller, because every mutating action reloads the whole
screen and cutting a portrait costs a JPEG decode per person. Without the
cache, confirming one face would re-decode a proxy for every person in
the library, and the rail does not change when a suggestion is accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:52:38 +02:00
co-authored by Claude Opus 5
parent 2944c1b704
commit d562ceaaf4
2 changed files with 74 additions and 8 deletions
+38
View File
@@ -34,6 +34,13 @@ use crate::faces::{crop_face, FaceCrop, FACE_TIER};
/// the same person* — is one the user cannot make from a postage stamp.
pub const FACE_CROP_EDGE: u32 = 128;
/// Edge of the portrait beside a name in the people rail.
///
/// Small on purpose: the rail is for *recognising* a person you already know,
/// not for judging a likeness, and forty of them at grid size would be a
/// second grid competing with the real one.
pub const COVER_CROP_EDGE: u32 = 80;
/// A row in the people rail.
#[derive(Debug, Clone, PartialEq)]
pub struct PersonRow {
@@ -185,6 +192,37 @@ pub fn load_faces(
Ok(out)
}
/// The face to show as a person's portrait.
///
/// Confirmed faces first, then the largest — `crop_px` is the number of source
/// pixels the face actually occupied, so the largest is the one the user has
/// the best chance of recognising. Falling back to a suggestion means a
/// freshly clustered group still has a face beside it, which is the moment the
/// portrait matters most: the rail is how the user decides which unnamed group
/// to open first.
pub fn load_cover(
catalog: &Catalog,
store: &ThumbStore,
person: PersonId,
) -> Result<Option<FaceCrop>, dr_catalog::CatalogError> {
let rows = faces::for_person(catalog.connection(), person, true)?;
let Some(best) = rows
.into_iter()
.max_by(|a, b| {
a.confirmed
.cmp(&b.confirmed)
.then(a.crop_px.total_cmp(&b.crop_px))
})
else {
return Ok(None);
};
let Some((w, h, rgba)) = decode_proxy(catalog, store, best.image_id) else {
return Ok(None);
};
Ok(crop_face(&rgba, w, h, &best, COVER_CROP_EDGE))
}
fn decode_proxy(
catalog: &Catalog,
store: &ThumbStore,