← 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
M .gitignoreA lib/scene.jsM public/css/site.cssM public/js/build.jsM server.js
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 →