← back to Interiordesignershowroom

scripts/gen-guide-heroes.js

89 lines

// 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 COST = scene.COST_PER_IMAGE;
const OUT_DIR = path.join(__dirname, '..', 'public', 'img', 'guides');
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 = {
  'best-velvet-sofas':
    'a designer living room anchored by a plush emerald-green velvet sofa, brass accents, a marble coffee table and a large abstract artwork',
  'shop-the-look-mid-century-bedroom':
    'a warm mid-century-modern bedroom: walnut furniture, a low platform bed with mustard and cream linens, a globe pendant, and a leafy plant',
  'best-sectionals-small-spaces':
    'a compact modern living room making smart use of a low-profile bouclé sectional, floating shelves, and a round jute rug',
  'how-to-layer-lighting':
    'an elegant living room at dusk demonstrating layered lighting — a warm floor lamp, table lamps, and recessed accent lights washing the walls',
  'coastal-living-room-budget':
    'an elevated coastal living room: pale oak floors, a linen slipcovered sofa, rattan accents, blue-and-white textiles, and soft ocean light',
};

(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');
  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) {
    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 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 += (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)} (DB updated).`);
  process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });