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