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:
+44
-7
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user