[object Object]

← back to Interiordesignershowroom

auto-save: 2026-08-01T22:40:01 (3 files) — lib/rooms.js lib/hotspots.js scripts/gen-room-setting.js

8df6b7bbf688da98c786e8a111609640c6eda2eb · 2026-08-01 22:40:07 -0700 · Steve Abrams

Files touched

Diff

commit 8df6b7bbf688da98c786e8a111609640c6eda2eb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 22:40:07 2026 -0700

    auto-save: 2026-08-01T22:40:01 (3 files) — lib/rooms.js lib/hotspots.js scripts/gen-room-setting.js
---
 lib/hotspots.js             | 72 +++++++++++++++++++++++++++++++++++++
 lib/rooms.js                | 11 +++---
 scripts/gen-room-setting.js | 86 +++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 165 insertions(+), 4 deletions(-)

diff --git a/lib/hotspots.js b/lib/hotspots.js
new file mode 100644
index 0000000..7258f95
--- /dev/null
+++ b/lib/hotspots.js
@@ -0,0 +1,72 @@
+// Vision-locate products inside a generated room-setting image so the UI can put
+// a shoppable hotspot ON each actual piece. Uses Gemini 2.5 Flash bounding-box
+// detection (returns [ymin,xmin,ymax,xmax] normalized 0-1000). Any product the
+// model can't confidently place is simply omitted — the frontend renders those as
+// edge chips so nothing becomes unselectable. Cheap: one Flash text call per scene.
+const fs = require('fs');
+
+const MODEL = 'gemini-2.5-flash';
+const COST_PER_CALL = 0.001; // ~1 image + small JSON out on Flash; shown to Steve
+
+// products: [{id, title, image_url, price, sale_price, advertiser}]
+// imagePath: absolute path to the generated PNG on disk
+// returns: [{id, title, price, image_url, advertiser, box:{x,y,w,h}}]  (x/y/w/h in %)
+async function locateProducts(imagePath, products = []) {
+  const key = process.env.GEMINI_API_KEY;
+  if (!key || !products.length) return { hotspots: [], cost: 0 };
+  let b64;
+  try { b64 = fs.readFileSync(imagePath).toString('base64'); }
+  catch { return { hotspots: [], cost: 0 }; }
+
+  const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
+  const prompt = [
+    'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
+    'For EACH product you can clearly locate, return its bounding box.',
+    'Respond with ONLY a compact JSON array, no prose, no code fence:',
+    '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
+    'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right).',
+    'Omit any product you cannot confidently locate. Products:',
+    list,
+  ].join('\n');
+
+  let j;
+  try {
+    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: [{ inlineData: { mimeType: 'image/png', data: b64 } }, { text: prompt }] }],
+        generationConfig: { temperature: 0, responseMimeType: 'application/json' },
+      }),
+    });
+    j = await res.json();
+  } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
+  if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
+
+  const text = (j.candidates && j.candidates[0] && j.candidates[0].content
+    && j.candidates[0].content.parts || []).map((p) => p.text || '').join('');
+  let raw;
+  try { raw = JSON.parse(text); } catch { raw = []; }
+  if (!Array.isArray(raw)) raw = [];
+
+  const seen = new Set();
+  const hotspots = [];
+  for (const d of raw) {
+    const idx = Number(d.i) - 1;
+    const p = products[idx];
+    if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
+    let [ymin, xmin, ymax, xmax] = d.box.map(Number);
+    // clamp to 0-1000 and ensure min<max
+    const cl = (n) => Math.max(0, Math.min(1000, n || 0));
+    ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
+    if (xmax <= xmin || ymax <= ymin) continue;
+    seen.add(p.id);
+    hotspots.push({
+      id: p.id, title: p.title, price: p.sale_price ?? p.price,
+      image_url: p.image_url, advertiser: p.advertiser,
+      box: { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 },
+    });
+  }
+  return { hotspots, cost: COST_PER_CALL };
+}
+
+module.exports = { locateProducts, COST_PER_CALL };
diff --git a/lib/rooms.js b/lib/rooms.js
index 25ed04f..b198118 100644
--- a/lib/rooms.js
+++ b/lib/rooms.js
@@ -32,14 +32,17 @@ async function createRoom(d = {}) {
   const base = slugify(d.title || `${d.style || ''} ${d.room_type || 'room'}`);
   let slug = base, n = 1;
   while ((await db.query('SELECT 1 FROM rooms WHERE slug=$1', [slug])).rowCount) slug = `${base}-${++n}`;
-  const isCurator = d.created_by === 'curator';
+  const cb = ['curator', 'auto', 'visitor'].includes(d.created_by) ? d.created_by : 'visitor';
+  const isCurator = cb === 'curator';
   const title = (d.title || `${d.style || ''} ${d.room_type || 'Room'}`).trim().replace(/\b\w/g, c => c.toUpperCase());
   const ids = Array.isArray(d.product_ids) ? d.product_ids.map(Number).filter(Boolean) : [];
+  // hotspots: [{id,box:{x,y,w,h},...}] — stored as jsonb so the saved room stays shoppable
+  const hotspots = Array.isArray(d.hotspots) ? d.hotspots : [];
   const { rows } = await db.query(
-    `INSERT INTO rooms (slug,title,room_type,style,wall_paint_id,product_ids,scene_image,note,created_by,featured,public)
-     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING slug`,
+    `INSERT INTO rooms (slug,title,room_type,style,wall_paint_id,product_ids,scene_image,note,created_by,featured,public,hotspots)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,$11) RETURNING slug`,
     [slug, title, d.room_type || null, d.style || null, d.wall_paint_id || null, ids,
-     d.scene_image || null, d.note || null, isCurator ? 'curator' : 'visitor', isCurator]);
+     d.scene_image || null, d.note || null, cb, isCurator, JSON.stringify(hotspots)]);
   return rows[0].slug;
 }
 
