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