[object Object]

← back to Interiordesignershowroom

Room Builder: AI photoreal render (Gemini nano-banana, feeds real product images as refs, $0.039/img, rate-limited) in the middle + saved as room scene; hover-detail popup on items; tray images bar

628a4c79a64117e24762ff2df5aceaebf365d516 · 2026-08-01 20:38:50 -0700 · Steve Abrams

Files touched

Diff

commit 628a4c79a64117e24762ff2df5aceaebf365d516
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:38:50 2026 -0700

    Room Builder: AI photoreal render (Gemini nano-banana, feeds real product images as refs, $0.039/img, rate-limited) in the middle + saved as room scene; hover-detail popup on items; tray images bar
---
 .gitignore          |  1 +
 lib/scene.js        | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/css/site.css | 15 ++++++++++++++
 public/js/build.js  | 39 +++++++++++++++++++++++++++++++++--
 server.js           | 41 +++++++++++++++++++++++++++++++++++--
 5 files changed, 151 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index a0967a2..275e990 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ build/
 .next/
 data/*.json
 !data/.gitkeep
+public/img/rooms/
diff --git a/lib/scene.js b/lib/scene.js
new file mode 100644
index 0000000..3652a67
--- /dev/null
+++ b/lib/scene.js
@@ -0,0 +1,59 @@
+// Photoreal room-scene generator (Gemini 2.5 Flash Image / "nano-banana").
+// Feeds the selected product images in as references so the rendered room shows
+// the ACTUAL affiliate pieces, arranged in a real photoreal interior. ~$0.039/image.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const MODEL = 'gemini-2.5-flash-image';
+const OUT_DIR = path.join(__dirname, '..', 'public', 'img', 'rooms');
+const COST_PER_IMAGE = 0.039;
+
+async function fetchInline(url) {
+  try {
+    const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
+    if (!res.ok) return null;
+    const buf = Buffer.from(await res.arrayBuffer());
+    if (buf.length > 4 * 1024 * 1024 || buf.length < 200) return null;
+    const mime = (res.headers.get('content-type') || 'image/jpeg').split(';')[0];
+    if (!/^image\//.test(mime)) return null;
+    return { inlineData: { mimeType: mime, data: buf.toString('base64') } };
+  } catch (_) { return null; }
+}
+
+async function generateScene({ style, color, theme, period, room_type, wall, products = [], key } = {}) {
+  key = key || process.env.GEMINI_API_KEY;
+  if (!key) throw new Error('GEMINI_API_KEY not set');
+  fs.mkdirSync(OUT_DIR, { recursive: true });
+
+  const vibe = [period, style, theme].filter(Boolean).join(' ') || 'contemporary';
+  const roomWord = (room_type || 'room').replace(/-/g, ' ');
+  const palette = color ? `${color} color palette` : 'cohesive, tasteful palette';
+  const wallTxt = wall ? `, walls painted ${wall}` : '';
+
+  const parts = [];
+  for (const u of (products || []).map(p => p.image_url).filter(Boolean).slice(0, 4)) {
+    const inl = await fetchInline(u);
+    if (inl) parts.push(inl);
+  }
+  const instruction = parts.length
+    ? `Create ONE photorealistic, magazine-quality interior photograph of a ${vibe} ${roomWord} that naturally incorporates the exact furniture and decor pieces shown in the reference images, arranged believably in a real, styled room with a ${palette}${wallTxt}. Wide-angle, soft natural daylight, realistic shadows and materials. No text, no watermarks, no collage — a single cohesive room photo.`
+    : `Create ONE photorealistic, magazine-quality interior photograph of a ${vibe} ${roomWord} with a ${palette}${wallTxt}, tastefully furnished and styled. Wide-angle, soft natural daylight. No text, no watermarks.`;
+  parts.push({ text: instruction });
+
+  const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${key}`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ contents: [{ parts }], generationConfig: { responseModalities: ['IMAGE'] } }),
+  });
+  const j = await res.json();
+  if (j.error) throw new Error(j.error.message || 'gemini error');
+  const out = (j.candidates && j.candidates[0] && j.candidates[0].content.parts || []).find(p => p.inlineData);
+  if (!out) throw new Error('no image returned');
+
+  const id = crypto.createHash('sha1').update(String(Date.now()) + Math.random()).digest('hex').slice(0, 16);
+  const file = `${id}.png`;
+  fs.writeFileSync(path.join(OUT_DIR, file), Buffer.from(out.inlineData.data, 'base64'));
+  return { url: `/img/rooms/${file}`, cost: COST_PER_IMAGE, refs: parts.length - 1 };
+}
+
+module.exports = { generateScene, COST_PER_IMAGE };
diff --git a/public/css/site.css b/public/css/site.css
index a701af3..bc066ed 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -180,3 +180,18 @@ h2{font-size:1.4rem;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid v
 .empty{color:var(--muted);font-size:.85rem;grid-column:1/-1}
 .noimg{display:grid;place-items:center;color:var(--muted);font-size:.7rem}
 @media(max-width:980px){.rb-body{grid-template-columns:1fr}.rb-left,.rb-right{position:static}.rb-products{grid-template-columns:repeat(auto-fill,minmax(110px,1fr));max-height:none}}
+
+/* Room Builder — render, hover popup, tray images bar */
+.rb-render-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px}
+.rb-cost{font-size:.78rem;color:var(--muted)}
+.rb-scene-img{width:100%;border-radius:10px;display:block}
+.rb-scene-item{cursor:default}
+/* bigger dragged-product thumbnails so the tray reads as an images bar */
+.rb-chip.prod{background:#4a4136;padding:2px 8px 2px 2px;gap:6px}
+.rb-chip.prod img{width:34px;height:34px;border-radius:5px}
+.rb-pop{position:fixed;z-index:100;width:246px;background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 30px rgba(30,20,10,.22);overflow:hidden;pointer-events:none;font-size:.8rem}
+.rb-pop img{width:100%;aspect-ratio:4/3;object-fit:cover;background:#efe8dd}
+.rb-pop-b{padding:9px 11px}
+.rb-pop-b b{font-size:.85rem;line-height:1.25;display:block}
+.rb-pop-p{color:var(--ink);font-weight:600;margin-top:4px}
+.rb-pop-t{color:var(--muted);font-size:.72rem;text-transform:capitalize;margin-top:2px}
diff --git a/public/js/build.js b/public/js/build.js
index 4dd521b..5de1065 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -37,7 +37,7 @@
   function preview() {
     var ids = Object.keys(board), el = $('rbRender');
     if (!ids.length) { el.innerHTML = '<div class="rb-render-ph">Your room preview builds here.<br><small>Pick a style + drag pieces, or just hit <b>GO</b> to generate from your vibe.</small></div>'; return; }
-    el.innerHTML = '<div class="rb-scene">' + ids.map(function (id) { return '<div class="rb-scene-item"><img src="' + board[id].image_url + '" alt=""></div>'; }).join('') + '</div>';
+    el.innerHTML = '<div class="rb-scene">' + ids.map(function (id) { return '<div class="rb-scene-item" data-id="' + id + '"><img src="' + board[id].image_url + '" alt=""></div>'; }).join('') + '</div>';
   }
 
   function query() {
@@ -98,11 +98,46 @@
 
   $('rbRoom').addEventListener('change', function () { room = this.value; chips(); loadProducts(); });
   $('rbSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadProducts(); });
+
+  // AI photoreal render (paid — cost shown)
+  var lastScene = null;
+  $('rbRenderBtn').addEventListener('click', function () {
+    var btn = this, cost = $('rbCost'), rr = $('rbRender');
+    btn.textContent = 'Rendering…'; btn.disabled = true;
+    rr.innerHTML = '<div class="rb-render-ph">🎨 Painting your room…<br><small>~10–20s</small></div>';
+    fetch('/api/render', { method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ style: sel.style, color: sel.color, theme: sel.theme, period: sel.period, room_type: room, wall_paint_id: wallPaint && wallPaint.id, product_ids: Object.keys(board).map(Number) }) })
+      .then(function (r) { return r.json(); }).then(function (d) {
+        btn.textContent = '🎨 Render photoreal room'; btn.disabled = false;
+        if (d.url) { lastScene = d.url; rr.innerHTML = '<img class="rb-scene-img" src="' + d.url + '" alt="rendered room">'; cost.textContent = 'rendered ✓ · cost $' + Number(d.cost).toFixed(3); }
+        else { rr.innerHTML = '<div class="rb-render-ph">Render failed: ' + (d.error || '') + '</div>'; }
+      }).catch(function () { btn.textContent = '🎨 Render photoreal room'; btn.disabled = false; rr.innerHTML = '<div class="rb-render-ph">Render error.</div>'; });
+  });
+
+  // hover popup with item details
+  var pop = document.createElement('div'); pop.className = 'rb-pop'; pop.style.display = 'none'; document.body.appendChild(pop);
+  document.addEventListener('mouseover', function (e) {
+    var c = e.target.closest('.rbp,.rb-scene-item,.card'); if (!c) return;
+    var p = lastRows[c.getAttribute('data-id')]; if (!p) return;
+    pop.innerHTML = '<img src="' + (p.image_url || '') + '" alt=""><div class="rb-pop-b"><b>' + (p.title || '') + '</b>' +
+      '<div class="rb-pop-p">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div>' +
+      '<div class="rb-pop-t">' + [p.room, p.style, p.color].filter(Boolean).join(' · ') + '</div></div>';
+    pop.style.display = 'block';
+  });
+  document.addEventListener('mousemove', function (e) {
+    if (pop.style.display !== 'block') return;
+    var x = e.clientX + 16, y = e.clientY + 16;
+    if (x > window.innerWidth - 270) x = e.clientX - 262;
+    if (y > window.innerHeight - 180) y = e.clientY - 176;
+    pop.style.left = x + 'px'; pop.style.top = y + 'px';
+  });
+  document.addEventListener('mouseout', function (e) { if (e.target.closest('.rbp,.rb-scene-item,.card')) pop.style.display = 'none'; });
+
   $('rbGo').addEventListener('click', function () {
     var go = $('rbGo'); go.textContent = 'Building…'; go.disabled = true;
     fetch('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ title: $('rbTitle').value.trim(), room_type: room, style: sel.style, color: sel.color,
-        note: [sel.hue, sel.theme, sel.period].filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, product_ids: Object.keys(board).map(Number) }) })
+        note: [sel.hue, sel.theme, sel.period].filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, scene_image: lastScene, product_ids: Object.keys(board).map(Number) }) })
       .then(function (r) { return r.json(); }).then(function (d) { if (d.url) window.location = d.url; else { go.textContent = 'GO ▶'; go.disabled = false; alert(d.error || 'Error'); } })
       .catch(function () { go.textContent = 'GO ▶'; go.disabled = false; });
   });
