← 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
A lib/repeat-prompt.jsM server.js
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 →