[object Object]

← back to Wallco Ai

curator: generalize to any collection via ?category= (loads drunk-animals etc) + 'Auto-ID all visible' batch button (capped 60, 4-concurrent); drunk-aware auto-ID detects 'No bottle' + 'Not animals' (off-concept florals/damask); box labels→defect_tags so chips reflect confirmed boxes; header count uses category

1dbe891286dfda73d50d74cde6e1ff0a39951c40 · 2026-05-27 10:20:02 -0700 · Steve Abrams

Files touched

Diff

commit 1dbe891286dfda73d50d74cde6e1ff0a39951c40
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 27 10:20:02 2026 -0700

    curator: generalize to any collection via ?category= (loads drunk-animals etc) + 'Auto-ID all visible' batch button (capped 60, 4-concurrent); drunk-aware auto-ID detects 'No bottle' + 'Not animals' (off-concept florals/damask); box labels→defect_tags so chips reflect confirmed boxes; header count uses category
---
 public/admin/cactus-curator.html | 43 ++++++++++++++++++++++++++++++++++------
 server.js                        | 41 +++++++++++++++++++++++++++++---------
 2 files changed, 69 insertions(+), 15 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 60e930c..b27519f 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -57,6 +57,10 @@
                 background:#ff3b30; color:#fff; border-radius:50%; font:800 12px sans-serif; cursor:pointer; z-index:6; }
   .bad-box .lbl { position:absolute; bottom:-1px; left:-1px; background:#ff3b30; color:#fff; font:800 9px sans-serif; padding:1px 5px; border-radius:0 3px 0 0; }
   body.shifting .thumb { cursor:crosshair; }
+  .ctl-btn { padding:6px 12px; border:1px solid #6a5a3a; border-radius:6px; background:#221e16; color:#ffd68a;
+             font:700 12px sans-serif; cursor:pointer; }
+  .ctl-btn:hover { background:#ffb300; color:#1a1a1a; }
+  .ctl-btn:disabled { opacity:.6; cursor:wait; }
   /* candidate (auto-detected, unconfirmed) boxes — amber dashed with confirm/delete */
   .bad-box.cand { border-color:#ffb300; background:rgba(255,179,0,.13); }
   .bad-box.cand .lbl { background:#ffb300; color:#1a1a1a; }
@@ -149,6 +153,7 @@
     <label class="ctl">Density
       <input type="range" id="density" min="2" max="9" step="1" value="5">
     </label>
+    <button id="autoall" class="ctl-btn" title="auto-detect bad areas on every visible card">🔍 Auto-ID all visible</button>
     <span class="legend"><b>drag</b> across images to select · <b>shift+drag on an image</b> = ▢ box a bad area (red) · <b>shift+click</b> a card to add one · keys on hover: <code>1</code>bad <code>2</code>digital <code>3</code>fix <code>4</code>publish <code>x</code>select</span>
   </div>
 </header>
@@ -170,6 +175,8 @@
 
 <script>
 const ADMIN = new URLSearchParams(location.search).get('admin');
+// Collection is a URL param — this curator now serves cactus, drunk-animals, etc.
+const CATEGORY = (new URLSearchParams(location.search).get('category') || 'cactus');
 const q = s => ADMIN ? (s + (s.includes('?')?'&':'?') + 'admin=' + encodeURIComponent(ADMIN)) : s;
 const $ = id => document.getElementById(id);
 const grid = $('grid');
@@ -190,6 +197,25 @@ $('sort').value = LS.s;
 $('filter').value = LS.f;
 
 $('density').addEventListener('input', e => { LS.d = e.target.value; applyDensity(e.target.value); });
+// Auto-ID every visible card (capped, limited concurrency so we don't hammer Gemini).
+$('autoall').addEventListener('click', async () => {
+  const cards = [...grid.querySelectorAll('.card')];
+  if (!cards.length) return;
+  const CAP = 60, todo = cards.slice(0, CAP);
+  if (!confirm(`Auto-ID ${todo.length} visible card(s) via Gemini vision (~$${(todo.length*0.003).toFixed(2)})`
+      + (cards.length>CAP ? ` — capped at ${CAP} of ${cards.length}; narrow the filter for the rest.` : '?'))) return;
+  const btn = $('autoall'), orig = btn.textContent; btn.disabled = true;
+  let done = 0, idx = 0;
+  const worker = async () => {
+    while (idx < todo.length) {
+      const el = todo[idx++], id = parseInt(el.dataset.id, 10), d = ALL.find(x => x.id === id);
+      if (d) { try { await autoId(d, el, el.querySelector('.auto-id-btn')); } catch {} }
+      btn.textContent = `… ${++done}/${todo.length}`;
+    }
+  };
+  await Promise.all(Array.from({ length: 4 }, worker));
+  btn.textContent = orig; btn.disabled = false;
+});
 $('sort').addEventListener('change', e => { LS.s = e.target.value; render(); });
 $('filter').addEventListener('change', e => { LS.f = e.target.value; render(); });
 
@@ -231,7 +257,8 @@ function verdictClass(v){ return v==='PASS'?'pass':v==='WARN'?'warn':v==='FAIL'?
 // Map a bad_examples defect_tag → short human label for the red chip.
 const TAG_LABEL = { seams:'Seams', repeat_break_lr:'Seams', repeat_break_tb:'Seams',
   edges_fail:'Seams', poor_edges:'Poor edges', cartoonish:'Cartoonish', blurry:'Blurry',
-  ghost:'Ghost', bleed:'Bleed', heal_derivative:'Healed', manual_bad_region:'Flagged' };
+  ghost:'Ghost', bleed:'Bleed', heal_derivative:'Healed', manual_bad_region:'Flagged',
+  no_bottle:'No bottle', not_animals:'Not animals' };
 function failReason(d){
   const reasons = [];
   const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
@@ -368,7 +395,7 @@ function renderBoxes(d, el){
 // Auto-detect candidate bad areas via Gemini vision → render as amber pending boxes.
 function autoId(d, el, btn){
   if (btn) { btn.classList.add('busy'); btn.textContent = '… scanning'; }
-  fetch(q(`/api/admin/cactus/${d.id}/auto-id`))
+  return fetch(q(`/api/admin/cactus/${d.id}/auto-id`))
     .then(r => r.json())
     .then(j => {
       if (!j.ok) { alert('Auto-ID failed: ' + (j.error||'unknown')); return; }
@@ -380,8 +407,10 @@ function autoId(d, el, btn){
     .finally(() => { if (btn && d.candidates && d.candidates.length) { btn.classList.remove('busy'); btn.textContent = '🔍 Auto-ID'; }
                      else if (btn) btn.classList.remove('busy'); });
 }
-// Defect-type picker shown after drawing a bad-area box.
-const BOX_LABELS = ['Seams','Poor edges','Cartoonish','Blurry','Ghost','Bleed'];
+// Defect-type picker shown after drawing a bad-area box (collection-aware).
+const BOX_LABELS = /drunk/i.test(CATEGORY)
+  ? ['No bottle','Not animals','Poor edges','Cartoonish','Seams','Blurry']
+  : ['Seams','Poor edges','Cartoonish','Blurry','Ghost','Bleed'];
 let _picker = null;
 function closePicker(){ if(_picker){ document.removeEventListener('mousedown', _picker._out, true); _picker.remove(); _picker=null; } }
 function pickLabel(clientX, clientY){
@@ -425,7 +454,7 @@ function render(){
 function updateStat(){
   const total = ALL.length;
   const done = ALL.filter(d => decided.get(d.id) || d.user_removed || d.needs_fixing_at || d.digital_file_at || d.web_viewer).length;
-  $('stat').innerHTML = `<b>${total}</b> cactus · <b>${done}</b> decided · <b>${total-done}</b> left`;
+  $('stat').innerHTML = `<b>${total}</b> ${CATEGORY} · <b>${done}</b> decided · <b>${total-done}</b> left`;
 }
 
 function toggleSel(id, el){
@@ -610,10 +639,12 @@ function applySelection(next){
 
 async function load(){
   try {
-    const r = await fetch(q('/api/admin/cactus/list?include=removed'));
+    const r = await fetch(q('/api/admin/cactus/list?include=removed&category=' + encodeURIComponent(CATEGORY)));
     if(!r.ok) throw new Error('list '+r.status+(r.status===404?' (admin gate — append ?admin=TOKEN)':''));
     const j = await r.json();
     ALL = j.items || [];
+    // Reflect the loaded collection in the title.
+    const h = document.querySelector('h1'); if(h && CATEGORY!=='cactus') h.textContent = '❖ ' + CATEGORY + ' Curator';
     render();
   } catch(e){ $('err').style.display='block'; $('err').textContent = 'Failed to load: '+e.message; }
 }
diff --git a/server.js b/server.js
index bc777e1..8fd5d52 100644
--- a/server.js
+++ b/server.js
@@ -1177,6 +1177,9 @@ app.get('/api/admin/defect-registry/list', (req, res) => {
 app.get('/api/admin/cactus/list', (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const includeRemoved = String(req.query.include || '') === 'removed';
+  // Category is now a param (default cactus) so this curator serves any collection
+  // — cactus, drunk-animals, etc. Sanitized to a safe ILIKE fragment.
+  const cat = (String(req.query.category || 'cactus').replace(/[^a-zA-Z0-9 ._·-]/g, '').slice(0, 40)) || 'cactus';
   try {
     const rawJson = psqlQuery(
       "SELECT COALESCE(json_agg(t ORDER BY t.rank_score DESC NULLS LAST, t.id), '[]'::json) FROM (" +
@@ -1197,7 +1200,7 @@ app.get('/api/admin/cactus/list', (req, res) => {
         "FROM all_designs d " +
         "LEFT JOIN wallco_cactus_rank r ON r.design_id = d.id " +
         "LEFT JOIN bad_examples be ON be.design_id = d.id " +
-        "WHERE d.category ILIKE '%cactus%' " +
+        "WHERE d.category ILIKE '%" + cat + "%' " +
         // Exclude seam-healer derivatives (midheal_/edgeheal_/smartfix_): they all
         // carry the old destructive blur (horizontal/vertical seam line + fuzzy
         // edges) and should never be curated — curate the clean round-1 originals.
@@ -1339,16 +1342,22 @@ app.post('/api/admin/cactus/:id/annotations', express.json({ limit: '32kb' }), (
       return res.json({ ok: true, id, boxes: [] });
     }
     const bj = JSON.stringify(boxes).replace(/'/g, "''");
+    // Box labels → defect tags so the red chips reflect what was boxed.
+    const LABEL_TAG = { 'Seams':'seams','Poor edges':'poor_edges','Cartoonish':'cartoonish','Blurry':'blurry',
+      'Ghost':'ghost','Bleed':'bleed','No bottle':'no_bottle','Not animals':'not_animals' };
+    const tagSet = new Set(['manual_bad_region']);
+    boxes.forEach(b => { const t = LABEL_TAG[b.label]; if (t) tagSet.add(t); });
+    const tagsSql = 'ARRAY[' + [...tagSet].map(t => `'${t}'`).join(',') + ']::text[]';
     psqlExecLocal(
 `INSERT INTO bad_examples (design_id, defect_tags, category, prompt, negative_prompt, seed, generator,
    width_in, height_in, dominant_hex, parent_design_id, local_path, boxes, source, design_created_at)
- SELECT s.id, ARRAY['manual_bad_region']::text[], s.category, s.prompt, s.negative_prompt, s.seed, s.generator,
+ SELECT s.id, ${tagsSql}, s.category, s.prompt, s.negative_prompt, s.seed, s.generator,
    s.width_in, s.height_in, s.dominant_hex, s.parent_design_id, s.local_path, '${bj}'::jsonb,
    'curator-annotation', s.created_at
  FROM spoon_all_designs s WHERE s.id=${id}
  ON CONFLICT (design_id) DO UPDATE SET
    boxes='${bj}'::jsonb,
-   defect_tags=(SELECT array_agg(DISTINCT t) FROM unnest(bad_examples.defect_tags || ARRAY['manual_bad_region']) t),
+   defect_tags=(SELECT array_agg(DISTINCT t) FROM unnest(bad_examples.defect_tags || ${tagsSql}) t),
    source='curator-annotation', recorded_at=now();`);
     res.json({ ok: true, id, boxes });
   } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
@@ -1366,14 +1375,28 @@ app.get('/api/admin/cactus/:id/auto-id', async (req, res) => {
   const KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
   if (!KEY) return res.status(503).json({ ok: false, error: 'GEMINI_API_KEY not configured' });
   try {
-    const lp = (psqlQuery(`SELECT local_path FROM spoon_all_designs WHERE id=${id} LIMIT 1;`) || '').trim();
+    let lp = '', category = '';
+    try { const r = JSON.parse(psqlQuery(`SELECT row_to_json(t) FROM (SELECT local_path, category FROM spoon_all_designs WHERE id=${id} LIMIT 1) t;`));
+          lp = (r.local_path || '').trim(); category = r.category || ''; } catch {}
     if (!lp || !fs.existsSync(lp)) return res.status(404).json({ ok: false, error: 'image not on disk' });
     const b64 = fs.readFileSync(lp).toString('base64');
-    const prompt = "This is a wallpaper tile. Find DEFECTIVE AREAS and return bounding boxes. Labels: "
-      + "'Seams' (motif sliced at a repeat seam), 'Poor edges' (wobbly/rough/imprecise silhouette edges), "
-      + "'Cartoonish' (flat clip-art/childish style, not refined), 'Blurry', 'Ghost' (faded duplicate), 'Bleed'. "
-      + "Return ONLY a JSON array, up to 6: [{\"x\":0..1,\"y\":0..1,\"w\":0..1,\"h\":0..1,\"label\":\"...\"}] "
-      + "as fractions of the image, origin top-left. Return [] if the tile is clean.";
+    // Category-aware defect prompt. Drunk-* collections have a HARD spec: every
+    // animal must visibly hold an alcoholic drink (bottle/cocktail) + ideally a
+    // lit joint — so "no bottle" and "not even animals" (florals/damask slipped
+    // into the collection) are the defects to box.
+    const isDrunk = /drunk/i.test(category);
+    const prompt = isDrunk
+      ? "This is a 'DRUNK ANIMALS' novelty wallpaper. HARD RULE: every animal must visibly hold/have an "
+        + "alcoholic drink (bottle, cocktail, glass) AND ideally a lit joint. Box the DEFECTS. Labels: "
+        + "'No bottle' (an animal with NO visible drink/bottle), 'Not animals' (region/tile has NO animals at "
+        + "all — e.g. florals, damask, geometric — wrong for this collection), 'Poor edges', 'Cartoonish', 'Seams'. "
+        + "Box each offending animal or the whole tile if it's off-concept. Return ONLY a JSON array up to 8 "
+        + "[{\"x\":0..1,\"y\":0..1,\"w\":0..1,\"h\":0..1,\"label\":\"...\"}] fractions, origin top-left. [] if every animal has a drink."
+      : "This is a wallpaper tile. Find DEFECTIVE AREAS and return bounding boxes. Labels: "
+        + "'Seams' (motif sliced at a repeat seam), 'Poor edges' (wobbly/rough/imprecise silhouette edges), "
+        + "'Cartoonish' (flat clip-art/childish style, not refined), 'Blurry', 'Ghost' (faded duplicate), 'Bleed'. "
+        + "Return ONLY a JSON array, up to 6: [{\"x\":0..1,\"y\":0..1,\"w\":0..1,\"h\":0..1,\"label\":\"...\"}] "
+        + "as fractions of the image, origin top-left. Return [] if the tile is clean.";
     const body = { contents: [{ parts: [
       { inline_data: { mime_type: 'image/png', data: b64 } }, { text: prompt },
     ] }], generationConfig: { temperature: 0, responseMimeType: 'application/json' } };

← 86b2fdf cactus-curator: AUTO-ID bad areas via Gemini vision (candida  ·  back to Wallco Ai  ·  Validate cactus seamless redo: circular-padding + gate = 4/4 99be6d6 →