[object Object]

← back to Interiordesignershowroom

TK-11260: HOTSPOT_PROVIDER dispatcher + openai fallback, backfill 14 empty-hotspot rooms

323ecdb5ca2307061dbbd6f9de1579047570461e · 2026-09-05 10:01:19 -0700 · Steve Abrams

Gemini AI Studio prepay is still depleted (confirmed live 429
RESOURCE_EXHAUSTED), so the 12 Flux-regenerated room scenes from TK-10402
plus steelcase-office/mid-century-room stayed at hotspots=[] (rendered but
not shoppable). Mirrors the existing SCENE_PROVIDER pattern (lib/scene.js):
lib/hotspots.js is now a thin dispatcher over lib/hotspot-providers/{gemini,openai}.js,
same {locateProducts, COST_PER_CALL} contract, so server.js and
backfill-room-scenes.js are untouched.

Ran scripts/backfill-hotspots.js (new, hotspots-only — ~10x cheaper than a
full scene regen) with HOTSPOT_PROVIDER=openai against the LOCAL dev DB:
14 rooms updated, $0.07 total (gpt-5.2 vision, ~$0.005/room). 13/14 rooms
now carry at least one hotspot; "room" got a genuine 0-match (frontend
edge-chip fallback covers it, same as Gemini's own omit-if-unsure behavior).

Ledgered as reversible (undo = UPDATE rooms SET hotspots='[]' for the 14
slugs) at ~/.claude/yolo-queue/executed-reversible/ledger.jsonl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAXbNq4CYRmd9hRam2NU6P

Files touched

Diff

commit 323ecdb5ca2307061dbbd6f9de1579047570461e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 5 10:01:19 2026 -0700

    TK-11260: HOTSPOT_PROVIDER dispatcher + openai fallback, backfill 14 empty-hotspot rooms
    
    Gemini AI Studio prepay is still depleted (confirmed live 429
    RESOURCE_EXHAUSTED), so the 12 Flux-regenerated room scenes from TK-10402
    plus steelcase-office/mid-century-room stayed at hotspots=[] (rendered but
    not shoppable). Mirrors the existing SCENE_PROVIDER pattern (lib/scene.js):
    lib/hotspots.js is now a thin dispatcher over lib/hotspot-providers/{gemini,openai}.js,
    same {locateProducts, COST_PER_CALL} contract, so server.js and
    backfill-room-scenes.js are untouched.
    
    Ran scripts/backfill-hotspots.js (new, hotspots-only — ~10x cheaper than a
    full scene regen) with HOTSPOT_PROVIDER=openai against the LOCAL dev DB:
    14 rooms updated, $0.07 total (gpt-5.2 vision, ~$0.005/room). 13/14 rooms
    now carry at least one hotspot; "room" got a genuine 0-match (frontend
    edge-chip fallback covers it, same as Gemini's own omit-if-unsure behavior).
    
    Ledgered as reversible (undo = UPDATE rooms SET hotspots='[]' for the 14
    slugs) at ~/.claude/yolo-queue/executed-reversible/ledger.jsonl.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01VAXbNq4CYRmd9hRam2NU6P
---
 lib/hotspot-providers/gemini.js |  86 ++++++++++++++++++++++++++++++++++
 lib/hotspot-providers/openai.js | 100 +++++++++++++++++++++++++++++++++++++++
 lib/hotspots.js                 | 101 ++++++++++------------------------------
 scripts/backfill-hotspots.js    |  94 +++++++++++++++++++++++++++++++++++++
 4 files changed, 305 insertions(+), 76 deletions(-)

diff --git a/lib/hotspot-providers/gemini.js b/lib/hotspot-providers/gemini.js
new file mode 100644
index 0000000..81f46a4
--- /dev/null
+++ b/lib/hotspot-providers/gemini.js
@@ -0,0 +1,86 @@
+// HOTSPOT PROVIDER: gemini — Vision-locate products inside a generated room-setting
+// image so the UI can put a shoppable hotspot ON each actual piece. Uses Gemini 2.5
+// Flash bounding-box detection (returns [ymin,xmin,ymax,xmax] normalized 0-1000). Any
+// product the model can't confidently place is simply omitted — the frontend renders
+// those as edge chips so nothing becomes unselectable. Cheap: one Flash text call per
+// scene. Selected via HOTSPOT_PROVIDER=gemini (default) in lib/hotspots.js.
+const fs = require('fs');
+
+const MODEL = 'gemini-2.5-flash';
+const COST_PER_CALL = 0.001; // ~1 image + small JSON out on Flash; shown to Steve
+
+function buildPrompt(products) {
+  const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
+  return [
+    'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
+    'For EACH product you can clearly locate, return its bounding box.',
+    'Respond with ONLY a compact JSON array, no prose, no code fence:',
+    '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
+    'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right).',
+    'Omit any product you cannot confidently locate. Products:',
+    list,
+  ].join('\n');
+}
+
+// Intersection-over-union of two %-boxes — used to reject a new hotspot that lands
+// on the same visual object as one already accepted (lookalike products the model
+// tagged to the single instance it could actually see in the scene).
+function iou(a, b) {
+  const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
+  const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
+  const inter = ix * iy;
+  return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
+}
+
+function toHotspots(raw, products) {
+  const seen = new Set();
+  const hotspots = [];
+  for (const d of raw) {
+    const idx = Number(d.i) - 1;
+    const p = products[idx];
+    if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
+    let [ymin, xmin, ymax, xmax] = d.box.map(Number);
+    const cl = (n) => Math.max(0, Math.min(1000, n || 0));
+    ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
+    if (xmax <= xmin || ymax <= ymin) continue;
+    const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
+    if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue; // don't stack on one object
+    seen.add(p.id);
+    hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
+  }
+  return hotspots;
+}
+
+// products: [{id, title, image_url, price, sale_price, advertiser}]
+// imagePath: absolute path to the generated PNG on disk
+async function locateProducts(imagePath, products = []) {
+  const key = process.env.GEMINI_API_KEY;
+  if (!key || !products.length) return { hotspots: [], cost: 0 };
+  let b64;
+  try { b64 = fs.readFileSync(imagePath).toString('base64'); }
+  catch { return { hotspots: [], cost: 0 }; }
+
+  const prompt = buildPrompt(products);
+  let j;
+  try {
+    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: [{ inlineData: { mimeType: 'image/png', data: b64 } }, { text: prompt }] }],
+        generationConfig: { temperature: 0, responseMimeType: 'application/json' },
+      }),
+    });
+    j = await res.json();
+  } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
+  if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
+
+  const text = (j.candidates && j.candidates[0] && j.candidates[0].content
+    && j.candidates[0].content.parts || []).map((p) => p.text || '').join('');
+  let raw;
+  try { raw = JSON.parse(text); } catch { raw = []; }
+  if (!Array.isArray(raw)) raw = [];
+
+  return { hotspots: toHotspots(raw, products), cost: COST_PER_CALL };
+}
+
+module.exports = { locateProducts, COST_PER_CALL };
diff --git a/lib/hotspot-providers/openai.js b/lib/hotspot-providers/openai.js
new file mode 100644
index 0000000..407b2b4
--- /dev/null
+++ b/lib/hotspot-providers/openai.js
@@ -0,0 +1,100 @@
+// HOTSPOT PROVIDER: openai — FALLBACK for when the Gemini prepay is depleted
+// (TK-11260, follow-on to the TK-10402 SCENE_PROVIDER fallback pattern). Same
+// contract as hotspot-providers/gemini.js: vision-locate each product inside a
+// generated room-setting image and return a normalized %-box per product so the
+// frontend can render a shoppable hotspot ON the actual piece. Uses gpt-5.2 (vision)
+// via the Responses API — cost ~$0.003-0.006/room (image + short JSON out), a
+// little pricier than Gemini Flash's ~$0.001 but funded when Gemini isn't.
+// Selected via HOTSPOT_PROVIDER=openai in lib/hotspots.js.
+const fs = require('fs');
+
+const MODEL = 'gpt-5.2';
+const COST_PER_CALL = 0.005; // conservative estimate for one image + small JSON out; shown to Steve
+
+function buildPrompt(products) {
+  const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
+  return [
+    'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
+    'For EACH product you can clearly locate, return its bounding box.',
+    'Respond with ONLY a compact JSON array, no prose, no code fence, no markdown:',
+    '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
+    'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right), relative to the full image.',
+    'Omit any product you cannot confidently locate. Products:',
+    list,
+  ].join('\n');
+}
+
+function iou(a, b) {
+  const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
+  const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
+  const inter = ix * iy;
+  return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
+}
+
+function toHotspots(raw, products) {
+  const seen = new Set();
+  const hotspots = [];
+  for (const d of raw) {
+    const idx = Number(d.i) - 1;
+    const p = products[idx];
+    if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
+    let [ymin, xmin, ymax, xmax] = d.box.map(Number);
+    const cl = (n) => Math.max(0, Math.min(1000, n || 0));
+    ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
+    if (xmax <= xmin || ymax <= ymin) continue;
+    const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
+    if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue;
+    seen.add(p.id);
+    hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
+  }
+  return hotspots;
+}
+
+function extractJsonArray(text) {
+  if (!text) return [];
+  // Strip a stray code fence if the model ignores the "no code fence" instruction.
+  const cleaned = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
+  try {
+    const parsed = JSON.parse(cleaned);
+    return Array.isArray(parsed) ? parsed : [];
+  } catch { return []; }
+}
+
+async function locateProducts(imagePath, products = []) {
+  const key = process.env.OPENAI_API_KEY;
+  if (!key || !products.length) return { hotspots: [], cost: 0 };
+  let b64;
+  try { b64 = fs.readFileSync(imagePath).toString('base64'); }
+  catch { return { hotspots: [], cost: 0 }; }
+
+  const prompt = buildPrompt(products);
+  let j;
+  try {
+    const res = await fetch('https://api.openai.com/v1/responses', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
+      body: JSON.stringify({
+        model: MODEL,
+        input: [{
+          role: 'user',
+          content: [
+            { type: 'input_text', text: prompt },
+            { type: 'input_image', image_url: `data:image/png;base64,${b64}` },
+          ],
+        }],
+      }),
+    });
+    j = await res.json();
+  } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
+  if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
+
+  const text = (j.output || [])
+    .flatMap((o) => (o.content || []))
+    .map((c) => c.text || '')
+    .join('');
+  const raw = extractJsonArray(text);
+
+  return { hotspots: toHotspots(raw, products), cost: COST_PER_CALL };
+}
+
+module.exports = { locateProducts, COST_PER_CALL };
diff --git a/lib/hotspots.js b/lib/hotspots.js
index b6b3bb9..7011d2b 100644
--- a/lib/hotspots.js
+++ b/lib/hotspots.js
@@ -1,79 +1,28 @@
-// Vision-locate products inside a generated room-setting image so the UI can put
-// a shoppable hotspot ON each actual piece. Uses Gemini 2.5 Flash bounding-box
-// detection (returns [ymin,xmin,ymax,xmax] normalized 0-1000). Any product the
-// model can't confidently place is simply omitted — the frontend renders those as
-// edge chips so nothing becomes unselectable. Cheap: one Flash text call per scene.
-const fs = require('fs');
+// Hotspot-locate DISPATCHER (TK-11260, following the TK-10402 SCENE_PROVIDER pattern).
+// Selects the vision backend by HOTSPOT_PROVIDER so a depleted provider is a one-flag flip:
+//
+//   HOTSPOT_PROVIDER=gemini  (default) — Gemini 2.5 Flash, ~$0.001/room. Cheapest.
+//   HOTSPOT_PROVIDER=openai            — gpt-5.2 vision fallback, ~$0.005/room. Needs a
+//                                         funded OPENAI_API_KEY (already routed via secrets).
+//
+// Both providers export the same contract { locateProducts(imagePath, products), COST_PER_CALL },
+// so every caller (server.js /api/rooms, scripts/backfill-room-scenes.js, the one-off
+// hotspots-only backfill) is untouched. Adding a new backend = drop a
+// lib/hotspot-providers/<name>.js exporting that contract + name it here.
+const PROVIDERS = {
+  gemini: () => require('./hotspot-providers/gemini'),
+  openai: () => require('./hotspot-providers/openai'),
+};
 
