← back to Interiordesignershowroom

lib/roomgen.js

85 lines

// Core room-generation pipeline, extracted from scripts/gen-room-setting.js so
// both the standalone room cron AND the guide generator (scripts/gen-guide.js)
// build rooms the same way: pick a coherent room (room-type + style + a diverse
// set of pieces) -> render an AD-style scene (Gemini image) -> vision-locate each
// piece so the room is shoppable (Gemini Flash) -> save it PUBLIC.
const path = require('path');
const rooms = require('./rooms');
const scene = require('./scene');
const hotspots = require('./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|sideboard|buffet|console|hutch/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;
}

// loosen filters until we land a populated, coherent pool (style+room -> room -> any)
async function candidatePool(wantRoom, wantStyle) {
  const room = wantRoom || pick(ROOM_TYPES);
  const style = wantStyle || pick(STYLES);
  for (const attempt of [{ room, style }, { room }, {}]) {
    const rows = await rooms.searchProducts({ ...attempt, limit: 40 });
    if (rows.length >= 4) {
      rows.sort(() => Math.random() - 0.5); // light shuffle for variety across runs
      return { room, style: attempt.style || rows[0].style || null, pool: rows };
    }
  }
  return { room, style: null, pool: [] };
}

// Generate + save one public shoppable room. Returns everything the caller needs
// (incl. run cost) or throws when inventory can't fill a room.
async function generateRoom({ room, style, log = () => {} } = {}) {
  const t0 = Date.now();
  const picked = await candidatePool(room, style);
  if (picked.pool.length < 4) throw new Error('not enough inventory to build a room');
  const products = diverseSet(picked.pool, 6);
  const title = `${cap(picked.style) ? cap(picked.style) + ' ' : ''}${cap(picked.room)}`;
  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: picked.style, room_type: picked.room, products });
  let cost = out.cost || 0;
  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;
  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: picked.room, style: picked.style,
    product_ids: products.map((p) => p.id),
    scene_image: out.url, hotspots: loc.hotspots, created_by: 'auto',
  });
  log(`[room-gen] saved /room/${slug}  |  run cost $${cost.toFixed(3)}  |  ${((Date.now() - t0) / 1000).toFixed(1)}s`);
  return { slug, title, room: picked.room, style: picked.style, products, scene_image: out.url, hotspots: loc.hotspots, cost };
}

module.exports = { generateRoom, ROOM_TYPES, STYLES, cap };