← back to Quadrille Showroom

scripts/gen-assets.js

155 lines

#!/usr/bin/env node
/**
 * gen-assets.js — enrich the showroom wings with generated assets.
 *
 * Two layers (see the README in the showroom: tiles preserve the real design,
 * rooms are invented settings where AI models are appropriate):
 *
 *   TILES  ($0 local)  — for wallcoverings whose catalog photo FAILS the FullDesign
 *                        seam gate (lrScore >= 30), build a mirror book-match tile
 *                        (scripts/img_tools.py) so the GENUINE pattern tiles cleanly.
 *                        Output: data/gen-tiles/<sku>.png  (server prefers it per-SKU).
 *
 *   ROOMS  (paid)      — for wallcoverings with no distinct room shot, render a room
 *                        backdrop via Gemini 2.5 Flash Image (~$0.003/img). Invention
 *                        is fine here — it's a setting, not the product.
 *                        Output: data/gen-rooms/<sku>.jpg
 *
 * Usage:
 *   node scripts/gen-assets.js                 # dry-run: classify a sample + cost preview ($0)
 *   node scripts/gen-assets.js --tiles --limit N   # build N local seamless tiles ($0)
 *   node scripts/gen-assets.js --rooms --execute --limit N --ceiling 2.00   # paid Gemini rooms
 *
 * Hard rule (Steve): always show $ cost. Per-call + running total printed; --rooms
 * never spends without --execute, and stops at --ceiling.
 */
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

const DATA = path.join(__dirname, '..', 'data');
const TILES = path.join(DATA, 'gen-tiles');
const ROOMS = path.join(DATA, 'gen-rooms');
const PY = path.join(__dirname, 'img_tools.py');
const ROOM_COST = 0.003; // Gemini 2.5 Flash Image, per render

const args = process.argv.slice(2);
const has = (f) => args.includes(f);
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
const LIMIT = parseInt(val('--limit', '0')) || 0;
const CEILING = parseFloat(val('--ceiling', '1.00'));
const EXECUTE = has('--execute');

const data = JSON.parse(fs.readFileSync(path.join(DATA, 'showroom-products.json'), 'utf8'));
const products = data.products;

function score(url) {
  try { return parseFloat(execFileSync('python3', [PY, 'score', url], { encoding: 'utf8', timeout: 30000 }).trim()); }
  catch { return 999; }
}

function dryRun() {
  console.log(`\n[gen-assets] DRY RUN — no writes, no spend.  catalog: ${products.length} wallcoverings\n`);
  // Sample-classify for a fail-rate estimate (full classify happens during --tiles).
  const SAMPLE = Math.min(40, products.length);
  const step = Math.max(1, Math.floor(products.length / SAMPLE));
  let fails = 0, tested = 0;
  for (let i = 0; i < products.length && tested < SAMPLE; i += step) {
    const s = score(products[i].image);
    if (s >= 30) fails++;
    tested++;
    process.stdout.write(`\r  sampling FullDesign… ${tested}/${SAMPLE}`);
  }
  const failRate = fails / tested;
  const tilesNeeded = Math.round(failRate * products.length);
  const noRoom = products.filter(p => !p.room).length;
  console.log(`\n\n  Tiles:  ~${(failRate * 100).toFixed(0)}% of photos fail the seam gate → ~${tilesNeeded} mirror tiles to build`);
  console.log(`          method: $0 local mirror book-match (preserves the real design)`);
  console.log(`  Rooms:  ${noRoom} wallcoverings have no distinct room shot`);
  console.log(`          Gemini 2.5 Flash Image @ $${ROOM_COST}/img → full run ≈ $${(noRoom * ROOM_COST).toFixed(2)}`);
  console.log(`\n  Next:`);
  console.log(`    node scripts/gen-assets.js --tiles --limit 20            # $0, build sample tiles`);
  console.log(`    node scripts/gen-assets.js --rooms --execute --limit 25 --ceiling 0.10   # ~$0.08 sample`);
  console.log(`    (full room run is ~$${(noRoom * ROOM_COST).toFixed(2)} — Steve's go before the big batch)\n`);
}

