← 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
A lib/scene-providers/gemini.jsA lib/scene-providers/replicate-flux.jsM lib/scene.js
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 →