[object Object]

← back to Interiordesignershowroom

TK-10402: pluggable scene-provider seam + Flux-Kontext fallback (inert until funded)

50e712071d45da864d5a27ba223f2ebb037c5713 · 2026-08-25 12:25:30 -0700 · Steve Abrams

Extracts the Gemini nano-banana impl behind a SCENE_PROVIDER dispatcher (lib/scene.js)
so a depleted provider is a one-flag flip. Default=gemini is byte-identical ($0.039,
same output path/contract) — backfill-room-scenes.js + lib/roomgen untouched.
Adds lib/scene-providers/replicate-flux.js: reference-capable (keeps 'real product in
the room'), throws a clear actionable error + spends nothing until a REPLICATE_API_TOKEN
is funded. Gemini prepay top-up remains the recommended path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 50e712071d45da864d5a27ba223f2ebb037c5713
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 25 12:25:30 2026 -0700

    TK-10402: pluggable scene-provider seam + Flux-Kontext fallback (inert until funded)
    
    Extracts the Gemini nano-banana impl behind a SCENE_PROVIDER dispatcher (lib/scene.js)
    so a depleted provider is a one-flag flip. Default=gemini is byte-identical ($0.039,
    same output path/contract) — backfill-room-scenes.js + lib/roomgen untouched.
    Adds lib/scene-providers/replicate-flux.js: reference-capable (keeps 'real product in
    the room'), throws a clear actionable error + spends nothing until a REPLICATE_API_TOKEN
    is funded. Gemini prepay top-up remains the recommended path.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/scene-providers/gemini.js         | 114 +++++++++++++++++++++++++++++
 lib/scene-providers/replicate-flux.js |  83 +++++++++++++++++++++
 lib/scene.js                          | 134 ++++++----------------------------
 3 files changed, 221 insertions(+), 110 deletions(-)

diff --git a/lib/scene-providers/gemini.js b/lib/scene-providers/gemini.js
new file mode 100644
index 0000000..addea64
--- /dev/null
+++ b/lib/scene-providers/gemini.js
@@ -0,0 +1,114 @@
+// 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 };
+}
+
+module.exports = { generateScene, COST_PER_IMAGE };
diff --git a/lib/scene-providers/replicate-flux.js b/lib/scene-providers/replicate-flux.js
new file mode 100644
index 0000000..6038c2e
--- /dev/null
+++ b/lib/scene-providers/replicate-flux.js
@@ -0,0 +1,83 @@
+// 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 buf = Buffer.from(await (await fetch(imgUrl)).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 };
+}
+
+module.exports = { generateScene, COST_PER_IMAGE };
diff --git a/lib/scene.js b/lib/scene.js
index a6c009e..14d90ab 100644
--- a/lib/scene.js
+++ b/lib/scene.js
@@ -1,112 +1,26 @@
-// 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.
-const fs = require('fs');
-const path = require('path');
-const crypto = require('crypto');
-
-const MODEL = 'gemini-2.5-flash-image';
-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 };
+// Room-scene generator DISPATCHER (TK-10402).
+// Selects the image backend by SCENE_PROVIDER so a depleted provider is a one-flag flip:
+//
+//   SCENE_PROVIDER=gemini          (default) — nano-banana, multi-product reference. BEST + cheapest ($6 total).
+//   SCENE_PROVIDER=replicate-flux  — Flux-Kontext fallback, single-reference. Needs a funded REPLICATE_API_TOKEN.
+//
+// Both providers export the same contract { generateScene, COST_PER_IMAGE }, so every
+// caller (scripts/backfill-room-scenes.js, lib/roomgen, etc.) is untouched. Adding a new
+// backend = drop a lib/scene-providers/<name>.js exporting that contract + name it here.
+const PROVIDERS = {
+  gemini: () => require('./scene-providers/gemini'),
+  'replicate-flux': () => require('./scene-providers/replicate-flux'),
+};
+
+const NAME = (process.env.SCENE_PROVIDER || 'gemini').toLowerCase();
+const load = PROVIDERS[NAME];
+if (!load) {
+  throw new Error(`Unknown SCENE_PROVIDER "${NAME}". Valid: ${Object.keys(PROVIDERS).join(', ')}`);
 }
+const provider = load();
 
-module.exports = { generateScene, COST_PER_IMAGE };
+module.exports = {
+  PROVIDER: NAME,
+  generateScene: provider.generateScene,
+  COST_PER_IMAGE: provider.COST_PER_IMAGE,
+};

← 4e4edbb chore: v0.4.5 (session close) — backfill-room-scenes.js lint  ·  back to Interiordesignershowroom  ·  chore: refactor review (clean, no changes), v0.5.0 (session 797a158 →