← back to Interiordesignershowroom
scripts/backfill-room-scenes.js
91 lines
// TK-10402 (Option B) — backfill scene_image for EXISTING rooms whose scene was
// NULLed by the TK-10390 reconcile. Unlike scripts/gen-room-setting.js (which
// CREATES new rooms), this regenerates the hero scene for rooms that already exist,
// reusing each room's OWN products as image references, then writes the scene +
// 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
//
// Paid + writes DB. Whichever DATABASE_URL is active is the one updated (run on prod
// to fill the reconciled prod rows; run local to fill local). Per-image + 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 scene = require('../lib/scene');
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 || 12));
(async () => {
const where = FORCE ? '' : 'WHERE scene_image IS NULL';
const lim = LIMIT ? `LIMIT ${LIMIT}` : '';
const { rows: targets } = await db.query(
`SELECT slug, title, room_type, style, product_ids FROM rooms ${where} ORDER BY created_at ${lim}`);
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, 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; }
const { rows: products } = await db.query(
`SELECT id, title, image_url, price, sale_price, advertiser, brand
FROM products WHERE id = ANY($1) AND NOT suppressed AND image_url IS NOT NULL`, [ids]);
if (!products.length) { console.log(` · ${r.slug}: no usable product images — skipped`); skipped++; continue; }
// 1) render the scene (paid) using this room's own pieces as references
const out = await scene.generateScene({ style: r.style, room_type: r.room_type, products });
let cost = out.cost || 0;
// 2) vision-locate the pieces so the room stays shoppable (paid)
const imgPath = path.join(__dirname, '..', 'public', out.url);
const loc = await hotspots.locateProducts(imgPath, products);
cost += loc.cost || 0;
// 3) write scene + hotspots back to THIS room
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++; 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)}.`);
process.exit(0);
})().catch((e) => { console.error('[backfill-room-scenes]', e.message); process.exit(1); });