[object Object]

← 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

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 →