← back to Wallco Ai
Stage 4: scan-composition.js + gen-poodle-demo.js
a992ad5cab47c133afab28a4f5fc67fbaaf05eed · 2026-05-24 21:16:35 -0700 · Steve Abrams
scan-composition.js — sweeps spoon_all_designs via lib/composition-detector
to find centered-hero composition defects across the catalog. Sister to
scan-ghost-layer.js with the same arg shape (--limit, --concurrency,
--category, --source, --min-instances, --stable), same resume semantics
(skips ids already in results.jsonl), same vendor-cat filter (Steve's
standing rule — wallco-AI generations only).
Output:
data/composition-scan-results.jsonl — { id, is_repeating_field,
instance_count, hero_centered, confidence, what_you_see, ... }
data/composition-scan-flagged.jsonl — fails by hero_centered=true OR
instance_count<min_instances, with flagged_reason + threshold encoded
inline so the log can be re-thresholded later without re-running Gemini.
Smoke-tested on 20 most-recent designs: 20/20 PASS rep, none flagged
(expected — top of the catalog is mostly fresh smartfix-v2 outputs).
gen-poodle-demo.js — one-off demo that produces a fresh wallpaper tile
using the new lib/repeat-prompt + lib/composition-detector pipeline
end-to-end. Inserts as a new PG row. Used to validate the full pipeline
on a never-seen motif (poodle); successfully produced design 39347 with
9 instances in diamond half-drop, first attempt.
Files touched
A scripts/gen-poodle-demo.jsA scripts/scan-composition.js
Diff
commit a992ad5cab47c133afab28a4f5fc67fbaaf05eed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 24 21:16:35 2026 -0700
Stage 4: scan-composition.js + gen-poodle-demo.js
scan-composition.js — sweeps spoon_all_designs via lib/composition-detector
to find centered-hero composition defects across the catalog. Sister to
scan-ghost-layer.js with the same arg shape (--limit, --concurrency,
--category, --source, --min-instances, --stable), same resume semantics
(skips ids already in results.jsonl), same vendor-cat filter (Steve's
standing rule — wallco-AI generations only).
Output:
data/composition-scan-results.jsonl — { id, is_repeating_field,
instance_count, hero_centered, confidence, what_you_see, ... }
data/composition-scan-flagged.jsonl — fails by hero_centered=true OR
instance_count<min_instances, with flagged_reason + threshold encoded
inline so the log can be re-thresholded later without re-running Gemini.
Smoke-tested on 20 most-recent designs: 20/20 PASS rep, none flagged
(expected — top of the catalog is mostly fresh smartfix-v2 outputs).
gen-poodle-demo.js — one-off demo that produces a fresh wallpaper tile
using the new lib/repeat-prompt + lib/composition-detector pipeline
end-to-end. Inserts as a new PG row. Used to validate the full pipeline
on a never-seen motif (poodle); successfully produced design 39347 with
9 instances in diamond half-drop, first attempt.
---
scripts/gen-poodle-demo.js | 120 ++++++++++++++++++++++++
scripts/scan-composition.js | 218 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 338 insertions(+)
diff --git a/scripts/gen-poodle-demo.js b/scripts/gen-poodle-demo.js
new file mode 100644
index 0000000..44dd11c
--- /dev/null
+++ b/scripts/gen-poodle-demo.js
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+// gen-poodle-demo — generate ONE new design using the canonical
+// lib/repeat-prompt builder + composition gate. Demonstrates the new
+// generation schema end-to-end and inserts the result into PG.
+//
+// Usage: node scripts/gen-poodle-demo.js
+//
+// Motif fixed to "standard poodle silhouette" so the result is comparable
+// to the broken 39328 / 37987 outputs (both centered-hero defects).
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { buildRepeatPrompt, inferDensity } = require('../lib/repeat-prompt');
+const { gateComposition } = require('../lib/composition-detector');
+
+const ROOT = path.join(__dirname, '..');
+const GEN_DIR = path.join(ROOT, 'data', 'generated');
+
+function readKey() {
+ if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
+ const env = fs.readFileSync(path.join(ROOT, '.env'), 'utf8');
+ const m = env.match(/^GEMINI_API_KEY=(.+)$/m);
+ if (!m) throw new Error('GEMINI_API_KEY missing');
+ return m[1].trim();
+}
+
+async function geminiImage(prompt) {
+ const KEY = readKey();
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`;
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ }),
+ });
+ if (!r.ok) throw new Error(`gemini ${r.status}: ${(await r.text()).slice(0, 200)}`);
+ const j = await r.json();
+ const part = j?.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+ const b64 = part?.inline_data?.data || part?.inlineData?.data;
+ if (!b64) throw new Error('gemini returned no image');
+ return b64;
+}
+
+function psqlInsert(sql) {
+ const r = spawnSync('psql', ['dw_unified', '-At', '-q'], { input: sql, encoding: 'utf8' });
+ if (r.status !== 0) throw new Error(r.stderr || 'psql failed');
+ return r.stdout.trim();
+}
+
+(async () => {
+ // Motif description — written exactly as the smart-fix `detect` step would
+ // emit for an existing poodle design. Lib/repeat-prompt wraps it into
+ // the multi-instance language.
+ const motifDescription = 'a standard poodle silhouette standing in three-quarter profile, classic show clip with rounded pompoms on legs and tail, single solid color silhouette';
+ const groundHex = '#f5efe5';
+ const groundDesc = 'antique cream';
+ const density = inferDensity(motifDescription);
+ console.log(`[gen-poodle] motif: ${motifDescription.slice(0, 70)}...`);
+ console.log(`[gen-poodle] inferred density: ${density}`);
+ console.log(`[gen-poodle] ground: ${groundDesc} ${groundHex}`);
+
+ let candidate = null, gate = null, attempt = 0;
+ const MAX = 3;
+ while (attempt < MAX) {
+ const prompt = buildRepeatPrompt({ motifDescription, groundHex, groundDesc, density, attempt });
+ console.log(`\n[gen-poodle] attempt ${attempt} · layout=${['diamond','brick','drop-half','grid'][attempt%4]}`);
+ const t0 = Date.now();
+ const b64 = await geminiImage(prompt);
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+ console.log(` gen: ${elapsed}s · ${b64.length} b64 chars`);
+ // Composition gate
+ gate = await gateComposition(b64, { minInstances: 3 });
+ console.log(` composition: ${gate.ok ? 'PASS' : 'FAIL'} · ${gate.reason} · n=${gate.result.instance_count} hero=${gate.result.hero_centered}`);
+ console.log(` what_you_see: ${gate.result.what_you_see}`);
+ if (gate.ok) { candidate = b64; break; }
+ attempt++;
+ }
+ if (!candidate) {
+ console.log(`\n[gen-poodle] gate failed all ${MAX} attempts — accepting last candidate anyway`);
+ // Re-fetch the last for saving
+ const fallbackPrompt = buildRepeatPrompt({ motifDescription, groundHex, groundDesc, density, attempt: MAX - 1 });
+ candidate = await geminiImage(fallbackPrompt);
+ }
+
+ const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+ const filename = `poodle_demo_${Date.now()}_${newSeed}.png`;
+ const outPath = path.join(GEN_DIR, filename);
+ fs.writeFileSync(outPath, Buffer.from(candidate, 'base64'));
+ console.log(`\n[gen-poodle] saved → ${outPath}`);
+
+ const esc = v => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+ const sql = `
+INSERT INTO spoon_all_designs
+ (kind, brand, width_in, height_in, generator, prompt, seed, image_url, local_path,
+ dominant_hex, palette, motifs, tags, category, is_published)
+VALUES
+ ('seamless_tile', 'wallco.ai', 24, 24,
+ 'gemini-2.5-flash-image-repeat-v2',
+ ${esc('repeat-v2 demo: ' + motifDescription)},
+ ${newSeed},
+ '/designs/img/by-id/__NEW__',
+ ${esc(outPath)},
+ ${esc(groundHex)},
+ ${esc(JSON.stringify([groundHex]))}::jsonb,
+ NULL, NULL,
+ ${esc('designer-zoo-calm')},
+ TRUE)
+RETURNING id;`;
+ const newId = parseInt(psqlInsert(sql), 10);
+ if (!newId) throw new Error('insert returned no id');
+ spawnSync('psql', ['dw_unified', '-q', '-c',
+ `UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}' WHERE id=${newId}`],
+ { encoding: 'utf8' });
+ console.log(`[gen-poodle] PG id: ${newId}`);
+ console.log(`[gen-poodle] view: http://127.0.0.1:9877/design/${newId}`);
+ console.log(`[gen-poodle] composition: ${JSON.stringify(gate?.result || {})}`);
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
diff --git a/scripts/scan-composition.js b/scripts/scan-composition.js
new file mode 100644
index 0000000..6cd12cd
--- /dev/null
+++ b/scripts/scan-composition.js
@@ -0,0 +1,218 @@
+#!/usr/bin/env node
+// scan-composition — sweep the catalog for centered-hero composition defects.
+//
+// Sister script to scan-ghost-layer. Iterates spoon_all_designs (published,
+// has local_path), classifies each image via lib/composition-detector,
+// appends one JSONL row per design to data/composition-scan-results.jsonl.
+// Resumable — skips IDs already in the results file so SIGINT + restart is safe.
+//
+// A "composition defect" = the design is a single centered hero motif with
+// wrap-half artefacts at the corners, NOT a multi-instance repeating field.
+// Both can be mathematically tileable; only the latter is a real wallpaper.
+//
+// Usage:
+// node scripts/scan-composition.js # all published designs
+// node scripts/scan-composition.js --limit 200 # smoke test
+// node scripts/scan-composition.js --concurrency 8 # parallel workers (default 4)
+// node scripts/scan-composition.js --category cactus # restrict by category
+// node scripts/scan-composition.js --min-instances 4 # raise the bar (default 3)
+// node scripts/scan-composition.js --stable # 3-vote majority per item (3x cost)
+//
+// Output:
+// data/composition-scan-results.jsonl — one line per design:
+// { id, ts, is_repeating_field, instance_count, hero_centered, confidence,
+// what_you_see, category, source, image_url }
+// data/composition-scan-flagged.jsonl — append-only flagged subset
+// (centered hero OR instance_count < min_instances).
+//
+// Cost estimate: ~$0.0005/check × N items. For 22k items at concurrency 4,
+// roughly $11 + 1-2 hours wall time. Add --stable for 3x cost.
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { analyzeComposition, analyzeCompositionStable } = require('../lib/composition-detector');
+
+// ── args ────────────────────────────────────────────────────────────────
+const ARGS = process.argv.slice(2);
+function arg(name, def) {
+ const i = ARGS.indexOf('--' + name);
+ return i >= 0 ? ARGS[i + 1] : def;
+}
+const LIMIT = parseInt(arg('limit', '0'), 10);
+const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '4'), 10));
+const CATEGORY = arg('category', null);
+const MIN_INSTANCES = Math.max(2, parseInt(arg('min-instances', '3'), 10));
+const STABLE = ARGS.includes('--stable');
+const SOURCE = (arg('source', 'both') || 'both').toLowerCase();
+
+const ROOT = path.join(__dirname, '..');
+const RESULTS = path.join(ROOT, 'data', 'composition-scan-results.jsonl');
+const FLAGGED = path.join(ROOT, 'data', 'composition-scan-flagged.jsonl');
+
+// Vendor-imported categories — Steve's standing rule: scan only wallco-AI
+// generations. Real designer products from Shopify/etc. keep their (intentional)
+// composition choices. Mirrors scan-ghost-layer.js after the 2026-05-24
+// false-positive incident on real vendor SKUs.
+const VENDOR_CATS = new Set([
+ 'thibaut','dolce-gabbana','koroseal','graduate-collection','traditional-whimsy',
+ 'phillipe-romano','coordonn-','ralph-lauren','mind-the-gap','designer-wallcoverings',
+ 'malibu-wallpaper','maya-romanoff','roberto-cavalli-wallpaper','china-seas',
+ 'daisy-bennett','glitter-walls','dw-shopify',
+]);
+
+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: 50_000_000 });
+ if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+ return r.stdout;
+}
+
+function loadScanned() {
+ if (!fs.existsSync(RESULTS)) return new Set();
+ const ids = new Set();
+ for (const line of fs.readFileSync(RESULTS, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try { ids.add(JSON.parse(line).id); } catch {}
+ }
+ return ids;
+}
+
+function fetchPgTargets() {
+ const where = [
+ 'is_published = TRUE',
+ 'user_removed IS NOT TRUE',
+ 'local_path IS NOT NULL',
+ ];
+ if (CATEGORY) where.push(`category = '${CATEGORY.replace(/'/g, "''")}'`);
+ const sql = `SELECT id, category, local_path
+ FROM spoon_all_designs
+ WHERE ${where.join(' AND ')}
+ ORDER BY id DESC`;
+ const raw = psql(sql);
+ const rows = [];
+ for (const line of raw.split('\n')) {
+ if (!line.trim()) continue;
+ const [id, category, local_path] = line.split('|');
+ if (VENDOR_CATS.has((category || '').toLowerCase())) continue;
+ rows.push({ id: parseInt(id, 10), category, local_path, source: 'pg' });
+ }
+ return rows;
+}
+
+const GENERATED_DIR = path.join(ROOT, 'data', 'generated');
+
+function fetchJsonTargets(excludeIds) {
+ const f = path.join(ROOT, 'data', 'designs.json');
+ if (!fs.existsSync(f)) return [];
+ const arr = JSON.parse(fs.readFileSync(f, 'utf8'));
+ const rows = [];
+ for (const d of arr) {
+ if (excludeIds.has(d.id)) continue;
+ if (!d.id) continue;
+ if (CATEGORY && d.category !== CATEGORY) continue;
+ if (VENDOR_CATS.has((d.category || '').toLowerCase())) continue;
+ let diskPath = null;
+ if (d.image_url && d.image_url.startsWith('/designs/img/')) {
+ const fn = d.image_url.replace(/^\/designs\/img\//, '');
+ const candidate = path.join(GENERATED_DIR, fn);
+ if (fs.existsSync(candidate)) diskPath = candidate;
+ }
+ if (!diskPath) continue;
+ rows.push({ id: d.id, category: d.category, local_path: diskPath, image_url: d.image_url, source: 'json' });
+ }
+ return rows;
+}
+
+function fetchTargets() {
+ let rows = [];
+ if (SOURCE === 'pg' || SOURCE === 'both') rows = rows.concat(fetchPgTargets());
+ if (SOURCE === 'json' || SOURCE === 'both') {
+ const have = new Set(rows.map(r => r.id));
+ rows = rows.concat(fetchJsonTargets(have));
+ }
+ if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+ return rows;
+}
+
+async function processOne(row) {
+ if (!row.local_path || !fs.existsSync(row.local_path)) {
+ return { id: row.id, ts: new Date().toISOString(), error: 'file_missing', category: row.category, source: row.source };
+ }
+ try {
+ const r = STABLE
+ ? await analyzeCompositionStable(row.local_path)
+ : await analyzeComposition(row.local_path);
+ return {
+ id: row.id, ts: new Date().toISOString(),
+ is_repeating_field: r.is_repeating_field,
+ instance_count: r.instance_count,
+ hero_centered: r.hero_centered,
+ confidence: r.confidence,
+ what_you_see: r.what_you_see,
+ ...(STABLE ? { unanimous: r.unanimous, votes: r.votes } : {}),
+ category: row.category,
+ source: row.source,
+ image_url: `/designs/img/by-id/${row.id}`,
+ };
+ } catch (e) {
+ return { id: row.id, ts: new Date().toISOString(), error: e.message.slice(0, 200), category: row.category, source: row.source };
+ }
+}
+
+async function runPool(rows, workers, onResult) {
+ let cursor = 0;
+ const next = () => (cursor < rows.length ? rows[cursor++] : null);
+ async function workerLoop() {
+ while (true) {
+ const r = next();
+ if (!r) return;
+ const out = await processOne(r);
+ onResult(out);
+ }
+ }
+ await Promise.all(Array.from({ length: workers }, workerLoop));
+}
+
+(async () => {
+ console.log(`[scan-composition] starting · limit=${LIMIT || 'all'} concurrency=${CONCURRENCY} category=${CATEGORY || 'all'} minInstances=${MIN_INSTANCES} stable=${STABLE}`);
+ const scanned = loadScanned();
+ console.log(`[scan-composition] already-scanned: ${scanned.size}`);
+ const all = fetchTargets();
+ const todo = all.filter(r => !scanned.has(r.id));
+ console.log(`[scan-composition] eligible: ${all.length}, todo: ${todo.length}`);
+
+ let n = 0, flagged = 0, errored = 0;
+ const t0 = Date.now();
+ const onResult = (out) => {
+ n++;
+ fs.appendFileSync(RESULTS, JSON.stringify(out) + '\n');
+ // Flag if NOT a repeating field per OUR threshold (count < MIN_INSTANCES
+ // OR hero_centered). Don't trust the detector's own is_repeating_field
+ // flag — compute it from raw observations + our local threshold so the
+ // scan log can be re-thresholded later without re-running Gemini.
+ const failsByCount = Number.isFinite(out.instance_count) && out.instance_count < MIN_INSTANCES;
+ const failsByHero = out.hero_centered === true;
+ if (!out.error && (failsByCount || failsByHero)) {
+ flagged++;
+ fs.appendFileSync(FLAGGED, JSON.stringify({
+ ...out,
+ flagged_reason: failsByHero ? 'centered-hero' : `too-few-instances-(${out.instance_count}<${MIN_INSTANCES})`,
+ min_instances_threshold: MIN_INSTANCES,
+ }) + '\n');
+ }
+ if (out.error) errored++;
+ if (n % 25 === 0 || n === todo.length) {
+ const rate = n / ((Date.now() - t0) / 1000);
+ const eta = ((todo.length - n) / rate).toFixed(0);
+ console.log(`[scan-composition] ${n}/${todo.length} · ${flagged} flagged · ${errored} errored · ${rate.toFixed(1)}/s · ETA ${eta}s`);
+ }
+ };
+ await runPool(todo, CONCURRENCY, onResult);
+ console.log(`[scan-composition] done · scanned=${n} flagged=${flagged} errored=${errored}`);
+ console.log(`[scan-composition] flagged log: ${FLAGGED}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
← f5a6676 /design/:id → 302 to newest published child via parent_desig
·
back to Wallco Ai
·
remove broken /admin/fixes-feed viewer 5138c6e →