diff --git a/server.js b/server.js
index 1744ad2..64bf7e9 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@ const db = require('./lib/db');
 const { SITE, esc, layout, productCard } = require('./lib/render');
 const catalog = require('./lib/catalog');
 const rooms = require('./lib/rooms');
+const scene = require('./lib/scene');
 
 const app = express();
 const PORT = process.env.PORT || 9820;
@@ -237,7 +238,8 @@ app.post('/api/rooms', async (req, res, next) => {
     const slug = await rooms.createRoom({
       title: b.title, room_type: b.room_type, style: b.style,
       wall_paint_id: b.wall_paint_id ? Number(b.wall_paint_id) : null,
-      product_ids: ids, note: b.note, created_by: b.created_by === 'curator' ? 'curator' : 'visitor',
+      product_ids: ids, note: b.note, scene_image: b.scene_image || null,
+      created_by: b.created_by === 'curator' ? 'curator' : 'visitor',
     });
     res.json({ slug, url: `/room/${slug}` });
   } catch (e) { next(e); }
@@ -251,6 +253,37 @@ const DIMENSIONS = {
   period: ['mid-century', 'art deco', 'victorian', 'contemporary', '70s retro', 'bauhaus', 'traditional'],
 };
 
+// Cost guard: cap paid renders (global + per-IP, rolling hour) so a public
+// render button can't run up an unbounded Gemini bill.
+const _renderHits = [];
+function renderAllowed(ip) {
+  const now = Date.now(), hourAgo = now - 3600e3;
+  while (_renderHits.length && _renderHits[0].t < hourAgo) _renderHits.shift();
+  const globalN = _renderHits.length, perIp = _renderHits.filter(h => h.ip === ip).length;
+  const GLOBAL_CAP = parseInt(process.env.RENDER_HOURLY_CAP || '60', 10);
+  const IP_CAP = parseInt(process.env.RENDER_IP_CAP || '8', 10);
+  if (globalN >= GLOBAL_CAP || perIp >= IP_CAP) return false;
+  _renderHits.push({ t: now, ip });
+  return true;
+}
+
+// Photoreal AI render of a room scene (paid — Gemini image, ~$0.039/image).
+app.post('/api/render', async (req, res) => {
+  try {
+    const ip = (req.headers['x-forwarded-for'] || req.ip || '').split(',')[0].trim();
+    if (!renderAllowed(ip)) return res.status(429).json({ error: 'Render limit reached — try again in a bit.' });
+    const b = req.body || {};
+    const ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 4) : [];
+    let products = [];
+    if (ids.length) products = (await db.query('SELECT image_url FROM products WHERE id = ANY($1)', [ids])).rows;
+    let wall = null;
+    if (b.wall_paint_id) { const w = (await db.query('SELECT title FROM products WHERE id=$1', [Number(b.wall_paint_id)])).rows[0]; wall = w && w.title; }
+    const out = await scene.generateScene({ style: b.style, color: b.color, theme: b.theme, period: b.period, room_type: b.room_type, wall, products });
+    console.log(`[render] $${out.cost} scene (${out.refs} refs)`); // cost line per Steve's rule
+    res.json(out);
+  } catch (e) { console.error('[render]', e.message); res.status(500).json({ error: e.message }); }
+});
+
 app.get('/build', (_req, res) => {
   const roomOpts = ROOM_TYPES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
   const cap = s => s.replace(/\b\w/g, c => c.toUpperCase());
@@ -277,7 +310,11 @@ app.get('/build', (_req, res) => {
           <div class="paint-strip" id="rbPaints"></div></details>
       </aside>
       <div class="rb-mid" id="rbMid">
-        <div class="rb-render" id="rbRender"><div class="rb-render-ph">Your room preview builds here.<br><small>Pick a style + drag pieces, or just hit <b>GO</b> to generate from your vibe.</small></div></div>
+        <div class="rb-render-bar">
+          <button id="rbRenderBtn" class="cta sm">🎨 Render photoreal room</button>
+          <span class="rb-cost" id="rbCost">~$0.039 per render (Gemini) · uses your selected pieces</span>
+        </div>
+        <div class="rb-render" id="rbRender"><div class="rb-render-ph">Your room renders here.<br><small>Pick a vibe + drag pieces, then <b>Render</b> for a photoreal scene — or hit <b>GO</b> to save.</small></div></div>
         <h4 class="rb-ideas-h">✨ New ideas <span class="subtle">— tap to add</span></h4>
         <div class="rb-ideas" id="rbIdeas"></div>
       </div>

← 1e3d7b1 Room Builder: tray+GO, collapsed left tabs (trending/style/h  ·  back to Interiordesignershowroom  ·  Room Builder: 12-color multi-select swatches (Alabaster→Blus bcddff0 →