-const MODEL = 'gemini-2.5-flash';
-const COST_PER_CALL = 0.001; // ~1 image + small JSON out on Flash; shown to Steve
-
-// products: [{id, title, image_url, price, sale_price, advertiser}]
-// imagePath: absolute path to the generated PNG on disk
-// returns: [{id, title, price, image_url, advertiser, box:{x,y,w,h}}]  (x/y/w/h in %)
-async function locateProducts(imagePath, products = []) {
-  const key = process.env.GEMINI_API_KEY;
-  if (!key || !products.length) return { hotspots: [], cost: 0 };
-  let b64;
-  try { b64 = fs.readFileSync(imagePath).toString('base64'); }
-  catch { return { hotspots: [], cost: 0 }; }
-
-  const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
-  const prompt = [
-    'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
-    'For EACH product you can clearly locate, return its bounding box.',
-    'Respond with ONLY a compact JSON array, no prose, no code fence:',
-    '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
-    'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right).',
-    'Omit any product you cannot confidently locate. Products:',
-    list,
-  ].join('\n');
-
-  let j;
-  try {
-    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: [{ inlineData: { mimeType: 'image/png', data: b64 } }, { text: prompt }] }],
-        generationConfig: { temperature: 0, responseMimeType: 'application/json' },
-      }),
-    });
-    j = await res.json();
-  } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
-  if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
-
-  const text = (j.candidates && j.candidates[0] && j.candidates[0].content
-    && j.candidates[0].content.parts || []).map((p) => p.text || '').join('');
-  let raw;
-  try { raw = JSON.parse(text); } catch { raw = []; }
-  if (!Array.isArray(raw)) raw = [];
-
-  // Intersection-over-union of two %-boxes — used to reject a new hotspot that lands
-  // on the same visual object as one already accepted (lookalike products the model
-  // tagged to the single instance it could actually see in the scene).
-  const iou = (a, b) => {
-    const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
-    const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
-    const inter = ix * iy;
-    return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
-  };
-  const seen = new Set();
-  const hotspots = [];
-  for (const d of raw) {
-    const idx = Number(d.i) - 1;
-    const p = products[idx];
-    if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
-    let [ymin, xmin, ymax, xmax] = d.box.map(Number);
-    // clamp to 0-1000 and ensure min<max
-    const cl = (n) => Math.max(0, Math.min(1000, n || 0));
-    ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
-    if (xmax <= xmin || ymax <= ymin) continue;
-    const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
-    if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue; // don't stack on one object
-    seen.add(p.id);
-    hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
-  }
-  return { hotspots, cost: COST_PER_CALL };
+const NAME = (process.env.HOTSPOT_PROVIDER || 'gemini').toLowerCase();
+const load = PROVIDERS[NAME];
+if (!load) {
+  throw new Error(`Unknown HOTSPOT_PROVIDER "${NAME}". Valid: ${Object.keys(PROVIDERS).join(', ')}`);
 }