function buildTiles() {
  fs.mkdirSync(TILES, { recursive: true });
  const wallcov = products.filter(p => p.image);
  let built = 0, skipped = 0, checked = 0;
  console.log(`\n[gen-assets] TILES ($0 local mirror book-match)  limit=${LIMIT || 'all'}\n`);
  for (const p of wallcov) {
    if (LIMIT && built >= LIMIT) break;
    const out = path.join(TILES, `${p.sku}.png`);
    if (fs.existsSync(out)) { skipped++; continue; }
    checked++;
    let before;
    try { before = parseFloat(execFileSync('python3', [PY, 'score', p.image], { encoding: 'utf8', timeout: 30000 }).trim()); }
    catch { continue; }
    if (before < 30) continue; // already seamless — no tile needed
    try {
      const res = execFileSync('python3', [PY, 'mirror', p.image, out], { encoding: 'utf8', timeout: 60000 }).trim();
      const [b, a] = res.split(' ');
      built++;
      console.log(`  ✓ ${p.sku}  seam ${b} → ${a}  $0 (local)   [${built}${LIMIT ? '/' + LIMIT : ''}]`);
    } catch (e) { console.log(`  ✗ ${p.sku}  ${e.message}`); }
  }
  console.log(`\n  built ${built} tiles · skipped ${skipped} existing · cost $0 (local).`);
  console.log(`  Restart the server to pick them up (loaded per-SKU at boot).`);
}

async function buildRooms() {
  fs.mkdirSync(ROOMS, { recursive: true });
  const need = products.filter(p => !p.room && (!fs.existsSync(path.join(ROOMS, `${p.sku}.jpg`))));
  const key = process.env.GEMINI_API_KEY;
  let spent = 0, made = 0;
  console.log(`\n[gen-assets] ROOMS (Gemini 2.5 Flash Image @ $${ROOM_COST}/img)  execute=${EXECUTE}  ceiling=$${CEILING.toFixed(2)}  limit=${LIMIT || 'all'}\n`);
  if (!EXECUTE) { console.log(`  --execute not set → no spend. ${need.length} candidates, full run ≈ $${(need.length * ROOM_COST).toFixed(2)}.`); return; }
  if (!key) { console.log('  GEMINI_API_KEY not in env → cannot render. Route it via the secrets skill, then re-run.'); return; }
  for (const p of need) {
    if (LIMIT && made >= LIMIT) break;
    if (spent + ROOM_COST > CEILING) { console.log(`\n  ceiling $${CEILING.toFixed(2)} reached — stopping.`); break; }
    try {
      await renderRoomGemini(p, key, path.join(ROOMS, `${p.sku}.jpg`));
      spent += ROOM_COST; made++;
      console.log(`  ✓ ${p.sku}  room rendered  $${ROOM_COST.toFixed(3)}  (running $${spent.toFixed(3)})   [${made}${LIMIT ? '/' + LIMIT : ''}]`);
    } catch (e) { console.log(`  ✗ ${p.sku}  ${e.message}`); }
  }
  console.log(`\n  rendered ${made} rooms · spent $${spent.toFixed(3)}.`);
  // Best-effort cost-tracker log
  try {
    const log = path.join(process.env.HOME, '.claude', 'skills', 'cost-tracker', 'log.js');
    if (made && fs.existsSync(log)) execFileSync('node', [log, 'gemini-image', String(made), String(spent)], { stdio: 'ignore' });
  } catch {}
}

// Gemini 2.5 Flash Image — composite the real swatch into a styled room (the setting
// is invented; the product texture is passed as inline_data so the wall reads true).
async function renderRoomGemini(product, key, outPath) {
  const https = require('https');
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${key}`;
  // Fetch the swatch bytes (via the public CDN) to send as reference.
  const swatch = await fetchBuffer(product.rawImage || product.image);
  const body = JSON.stringify({
    contents: [{ parts: [
      { text: `Photorealistic interior: a refined living room with this ${product.vendor} wallcovering pattern applied to the main wall, natural daylight, tasteful furniture, designer styling. Keep the wallpaper pattern faithful to the reference image.` },
      { inline_data: { mime_type: 'image/jpeg', data: swatch.toString('base64') } },
    ]}],
    generationConfig: { responseModalities: ['IMAGE'] },
  });
  const json = await postJSON(url, body);
  const part = ((((json.candidates || [])[0] || {}).content || {}).parts || []).find(p => p.inline_data || p.inlineData);
  const b64 = part && (part.inline_data ? part.inline_data.data : part.inlineData.data);
  if (!b64) throw new Error('no image in response');
  fs.writeFileSync(outPath, Buffer.from(b64, 'base64'));

  function fetchBuffer(u) { return new Promise((res, rej) => { https.get(u, r => { const c = []; r.on('data', d => c.push(d)); r.on('end', () => res(Buffer.concat(c))); }).on('error', rej); }); }
  function postJSON(u, payload) { return new Promise((res, rej) => { const r = https.request(u, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, resp => { const c = []; resp.on('data', d => c.push(d)); resp.on('end', () => { try { res(JSON.parse(Buffer.concat(c).toString())); } catch (e) { rej(e); } }); }); r.on('error', rej); r.write(payload); r.end(); }); }
}

(async () => {
  if (has('--tiles')) buildTiles();
  else if (has('--rooms')) await buildRooms();
  else dryRun();
})();