← back to Marketing Command Center
lib/room-render.js
185 lines
'use strict';
// Room-setting renderer (Treatment 2) for the #vendors "Make it ours" system.
//
// MODEL RE-ROUTE (2026-08-25, TK-10845): the actual IMAGE GENERATION now runs on
// REPLICATE SDXL instead of the Gemini room-setting-generator (:8106 /
// gemini-2.5-flash-image). Per Steve's model-routing rule "image gen -> SDXL"
// and the directive "use other model unless for vision" — a room render is
// GENERATION (not vision), so it moves off the depleted Gemini credits onto
// Replicate SDXL (~1¢/img). The generated room image STILL runs through the
// Change-1 Claude settlement gate (in server.js) before it is saved/shown.
//
// SHAPE PRESERVED — the server endpoint's consumer contract is unchanged:
// generateRoom(patternBase64, opts) -> { ok, buffer?, mime?, cost, error? }
// roomAppUp() -> Promise<boolean> (now = "is Replicate reachable / configured")
// ROOM_RENDER_COST, ROOM_APP exports kept.
//
// COST: SDXL ~1¢/image on Replicate (a single 1024x1024 render, ~25 steps). The
// caller surfaces + logs it. The cost is real once the prediction succeeds.
const REPLICATE_API = 'https://api.replicate.com/v1';
// stability-ai/sdxl pinned version (resolved 2026-08-25); overridable via env.
const SDXL_VERSION =
process.env.SDXL_VERSION || '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc';
// Kept for back-compat with the endpoint's cost label / 503 copy.
const ROOM_APP = process.env.ROOM_APP_URL || 'https://api.replicate.com';
// SDXL on Replicate is ~1¢/render.
const ROOM_RENDER_COST = 0.01;
const REPLICATE_KEY_NAMES = ['REPLICATE_API_TOKEN', 'REPLICATE_API_KEY'];
function replicateToken() {
for (const n of REPLICATE_KEY_NAMES) if (process.env[n]) return process.env[n];
try {
const fs = require('fs');
const os = require('os');
const path = require('path');
const p = path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
const s = fs.readFileSync(p, 'utf8');
for (const n of REPLICATE_KEY_NAMES) {
const m = s.match(new RegExp('^' + n + '=(.*)$', 'm'));
if (m) return m[1].replace(/^["']|["']$/g, '').trim();
}
} catch { /* no secrets file */ }
return '';
}
// "Is the renderer available?" — now means: is a Replicate token configured?
// (Kept async + same name so the endpoint's `if (!(await roomAppUp()))` guard
// still short-circuits with a helpful message instead of 502-ing mid-render.)
async function roomAppUp() {
const tok = replicateToken();
if (!tok) return false;
try {
const c = new AbortController();
const t = setTimeout(() => c.abort(), 4000);
const r = await fetch(REPLICATE_API + '/account', {
headers: { Authorization: 'Bearer ' + tok },
signal: c.signal,
});
clearTimeout(t);
// 200 = reachable+authed; 401 still means "reachable" (endpoint offline is the
// failure we guard against). Any 2xx/401 => proceed.
return r.ok || r.status === 401;
} catch { return false; }
}
// Build a room-setting prompt for a given room type (SDXL is txt2img/img2img; we
// describe the scene and use the pattern as an img2img reference so the actual
// wallcovering appears on the wall).
function roomPrompt(roomType) {
const rooms = {
living_room: 'an elegant modern living room, feature wall covered in this wallcovering pattern, styled sofa and decor',
bedroom: 'a serene luxury bedroom, accent wall covered in this wallcovering pattern, styled bed and nightstands',
dining_room: 'a refined dining room, feature wall covered in this wallcovering pattern, dining table and chairs',
office: 'a sophisticated home office, feature wall covered in this wallcovering pattern, desk and shelving',
bathroom: 'a designer bathroom, feature wall covered in this wallcovering pattern, vanity and mirror',
};
const scene = rooms[roomType] || rooms.living_room;
return (
`Interior design photograph of ${scene}. Photorealistic, natural daylight, ` +
`professional architectural photography, the wallcovering pattern rendered ` +
`crisply and true to the reference on the wall, tasteful furnishings, high detail, 4k.`
);
}
// Poll a Replicate prediction to a terminal state.
async function pollPrediction(token, id, deadlineMs) {
while (Date.now() < deadlineMs) {
const r = await fetch(REPLICATE_API + '/predictions/' + id, {
headers: { Authorization: 'Bearer ' + token },
});
const j = await r.json();
if (j.status === 'succeeded') return { ok: true, output: j.output };
if (j.status === 'failed' || j.status === 'canceled') {
return { ok: false, error: String(j.error || j.status).slice(0, 160) };
}
await new Promise((res) => setTimeout(res, 2000));
}
return { ok: false, error: 'render timed out' };
}
// Generate one room setting from a base64 pattern via Replicate SDXL.
// @returns {Promise<{ok:boolean, buffer?:Buffer, mime?:string, cost:number, error?:string}>}
async function generateRoom(patternBase64, opts = {}) {
const pattern = String(patternBase64 || '');
if (!pattern) return { ok: false, cost: 0, error: 'no pattern image' };
const token = replicateToken();
if (!token) return { ok: false, cost: 0, error: 'REPLICATE_API_TOKEN not set' };
const roomType = opts.roomType || 'living_room';
// SDXL accepts a data: URL for the img2img `image` input.
const dataUrl = pattern.startsWith('data:') ? pattern : 'data:image/png;base64,' + pattern;
const input = {
prompt: roomPrompt(roomType),
negative_prompt:
'blurry, distorted pattern, warped wallcovering, low quality, watermark, text, logo, deformed, ugly',
image: dataUrl, // img2img reference so the pattern shows on the wall
prompt_strength: 0.72, // keep enough of the scene while honoring the pattern
width: 1024,
height: 1024,
num_inference_steps: 30,
guidance_scale: 7.5,
scheduler: 'K_EULER',
num_outputs: 1,
refine: 'expert_ensemble_refiner',
apply_watermark: false,
};
let createJson;
try {
const c = new AbortController();
const t = setTimeout(() => c.abort(), 15000);
const res = await fetch(REPLICATE_API + '/predictions', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json',
Prefer: 'wait=5', // let Replicate hold briefly; we still poll below
},
body: JSON.stringify({ version: SDXL_VERSION, input }),
signal: c.signal,
});
clearTimeout(t);
createJson = await res.json();
} catch (e) {
return { ok: false, cost: 0, error: 'replicate create: ' + String((e && e.message) || e).slice(0, 120) };
}
if (!createJson || createJson.error || !createJson.id) {
return { ok: false, cost: 0, error: (createJson && createJson.detail) ? String(createJson.detail).slice(0, 160) : 'replicate create failed' };
}
// Resolve output (either already-terminal from Prefer:wait, or poll).
let output = createJson.output;
let status = createJson.status;
if (status !== 'succeeded') {
const polled = await pollPrediction(token, createJson.id, Date.now() + 120000);
if (!polled.ok) return { ok: false, cost: 0, error: polled.error };
output = polled.output;
}
// SDXL returns an array of image URLs (num_outputs:1 => one URL).
const url = Array.isArray(output) ? output[0] : output;
if (!url || typeof url !== 'string') return { ok: false, cost: 0, error: 'no image URL from SDXL' };
let buffer;
let mime = 'image/png';
try {
const c = new AbortController();
const t = setTimeout(() => c.abort(), 30000);
const imgRes = await fetch(url, { signal: c.signal });
clearTimeout(t);
if (!imgRes.ok) return { ok: false, cost: 0, error: 'fetch render ' + imgRes.status };
mime = imgRes.headers.get('content-type') || (url.endsWith('.jpg') || url.endsWith('.jpeg') ? 'image/jpeg' : 'image/png');
buffer = Buffer.from(await imgRes.arrayBuffer());
} catch (e) {
return { ok: false, cost: 0, error: 'download render: ' + String((e && e.message) || e).slice(0, 120) };
}
if (!buffer || !buffer.length) return { ok: false, cost: 0, error: 'empty render' };
// The prediction succeeded -> Replicate billed for it (~1¢).
return { ok: true, buffer, mime, cost: ROOM_RENDER_COST };
}
module.exports = { generateRoom, roomAppUp, ROOM_RENDER_COST, ROOM_APP, SDXL_VERSION };