+const provider = load();
 
-module.exports = { locateProducts, COST_PER_CALL };
+module.exports = {
+  PROVIDER: NAME,
+  locateProducts: provider.locateProducts,
+  COST_PER_CALL: provider.COST_PER_CALL,
+};
diff --git a/scripts/backfill-hotspots.js b/scripts/backfill-hotspots.js
new file mode 100644
index 0000000..f6a46fd
--- /dev/null
+++ b/scripts/backfill-hotspots.js
@@ -0,0 +1,94 @@
+// TK-11260 — hotspots-ONLY backfill for rooms that already have a scene_image but
+// empty hotspots ('[]'). Follow-on to TK-10402's Flux backfill: the Flux scene path
+// (SCENE_PROVIDER=replicate-flux) doesn't run vision-locate, so those 12 rooms
+// rendered but weren't shoppable. Unlike scripts/backfill-room-scenes.js (which also
+// regenerates the SCENE — paid, ~$0.04/room), this only re-runs lib/hotspots against
+// the EXISTING scene_image, so it's ~10x cheaper.
+//
+//   node scripts/backfill-hotspots.js --dry-run              # $0: list targets + est cost
+//   node scripts/backfill-hotspots.js --limit 1               # CANARY: one room, show cost
+//   node scripts/backfill-hotspots.js --max-cost 1             # hard spend cap (default $1)
+//   node scripts/backfill-hotspots.js                          # all empty-hotspots rooms w/ a scene
+//   HOTSPOT_PROVIDER=openai node scripts/backfill-hotspots.js  # force the fallback provider
+//
+// Idempotent: only rooms with scene_image NOT NULL AND (hotspots IS NULL OR hotspots = '[]')
+// unless --force. Whichever DATABASE_URL is active is the one updated. Per-room + running
+// cost printed every step. Failures are per-room (logged, skipped) — never aborts the batch.
+require('dotenv').config();
+const path = require('path');
+const db = require('../lib/db');
+const hotspots = require('../lib/hotspots');
+const { flag, num, MAX_FAILS } = require('../lib/run-guard');
+
+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 || 1));
+
+// The provider contract silently returns {hotspots:[],cost:0} when its key is
+// missing (intentional graceful-degrade for the LIVE server path — a room must
+// still render without a configured vision key). That's dangerous for a batch
+// backfill: a missing/unrouted key would look identical to "nothing found" and
+// get written as a false "done". Fail loud here instead.
+const REQUIRED_KEY = { gemini: 'GEMINI_API_KEY', openai: 'OPENAI_API_KEY' };
+const needKey = REQUIRED_KEY[hotspots.PROVIDER];
+if (needKey && !process.env[needKey]) {
+  console.error(`[hotspots] ABORT: HOTSPOT_PROVIDER=${hotspots.PROVIDER} needs ${needKey}, which is not set. $0 spent.`);
+  process.exit(2);
+}
+
+(async () => {
+  const where = FORCE
+    ? 'WHERE scene_image IS NOT NULL'
+    : "WHERE scene_image IS NOT NULL AND (hotspots IS NULL OR hotspots::text = '[]')";
+  const lim = LIMIT ? `LIMIT ${LIMIT}` : '';
+  const { rows: targets } = await db.query(
+    `SELECT slug, scene_image, product_ids FROM rooms ${where} ORDER BY updated_at ${lim}`);
+
+  const perCall = hotspots.COST_PER_CALL || 0;
+  const est = targets.length * perCall;
+  console.log(`[hotspots] provider=${hotspots.PROVIDER} — ${targets.length} room(s) need hotspots. Est cost ~$${est.toFixed(3)} ($${perCall}/room). Cap: $${MAX_COST.toFixed(2)}.`);
+  if (DRY) {
+    for (const r of targets) console.log(`  · would locate ${r.slug} (${(r.product_ids || []).length} products)`);
+    console.log('[hotspots] DRY RUN — nothing called, $0 spent.');
+    process.exit(0);
+  }
+  if (est > MAX_COST) {
+    console.error(`[hotspots] ABORT: estimated $${est.toFixed(3)} 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, fails = 0;
+  for (const r of targets) {
+    if (spent + perCall > MAX_COST) {
+      console.error(`[hotspots] 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; }
+      const { rows: products } = await db.query(
+        `SELECT id, title, image_url, price, sale_price, advertiser FROM products WHERE id = ANY($1) AND NOT suppressed`, [ids]);
+      if (!products.length) { console.log(`  · ${r.slug}: no usable products — skipped`); skipped++; continue; }
+
+      const imgPath = path.join(__dirname, '..', 'public', r.scene_image);
+      const loc = await hotspots.locateProducts(imgPath, products);
+      if (loc.error) throw new Error(loc.error);
+
+      await db.query('UPDATE rooms SET hotspots=$1, updated_at=now() WHERE slug=$2',
+        [JSON.stringify(loc.hotspots || []), r.slug]);
+
+      spent += loc.cost || 0; done++; fails = 0;
+      console.log(`  ✓ ${r.slug}: ${loc.hotspots.length}/${products.length} spots ($${(loc.cost || 0).toFixed(4)})  running: $${spent.toFixed(4)}`);
+    } catch (e) {
+      fails += 1;
+      console.error(`  ✗ ${r.slug}: ${e.message}`);
+      if (fails >= MAX_FAILS) {
+        console.error(`[hotspots] ABORT: ${MAX_FAILS} consecutive failures — likely a dead/unfunded credential. Spent $${spent.toFixed(4)}.`);
+        break;
+      }
+    }
+  }
+  console.log(`[hotspots] done. ${done} updated, ${skipped} skipped, spent $${spent.toFixed(4)}.`);
+  await db.pool.end();
+})();

← 156ccf7 TK-10402: undo script for Flux room-scene backfill  ·  back to Interiordesignershowroom  ·  TK-11341: add AdSense Auto-Ads loader + ads.txt (revert to r 0358e6e →