[object Object]

← back to Wallco Ai

elements: backend POST /api/admin/elements/compose — real Gemini img-to-img fuse

c812cf648f9b753afd73358d72db24e79d69bffe · 2026-05-30 21:28:10 -0700 · Steve Abrams

1-6 selected element ids -> multi-image gemini-2.5-flash-image fuse (auto mode:
1=reference, 2=blend, 3+=compose). Result inserted UNPUBLISHED (is_published=FALSE)
so it goes through the curator/settlement gate like every fresh generation;
parent_design_id linkage + appendFixEvent audit. Source pixels resolved via the
grid's /designs/img/by-id route (covers stale local_path on sparse checkouts),
local_path fast-path first. isAdmin gate, matching sibling /api/admin routes.

Pairs the already-committed frontend drawer-cta wiring (8eef445a). Smoke-tested
local :9905 — reference #54724 (10.6s) + blend #54725 (12.3s), real ~1.3MB PNGs,
both rows pub=f, parent=54723, images serve 200. LOCAL ONLY, no deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c812cf648f9b753afd73358d72db24e79d69bffe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 30 21:28:10 2026 -0700

    elements: backend POST /api/admin/elements/compose — real Gemini img-to-img fuse
    
    1-6 selected element ids -> multi-image gemini-2.5-flash-image fuse (auto mode:
    1=reference, 2=blend, 3+=compose). Result inserted UNPUBLISHED (is_published=FALSE)
    so it goes through the curator/settlement gate like every fresh generation;
    parent_design_id linkage + appendFixEvent audit. Source pixels resolved via the
    grid's /designs/img/by-id route (covers stale local_path on sparse checkouts),
    local_path fast-path first. isAdmin gate, matching sibling /api/admin routes.
    
    Pairs the already-committed frontend drawer-cta wiring (8eef445a). Smoke-tested
    local :9905 — reference #54724 (10.6s) + blend #54725 (12.3s), real ~1.3MB PNGs,
    both rows pub=f, parent=54723, images serve 200. LOCAL ONLY, no deploy.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 121 insertions(+)

diff --git a/server.js b/server.js
index 50463e9..cf89638 100644
--- a/server.js
+++ b/server.js
@@ -2625,6 +2625,127 @@ app.get('/api/admin/elements/items', (req, res) => {
   }
 });
 
