← back to Interiordesignershowroom

lib/scene-providers/gemini.js

139 lines

// SCENE PROVIDER: gemini — 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. Selected via SCENE_PROVIDER=gemini (default) in lib/scene.js.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const MODEL = 'gemini-2.5-flash-image';
// __dirname is now lib/scene-providers/, so climb two levels to reach the project root.
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
    ? `a refined, largely neutral palette (warm whites, greige, natural wood, stone) grounded and accented by ${color} tones`
    : 'a refined neutral-plus-accent palette (warm whites, greige, natural wood, stone with one restrained accent)';
  const wallTxt = wall ? ` The walls are finished in ${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);
  }

  // Shared "make it read like Architectural Digest" photography direction.
  const camera =
    'Shoot it as a professional architectural interior photograph on a full-frame DSLR ' +
    'with a natural ~24mm wide-angle lens (wide but undistorted — absolutely no fisheye ' +
    'curvature, straight verticals), eye-level, with a rule-of-thirds composition and ' +
    'comfortable headroom above the furniture.';
  const light =
    'The room has a real window letting in soft, directional natural daylight that ' +
    'produces gentle, believable shadows and warm falloff across the space — the calm, ' +
    'layered, expensive quality of light in Architectural Digest, Kelly Wearstler, and ' +
    'Studio McGee interiors.';
  const styling =
    'Style it tastefully and sparingly: a few hardcover books, a couple of ceramic ' +
    'vessels, a live plant or fresh cut branches, a framed piece of art, a soft throw or ' +
    'linen textile — layered but calm, never cluttered.';
  const materials =
    `Use real, photorealistic natural materials — wood grain, linen, wool, stone, brass, ` +
    `glass, aged leather — in ${palette}, cohesive with a ${vibe} sensibility.${wallTxt}`;
  const negative =
    'It MUST be ONE single continuous photographed room — never a grid, collage, ' +
    'contact sheet, product catalog, moodboard, or side-by-side comparison. No text, ' +
    'no captions, no labels, no watermarks, no logos, no brand marks, no people, no ' +
    'pets, no reflections of a camera. Furniture must sit at correct human scale with ' +
    'no warped, melted, duplicated, or distorted pieces, and no floating objects.';

  const instruction = parts.length
    ? [
        `A high-end interior-design editorial photograph of a ${vibe} ${roomWord}.`,
        `Take the EXACT furniture and decor pieces shown in the reference images and ` +
          `integrate them naturally into ONE cohesive, believably-designed room — place ` +
          `each piece where a real interior designer would put it, at correct scale ` +
          `relative to the room and to each other, resting on the floor or surfaces with ` +
          `contact shadows, matching their real materials and finishes. They are the ` +
          `hero pieces of a single lived-in space, NOT products laid out on a page.`,
        camera,
        light,
        materials,
        styling,
        negative,
        `The final image should look like a full-page flagship photograph pulled ` +
          `straight from Architectural Digest.`,
      ].join(' ')
    : [
        `A high-end interior-design editorial photograph of a ${vibe} ${roomWord}, ` +
          `tastefully furnished and styled as one cohesive, believably-designed space.`,
        camera,
        light,
        materials,
        styling,
        negative,
        `The final image should look like a full-page flagship photograph pulled ` +
          `straight from Architectural Digest.`,
      ].join(' ');
  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 };
}

// EDITORIAL (no product references) — used by guide heroes. Returns the raw PNG
// buffer so the caller owns naming/placement (guides live in public/img/guides).
// Prompt text is byte-identical to the one gen-guide-heroes.js used inline before
// the provider refactor, so the default Gemini path is unchanged.
async function generateEditorial({ subject, key } = {}) {
  key = key || process.env.GEMINI_API_KEY;
  if (!key) throw new Error('GEMINI_API_KEY not set');
  const instruction = [
    `A high-end interior-design editorial photograph of ${subject}.`,
    'Shot on a full-frame camera with a 35mm lens, natural window light, shallow depth of field.',
    'Styled as one cohesive, believably-designed space — like a full-page flagship photograph pulled straight from Architectural Digest.',
    'Wide 16:9 landscape composition. No people, no text, no watermarks, no logos.',
  ].join(' ');
  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: [{ text: instruction }] }], 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');
  return { buffer: Buffer.from(out.inlineData.data, 'base64'), cost: COST_PER_IMAGE };
}

module.exports = { generateScene, generateEditorial, COST_PER_IMAGE };