Surname → ethnicity routing + ComfyUI fallback for sparse pool buckets + cache-buster

Three problems J flagged ("not matching properly", "same faces", "still
showing old icons") had three different roots:

1. MISMATCH: front-end was first-name only, so "Anna Cruz" / "Patricia
   Garcia" / "John Jimenez" all defaulted to caucasian. Added
   SURNAMES_HISPANIC / _SOUTH_ASIAN / _EAST_ASIAN / _MIDDLE_EASTERN
   dicts to both search.html and console.html. Surname is checked
   FIRST (stronger signal for hispanic + asian than first names),
   then first-name fallback. Cruz → hispanic, Patel → south_asian,
   Nguyen → east_asian, regardless of first name.

2. SAME FACES: pool buckets are uneven — woman/south_asian=3,
   man/black=4, woman/middle_eastern=2 — so any worker in those
   buckets collapses to 2-4 photos no matter how good the hash is.
   /headshots/:key now 302-redirects to /headshots/generate/:key
   when the gender × race intersection is below 30 faces. ComfyUI
   on-demand gives infinite uniqueness for the sparse buckets
   (deterministic-per-worker via djb2 seed). Dense buckets still
   serve from the pool — no GPU cost there.

3. STALE CACHE: Cache-Control was max-age=86400, immutable — pinned
   old photos in browsers for 24h after any server-side update.
   Dropped to max-age=3600, must-revalidate, and added a v=2
   cache-buster query param to all front-end /headshots/ URLs so
   existing cached entries are bypassed on next page load.

Also surfacing X-Face-Pool-Bucket / Bucket-Size headers for diagnosis.

Verified: playwright run shows surname routing correct (Torres,
Rivera, Alvarez, Gutierrez, Patel, Nguyen, Omar all bucketed
correctly), sparse buckets 302 to ComfyUI, dense buckets stay on
the thumb pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-28 06:01:04 -05:00
co-authored by Claude Opus 4.7
parent a3b65f314e
commit f9a408e4c4
3 changed files with 154 additions and 30 deletions
+44 -7
View File
@@ -1378,17 +1378,45 @@ async function main() {
if (!F || !F.all.length) {
return new Response("face pool empty", { status: 503 });
}
// Pool selection: try gender×race intersection first, then
// gender-only, then race-only, then full pool. Always returns
// a face so the worker card never falls back to the monogram.
// Pool selection: try gender×race intersection first. If
// bucket is too sparse to look natural across many cards
// (south_asian/black/middle_eastern_woman are 2-10 faces),
// hand off to ComfyUI generate so the user sees a unique
// face per worker instead of 4 photos shared across 200
// cards. Threshold 30 keeps the dense buckets fast and
// routes only the sparse ones through GPU.
const wantRace = url.searchParams.get("e") || "";
const SPARSE_THRESHOLD = 30;
let pool = F.all;
if (wantGender && wantRace && F.byGR[wantGender + "/" + wantRace]?.length) {
pool = F.byGR[wantGender + "/" + wantRace];
let bucket = "all";
if (wantGender && wantRace) {
const gr = F.byGR[wantGender + "/" + wantRace] || [];
if (gr.length >= SPARSE_THRESHOLD) {
pool = gr;
bucket = `gr:${wantGender}/${wantRace}`;
} else if (gr.length > 0) {
// Sparse intersection — route to ComfyUI for uniqueness.
const role = url.searchParams.get("role") || "warehouse worker";
const age = url.searchParams.get("age") || "32";
const genUrl = `/headshots/generate/${encodeURIComponent(key)}?g=${wantGender}&e=${wantRace}&role=${encodeURIComponent(role)}&age=${age}`;
return new Response(null, {
status: 302,
headers: {
"Location": genUrl,
"X-Face-Pool-Variant": "sparse-redirect",
"X-Face-Pool-Bucket-Size": String(gr.length),
},
});
} else if (F.byG[wantGender]?.length) {
pool = F.byG[wantGender];
bucket = `g:${wantGender}`;
}
} else if (wantGender && F.byG[wantGender]?.length) {
pool = F.byG[wantGender];
bucket = `g:${wantGender}`;
} else if (wantRace && F.byR[wantRace]?.length) {
pool = F.byR[wantRace];
bucket = `r:${wantRace}`;
}
// Hash key → pool index. djb2-ish, fits any string.
let h = 5381;
@@ -1399,15 +1427,22 @@ async function main() {
// (~580KB). 60× smaller — without this, a 40-card grid
// overruns Chrome's parallel-connection budget and ~75% of
// tiles never finish decoding.
//
// Cache-Control: 1h public + must-revalidate, NOT immutable.
// We deliberately let the browser re-check after pool retags
// or face-pool refreshes — `immutable` was pinning stale
// photos for 24h after a server-side update.
const thumbName = pick.file.replace(/\.jpg$/, ".webp");
const thumb = Bun.file(`${HEADSHOT_DIR}/_thumbs/${thumbName}`);
if (await thumb.exists()) {
return new Response(thumb, {
headers: {
"Content-Type": "image/webp",
"Cache-Control": "public, max-age=86400, immutable",
"Cache-Control": "public, max-age=3600, must-revalidate",
"X-Face-Pool-Idx": String(pick.id),
"X-Face-Pool-Gender": pick.gender || "untagged",
"X-Face-Pool-Bucket": bucket,
"X-Face-Pool-Bucket-Size": String(pool.length),
"X-Face-Pool-Variant": "thumb-384",
},
});
@@ -1419,9 +1454,11 @@ async function main() {
return new Response(file, {
headers: {
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=86400, immutable",
"Cache-Control": "public, max-age=3600, must-revalidate",
"X-Face-Pool-Idx": String(pick.id),
"X-Face-Pool-Gender": pick.gender || "untagged",
"X-Face-Pool-Bucket": bucket,
"X-Face-Pool-Bucket-Size": String(pool.length),
"X-Face-Pool-Variant": "native-1024",
},
});