← 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); });