[object Object]

← back to Wallco Ai

Stage 1: canonical lib/repeat-prompt.js + wire into smart-fix

9e5767b48df843565ab84adde118d8ec4cc82282 · 2026-05-24 08:14:04 -0700 · Steve Abrams

Adds lib/repeat-prompt.js — single source of truth for "render a multi-motif
repeating wallpaper tile" prompts. Replaces the per-endpoint buildGenPrompt
helpers that each independently said "The motif: X" (singular), which
Gemini read as "render ONE motif centered on the canvas" — producing
single-hero compositions with wrap-half artefacts at the corners
(mathematically tileable but compositionally NOT a wallpaper repeat).

The bug was visible on designs 39328, 39341 (smartfix of 37987), 39336,
and many recent recolor outputs.

lib API:
  buildRepeatPrompt({ motifDescription, groundHex, groundDesc,
                      density, layout, attempt }) -> string
  inferDensity(motifDescription) -> 'sparse' | 'medium' | 'dense'

Each prompt variant explicitly says:
  - "Multiple identical instances of the SAME motif"
  - "[4-6 | 7-12 | 12-20] instances per tile"
  - Names a layout: diamond half-drop / brick offset / drop-half / grid
  - "DO NOT render a single large centered hero motif"
  - "motifs continue continuously across left↔right and top↔bottom edges"
3 phrasing variants cycle per attempt for retry diversity.

Wired into the smart-fix endpoint (server.js:7432). Old "legacy" prompt
kept inline behind a ?legacy=1 query toggle for Stage 3 A/B smoke test.
Default path is V2 (multi-instance).

Other gen callsites (regen-reverse at :7202, generate_designs.js, bg-swap,
crop-and-fix) still use their own prompts — they'll migrate as Stage 3
A/B confirms the new lib wins. bg-swap and crop-and-fix may not need
migration since they preserve source composition.

Files touched

Diff

commit 9e5767b48df843565ab84adde118d8ec4cc82282
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 08:14:04 2026 -0700

    Stage 1: canonical lib/repeat-prompt.js + wire into smart-fix
    
    Adds lib/repeat-prompt.js — single source of truth for "render a multi-motif
    repeating wallpaper tile" prompts. Replaces the per-endpoint buildGenPrompt
    helpers that each independently said "The motif: X" (singular), which
    Gemini read as "render ONE motif centered on the canvas" — producing
    single-hero compositions with wrap-half artefacts at the corners
    (mathematically tileable but compositionally NOT a wallpaper repeat).
    
    The bug was visible on designs 39328, 39341 (smartfix of 37987), 39336,
    and many recent recolor outputs.
    
    lib API:
      buildRepeatPrompt({ motifDescription, groundHex, groundDesc,
                          density, layout, attempt }) -> string
      inferDensity(motifDescription) -> 'sparse' | 'medium' | 'dense'
    
    Each prompt variant explicitly says:
      - "Multiple identical instances of the SAME motif"
      - "[4-6 | 7-12 | 12-20] instances per tile"
      - Names a layout: diamond half-drop / brick offset / drop-half / grid
      - "DO NOT render a single large centered hero motif"
      - "motifs continue continuously across left↔right and top↔bottom edges"
    3 phrasing variants cycle per attempt for retry diversity.
    
    Wired into the smart-fix endpoint (server.js:7432). Old "legacy" prompt
    kept inline behind a ?legacy=1 query toggle for Stage 3 A/B smoke test.
    Default path is V2 (multi-instance).
    
    Other gen callsites (regen-reverse at :7202, generate_designs.js, bg-swap,
    crop-and-fix) still use their own prompts — they'll migrate as Stage 3
    A/B confirms the new lib wins. bg-swap and crop-and-fix may not need
    migration since they preserve source composition.
---
 lib/repeat-prompt.js |  84 +++++++++++++++
 server.js            | 295 +++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 373 insertions(+), 6 deletions(-)

