← back to Interiordesignershowroom
lib/scene-providers/replicate-flux.js
123 lines
// SCENE PROVIDER: replicate-flux — FALLBACK for when the Gemini prepay is depleted
// (TK-10402). Flux-Kontext is image-reference-capable, so unlike plain SDXL it keeps
// the core feature: the rendered room actually contains a supplied product image
// rather than inventing generic furniture. Selected via SCENE_PROVIDER=replicate-flux.
//
// INERT UNTIL FUNDED: needs a routed + funded REPLICATE_API_TOKEN (via the `secrets`
// skill). With no token it throws a clear, actionable error and spends nothing.
//
// LIMITATION vs gemini: Flux-Kontext takes ONE reference image, not the up-to-4
// multi-product composite Gemini does — so it anchors on the room's hero piece and
// styles the rest to match. Hotspots still come from lib/hotspots (Gemini vision); if
// GEMINI_API_KEY is also unavailable, hotspots return empty and the frontend renders
// edge chips, so rooms stay shoppable. Gemini remains the better/cheaper path ($6 total).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MODEL = 'black-forest-labs/flux-kontext-pro';
const OUT_DIR = path.join(__dirname, '..', '..', 'public', 'img', 'rooms');
const COST_PER_IMAGE = 0.04; // ~flux-kontext-pro per-image, approximate — shown to Steve
function buildPrompt({ style, color, theme, period, room_type, wall } = {}) {
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) accented by ${color} tones`
: 'a refined neutral-plus-accent palette (warm whites, greige, natural wood, stone with one restrained accent)';
const wallTxt = wall ? ` Walls finished in ${wall}.` : '';
return [
`A high-end interior-design editorial photograph of a ${vibe} ${roomWord}.`,
'Integrate the supplied product image as a hero piece placed naturally in ONE cohesive,',
'believably-designed room at correct human scale with contact shadows.',
'Shot as a professional architectural interior photograph on a full-frame DSLR with a',
'natural ~24mm lens (no fisheye, straight verticals), eye-level, rule-of-thirds.',
'Soft directional natural daylight with gentle believable shadows — the calm, layered,',
'expensive quality of light in Architectural Digest / Studio McGee interiors.',
`Real photorealistic natural materials in ${palette}, cohesive with a ${vibe} sensibility.${wallTxt}`,
'ONE single continuous photographed room — never a grid, collage, catalog, or moodboard.',
'No text, captions, labels, watermarks, logos, people, or pets. No warped or floating objects.',
'It should look like a full-page flagship photograph pulled straight from Architectural Digest.',
].join(' ');
}
// products: [{image_url, title, ...}]; returns { url, cost, refs } like the gemini provider.
async function generateScene(opts = {}) {
const token = opts.token || process.env.REPLICATE_API_TOKEN;
if (!token) {
throw new Error(
'replicate-flux fallback selected but REPLICATE_API_TOKEN is not set. ' +
'Route + fund a Replicate token via the `secrets` skill, then re-run. ' +
'(Preferred path is still funding Gemini prepay — see TK-10402 memo.)');
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const products = opts.products || [];
const hero = products.map((p) => p.image_url).filter(Boolean)[0] || null;
const prompt = buildPrompt(opts);
const start = await fetch('https://api.replicate.com/v1/models/' + MODEL + '/predictions', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json', Prefer: 'wait' },
body: JSON.stringify({ input: { prompt, ...(hero ? { input_image: hero } : {}), output_format: 'png', aspect_ratio: '3:2' } }),
});
const j = await start.json();
if (j.error) throw new Error('replicate error: ' + (j.error.detail || j.error));
// With Prefer: wait the prediction usually resolves inline; fall back to polling.
let out = j;
for (let i = 0; i < 60 && out.status && !['succeeded', 'failed', 'canceled'].includes(out.status); i++) {
await new Promise((r) => setTimeout(r, 2000));
out = await (await fetch(out.urls.get, { headers: { Authorization: 'Bearer ' + token } })).json();
}
if (out.status !== 'succeeded') throw new Error('replicate prediction ' + (out.status || 'no-status'));
const imgUrl = Array.isArray(out.output) ? out.output[0] : out.output;
if (!imgUrl) throw new Error('no image returned');
const dl = await fetch(imgUrl, { signal: AbortSignal.timeout(30000) });
if (!dl.ok) throw new Error('image download failed: HTTP ' + dl.status);
const buf = Buffer.from(await dl.arrayBuffer());
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), buf);
return { url: `/img/rooms/${file}`, cost: COST_PER_IMAGE, refs: hero ? 1 : 0 };
}
// EDITORIAL (no product reference) — guide heroes. Same contract as the gemini
// provider's generateEditorial: returns { buffer, cost }. 16:9 to double as the OG card.
async function generateEditorial({ subject, token } = {}) {
token = token || process.env.REPLICATE_API_TOKEN;
if (!token) {
throw new Error(
'replicate-flux fallback selected but REPLICATE_API_TOKEN is not set. ' +
'Route + fund a Replicate token via the `secrets` skill, then re-run.');
}
const prompt = [
`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 start = await fetch('https://api.replicate.com/v1/models/' + MODEL + '/predictions', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json', Prefer: 'wait' },
body: JSON.stringify({ input: { prompt, output_format: 'png', aspect_ratio: '16:9' } }),
});
const j = await start.json();
if (j.error) throw new Error('replicate error: ' + (j.error.detail || j.error));
let out = j;
for (let i = 0; i < 60 && out.status && !['succeeded', 'failed', 'canceled'].includes(out.status); i++) {
await new Promise((r) => setTimeout(r, 2000));
out = await (await fetch(out.urls.get, { headers: { Authorization: 'Bearer ' + token } })).json();
}
if (out.status !== 'succeeded') throw new Error('replicate prediction ' + (out.status || 'no-status'));
const imgUrl = Array.isArray(out.output) ? out.output[0] : out.output;
if (!imgUrl) throw new Error('no image returned');
const dl = await fetch(imgUrl, { signal: AbortSignal.timeout(30000) });
if (!dl.ok) throw new Error('image download failed: HTTP ' + dl.status);
return { buffer: Buffer.from(await dl.arrayBuffer()), cost: COST_PER_IMAGE };
}
module.exports = { generateScene, generateEditorial, COST_PER_IMAGE };