+// ── POST /api/admin/elements/compose — REAL Gemini img-to-img fuse.
+// Body: { ids:[int], hint?:string }. 1-6 selected element designs → multi-image
+// gemini-2.5-flash-image fuse, mode auto from count (1=reference, 2=blend, 3+=compose).
+// Inserts the result UNPUBLISHED (is_published=FALSE) so it goes through the
+// curator/settlement gate like every other fresh generation. One human click =
+// one paid Gemini call. Source pixels resolved via the SAME route the grid
+// renders with (/designs/img/by-id/:id) so it works even where local_path is
+// stale on a sparse checkout.
+app.post('/api/admin/elements/compose', express.json({ limit: '16kb' }), async (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const ids = Array.isArray(req.body && req.body.ids)
+    ? req.body.ids.map(x => parseInt(x, 10)).filter(Number.isFinite).slice(0, 6)
+    : [];
+  if (!ids.length) return res.status(400).json({ error: 'no element ids' });
+  const hint = String((req.body && req.body.hint) || '').slice(0, 600);
+  const mode = ids.length === 1 ? 'reference' : ids.length === 2 ? 'blend' : 'compose';
+
+  try {
+    const selfBase = `http://127.0.0.1:${PORT}`;
+    const imgParts = [];
+    const okIds = [];
+    let primary = null;
+    for (const id of ids) {
+      let buf = null, mime = 'image/png';
+      const d = DESIGNS.find(x => x.id === id);
+      if (d && d.local_path && fs.existsSync(d.local_path)) {
+        buf = fs.readFileSync(d.local_path);
+      } else {
+        try {
+          const resp = await fetch(`${selfBase}/designs/img/by-id/${id}`);
+          if (resp.ok) {
+            buf = Buffer.from(await resp.arrayBuffer());
+            mime = resp.headers.get('content-type') || 'image/png';
+          }
+        } catch (e) { /* skip this source */ }
+      }
+      if (!buf || !buf.length) continue;
+      imgParts.push({ inline_data: { mime_type: mime.split(';')[0].trim(), data: buf.toString('base64') } });
+      okIds.push(id);
+      if (!primary && d) primary = d;
+    }
+    if (!imgParts.length) {
+      return res.status(404).json({ error: 'no source image resolvable for the selected element(s)' });
+    }
+
+    const modePrompt = ({
+      reference: 'Use the single reference image above as inspiration. Generate a NEW, original wallcovering design in the same spirit — borrow its motif vocabulary, palette, and compositional rhythm, but produce a fresh distinct pattern, not a copy.',
+      blend:     'BLEND the two reference images above into ONE new cohesive wallcovering design. Combine the motifs of one with the palette/ground of the other (or merge both motif sets) so the result reads as a single intentional pattern, not a collage. Resolve any palette clash into one harmonious scheme.',
+      compose:   `COMPOSE the ${imgParts.length} reference images above into ONE new cohesive wallcovering design. Distill the strongest motif and the most pleasing palette across all references into a single unified, balanced pattern — a deliberate new design, not a montage of the inputs.`,
+    })[mode];
+
+    const composePrompt = [
+      modePrompt,
+      hint ? `Art director note: ${hint}` : '',
+      'Keep the flat hand-painted-silk aesthetic: hard razor-sharp silhouette edges, single-layer screenprint, NO gradient, NO atmospheric perspective, NO half-tones, NO translucency.',
+      'Produce a clean repeating wallpaper field. No watermark, no text, no border, no signature, no reference-image framing or thumbnails — output only the final flat design.',
+    ].filter(Boolean).join('\n');
+
+    const t0 = Date.now();
+    let j;
+    try {
+      j = await geminiCall({
+        model: 'gemini-2.5-flash-image',
+        parts: [...imgParts, { text: composePrompt }],
+        generationConfig: { responseModalities: ['IMAGE'] },
+        note: 'elements-compose-' + mode + '-from-' + okIds.join('+'),
+      });
+    } catch (e) {
+      if (e.code === 'NO_KEY') return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
+      return res.status(502).json({ error: e.message });
+    }
+    const data = geminiImage(j);
+    if (!data) {
+      const text = geminiText(j);
+      return res.status(502).json({ error: 'gemini returned no image: ' + (text || '').slice(0, 240) });
+    }
+
+    const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+    const filename = `compose_${okIds.join('-').slice(0, 40)}_${Date.now()}_${newSeed}.png`;
+    const outPath = path.join(IMG_DIR, filename);
+    fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+
+    const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+    const promptText = `Elements ${mode} of ${okIds.map(i => '#' + i).join(', ')}` + (hint ? ` — ${hint}` : '') + `: ${composePrompt.slice(0, 1000)}`;
+    let newId = 0;
+    try {
+      const ins = psqlExecLocal(`
+INSERT INTO spoon_all_designs
+  (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+   image_url, local_path, dominant_hex, palette, category, is_published, parent_design_id)
+VALUES
+  (${esc((primary && primary.kind) || 'seamless_tile')}, 'wallco.ai',
+   ${(primary && primary.width_in) || 'NULL'}, ${(primary && primary.height_in) || 'NULL'}, ${(primary && primary.panels) || 'NULL'},
+   'gemini-2.5-flash-image-compose',
+   ${esc(promptText)},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc(primary && primary.dominant_hex)},
+   ${esc(JSON.stringify((primary && primary.palette) || []))}::jsonb,
+   ${esc(primary && primary.category)},
+   FALSE,
+   ${okIds[0]})
+RETURNING id`);
+      newId = parseInt(ins, 10);
+    } catch (e) {
+      return res.status(500).json({ error: 'insert failed: ' + (e && e.message || e), saved_png: outPath });
+    }
+    if (!newId) return res.status(500).json({ error: 'insert returned no id', saved_png: outPath });
+    psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}' WHERE id=${newId}`);
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    try { appendFixEvent({ kind: 'elements-compose', mode, src_ids: okIds, new_id: newId, elapsed_s: elapsed }); } catch (e) {}
+    return res.json({ ok: true, new_id: newId, mode, elapsed_s: elapsed,
+      source_ids: okIds, preview_url: `/designs/img/by-id/${newId}`,
+      published: false, note: 'Generated UNPUBLISHED — review in the curator queue before it goes live.' });
+  } catch (e) {
+    console.error('[elements-compose]', e);
+    return res.status(500).json({ error: String(e && e.message || e) });
+  }
+});
+
 // Human-readable failure reason from the failing lenses — "show why it failed"
 // (e.g. "Mismatch at horizontal midline"). Maps the 6 lens keys to plain phrases.
 function edgesFailReason(lenses, maxDe) {

← 8eef445 security: add rel=noopener noreferrer to all target=_blank l  ·  back to Wallco Ai  ·  cactus-curator: park gate-failed designs by default, require 98f6c51 →