diff --git a/lib/repeat-prompt.js b/lib/repeat-prompt.js
new file mode 100644
index 0000000..3301bae
--- /dev/null
+++ b/lib/repeat-prompt.js
@@ -0,0 +1,84 @@
+// repeat-prompt — canonical wallpaper-tile prompt builder.
+//
+// Single source of truth for "render a multi-motif repeating wallpaper tile"
+// prompts sent to gemini-2.5-flash-image. Replaces the per-endpoint
+// buildGenPrompt helpers that were each independently asking for "the motif"
+// (singular) on a ground — which Gemini interpreted as "render ONE motif
+// centered on the canvas". That produces a single-hero composition with
+// wrap-half artefacts at the corners; mathematically tileable but
+// compositionally NOT a wallpaper repeat. See task #5/#6, 2026-05-24.
+//
+// Public API:
+//   buildRepeatPrompt({
+//     motifDescription,   // 1-3 sentence description of ONE instance of the motif
+//     groundHex,          // '#RRGGBB' or null
+//     groundDesc,         // 'warm cream', 'sage green', etc. or null
+//     density = 'sparse', // 'sparse' (4-6/tile) | 'medium' (7-12/tile) | 'dense' (12-20/tile)
+//     layout  = null,     // 'diamond' | 'brick' | 'drop-half' | 'grid' | null (cycle via attempt)
+//     attempt = 0,        // 0..N — picks among phrasing variants for retry
+//   }) -> string
+
+const DENSITY_SPEC = {
+  sparse: { count: '4 to 6 instances', spacing: 'with generous negative space between them' },
+  medium: { count: '7 to 12 instances', spacing: 'evenly spaced with moderate negative space' },
+  dense:  { count: '12 to 20 instances', spacing: 'tightly packed in a regular grid' },
+};
+
+const LAYOUT_SPEC = {
+  diamond:    'diamond half-drop repeat (each row shifted half a column relative to the row above)',
+  brick:      'brick offset repeat (each row shifted horizontally by half a motif width)',
+  'drop-half': 'drop-half repeat (alternating columns offset vertically by half the row pitch)',
+  grid:       'rectilinear grid (motifs in straight rows and columns)',
+};
+const LAYOUT_CYCLE = ['diamond', 'brick', 'drop-half', 'grid'];
+
+function pickLayout(layout, attempt) {
+  if (layout && LAYOUT_SPEC[layout]) return layout;
+  return LAYOUT_CYCLE[attempt % LAYOUT_CYCLE.length];
+}
+
+function buildRepeatPrompt({ motifDescription, groundHex, groundDesc, density = 'sparse', layout = null, attempt = 0 } = {}) {
+  if (!motifDescription || typeof motifDescription !== 'string') {
+    throw new Error('repeat-prompt: motifDescription required');
+  }
+  const dens = DENSITY_SPEC[density] || DENSITY_SPEC.sparse;
+  const lay  = pickLayout(layout, attempt);
+  const ground = groundDesc && groundHex ? `${groundDesc} (${groundHex})`
+                : groundHex             ? groundHex
+                : groundDesc            ? groundDesc
+                : 'warm cream (#fbf8f1)';
+
+  // Three phrasing variants — same constraints, different verbal framing.
+  // Each one MUST explicitly say "multiple instances" + "repeating field" +
+  // "no single centered hero" so Gemini cannot fall back to default
+  // hero-portrait framing on ambiguous motif descriptions.
+  const variants = [
+    // Variant A — screenprint / vinyl die-cut framing
+    `A wallpaper repeat tile. Multiple identical instances of the SAME motif arranged as a ${LAYOUT_SPEC[lay]}: ${dens.count} per tile, ${dens.spacing}. Each instance: ${motifDescription} Every instance at the same scale, same orientation, same colors — repeated motifs, not one hero. Set against a flat uniform ${ground} ground with zero texture, zero gradient, zero atmospheric depth. Every motif pixel solid color alpha 1.0; every ground pixel the exact same ${ground} tone. Hard razor-sharp silhouette edges, vinyl die-cut sticker aesthetic. The tile is SEAMLESS — motifs continue continuously across the left↔right and top↔bottom edges (a motif partly clipped on the right edge has its other half on the left edge of the same tile). DO NOT render a single large centered hero motif. DO NOT enlarge one instance. Multiple equal small instances only.`,
+    // Variant B — stencil framing, count-first
+    `Wallpaper field of ${dens.count} of the same motif on a ${ground} ground, arranged in a ${LAYOUT_SPEC[lay]}. Every instance is the same shape, same scale, same color: ${motifDescription} ${dens.spacing.charAt(0).toUpperCase() + dens.spacing.slice(1)}. Single flat ink layer per motif on the solid ground — no shading, no gradients, no atmospheric perspective, no faded duplicates. Edges crisp like a hand-cut stencil. The tile must wrap seamlessly: take the output, place 4 copies in a 2×2 grid, and the seams must be invisible because every motif clipped at an edge continues on the opposite edge. NO single centered hero. NO enlarged feature motif. NO portrait-style composition — this is a wallpaper repeat, not a print.`,
+    // Variant C — cut-paper collage framing, layout-first
+    `Cut-paper collage wallpaper tile, ${LAYOUT_SPEC[lay]}. The motif (repeated ${dens.count} across the tile, ${dens.spacing}): ${motifDescription} Each cut from solid paper, one opaque color per motif, hard scissor-cut edges, mounted flat on a ${ground} backdrop with no texture. Identical copies of the motif — same scale, same orientation, same color. NOT one large centered figure with empty corners. NOT a portrait. NOT a medallion. A genuine all-over wallpaper repeat where the tile, placed adjacent to copies of itself, produces a continuous pattern with motifs flowing across every edge.`,
+  ];
+
+  return variants[attempt % variants.length];
+}
+
+// Helper — heuristically pick density from the source design's existing motif
+// count (or motif description complexity). Use when the caller doesn't know
+// what density the source wallpaper has.
+//   - tiny / busy motif (text, geometric, small floral) → 'dense'
+//   - mid-scale single-figure (animal silhouette, framed cameo, large flower)
+//     → 'sparse'
+//   - everything else → 'medium'
+function inferDensity(motifDescription) {
+  if (!motifDescription) return 'sparse';
+  const s = motifDescription.toLowerCase();
+  // Hero-scale signals — usually want sparse so each instance has room
+  if (/\b(cameo|portrait|medallion|frame|wreath|crest|emblem|crown|throne)\b/.test(s)) return 'sparse';
+  // Dense-pattern signals
+  if (/\b(geometric|tessellat|chevron|herringbone|small|tiny|dot|grain|texture|micro)\b/.test(s)) return 'dense';
+  return 'medium';
+}
+
+module.exports = { buildRepeatPrompt, inferDensity, DENSITY_SPEC, LAYOUT_SPEC };
diff --git a/server.js b/server.js
index 9c77866..b1a1961 100644
--- a/server.js
+++ b/server.js
@@ -904,6 +904,33 @@ app.get('/api/design/:id/inspired-by-badge', (req, res) => {
   }
 });
 
