← back to Interiordesignershowroom
TK-10402: route guide heroes through the SCENE_PROVIDER dispatcher + add spend caps
abbea826f092f969891005d03b57629823c483d7 · 2026-09-03 14:14:36 -0700 · Steve Abrams
The documented Flux runbook was broken for the guides half: gen-guide-heroes.js
called the Gemini endpoint directly and silently ignored SCENE_PROVIDER, so
`SCENE_PROVIDER=replicate-flux node scripts/gen-guide-heroes.js` would have failed
once per guide against the depleted Gemini key (~109 failures, prod scope).
- lib/scene-providers/{gemini,replicate-flux}.js: add generateEditorial({subject})
-> {buffer, cost}. Gemini's prompt text is byte-identical to the inline one it
replaces, so the default path is unchanged.
- lib/scene.js: export generateEditorial through the dispatcher.
- scripts/gen-guide-heroes.js: use scene.generateEditorial (provider-agnostic).
- lib/run-guard.js (new): shared --dry-run / --limit / --max-cost parsing so a cap
behaves identically across both paid drivers.
- both drivers: --dry-run ($0 target list + estimate), --max-cost hard cap (default
$12, pre-flight abort + per-image stop), and a consecutive-failure abort so an
unfunded credential can't emit the same error once per row across the batch.
Verified $0: dry-runs on both drivers, cap abort, default provider still gemini
($0.039), replicate-flux dispatch resolves, node --check on all touched files,
server.js + lib/roomgen unaffected. No provider call made; ticket stays gated on
Steve's spend + funded credential.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011X4xabTP7zwUuN7zeDPfbJ
Files touched
A lib/run-guard.jsM lib/scene-providers/gemini.jsM lib/scene-providers/replicate-flux.jsM lib/scene.jsM scripts/backfill-room-scenes.jsM scripts/gen-guide-heroes.js
Diff
commit abbea826f092f969891005d03b57629823c483d7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 3 14:14:36 2026 -0700
TK-10402: route guide heroes through the SCENE_PROVIDER dispatcher + add spend caps
The documented Flux runbook was broken for the guides half: gen-guide-heroes.js
called the Gemini endpoint directly and silently ignored SCENE_PROVIDER, so
`SCENE_PROVIDER=replicate-flux node scripts/gen-guide-heroes.js` would have failed
once per guide against the depleted Gemini key (~109 failures, prod scope).
- lib/scene-providers/{gemini,replicate-flux}.js: add generateEditorial({subject})
-> {buffer, cost}. Gemini's prompt text is byte-identical to the inline one it
replaces, so the default path is unchanged.
- lib/scene.js: export generateEditorial through the dispatcher.
- scripts/gen-guide-heroes.js: use scene.generateEditorial (provider-agnostic).
- lib/run-guard.js (new): shared --dry-run / --limit / --max-cost parsing so a cap
behaves identically across both paid drivers.
- both drivers: --dry-run ($0 target list + estimate), --max-cost hard cap (default
$12, pre-flight abort + per-image stop), and a consecutive-failure abort so an
unfunded credential can't emit the same error once per row across the batch.
Verified $0: dry-runs on both drivers, cap abort, default provider still gemini
($0.039), replicate-flux dispatch resolves, node --check on all touched files,
server.js + lib/roomgen unaffected. No provider call made; ticket stays gated on
Steve's spend + funded credential.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011X4xabTP7zwUuN7zeDPfbJ
---
lib/run-guard.js | 25 ++++++++++
lib/scene-providers/gemini.js | 26 ++++++++++-
lib/scene-providers/replicate-flux.js | 39 +++++++++++++++-
lib/scene.js | 4 ++
scripts/backfill-room-scenes.js | 39 +++++++++++++---
scripts/gen-guide-heroes.js | 87 +++++++++++++++++++++--------------
6 files changed, 176 insertions(+), 44 deletions(-)
diff --git a/lib/run-guard.js b/lib/run-guard.js
new file mode 100644
index 0000000..f6fb96f
--- /dev/null
+++ b/lib/run-guard.js
@@ -0,0 +1,25 @@
+// Shared CLI guards for the paid backfill drivers (TK-10402).
+// Keeps --dry-run / --limit / --max-cost parsing identical across
+// scripts/backfill-room-scenes.js and scripts/gen-guide-heroes.js so a cap set on one
+// behaves the same on the other.
+const argv = process.argv;
+
+// Number of consecutive provider failures after which a batch aborts. An unfunded or
+// revoked credential fails identically every call, so without this a batch burns the
+// whole target list emitting the same error hundreds of times.
+const MAX_FAILS = Number(process.env.MAX_CONSECUTIVE_FAILS || 3);
+
+function flag(name) { return argv.includes(name); }
+
+function num(name, fallback) {
+ const i = argv.indexOf(name);
+ if (i === -1) return fallback;
+ const v = Number(argv[i + 1]);
+ if (!Number.isFinite(v)) {
+ console.error(`[run-guard] ${name} needs a number (got "${argv[i + 1]}")`);
+ process.exit(2);
+ }
+ return v;
+}
+
+module.exports = { flag, num, MAX_FAILS };
diff --git a/lib/scene-providers/gemini.js b/lib/scene-providers/gemini.js
index addea64..5b30753 100644
--- a/lib/scene-providers/gemini.js
+++ b/lib/scene-providers/gemini.js
@@ -111,4 +111,28 @@ async function generateScene({ style, color, theme, period, room_type, wall, pro
return { url: `/img/rooms/${file}`, cost: COST_PER_IMAGE, refs: parts.length - 1 };
}
-module.exports = { generateScene, COST_PER_IMAGE };
+// EDITORIAL (no product references) — used by guide heroes. Returns the raw PNG
+// buffer so the caller owns naming/placement (guides live in public/img/guides).
+// Prompt text is byte-identical to the one gen-guide-heroes.js used inline before
+// the provider refactor, so the default Gemini path is unchanged.
+async function generateEditorial({ subject, key } = {}) {
+ key = key || process.env.GEMINI_API_KEY;
+ if (!key) throw new Error('GEMINI_API_KEY not set');
+ const instruction = [
+ `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 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: [{ text: instruction }] }], 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');
+ return { buffer: Buffer.from(out.inlineData.data, 'base64'), cost: COST_PER_IMAGE };
+}
+
+module.exports = { generateScene, generateEditorial, COST_PER_IMAGE };
diff --git a/lib/scene-providers/replicate-flux.js b/lib/scene-providers/replicate-flux.js
index 1b0f265..1294bf8 100644
--- a/lib/scene-providers/replicate-flux.js
+++ b/lib/scene-providers/replicate-flux.js
@@ -82,4 +82,41 @@ async function generateScene(opts = {}) {
return { url: `/img/rooms/${file}`, cost: COST_PER_IMAGE, refs: hero ? 1 : 0 };
}
-module.exports = { generateScene, COST_PER_IMAGE };
+// 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 };
diff --git a/lib/scene.js b/lib/scene.js
index 14d90ab..e331dbd 100644
--- a/lib/scene.js
+++ b/lib/scene.js
@@ -22,5 +22,9 @@ const provider = load();
module.exports = {
PROVIDER: NAME,
generateScene: provider.generateScene,
+ // Editorial (reference-free) hero used by scripts/gen-guide-heroes.js. Routed through
+ // the SAME dispatcher so the guides half honours SCENE_PROVIDER too — before this it
+ // called Gemini directly and silently ignored the flag (TK-10402).
+ generateEditorial: provider.generateEditorial,
COST_PER_IMAGE: provider.COST_PER_IMAGE,
};
diff --git a/scripts/backfill-room-scenes.js b/scripts/backfill-room-scenes.js
index 8990b20..f674f43 100644
--- a/scripts/backfill-room-scenes.js
+++ b/scripts/backfill-room-scenes.js
@@ -5,7 +5,9 @@
// hotspots back to that room. Gemini 2.5 Flash Image ("nano-banana"), ~$0.039/scene
// + ~$0.001/vision-locate. Idempotent: only rooms with a NULL scene_image unless --force.
//
+// node scripts/backfill-room-scenes.js --dry-run # $0: list targets + est cost
// node scripts/backfill-room-scenes.js --limit 1 # CANARY: one scene, show cost
+// node scripts/backfill-room-scenes.js --max-cost 5 # hard spend cap (default $12)
// node scripts/backfill-room-scenes.js # all NULL-scene rooms
// node scripts/backfill-room-scenes.js --force # regenerate every room
//
@@ -17,10 +19,12 @@ const path = require('path');
const db = require('../lib/db');
const scene = require('../lib/scene');
const hotspots = require('../lib/hotspots');
+const { flag, num, MAX_FAILS } = require('../lib/run-guard');
-const FORCE = process.argv.includes('--force');
-const li = process.argv.indexOf('--limit');
-const LIMIT = li !== -1 ? parseInt(process.argv[li + 1], 10) : null;
+const FORCE = flag('--force');
+const DRY = flag('--dry-run');
+const LIMIT = num('--limit', null);
+const MAX_COST = num('--max-cost', Number(process.env.MAX_COST || 12));
(async () => {
const where = FORCE ? '' : 'WHERE scene_image IS NULL';
@@ -28,11 +32,25 @@ const LIMIT = li !== -1 ? parseInt(process.argv[li + 1], 10) : null;
const { rows: targets } = await db.query(
`SELECT slug, title, room_type, style, product_ids FROM rooms ${where} ORDER BY created_at ${lim}`);
- const est = (targets.length * (scene.COST_PER_IMAGE + (hotspots.COST_PER_CALL || 0))).toFixed(2);
- console.log(`[scenes] ${targets.length} room(s) need a scene. Est cost ~$${est} (nano-banana $${scene.COST_PER_IMAGE}/img + vision-locate).`);
+ const perImg = scene.COST_PER_IMAGE + (hotspots.COST_PER_CALL || 0);
+ const est = targets.length * perImg;
+ console.log(`[scenes] ${targets.length} room(s) need a scene. Est cost ~$${est.toFixed(2)} (provider=${scene.PROVIDER} $${scene.COST_PER_IMAGE}/img + vision-locate). Cap: $${MAX_COST.toFixed(2)}.`);
+ if (DRY) {
+ for (const r of targets) console.log(` · would generate ${r.slug} (${r.style || '-'} / ${r.room_type || '-'}, ${(r.product_ids || []).length} products)`);
+ console.log('[scenes] DRY RUN — nothing generated, $0 spent.');
+ process.exit(0);
+ }
+ if (est > MAX_COST) {
+ console.error(`[scenes] ABORT: estimated $${est.toFixed(2)} exceeds --max-cost $${MAX_COST.toFixed(2)}. Raise the cap or use --limit. $0 spent.`);
+ process.exit(2);
+ }
- let spent = 0, done = 0, skipped = 0;
+ let spent = 0, done = 0, skipped = 0, fails = 0;
for (const r of targets) {
+ if (spent + perImg > MAX_COST) {
+ console.error(`[scenes] STOP: next room would exceed the $${MAX_COST.toFixed(2)} cap (spent $${spent.toFixed(3)}).`);
+ break;
+ }
try {
const ids = r.product_ids || [];
if (!ids.length) { console.log(` · ${r.slug}: no products — skipped`); skipped++; continue; }
@@ -54,10 +72,17 @@ const LIMIT = li !== -1 ? parseInt(process.argv[li + 1], 10) : null;
await db.query('UPDATE rooms SET scene_image=$1, hotspots=$2, updated_at=now() WHERE slug=$3',
[out.url, JSON.stringify(loc.hotspots || []), r.slug]);
- spent += cost; done++;
+ spent += cost; done++; fails = 0;
console.log(` ✓ ${r.slug} -> ${out.url} (${loc.hotspots.length}/${products.length} spots, $${cost.toFixed(3)}) running: $${spent.toFixed(3)}`);
} catch (e) {
+ fails += 1;
console.error(` ✗ ${r.slug}: ${e.message}`);
+ // A dead/unfunded credential fails identically on every room — bail rather than
+ // emitting the same error once per room across the whole batch.
+ if (fails >= MAX_FAILS) {
+ console.error(`[scenes] ABORT: ${fails} consecutive failures — provider looks unavailable. Spent $${spent.toFixed(3)}.`);
+ break;
+ }
}
}
console.log(`[scenes] done. ${done} generated, ${skipped} skipped. Actual spend: $${spent.toFixed(3)}.`);
diff --git a/scripts/gen-guide-heroes.js b/scripts/gen-guide-heroes.js
index 8de8cc7..32703c3 100644
--- a/scripts/gen-guide-heroes.js
+++ b/scripts/gen-guide-heroes.js
@@ -1,17 +1,30 @@
-// Generate a REAL editorial hero image per published guide with Gemini 2.5 Flash
-// Image ("nano-banana") — replacing the picsum.photos placeholders (Steve's rule:
-// no placeholders, only real images). Landscape 16:9 so it doubles as the OG card.
-// Idempotent: only touches guides whose hero is still a picsum placeholder unless
-// --force. Paid: ~$0.039/image. Run: node scripts/gen-guide-heroes.js [--force]
+// Generate a REAL editorial hero image per published guide — replacing the
+// picsum.photos placeholders (Steve's rule: no placeholders, only real images).
+// Landscape 16:9 so it doubles as the OG card. Idempotent: only touches guides whose
+// hero is still a picsum placeholder unless --force.
+//
+// PROVIDER-AGNOSTIC (TK-10402): goes through lib/scene's SCENE_PROVIDER dispatcher, so
+// `SCENE_PROVIDER=replicate-flux` actually works here. Before this it called the Gemini
+// endpoint directly and silently ignored the flag — the documented Flux runbook would
+// have failed once per guide against the depleted Gemini key.
+//
+// node scripts/gen-guide-heroes.js --dry-run # $0: list targets + est cost
+// node scripts/gen-guide-heroes.js --limit 1 # CANARY: one hero, show cost
+// node scripts/gen-guide-heroes.js --max-cost 5 # hard spend cap (default $12)
+// node scripts/gen-guide-heroes.js --force # regenerate every guide
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const db = require('../lib/db');
+const scene = require('../lib/scene');
+const { flag, num, MAX_FAILS } = require('../lib/run-guard');
-const MODEL = 'gemini-2.5-flash-image';
-const COST = 0.039;
+const COST = scene.COST_PER_IMAGE;
const OUT_DIR = path.join(__dirname, '..', 'public', 'img', 'guides');
-const FORCE = process.argv.includes('--force');
+const FORCE = flag('--force');
+const DRY = flag('--dry-run');
+const LIMIT = num('--limit', null);
+const MAX_COST = num('--max-cost', Number(process.env.MAX_COST || 12));
// Per-guide subject; falls back to the title if a slug isn't mapped.
const SUBJECTS = {
@@ -27,45 +40,49 @@ const SUBJECTS = {
'an elevated coastal living room: pale oak floors, a linen slipcovered sofa, rattan accents, blue-and-white textiles, and soft ocean light',
};
-async function genImage(prompt) {
- const key = process.env.GEMINI_API_KEY;
- if (!key) throw new Error('GEMINI_API_KEY not set');
- const instruction = [
- `A high-end interior-design editorial photograph of ${prompt}.`,
- '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 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: [{ text: instruction }] }], 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');
- return Buffer.from(out.inlineData.data, 'base64');
-}
-
(async () => {
fs.mkdirSync(OUT_DIR, { recursive: true });
const { rows } = await db.query('SELECT slug, title, hero_image FROM guides WHERE published ORDER BY created_at');
- const targets = rows.filter((g) => FORCE || !g.hero_image || /picsum\.photos/.test(g.hero_image));
- console.log(`[heroes] ${targets.length}/${rows.length} guides need a real hero. Est cost: $${(targets.length * COST).toFixed(3)} (${MODEL} @ $${COST}/img)`);
- let spent = 0, done = 0;
+ let targets = rows.filter((g) => FORCE || !g.hero_image || /picsum\.photos/.test(g.hero_image));
+ if (LIMIT) targets = targets.slice(0, LIMIT);
+ const est = targets.length * COST;
+ console.log(`[heroes] ${targets.length}/${rows.length} guides need a real hero. Est cost: $${est.toFixed(3)} (provider=${scene.PROVIDER} @ $${COST}/img). Cap: $${MAX_COST.toFixed(2)}.`);
+ if (DRY) {
+ for (const g of targets) console.log(` · would generate ${g.slug}`);
+ console.log(`[heroes] DRY RUN — nothing generated, $0 spent.`);
+ process.exit(0);
+ }
+ if (est > MAX_COST) {
+ console.error(`[heroes] ABORT: estimated $${est.toFixed(2)} exceeds --max-cost $${MAX_COST.toFixed(2)}. Raise the cap or use --limit. $0 spent.`);
+ process.exit(2);
+ }
+
+ let spent = 0, done = 0, fails = 0;
for (const g of targets) {
- const prompt = SUBJECTS[g.slug] || `a beautifully designed interior that illustrates "${g.title}"`;
+ if (spent + COST > MAX_COST) {
+ console.error(`[heroes] STOP: next image would exceed the $${MAX_COST.toFixed(2)} cap (spent $${spent.toFixed(3)}).`);
+ break;
+ }
+ const subject = SUBJECTS[g.slug] || `a beautifully designed interior that illustrates "${g.title}"`;
try {
- const buf = await genImage(prompt);
+ const out = await scene.generateEditorial({ subject });
+ const buf = out.buffer;
const rel = `/img/guides/${g.slug}.png`;
fs.writeFileSync(path.join(OUT_DIR, `${g.slug}.png`), buf);
await db.query('UPDATE guides SET hero_image=$1, updated_at=now() WHERE slug=$2', [rel, g.slug]);
- spent += COST; done += 1;
+ spent += (out.cost || COST); done += 1; fails = 0;
console.log(` ✓ ${g.slug} -> ${rel} (${(buf.length / 1024).toFixed(0)}kb) running total: $${spent.toFixed(3)}`);
} catch (e) {
+ fails += 1;
console.error(` ✗ ${g.slug}: ${e.message}`);
+ // A dead/unfunded credential fails identically on every guide — bail instead of
+ // burning the whole list producing the same error 109 times.
+ if (fails >= MAX_FAILS) {
+ console.error(`[heroes] ABORT: ${fails} consecutive failures — provider looks unavailable. Spent $${spent.toFixed(3)}.`);
+ break;
+ }
}
}
- console.log(`[heroes] done. ${done}/${targets.length} generated. Actual spend: $${spent.toFixed(3)} (local DB updated).`);
+ console.log(`[heroes] done. ${done}/${targets.length} generated. Actual spend: $${spent.toFixed(3)} (DB updated).`);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
← 1a8e5c7 TK-10402: AbortSignal 30s timeout + ok-check on Flux image d
·
back to Interiordesignershowroom
·
deploy: guard prod-only runtime data from rsync --delete 9c27c31 →