[object Object]

← back to Wallco Ai

dogs collection generator — 11 breeds (DTD verdict) × 20 designer colorways, opaque repeat-v2 + composition gate, is_published=FALSE

03497c0564b9e9f6b4815a21a0659161448a92e7 · 2026-05-26 14:57:28 -0700 · Steve Abrams

Files touched

Diff

commit 03497c0564b9e9f6b4815a21a0659161448a92e7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 26 14:57:28 2026 -0700

    dogs collection generator — 11 breeds (DTD verdict) × 20 designer colorways, opaque repeat-v2 + composition gate, is_published=FALSE
---
 scripts/generate-dogs.js | 243 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 243 insertions(+)

diff --git a/scripts/generate-dogs.js b/scripts/generate-dogs.js
new file mode 100644
index 0000000..e07ecb4
--- /dev/null
+++ b/scripts/generate-dogs.js
@@ -0,0 +1,243 @@
+#!/usr/bin/env node
+// generate-dogs.js — the wallco.ai "dogs" collection.
+//
+// 11 groups × 20 designer colorways = 220 single-ink solid-silhouette dog
+// designs. Top-10 AKC breeds + one Pit-Lab mix ("Labrabull"), per DTD verdict
+// 2026-05-26 (unanimous 3/3: Q1=A Labrabull mix, Q2=A `dogs · <breed>`
+// sub-grouping). Steve approved Gemini repeat-v2 engine (~$10-20) + the
+// curate-then-publish posture (everything lands is_published=FALSE; a hot-or-not
+// picker gates the actual go-live).
+//
+// "Opaque specs" (Steve directive) is enforced structurally by the canonical
+// lib/repeat-prompt builder: every motif pixel solid alpha 1.0, flat uniform
+// ground, hard die-cut edges, NO gradient / NO ghost layer / NO atmospheric
+// depth. Each design is ONE breed silhouette in ONE designer ink on ONE ground.
+//
+// Pipeline per design (mirrors scripts/gen-poodle-demo.js):
+//   buildRepeatPrompt → gemini-2.5-flash-image → gateComposition (>=3 instances,
+//   not centered-hero, up to 3 layout attempts) → Pillow palette extract →
+//   INSERT spoon_all_designs (category='dogs · <slug>', is_published=FALSE).
+//
+// Idempotent / resumable: a `[cw:<colorway-slug>]` marker is embedded in the
+// stored prompt; on restart we skip any (breed, colorway) row that already
+// exists, so a killed long-running batch resumes cleanly.
+//
+//   node scripts/generate-dogs.js                 # full 220
+//   node scripts/generate-dogs.js --breeds 3 --colorways 1   # 3-design smoke test
+//   node scripts/generate-dogs.js --only poodle,beagle       # named breeds only
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { spawnSync } = require('child_process');
+const { buildRepeatPrompt } = require('../lib/repeat-prompt');
+const { gateComposition } = require('../lib/composition-detector');
+
+const ROOT = path.join(__dirname, '..');
+const GEN_DIR = path.join(ROOT, 'data', 'generated');
+fs.mkdirSync(GEN_DIR, { recursive: true });
+
+// ── DTD-committed 11-group breed list (slug → silhouette motif) ─────────────
+const BREEDS = [
+  { slug: 'labrador-retriever',        name: 'Labrador Retriever',        motif: 'a Labrador Retriever in three-quarter standing profile — broad clean-lined head, otter tail, deep chest, athletic balanced build. A single solid-color silhouette with no interior detail.' },
+  { slug: 'french-bulldog',            name: 'French Bulldog',            motif: 'a French Bulldog sitting in profile — distinctive erect bat ears, compact heavy-boned body, short muzzle, wide stance. A single solid-color silhouette with no interior detail.' },
+  { slug: 'golden-retriever',          name: 'Golden Retriever',          motif: 'a Golden Retriever standing in three-quarter profile — feathered tail and coat fringe, gentle level head carriage, sturdy build. A single solid-color silhouette with no interior detail.' },
+  { slug: 'german-shepherd',           name: 'German Shepherd',           motif: 'a German Shepherd standing in profile — large erect pointed ears, gently sloping topline, bushy low-set tail, alert poised stance. A single solid-color silhouette with no interior detail.' },
+  { slug: 'poodle',                    name: 'Poodle',                    motif: 'a standard Poodle standing in three-quarter profile — classic continental show clip with rounded pompoms on the legs and tail tip, refined arched neck. A single solid-color silhouette with no interior detail.' },
+  { slug: 'bulldog',                   name: 'Bulldog',                   motif: 'an English Bulldog standing square in profile — wide low-slung heavily muscled body, broad wrinkled brow, small rose ears, short tail. A single solid-color silhouette with no interior detail.' },
+  { slug: 'rottweiler',                name: 'Rottweiler',                motif: 'a Rottweiler standing in profile — powerful blocky build, broad deep chest, level back, docked tail, square confident head. A single solid-color silhouette with no interior detail.' },
+  { slug: 'beagle',                    name: 'Beagle',                    motif: 'a Beagle standing in three-quarter profile — long low-set drop ears, compact sturdy hound body, gently upright tail. A single solid-color silhouette with no interior detail.' },
+  { slug: 'dachshund',                 name: 'Dachshund',                 motif: 'a Dachshund standing in profile — long low body, very short legs, long drop ears, tapered tail held level. A single solid-color silhouette with no interior detail.' },
+  { slug: 'german-shorthaired-pointer',name: 'German Shorthaired Pointer',motif: 'a German Shorthaired Pointer in the classic pointing stance in profile — one foreleg raised and tucked, muzzle and tail extended in a straight line, lean athletic build. A single solid-color silhouette with no interior detail.' },
+  { slug: 'pit-lab-mix',               name: 'Pit-Lab Mix',               motif: 'a Pit Bull Labrador mix (a "Labrabull" shelter dog) standing in three-quarter profile — broad blocky head, strong muscular athletic body, short smooth coat, medium half-folded drop ears, long tapering tail. A single solid-color silhouette with no interior detail.' },
+];
+
+// ── 20 designer tone-on-tone colorways (ground × ink) ───────────────────────
+// Reuses the validated `designer-zoo-calm` luxe palette so the dogs collection
+// reads as part of the same heritage-house aesthetic. Each is a flat 2-color
+// story: solid ground + single solid ink, contrast tuned so the silhouette
+// reads cleanly while staying tone-on-tone sophisticated.
+const COLORWAYS = [
+  { slug: 'bone-ivory',       name: 'Bone Ivory',       ground_hex: '#F5F0E8', ground_desc: 'bone ivory',        ink_hex: '#2A2622', ink_desc: 'charcoal noir' },
+  { slug: 'midnight-navy',    name: 'Midnight Navy',    ground_hex: '#1B2A3A', ground_desc: 'midnight navy',     ink_hex: '#AEBDCB', ink_desc: 'dove blue-grey' },
+  { slug: 'indigo-library',   name: 'Indigo Library',   ground_hex: '#20283D', ground_desc: 'deep library indigo', ink_hex: '#C9B07A', ink_desc: 'antique brass' },
+  { slug: 'deep-plum',        name: 'Deep Plum',        ground_hex: '#2E1D2B', ground_desc: 'deep plum',         ink_hex: '#D8C3A8', ink_desc: 'fawn' },
+  { slug: 'bordeaux-velvet',  name: 'Bordeaux Velvet',  ground_hex: '#3A1820', ground_desc: 'bordeaux velvet',   ink_hex: '#E3D2B8', ink_desc: 'champagne' },
+  { slug: 'saddle-mocha',     name: 'Saddle Mocha',     ground_hex: '#4A342A', ground_desc: 'saddle mocha',      ink_hex: '#E8DCC8', ink_desc: 'cream' },
+  { slug: 'greenhouse-glass', name: 'Greenhouse Glass', ground_hex: '#243B33', ground_desc: 'deep greenhouse teal', ink_hex: '#D5DDC9', ink_desc: 'pale sage' },
+  { slug: 'forest-loden',     name: 'Forest Loden',     ground_hex: '#2A3526', ground_desc: 'forest loden',      ink_hex: '#CBB994', ink_desc: 'fawn' },
+  { slug: 'oxblood',          name: 'Oxblood',          ground_hex: '#3E1A18', ground_desc: 'oxblood',           ink_hex: '#E0CFC0', ink_desc: 'oyster' },
+  { slug: 'champagne-linen',  name: 'Champagne Linen',  ground_hex: '#EFE6D4', ground_desc: 'champagne linen',   ink_hex: '#5A4636', ink_desc: 'umber' },
+  { slug: 'smoke-ash',        name: 'Smoke Ash',        ground_hex: '#4A4A4A', ground_desc: 'smoke ash grey',    ink_hex: '#E5E2DC', ink_desc: 'off-white' },
+  { slug: 'manor-saddle',     name: 'Manor Saddle',     ground_hex: '#5C4327', ground_desc: 'manor saddle brown',ink_hex: '#EDE2CE', ink_desc: 'cream' },
+  { slug: 'antique-tan',      name: 'Antique Tan',      ground_hex: '#C9B08A', ground_desc: 'antique tan',       ink_hex: '#3A2C1E', ink_desc: 'cocoa' },
+  { slug: 'stone-pewter',     name: 'Stone Pewter',     ground_hex: '#B8B5AC', ground_desc: 'stone pewter',      ink_hex: '#33312C', ink_desc: 'charcoal' },
+  { slug: 'antique-brass',    name: 'Antique Brass',    ground_hex: '#2C2A24', ground_desc: 'charcoal',          ink_hex: '#B08A4A', ink_desc: 'antique brass' },
+  { slug: 'slate-mist',       name: 'Slate Mist',       ground_hex: '#C2CACE', ground_desc: 'slate mist',        ink_hex: '#2E363A', ink_desc: 'charcoal blue' },
+  { slug: 'noir-ivory',       name: 'Noir Ivory',       ground_hex: '#121212', ground_desc: 'noir',             ink_hex: '#EDE6D6', ink_desc: 'ivory' },
+  { slug: 'cocoa-cream',      name: 'Cocoa Cream',      ground_hex: '#6B382C', ground_desc: 'cocoa',             ink_hex: '#F5F0E5', ink_desc: 'cream' },
+  { slug: 'malachite',        name: 'Malachite',        ground_hex: '#0E3B33', ground_desc: 'malachite',         ink_hex: '#C8B98C', ink_desc: 'antique gold' },
+  { slug: 'dove-grey',        name: 'Dove Grey',        ground_hex: '#D3D3CB', ground_desc: 'dove grey',         ink_hex: '#2A2A2A', ink_desc: 'noir' },
+];
+
+// ── args ────────────────────────────────────────────────────────────────────
+function parseArgs() {
+  const a = process.argv.slice(2);
+  const o = { breeds: BREEDS.length, colorways: COLORWAYS.length, only: null };
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] === '--breeds') o.breeds = parseInt(a[++i], 10);
+    else if (a[i] === '--colorways') o.colorways = parseInt(a[++i], 10);
+    else if (a[i] === '--only') o.only = a[++i].split(',').map(s => s.trim()).filter(Boolean);
+  }
+  return o;
+}
+
+// ── gemini image ─────────────────────────────────────────────────────────────
+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();
+}
+const GEMINI_KEY = readKey();
+
+async function geminiImage(prompt) {
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_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;
+}
+
+// ── psql helpers ─────────────────────────────────────────────────────────────
+function psql(sql) {
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-v', 'ON_ERROR_STOP=1'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+  return r.stdout.trim();
+}
+function alreadyExists(category, cwSlug) {
+  // dedupe on category + the [cw:<slug>] marker embedded in the stored prompt
+  const sql = `SELECT 1 FROM spoon_all_designs WHERE category='${category.replace(/'/g, "''")}' AND prompt LIKE '%[cw:${cwSlug}]%' LIMIT 1;`;
+  return psql(sql) === '1';
+}
+function extractPalette(localPath) {
+  const py = `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB'); img.thumbnail((300,300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+px = list(pal.getdata()); cnt = Counter(px); total = sum(cnt.values())
+print(json.dumps([{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb),'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]))
+`;
+  const r = spawnSync('python3', ['-c', py, localPath], { encoding: 'utf8' });
+  try { const p = JSON.parse(r.stdout.trim()); return { palette: p, dominant: p[0]?.hex || null }; }
+  catch { return { palette: [], dominant: null }; }
+}
+
+// ── one design ───────────────────────────────────────────────────────────────
+async function genOne(breed, cw) {
+  const category = `dogs · ${breed.slug}`;
+  if (alreadyExists(category, cw.slug)) {
+    console.log(`  · skip ${breed.slug}/${cw.slug} (already in PG)`);
+    return { skipped: true };
+  }
+  const motif = `${breed.motif} The silhouette is a single flat solid ${cw.ink_desc} (${cw.ink_hex}) shape — no fur texture, no facial detail, no outline, no shading inside it.`;
+  const MAX = 3;
+  let candidate = null, gate = null, usedAttempt = 0;
+  for (let attempt = 0; attempt < MAX; attempt++) {
+    const prompt = buildRepeatPrompt({
+      motifDescription: motif,
+      groundHex: cw.ground_hex,
+      groundDesc: cw.ground_desc,
+      density: 'medium',
+      attempt,
+    });
+    const b64 = await geminiImage(prompt);
+    gate = await gateComposition(b64, { minInstances: 3 }).catch(e => ({ ok: false, reason: 'gate-error: ' + e.message, result: {} }));
+    if (gate.ok) { candidate = b64; usedAttempt = attempt; break; }
+    process.stdout.write(`    retry (${gate.reason}) `);
+  }
+  if (!candidate) {
+    // Round-1-sacred: accept the last shot rather than infinite-retry. The
+    // curator pass + edges/ghost gates will catch a genuinely bad one.
+    const fb = buildRepeatPrompt({ motifDescription: motif, groundHex: cw.ground_hex, groundDesc: cw.ground_desc, density: 'medium', attempt: MAX - 1 });
+    candidate = await geminiImage(fb);
+    usedAttempt = MAX - 1;
+  }
+  const seed = crypto.randomInt(1, 2 ** 31 - 1);
+  const filename = `dog_${breed.slug}_${cw.slug}_${Date.now()}_${seed}.png`;
+  const outPath = path.join(GEN_DIR, filename);
+  fs.writeFileSync(outPath, Buffer.from(candidate, 'base64'));
+  const { palette, dominant } = extractPalette(outPath);
+
+  // Stored prompt carries the [cw:<slug>] dedupe marker + a human label.
+  const storedPrompt = `Dogs collection — ${breed.name} silhouette, ${cw.name} colorway [cw:${cw.slug}]. Ground ${cw.ground_desc} ${cw.ground_hex}, single-ink ${cw.ink_desc} ${cw.ink_hex}. ${breed.motif}`;
+  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, category, product_line, is_published)
+VALUES
+  ('seamless_tile', 'wallco.ai', 24, 24,
+   'gemini-2.5-flash-image-repeat-v2',
+   ${esc(storedPrompt)}, ${seed},
+   '/designs/img/by-id/__NEW__', ${esc(outPath)},
+   ${esc(dominant)}, ${esc(JSON.stringify(palette))}::jsonb,
+   ${esc(category)}, ${esc('Studio')}, FALSE)
+RETURNING id;`;
+  const newId = parseInt(psql(sql), 10);
+  if (!Number.isFinite(newId)) throw new Error('insert returned no id');
+  psql(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}' WHERE id=${newId};`);
+  return { id: newId, attempt: usedAttempt, gate: gate?.result };
+}
+
+// ── main ─────────────────────────────────────────────────────────────────────
+(async () => {
+  const opt = parseArgs();
+  let breeds = BREEDS.slice(0, opt.breeds);
+  if (opt.only) breeds = BREEDS.filter(b => opt.only.includes(b.slug));
+  const colorways = COLORWAYS.slice(0, opt.colorways);
+  const total = breeds.length * colorways.length;
+  console.log(`generate-dogs: ${breeds.length} breeds × ${colorways.length} colorways = ${total} designs`);
+  console.log(`engine: gemini-2.5-flash-image-repeat-v2 · is_published=FALSE (curate-then-publish)\n`);
+
+  const t0 = Date.now();
+  let ok = 0, skip = 0, fail = 0, retries = 0;
+  const made = [];
+  for (const breed of breeds) {
+    console.log(`\n# ${breed.name}  (dogs · ${breed.slug})`);
+    for (const cw of colorways) {
+      try {
+        const r = await genOne(breed, cw);
+        if (r.skipped) { skip++; continue; }
+        if (r.attempt > 0) retries += r.attempt;
+        ok++;
+        made.push(r.id);
+        console.log(`  ✓ #${r.id} ${cw.slug} (attempt ${r.attempt}, n=${r.gate?.instance_count ?? '?'})`);
+      } catch (e) {
+        fail++;
+        console.log(`  ✗ ${cw.slug}: ${e.message}`);
+      }
+    }
+  }
+  const dur = ((Date.now() - t0) / 1000 / 60).toFixed(1);
+  const billable = ok + retries; // every gemini call (incl. gate retries) is billable
+  console.log(`\n=== done in ${dur} min ===`);
+  console.log(`generated ${ok}, skipped ${skip}, failed ${fail}, gate-retries ${retries}`);
+  console.log(`billable gemini image calls ≈ ${billable} (≈ $${(billable * 0.039).toFixed(2)} @ $0.039/img)`);
+  if (made.length) console.log(`new ids: ${made.join(', ')}`);
+  console.log(`\nnext: curator pass + settlement/edges/ghost gates before any is_published=TRUE`);
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });

← bc99681 Add style-aware-sample.js — PG-palette-derived chinoiserie S  ·  back to Wallco Ai  ·  TODO: record dogs collection (DTD verdict, generate-dogs.js dddb2ad →