+// ── Public "what's new" feed — every published design from the last N hours.
+// Surfaces the seamless-agent's output + manual smart-fixes / regens / wand
+// edits in real time. Default 24h window. Lightweight JSON for the page.
+app.get('/api/new-designs', (req, res) => {
+  const hours = Math.max(1, Math.min(168, parseInt(req.query.hours || '24', 10)));
+  try {
+    const sql = `SELECT json_agg(t) FROM (
+      SELECT id, category, generator, dominant_hex, created_at
+      FROM spoon_all_designs
+      WHERE is_published = TRUE
+        AND brand = 'wallco.ai'
+        AND created_at > now() - interval '${hours} hours'
+      ORDER BY id DESC
+      LIMIT 200
+    ) t;`;
+    const raw = psqlQuery(sql);
+    const rows = raw ? JSON.parse(raw) : [];
+    res.json({ ok: true, hours, items: rows || [] });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.get('/new', (req, res) => {
+  res.sendFile(path.join(__dirname, 'public', 'new.html'));
+});
+
 // ── Fixes feed (before/after audit trail) ───────────────────────────────
 // Append-only log of every successful fix (Surgical / Remove+Fill / Reverse-
 // Regenerate / Magic-Wand-Apply). Each row links the source id (before) to
@@ -7402,7 +7429,15 @@ app.post('/api/design/:id/smart-fix', express.json({ limit: '4kb' }), async (req
     // Step 2: generate as a clean seamless tile of just the opaque motif.
     // POSITIVE LANGUAGE ONLY — negative anchors like "no ghost" have been
     // observed to make Gemini latch onto the very thing we want to avoid.
-    function buildGenPrompt(motif, ground_desc, ground_hex, attempt) {
+    //
+    // 2026-05-24: V1 ("legacy") said "The motif: X" (singular). Gemini read that
+    // as "render ONE motif centered on the canvas" → single-hero outputs with
+    // wrap-half artefacts at corners (mathematically tileable but compositionally
+    // 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');
+    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.`,
         `Stencil-cut wallpaper tile. ${motif} Single flat ink layer on a clean ${ground_desc || ''} (${ground_hex || ''}) ground. Every shape is filled in one solid color from edge to edge, like a vector silhouette. Edges crisp. Pattern wraps seamlessly when tiled in a 2x2 grid.`,
@@ -7410,6 +7445,17 @@ app.post('/api/design/:id/smart-fix', express.json({ limit: '4kb' }), async (req
       ];
       return variants[attempt % variants.length];
     }
+    const { buildRepeatPrompt, inferDensity } = require('./lib/repeat-prompt');
+    function buildGenPrompt(motif, ground_desc, ground_hex, attempt) {
+      if (USE_LEGACY) return buildGenPromptLegacy(motif, ground_desc, ground_hex, attempt);
+      return buildRepeatPrompt({
+        motifDescription: motif,
+        groundDesc:       ground_desc,
+        groundHex:        ground_hex,
+        density:          inferDensity(motif),
+        attempt,
+      });
+    }
     function pixelOpaqueGuarantee(motif, ground_desc, ground_hex) {
       // verify-loop helper, not the gen prompt itself
       return `Wallpaper of ${motif} on a ${ground_desc || ''} ${ground_hex || ''} background, every pixel solid color, single-layer flat screenprint, seamless tile.`;
@@ -7546,7 +7592,11 @@ print('OK seamless', W, H, 'band=', band)
     try { fs.unlinkSync(rawPath); } catch {}
 
     const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
-    const ins = psqlExecLocal(`
+    // Empty arrays need explicit casts in PG; `ARRAY[]::text[]` works but JSON.stringify quirks
+    // are easier with this helper.
+    const arrLit = (a, type) => (!Array.isArray(a) || a.length === 0) ? 'NULL'
+      : `ARRAY[${a.map(v => esc(v)).join(',')}]::${type}[]`;
+    const insertSQL = `
 INSERT INTO spoon_all_designs
   (kind, brand, width_in, height_in, panels, generator, prompt, seed,
    image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published,
@@ -7558,16 +7608,27 @@ VALUES
    ${esc(`Smart-fix of #${id} (auto-detected hero motif): ${(detect.motif_description || '').slice(0, 1500)}`)},
    ${newSeed},
    '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
    ${esc(detect.ground_hex || d.dominant_hex)},
    ${esc(JSON.stringify(d.palette || []))}::jsonb,
-   ${d.motifs ? `ARRAY[${(d.motifs || []).map(m => esc(m)).join(',')}]::text[]` : 'NULL'},
-   ${d.tags ? `ARRAY[${(d.tags || []).map(t => esc(t)).join(',')}]::text[]` : 'NULL'},
+   ${arrLit(d.motifs, 'text')},
+   ${arrLit(d.tags, 'text')},
    ${esc(d.category)},
    TRUE,
    ${id})
-RETURNING id`);
+RETURNING id`;
+    let ins;
+    try { ins = psqlExecLocal(insertSQL); }
+    catch (e) {
+      console.error('[smart-fix] INSERT err:', e.message.slice(0, 300));
+      console.error('[smart-fix] SQL was:', insertSQL.slice(0, 600));
+      return res.status(500).json({ error: 'insert failed: ' + e.message.slice(0, 240) });
+    }
     const newId = parseInt(ins, 10);
-    if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
+    if (!newId) {
+      console.error('[smart-fix] INSERT returned empty, SQL was:', insertSQL.slice(0, 600));
+      return res.status(500).json({ error: 'failed to insert new row (empty RETURNING)' });
+    }
     psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
     psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
     const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
@@ -7585,6 +7646,228 @@ RETURNING id`);
   }
 });
 
+// ── Background-texture library (PG) — natural-material backdrops that can
+// be swapped onto any design via Gemini image-edit.
+//
+// GET /api/bg-textures              → list all (slug, name, material, hex, image_url)
+// POST /api/bg-textures             → admin: add/update a texture
+// POST /api/design/:id/replace-bg   → admin: swap the design's background for the named texture
+// GET /api/dw-textures — real natural-material wallcoverings from DW catalog.
+// Surfaces shopify_products where material keywords (grasscloth, linen, raffia,
+// silk, cork, jute, sisal, hemp, woven) appear in title. Returns brand-grouped list.
+app.get('/api/dw-textures', (req, res) => {
+  try {
+    // json_agg so the embedded ' | ' in titles doesn't collide with psql's
+    // -At column delimiter.
+    const sql = `
+      SELECT COALESCE(json_agg(t ORDER BY t.rank, t.title), '[]'::json)
+      FROM (
+        SELECT title, sku, handle, image_url,
+          CASE
+            WHEN title ILIKE '%phillipe romano%' THEN 1
+            WHEN title ILIKE '%holly hunt%' THEN 2
+            WHEN title ILIKE '%carlisle%' THEN 3
+            ELSE 4
+          END AS rank
+        FROM shopify_products
+        WHERE image_url <> ''
+          AND status <> 'DELETED_FROM_SHOPIFY'
+          AND (
+            title ILIKE '%grasscloth%' OR title ILIKE '%linen%' OR title ILIKE '%raffia%'
+            OR title ILIKE '%silk%' OR title ILIKE '%cork%' OR title ILIKE '%jute%'
+            OR title ILIKE '%sisal%' OR title ILIKE '%hemp%' OR title ILIKE '%woven%'
+            OR title ILIKE '%abaca%' OR title ILIKE '%paperweave%'
+          )
+          AND (
+            title ILIKE '%holly hunt%' OR title ILIKE '%carlisle%'
+            OR title ILIKE '%phillipe romano%' OR title ILIKE '%novasuede%'
+            OR title ILIKE '%koroseal%' OR title ILIKE '%maya romanoff%'
+          )
+        LIMIT 240
+      ) t;
+    `;
+    const raw = psqlQuery(sql);
+    const rows = JSON.parse(raw || '[]');
+    const items = rows.map(r => {
+      const parts = (r.title || '').split(' | ');
+      const name = parts[0] || r.title;
+      const brand = parts[1] || 'DW';
+      const t = (r.title || '').toLowerCase();
+      const material = ['grasscloth','linen','raffia','silk','cork','jute','sisal','hemp','abaca','paperweave','woven']
+        .find(m => t.includes(m)) || 'natural';
+      return { title: r.title, name, brand, sku: r.sku, handle: r.handle, image_url: r.image_url, material };
+    });
+    res.json({ ok: true, items });
+  } catch (e) {
+    console.error('[dw-textures]', e.message);
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.get('/admin/bg-tester', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'bg-tester.html'));
+});
+
+app.get('/api/bg-textures', (req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT json_agg(t ORDER BY t.name) FROM (
+      SELECT id, slug, name, material, color_family, hex, description, image_url
+      FROM bg_textures
+    ) t;`);
+    const items = raw ? JSON.parse(raw) : [];
+    res.json({ ok: true, items: items || [] });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/bg-textures', express.json({ limit: '8kb' }), (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const { slug, name, material, color_family, hex, description } = req.body || {};
+  if (!slug || !name || !material || !description) {
+    return res.status(400).json({ error: 'slug, name, material, description required' });
+  }
+  try {
+    const esc = v => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+    const sql = `
+INSERT INTO bg_textures (slug, name, material, color_family, hex, description)
+VALUES (${esc(slug)}, ${esc(name)}, ${esc(material)}, ${esc(color_family)}, ${esc(hex)}, ${esc(description)})
+ON CONFLICT (slug) DO UPDATE SET
+  name = EXCLUDED.name, material = EXCLUDED.material,
+  color_family = EXCLUDED.color_family, hex = EXCLUDED.hex,
+  description = EXCLUDED.description
+RETURNING id`;
+    const ins = psqlExecLocal(sql);
+    res.json({ ok: true, id: parseInt(ins, 10), slug });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/design/:id/replace-bg', express.json({ limit: '8kb' }), async (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const slug = String(req.body?.texture_slug || '').trim();
+  const ad_hoc_url = String(req.body?.texture_image_url || '').trim();
+  const ad_hoc_name = String(req.body?.texture_name || 'custom').trim();
+  if (!slug && !ad_hoc_url) return res.status(400).json({ error: 'texture_slug or texture_image_url required' });
+  try {
+    // Resolve texture: either DB slug OR ad-hoc URL (e.g., DW product image)
+    let tex;
+    if (ad_hoc_url) {
+      tex = { slug: 'adhoc', name: ad_hoc_name, material: 'natural-wallcovering',
+              hex: null, description: `Use the supplied texture image AS-IS as the background. Keep its natural fiber/weave look visible.`,
+              image_url: ad_hoc_url };
+    } else {
+      const traw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT slug, name, material, hex, description, image_url FROM bg_textures WHERE slug=${"'" + slug.replace(/'/g,"''") + "'"} LIMIT 1) t;`);
+      if (!traw || traw.length < 3) return res.status(404).json({ error: 'texture not found: ' + slug });
+      tex = JSON.parse(traw);
+    }
+    // Lookup design
+    let d = null;
+    try {
+      const draw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category, width_in, height_in, panels, motifs, tags, dominant_hex, palette, local_path FROM spoon_all_designs WHERE id=${id}) t;`);
+      if (draw && draw.length > 2) d = JSON.parse(draw);
+    } catch {}
+    if (!d) return res.status(404).json({ error: 'design not found' });
+    if (!d.local_path || !fs.existsSync(d.local_path)) return res.status(404).json({ error: 'source PNG missing' });
+    const srcB64 = fs.readFileSync(d.local_path).toString('base64');
+
+    // If we have a real texture IMAGE (DW product photo etc.), fetch it and
+    // send BOTH images to Gemini — far better quality than text-only descriptions.
+    let texB64 = null, texMime = 'image/jpeg';
+    if (tex.image_url) {
+      try {
+        const r = await fetch(tex.image_url);
+        if (r.ok) {
+          const buf = Buffer.from(await r.arrayBuffer());
+          if (buf.length > 1024) {
+            texB64 = buf.toString('base64');
+            texMime = r.headers.get('content-type') || 'image/jpeg';
+            if (!texMime.startsWith('image/')) texMime = 'image/jpeg';
+          }
+        }
+      } catch (e) { console.warn('[replace-bg] texture fetch failed:', e.message); }
+    }
+
+    const prompt = texB64
+      ? (
+        `IMAGE 1 is a wallpaper design. IMAGE 2 is a real wallcovering texture (${tex.name} — ${tex.material}).\n\n` +
+        `Generate IMAGE 1 with its flat background REPLACED by the texture of IMAGE 2. Rules:\n` +
+        `• Keep EVERY foreground motif from IMAGE 1 EXACTLY — same silhouettes, colors, positions, scale, opacity.\n` +
+        `• The background area BEHIND the motifs is where IMAGE 2's texture goes. The texture should look like a real wallcovering surface (visible weave, fibers, or material grain).\n` +
+        `• The texture must be CLEARLY VISIBLE — not faded down to flat color. A viewer should immediately see the material.\n` +
+        `• Do NOT add new motifs. Do NOT recolor existing motifs.\n` +
+        `• Output must remain seamlessly tileable.\n` +
+        `• No watermark, no text, no signature.`
+      )
+      : (
+        `Edit this wallpaper image. KEEP every foreground motif (the silhouettes, animals, frames, ornaments, etc.) EXACTLY as they are — same shape, color, position, scale, opacity.\n\n` +
+        `REPLACE the flat solid background tone (the empty area BEHIND the motifs) with this texture material:\n\n` +
+        `${tex.description}\n\n` +
+        `The texture MUST BE CLEARLY VISIBLE in the output — a viewer should see real woven/fibrous/textured material in the background, not flat color. Imagine the motifs are screenprinted on top of an actual ${tex.material} wallpaper. ` +
+        `Do NOT recolor or alter any motif. Do NOT add new motifs. The texture is the background ONLY. Keep tile seamless.`
+      );
+
+    const parts = [{ inline_data: { mime_type: 'image/png', data: srcB64 } }];
+    if (texB64) parts.push({ inline_data: { mime_type: texMime, data: texB64 } });
+    parts.push({ text: prompt });
+
+    const t0 = Date.now();
+    let imgJ;
+    try {
+      imgJ = await geminiCall({
+        model: 'gemini-2.5-flash-image',
+        parts,
+        generationConfig: { responseModalities: ['IMAGE'] },
+        note: 'replace-bg-' + id + '-' + (slug || 'adhoc'),
+      });
+    } catch (e) {
+      if (e.code === 'NO_KEY') return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
+      return res.status(502).json({ error: 'gemini failed: ' + e.message });
+    }
+    const data = geminiImage(imgJ);
+    if (!data) return res.status(502).json({ error: 'gemini returned no image' });
+    const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+    const filename = `bgswap_${slug}_${id}_${Date.now()}_${newSeed}.png`;
+    const outPath = path.join(__dirname, 'data', 'generated', filename);
+    fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+    const esc = v => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+    const arrLit = (a, type) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::${type}[]`;
+    const insertSQL = `
+INSERT INTO spoon_all_designs
+  (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+   image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published, parent_design_id)
+VALUES
+  (${esc(d.kind || 'seamless_tile')}, 'wallco.ai',
+   ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+   'gemini-2.5-flash-image-bg-swap',
+   ${esc('BG-swap of #' + id + ' to ' + tex.name + ': ' + prompt.slice(0, 1200))},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc(tex.hex || d.dominant_hex)},
+   ${esc(JSON.stringify(d.palette || []))}::jsonb,
+   ${arrLit(d.motifs, 'text')},
+   ${arrLit(d.tags, 'text')},
+   ${esc(d.category)},
+   TRUE,
+   ${id})
+RETURNING id`;
+    let ins;
+    try { ins = psqlExecLocal(insertSQL); }
+    catch (e) {
+      console.error('[replace-bg] INSERT err:', e.message.slice(0, 240));
+      return res.status(500).json({ error: 'insert failed: ' + e.message.slice(0, 240) });
+    }
+    const newId = parseInt(ins, 10);
+    if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
+    psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
+    psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    appendFixEvent({ kind: 'replace-bg', src_id: id, new_id: newId, elapsed_s: elapsed, texture: tex.slug, texture_name: tex.name });
+    res.json({ ok: true, new_id: newId, src_id: id, texture: tex, elapsed_s: elapsed });
+  } catch (e) { console.error('[replace-bg]', e); res.status(500).json({ error: e.message }); }
+});
+
 app.post('/api/design/:id/ghosting', express.json({ limit: '16kb' }), (req, res) => {
   if (!adminRatingGate(req, res)) return;
   const id = parseInt(req.params.id, 10);

← d8f3683 seamless-detector: half-drop + ISOLATED_MOTIF + scenic-skip  ·  back to Wallco Ai  ·  Stage 2: composition-detector + smart-fix retry gate 5575ce7 →