[object Object]

← back to Wallco Ai

auto-fix: decouple ?legacy routing from prompt-version; fix PIL crash

352e9ceeda2246e6f80649cd0927788c80eead5d · 2026-05-24 22:15:32 -0700 · Steve Abrams

Three coupled fixes:

1. server.js — decouple my Stage-1 prompt-A/B toggle from the 2026-05-25
   smart-fix→opacity-snap redirect. Both were reading ?legacy=1, with
   conflicting semantics:
     • Outer redirect:   ?legacy=1 → bypass opacity-snap, run Gemini path
     • My inner toggle:  ?legacy=1 → use V1 single-motif prompt
   The collision made V2 unreachable. Renamed my inner toggle to
   ?promptv1=1 so:
     no query      → opacity-snap (PIL, preserves composition, free)
     ?legacy=1     → Gemini regenerate with V2 multi-instance prompt (good)
     ?legacy=1&promptv1=1 → Gemini + V1 prompt (explicit A/B benchmark only)

2. server.js — fix PIL palette crash in BOTH opacity-snap spots
   (standalone at :7430 and smart-fix redirect at :7598). PIL's
   getpalette() returns only as many bytes as distinct quantized colors
   × 3 channels — a 2-color image gets 6 bytes, not 9. Hardcoded
   range(3) + pal[:9] crashed on every 2-color source (37881 etc.) —
   observed 100% failure on the first auto-fix-composition run (25/25
   immediate failures before kill). Now iterates over actual palette
   length via min(k, len(pal_all)//3).

3. scripts/auto-fix-composition.js
   • URL now hits /api/design/:id/smart-fix?legacy=1 so it routes to the
     Gemini-regenerate path (V2 prompts) instead of opacity-snap (which
     preserves composition — useless for centered-hero defects).
   • loadDone() only counts rows where (r.ok && r.new_id) so transient
     failures get retried on resume instead of being permanently skipped.

Smoke-tested 37881 (face-skull-damask, centered hero) →  40552:
  attempts=2  prompt_mode=repeat-v2  composition n=6 hero=false  elapsed=21.2s

Files touched

Diff

commit 352e9ceeda2246e6f80649cd0927788c80eead5d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 22:15:32 2026 -0700

    auto-fix: decouple ?legacy routing from prompt-version; fix PIL crash
    
    Three coupled fixes:
    
    1. server.js — decouple my Stage-1 prompt-A/B toggle from the 2026-05-25
       smart-fix→opacity-snap redirect. Both were reading ?legacy=1, with
       conflicting semantics:
         • Outer redirect:   ?legacy=1 → bypass opacity-snap, run Gemini path
         • My inner toggle:  ?legacy=1 → use V1 single-motif prompt
       The collision made V2 unreachable. Renamed my inner toggle to
       ?promptv1=1 so:
         no query      → opacity-snap (PIL, preserves composition, free)
         ?legacy=1     → Gemini regenerate with V2 multi-instance prompt (good)
         ?legacy=1&promptv1=1 → Gemini + V1 prompt (explicit A/B benchmark only)
    
    2. server.js — fix PIL palette crash in BOTH opacity-snap spots
       (standalone at :7430 and smart-fix redirect at :7598). PIL's
       getpalette() returns only as many bytes as distinct quantized colors
       × 3 channels — a 2-color image gets 6 bytes, not 9. Hardcoded
       range(3) + pal[:9] crashed on every 2-color source (37881 etc.) —
       observed 100% failure on the first auto-fix-composition run (25/25
       immediate failures before kill). Now iterates over actual palette
       length via min(k, len(pal_all)//3).
    
    3. scripts/auto-fix-composition.js
       • URL now hits /api/design/:id/smart-fix?legacy=1 so it routes to the
         Gemini-regenerate path (V2 prompts) instead of opacity-snap (which
         preserves composition — useless for centered-hero defects).
       • loadDone() only counts rows where (r.ok && r.new_id) so transient
         failures get retried on resume instead of being permanently skipped.
    
    Smoke-tested 37881 (face-skull-damask, centered hero) →  40552:
      attempts=2  prompt_mode=repeat-v2  composition n=6 hero=false  elapsed=21.2s
---
 scripts/auto-fix-composition.js | 14 ++++++++++++--
 server.js                       | 33 ++++++++++++++++++++++++---------
 2 files changed, 36 insertions(+), 11 deletions(-)

diff --git a/scripts/auto-fix-composition.js b/scripts/auto-fix-composition.js
index ee64e19..9f378e8 100644
--- a/scripts/auto-fix-composition.js
+++ b/scripts/auto-fix-composition.js
@@ -71,11 +71,17 @@ function loadFlagged() {
 }
 
 function loadDone() {
+  // Only count rows that ACTUALLY succeeded — failed attempts (server 500,
+  // PIL crash, etc.) should be retried on the next run so a transient
+  // server bug doesn't permanently skip an id.
   if (!fs.existsSync(LOG_F)) return new Set();
   const ids = new Set();
   for (const line of fs.readFileSync(LOG_F, 'utf8').split('\n')) {
     if (!line.trim()) continue;
-    try { ids.add(JSON.parse(line).src_id); } catch {}
+    try {
+      const r = JSON.parse(line);
+      if (r.ok && r.new_id) ids.add(r.src_id);
+    } catch {}
   }
   return ids;
 }
@@ -96,7 +102,11 @@ async function srcPngExists(id) {
 async function fireSmartFix(id) {
   const t0 = Date.now();
   try {
-    const r = await fetch(`http://127.0.0.1:${PORT}/api/design/${id}/smart-fix`, {
+    // ?legacy=1 selects the Gemini-regenerate code path (bypasses the
+    // 2026-05-25 opacity-snap redirect). Inside that path, default is now V2
+    // repeat-prompt (multi-instance) — V1 only triggered by ?promptv1=1, not
+    // by ?legacy. So `?legacy=1` here gives us "Gemini + V2 prompt".
+    const r = await fetch(`http://127.0.0.1:${PORT}/api/design/${id}/smart-fix?legacy=1`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       // No body required for smart-fix; auto-detects motif from source.
diff --git a/server.js b/server.js
index 2e88a13..61bd95a 100644
--- a/server.js
+++ b/server.js
@@ -976,7 +976,7 @@ app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res)
       // data/generated/ so the etsy-export pipeline can grab it.
       psqlExecLocal(`UPDATE spoon_all_designs
         SET is_published=FALSE,
-            tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || 'etsy-digital-file'
+            tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || ARRAY['etsy-digital-file']::text[]
         WHERE id=${newId};`);
       if (row.parent_design_id) {
         psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
@@ -1022,7 +1022,7 @@ app.post('/api/fix-decision/bulk', express.json({ limit: '64kb' }), (req, res) =
       } else if (verdict === 'etsy') {
         psqlExecLocal(`UPDATE spoon_all_designs
           SET is_published=FALSE,
-              tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || 'etsy-digital-file'
+              tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || ARRAY['etsy-digital-file']::text[]
           WHERE id=${newId};`);
         if (row.parent_design_id) psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
       }
@@ -7428,10 +7428,15 @@ src = Image.open(sys.argv[1]).convert('RGB')
 k = int(sys.argv[3])
 dither_flag = Image.FLOYDSTEINBERG if sys.argv[4] == 'fs' else Image.NONE
 q = src.quantize(colors=k, method=Image.MEDIANCUT, dither=dither_flag)
-pal = q.getpalette()[: k*3]
-hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(k)]
+# PIL's getpalette() returns only as many bytes as distinct quantized
+# colors × 3 channels. A 2-color source returns 6 bytes even when k=3.
+# Iterate over the ACTUAL palette length, not the requested k.
+pal_all = q.getpalette() or []
+k_actual = min(k, len(pal_all) // 3)
+pal = pal_all[: k_actual*3]
+hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(k_actual)]
 q.convert('RGB').save(sys.argv[2], 'PNG', optimize=True)
-print(json.dumps({ 'palette': hexes, 'k': k, 'dither': sys.argv[4] }))
+print(json.dumps({ 'palette': hexes, 'k': k_actual, 'dither': sys.argv[4] }))
 `, d.local_path, outPath, String(k), dither], { encoding: 'utf8' });
     if (py.status !== 0) {
       return res.status(500).json({ error: 'opacity-snap PIL failed: ' + (py.stderr || py.stdout || '?').slice(0, 240) });
@@ -7596,10 +7601,15 @@ import sys, json
 from PIL import Image
 src = Image.open(sys.argv[1]).convert('RGB')
 q = src.quantize(colors=3, method=Image.MEDIANCUT, dither=Image.NONE)
-pal = q.getpalette()[:9]
-hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(3)]
+# PIL's getpalette() returns only as many bytes as distinct quantized
+# colors × 3 channels (2-color images get 6 bytes, not 9). Iterate over
+# the actual palette length, not hardcoded 3.
+pal_all = q.getpalette() or []
+k = min(3, len(pal_all) // 3)
+pal = pal_all[: k*3]
+hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(k)]
 q.convert('RGB').save(sys.argv[2], 'PNG', optimize=True)
-print(json.dumps({'palette': hexes}))
+print(json.dumps({'palette': hexes, 'k': k}))
 `, d.local_path, outPath], { encoding: 'utf8' });
       if (py.status !== 0) return res.status(500).json({ error: 'opacity-snap PIL failed: ' + (py.stderr||'').slice(0,200) });
       let meta = {}; try { meta = JSON.parse(py.stdout); } catch {}
@@ -7712,7 +7722,12 @@ RETURNING id`);
     // not a wallpaper repeat). V2 uses lib/repeat-prompt's buildRepeatPrompt
     // which explicitly asks for N identical instances in a named repeat layout.
     // A/B toggle via ?legacy=1 query param so Stage 3 smoke test can compare.
-    const USE_LEGACY = req.query && (req.query.legacy === '1' || req.query.legacy === 'true');
+    // Prompt-mode toggle — decoupled from the routing-level ?legacy=1 that
+    // selects the Gemini-regenerate code path vs opacity-snap. Inside the
+    // Gemini path: default is V2 (lib/repeat-prompt multi-instance); set
+    // ?promptv1=1 to force the deprecated V1 single-motif prompt for A/B
+    // benchmarking only.
+    const USE_LEGACY = req.query && (req.query.promptv1 === '1' || req.query.promptv1 === 'true');
     function buildGenPromptLegacy(motif, ground_desc, ground_hex, attempt) {
       const variants = [
         `A flat screenprint wallpaper. Hand-cut from solid vinyl. The motif: ${motif} Set against a uniform ${ground_desc || 'solid'} (${ground_hex || '#fbf8f1'}) background that has zero texture and zero gradient. Every motif pixel is a fully saturated solid color (alpha 1.0); every background pixel is the exact same ${ground_desc || 'background'} tone (alpha 1.0). Hard razor-sharp silhouette edges. Seamless tile — left edge matches right edge, top edge matches bottom edge.`,

← 0e7e4ff Stage 4 followup: scenic exemption + auto-fix-composition.js  ·  back to Wallco Ai  ·  ghost-detector: new frame-overlay lens — detects "pasted-on" 44322de →