← 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
M scripts/auto-fix-composition.jsM server.js
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 →