[object Object]

← back to Wallco Ai

aesthetic purge: add scripts/purge-neon-and-multicolor.js (neon + ≥4 hue buckets)

1db958091938a3ff64d971858265b0b9a6272fb0 · 2026-05-19 23:30:43 -0700 · Steve

Files touched

Diff

commit 1db958091938a3ff64d971858265b0b9a6272fb0
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 23:30:43 2026 -0700

    aesthetic purge: add scripts/purge-neon-and-multicolor.js (neon + ≥4 hue buckets)
---
 scripts/purge-neon-and-multicolor.js | 353 +++++++++++++++++++++++++++++++++++
 1 file changed, 353 insertions(+)

diff --git a/scripts/purge-neon-and-multicolor.js b/scripts/purge-neon-and-multicolor.js
new file mode 100755
index 0000000..aa9ea5f
--- /dev/null
+++ b/scripts/purge-neon-and-multicolor.js
@@ -0,0 +1,353 @@
+#!/usr/bin/env node
+/**
+ * Aesthetic purge — wallco.ai 2026-05-19.
+ *
+ * Steve's directive: "remove all bright neon patterns of patterns with more
+ * than 6 colors on wallco.ai and log to not show those colors any more.
+ * Focus more on tone on tone in high fashion colors."
+ *
+ * Two pass criteria (a design qualifies if ANY pass fires):
+ *
+ *   (A) PALETTE pass — measured from spoon_all_designs.palette (jsonb)
+ *       - distinct_hue_buckets >= 4   (the 4-bucket "more than 6 colors" rule
+ *         under a 5-cap palette; hue buckets count distinct families in the
+ *         12-bucket hue wheel rather than rg/by twins)
+ *       - OR any palette entry is neon: HSL S >= 70, L 45..75, pct >= 5
+ *
+ *   (B) TAG / PROMPT pass — string match on tags[] + prompt
+ *       - regex /\b(neon|fluorescent|electric|rainbow|day[- ]?glo|highlighter)\b/i
+ *
+ * For each matched design we mirror the same 6 steps as
+ * POST /api/design/:id/settlement-violation in server.js (~L5661):
+ *   1) SELECT row
+ *   2) Back up image to ~/.wallco-deleted/<DATE>/<id>_<basename>.png
+ *   3) Append JSONL entry to data/settlement-audit/do-not-want.jsonl
+ *   4) unlink local file
+ *   5) DELETE FROM spoon_all_designs WHERE id=$1
+ *   6) cache invalidation = pm2 reload wallco-ai (caller's job)
+ *
+ * Usage:
+ *   node scripts/purge-neon-and-multicolor.js --dry-run
+ *   node scripts/purge-neon-and-multicolor.js --apply
+ *
+ * Defaults are conservative: nothing happens without --apply.
+ */
+'use strict';
+
+const fs       = require('fs');
+const path     = require('path');
+const os       = require('os');
+const { spawnSync } = require('child_process');
+
+const ROOT       = path.join(__dirname, '..');
+const AUDIT_DIR  = path.join(ROOT, 'data', 'settlement-audit');
+const REGISTRY   = path.join(AUDIT_DIR, 'do-not-want.jsonl');
+const TODAY      = new Date().toISOString().slice(0, 10);
+const BACKUP_DIR = path.join(os.homedir(), '.wallco-deleted', TODAY);
+
+// ---- args ------------------------------------------------------------------
+const argv     = process.argv.slice(2);
+const DRY_RUN  = argv.includes('--dry-run');
+const APPLY    = argv.includes('--apply');
+if (!DRY_RUN && !APPLY) {
+  console.error('Usage: node scripts/purge-neon-and-multicolor.js [--dry-run | --apply]');
+  process.exit(2);
+}
+if (DRY_RUN && APPLY) {
+  console.error('Pass exactly one of --dry-run or --apply');
+  process.exit(2);
+}
+
+// ---- psql wrapper (mirrors generate_designs.js) ---------------------------
+function psql(sql) {
+  const onLinux = (process.platform === 'linux');
+  const cmd     = onLinux ? 'sudo' : 'psql';
+  const args    = onLinux
+    ? ['-n', '-u', 'postgres', 'psql', 'dw_unified', '-At', '-q']
+    : ['dw_unified', '-At', '-q'];
+  const r = spawnSync(cmd, args, { input: sql, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
+  if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+  return r.stdout;
+}
+
+// ---- colour analysis ------------------------------------------------------
+// 12-bucket hue wheel — distinct hue families count as separate "colors" in
+// human perception (red, orange, yellow, chartreuse, green, teal, cyan, blue,
+// indigo, violet, magenta, pink). Whites/blacks/very-low-sat greys collapse
+// to a single neutral bucket. This is the rule Steve's "more than 6 colors"
+// maps to under a 5-cap palette.
+function hexToHsl(hex) {
+  if (!hex || typeof hex !== 'string') return null;
+  const m = hex.trim().match(/^#?([0-9a-f]{6})$/i);
+  if (!m) return null;
+  const n = parseInt(m[1], 16);
+  const r = ((n >> 16) & 0xff) / 255;
+  const g = ((n >> 8)  & 0xff) / 255;
+  const b = ( n        & 0xff) / 255;
+  const mx = Math.max(r, g, b);
+  const mn = Math.min(r, g, b);
+  const l  = (mx + mn) / 2;
+  let h = 0, s = 0;
+  if (mx !== mn) {
+    const d = mx - mn;
+    s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+    switch (mx) {
+      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+      case g: h = (b - r) / d + 2; break;
+      case b: h = (r - g) / d + 4; break;
+    }
+    h *= 60;
+  }
+  return { h, s: s * 100, l: l * 100 };
+}
+
+// Returns a bucket name for a hex. Neutrals (sat<12 OR L<10 OR L>90) → 'neutral'.
+function hueBucket(hex) {
+  const hsl = hexToHsl(hex);
+  if (!hsl) return 'unknown';
+  if (hsl.s < 12 || hsl.l < 10 || hsl.l > 90) return 'neutral';
+  const h = hsl.h;
+  if (h < 15)   return 'red';
+  if (h < 45)   return 'orange';
+  if (h < 65)   return 'yellow';
+  if (h < 90)   return 'chartreuse';
+  if (h < 150)  return 'green';
+  if (h < 180)  return 'teal';
+  if (h < 210)  return 'cyan';
+  if (h < 250)  return 'blue';
+  if (h < 280)  return 'indigo';
+  if (h < 310)  return 'violet';
+  if (h < 340)  return 'magenta';
+  return 'red'; // 340..360
+}
+
+function isNeon(hex, pct) {
+  const hsl = hexToHsl(hex);
+  if (!hsl) return false;
+  return hsl.s >= 70 && hsl.l >= 45 && hsl.l <= 75 && (pct == null || pct >= 5);
+}
+
+// ---- tag / prompt regex ---------------------------------------------------
+const TAG_PROMPT_RE = /\b(neon|fluorescent|electric|rainbow|day[- ]?glo|highlighter|fluoro)\b/i;
+
+// ---- pull candidate set ---------------------------------------------------
+// We need: id, prompt, category, motifs, tags, generator, dominant_hex,
+// palette, parent_design_id, local_path. Pull as TSV-of-JSON-per-row for
+// safe transport through psql -At.
+console.log('[1/4] Loading all designs from dw_unified.spoon_all_designs ...');
+const rowsRaw = psql(`
+  SELECT row_to_json(t)
+  FROM (
+    SELECT id, prompt, category, motifs, tags, generator, dominant_hex,
+           palette, parent_design_id, local_path
+    FROM spoon_all_designs
+  ) t;
+`).split('\n').filter(Boolean);
+
+console.log(`     ${rowsRaw.length} rows fetched.`);
+
+// ---- evaluate -------------------------------------------------------------
+const matches = [];
+const breakdown = {
+  palette_only:   0,
+  tag_only:       0,
+  both:           0,
+  hue_buckets_4plus: 0,
+  hue_buckets_5plus: 0,
+  hue_buckets_6plus: 0,
+  neon_palette:   0,
+  tag_match:      0,
+};
+
+for (const line of rowsRaw) {
+  let r;
+  try { r = JSON.parse(line); } catch { continue; }
+
+  // PALETTE pass
+  let paletteHit = false;
+  let neonHit    = false;
+  let hueBuckets = 0;
+  const palette  = Array.isArray(r.palette) ? r.palette : [];
+
+  if (palette.length) {
+    const buckets = new Set();
+    for (const p of palette) {
+      const b = hueBucket(p.hex);
+      if (b !== 'neutral' && b !== 'unknown') buckets.add(b);
+      if (isNeon(p.hex, p.pct)) neonHit = true;
+    }
+    hueBuckets = buckets.size;
+    if (hueBuckets >= 4) paletteHit = true;
+    if (neonHit) paletteHit = true;
+  }
+  if (hueBuckets >= 4) breakdown.hue_buckets_4plus++;
+  if (hueBuckets >= 5) breakdown.hue_buckets_5plus++;
+  if (hueBuckets >= 6) breakdown.hue_buckets_6plus++;
+  if (neonHit) breakdown.neon_palette++;
+
+  // TAG / PROMPT pass
+  const haystack = [
+    r.prompt || '',
+    Array.isArray(r.tags) ? r.tags.join(' ') : '',
+    Array.isArray(r.motifs) ? r.motifs.join(' ') : '',
+  ].join(' ');
+  const tagHit = TAG_PROMPT_RE.test(haystack);
+  if (tagHit) breakdown.tag_match++;
+
+  if (paletteHit && tagHit)    breakdown.both++;
+  else if (paletteHit)         breakdown.palette_only++;
+  else if (tagHit)             breakdown.tag_only++;
+
+  if (paletteHit || tagHit) {
+    matches.push({
+      id: r.id,
+      prompt: r.prompt,
+      category: r.category,
+      motifs: r.motifs,
+      tags: r.tags,
+      generator: r.generator,
+      dominant_hex: r.dominant_hex,
+      palette,
+      parent_design_id: r.parent_design_id,
+      local_path: r.local_path,
+      _reasons: {
+        palette: paletteHit,
+        tag: tagHit,
+        hue_buckets: hueBuckets,
+        neon: neonHit,
+        tag_regex_match: tagHit ? haystack.match(TAG_PROMPT_RE)?.[0] : null,
+      },
+    });
+  }
+}
+
+console.log('\n[2/4] Evaluation breakdown:');
+console.log('     ', JSON.stringify(breakdown, null, 2).replace(/\n/g, '\n     '));
+console.log(`\n     TOTAL MATCHES: ${matches.length}`);
+
+// ---- sample print ---------------------------------------------------------
+function topReason(m) {
+  const r = m._reasons;
+  if (r.palette && r.tag)  return `palette+tag (buckets=${r.hue_buckets}, neon=${r.neon}, tag="${r.tag_regex_match}")`;
+  if (r.palette)            return `palette (buckets=${r.hue_buckets}, neon=${r.neon})`;
+  return `tag ("${r.tag_regex_match}")`;
+}
+console.log('\n[3/4] Sample (first 10 matches):');
+for (const m of matches.slice(0, 10)) {
+  const prompt = (m.prompt || '').slice(0, 80).replace(/\s+/g, ' ');
+  console.log(`     #${m.id} · ${m.category || '?'} · ${topReason(m)} · "${prompt}"`);
+}
+
+if (DRY_RUN) {
+  console.log('\n[4/4] DRY-RUN — nothing deleted. Re-run with --apply to execute.');
+  process.exit(0);
+}
+
+// ---- APPLY ----------------------------------------------------------------
+console.log(`\n[4/4] APPLY — purging ${matches.length} designs ...`);
+try { fs.mkdirSync(BACKUP_DIR, { recursive: true }); } catch {}
+try { fs.mkdirSync(AUDIT_DIR,  { recursive: true }); } catch {}
+
+const REASON = 'aesthetic-purge-2026-05-19: neon and/or ≥4 distinct hues — tone-on-tone direction';
+
+let deleted = 0;
+let backedUp = 0;
+let unlinked = 0;
+let appendedEntries = 0;
+let dbDeleteFails = 0;
+const errors = [];
+
+// Process in chunks so we don't blow the psql input buffer on a 1000-row
+// DELETE IN (...) — and so a single row failure doesn't tank the rest.
+const CHUNK = 50;
+for (let i = 0; i < matches.length; i += CHUNK) {
+  const chunk = matches.slice(i, i + CHUNK);
+
+  // 2) backup + 4) unlink (filesystem) — do this BEFORE the DB DELETE so a
+  // PG hiccup doesn't leave us with orphaned files. We accumulate audit
+  // entries first, write them after each chunk's DB DELETE succeeds.
+  const auditLines = [];
+  const idsToDelete = [];
+
+  for (const m of chunk) {
+    let backupFile = null;
+    if (m.local_path && fs.existsSync(m.local_path)) {
+      try {
+        const bn = `${m.id}_${path.basename(m.local_path)}`;
+        backupFile = path.join(BACKUP_DIR, bn);
+        fs.copyFileSync(m.local_path, backupFile);
+        backedUp++;
+      } catch (e) {
+        errors.push(`backup #${m.id}: ${e.message}`);
+        backupFile = null;
+      }
+    }
+
+    const entry = {
+      ts: new Date().toISOString(),
+      id: m.id,
+      reason: REASON,
+      prompt: m.prompt || null,
+      category: m.category || null,
+      motifs: m.motifs || [],
+      tags: m.tags || [],
+      generator: m.generator || null,
+      dominant_hex: m.dominant_hex || null,
+      parent_design_id: m.parent_design_id || null,
+      palette: m.palette || [],
+      backup_file: backupFile,
+      bulk_purge: true,
+      regen_suppressed: true,
+      detection: m._reasons,
+    };
+    auditLines.push(JSON.stringify(entry));
+    idsToDelete.push(m.id);
+  }
+
+  // 5) DELETE in one statement per chunk
+  try {
+    psql(`DELETE FROM spoon_all_designs WHERE id IN (${idsToDelete.join(',')});`);
+    deleted += idsToDelete.length;
+  } catch (e) {
+    dbDeleteFails += idsToDelete.length;
+    errors.push(`DELETE chunk @${i}: ${e.message}`);
+    // Skip the file unlink + audit append for this chunk so DB+disk stay in sync.
+    continue;
+  }
+
+  // Append audit entries for the chunk
+  try {
+    fs.appendFileSync(REGISTRY, auditLines.join('\n') + '\n', 'utf8');
+    appendedEntries += auditLines.length;
+  } catch (e) {
+    errors.push(`audit append @${i}: ${e.message}`);
+  }
+
+  // Now unlink files (DB row already gone; safe to remove disk image)
+  for (const m of chunk) {
+    if (m.local_path && fs.existsSync(m.local_path)) {
+      try { fs.unlinkSync(m.local_path); unlinked++; } catch (e) { errors.push(`unlink #${m.id}: ${e.message}`); }
+    }
+  }
+
+  if (((i / CHUNK) | 0) % 4 === 0) {
+    console.log(`     ... ${Math.min(i + CHUNK, matches.length)}/${matches.length} processed`);
+  }
+}
+
+console.log('\n========================================');
+console.log(' PURGE COMPLETE');
+console.log('========================================');
+console.log(`  matches considered : ${matches.length}`);
+console.log(`  db rows deleted    : ${deleted}`);
+console.log(`  db delete failures : ${dbDeleteFails}`);
+console.log(`  files backed up    : ${backedUp}  → ${BACKUP_DIR}`);
+console.log(`  files unlinked     : ${unlinked}`);
+console.log(`  audit lines appended: ${appendedEntries}  → ${REGISTRY}`);
+console.log(`  errors             : ${errors.length}`);
+if (errors.length) {
+  console.log('\nFirst 10 errors:');
+  for (const e of errors.slice(0, 10)) console.log('  - ' + e);
+}
+
+console.log('\nNext step: pm2 reload wallco-ai  (invalidates DESIGNS in-memory cache)');

← 079feef report: dw-stale-urls rescrape pass (4 vendors, 806 products  ·  back to Wallco Ai  ·  aesthetic purge: log 1007 designs to do-not-want.jsonl (neon eee8638 →