← 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 };