diff --git a/scripts/gen-room-setting.js b/scripts/gen-room-setting.js
new file mode 100644
index 0000000..a3543b1
--- /dev/null
+++ b/scripts/gen-room-setting.js
@@ -0,0 +1,86 @@
+// Auto-generate ONE shoppable room setting from the live CJ inventory:
+// pick a coherent room (room-type + style + a diverse set of pieces) -> render an
+// Architectural-Digest-style scene (Gemini image) -> vision-locate each piece so
+// the room is shoppable (Gemini Flash) -> save it PUBLIC. Meant to run on a timer
+// (launchd, every INTERVAL_MIN). Uses ANY advertiser (approved or not) — fine for
+// testing the room-generation pipeline before CJ links go live.
+//
+// Cost per run ≈ $0.039 (scene) + $0.001 (hotspots) = ~$0.04. Shown every run.
+// Run once:  node scripts/gen-room-setting.js
+require('dotenv').config();
+const path = require('path');
+const db = require('./../lib/db');
+const rooms = require('./../lib/rooms');
+const scene = require('./../lib/scene');
+const hotspots = require('./../lib/hotspots');
+
+const ROOM_TYPES = ['living-room', 'bedroom', 'dining', 'office'];
+const STYLES = ['modern', 'mid-century', 'coastal', 'traditional', 'scandinavian', 'boho', 'industrial', 'farmhouse'];
+const pick = (a) => a[Math.floor(Math.random() * a.length)];
+const cap = (s) => (s || '').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/-/g, ' ');
+
+// Choose a diverse, coordinated set of pieces: at most one per "category" bucket so
+// a room reads like a designed space (a sofa + a table + lighting + a rug), not 5 sofas.
+const BUCKETS = [
+  /sofa|sectional|loveseat|settee/i, /bed\b|headboard/i, /desk/i,
+  /coffee table|side table|end table|console|dining table|\btable\b/i,
+  /chair|stool|bench/i, /lamp|light|sconce|pendant|chandelier/i,
+  /rug/i, /art|print|mirror|wall/i, /shelf|bookcase|cabinet|dresser|credenza/i,
+  /vase|planter|decor|throw|pillow|cushion/i,
+];
+function diverseSet(pool, n = 6) {
+  const used = new Set(), out = [];
+  for (const p of pool) {
+    const b = BUCKETS.findIndex((rx) => rx.test(p.title));
+    const key = b === -1 ? `x${out.length}` : b;
+    if (used.has(key)) continue;
+    used.add(key); out.push(p);
+    if (out.length >= n) break;
+  }
+  // top up if we couldn't fill from distinct buckets
+  for (const p of pool) { if (out.length >= n) break; if (!out.includes(p)) out.push(p); }
+  return out;
+}
+
+async function candidatePool() {
+  const room = pick(ROOM_TYPES);
+  // loosen filters until we land a populated, coherent pool (style+room -> room -> any)
+  for (const attempt of [{ room, style: pick(STYLES) }, { room }, {}]) {
+    const rows = await rooms.searchProducts({ ...attempt, limit: 40 });
+    if (rows.length >= 4) {
+      // light shuffle for variety across runs
+      rows.sort(() => Math.random() - 0.5);
+      return { room, style: attempt.style || rows[0].style || null, pool: rows };
+    }
+  }
+  return { room, style: null, pool: [] };
+}
+
+(async () => {
+  const t0 = Date.now();
+  const { room, style, pool } = await candidatePool();
+  if (pool.length < 4) { console.error('[room-gen] not enough inventory to build a room'); process.exit(1); }
+  const products = diverseSet(pool, 6);
+  const title = `${cap(style) ? cap(style) + ' ' : ''}${cap(room)}`;
+  console.log(`[room-gen] "${title}" — ${products.length} pieces from ${new Set(products.map((p) => p.advertiser)).size} advertiser(s). Est cost ~$0.040`);
+
+  // 1) render the scene (paid Gemini image)
+  const out = await scene.generateScene({ style, room_type: room, products });
+  let cost = out.cost || 0;
+  console.log(`  ✓ scene ${out.url}  ($${cost.toFixed(3)}, ${out.refs} refs)`);
+
+  // 2) vision-locate the pieces so the room is shoppable (paid Gemini Flash)
+  const imgPath = path.join(__dirname, '..', 'public', out.url);
+  const loc = await hotspots.locateProducts(imgPath, products);
+  cost += loc.cost || 0;
+  console.log(`  ✓ hotspots ${loc.hotspots.length}/${products.length} located${loc.error ? ' (err: ' + loc.error + ')' : ''}  ($${(loc.cost || 0).toFixed(3)})`);
+
+  // 3) save the room PUBLIC + shoppable
+  const slug = await rooms.createRoom({
+    title, room_type: room, style,
+    product_ids: products.map((p) => p.id),
+    scene_image: out.url, hotspots: loc.hotspots, created_by: 'auto',
+  });
+  console.log(`[room-gen] saved /room/${slug}  |  run cost $${cost.toFixed(3)}  |  ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+  process.exit(0);
+})().catch((e) => { console.error('[room-gen]', e.message); process.exit(1); });

← 4a02b54 refine: real Gemini guide heroes (replace picsum placeholder  ·  back to Interiordesignershowroom  ·  feat: shoppable room-setting images (vision-located hotspots ffe693d →