← back to Dw Fleet Registry

batch-gen-rooms.mjs

72 lines

#!/usr/bin/env node
// Batch: for each standalone fleet site, fetch its top product (= the pattern), generate
// 3 room-setting heroes via :3075, 2x-upscale, save to viewer/static/rooms/<domain>/, and
// stage the pool (/static URLs) via the viewer. Resumable (skips sites already done).
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
const DIR = path.dirname(new URL(import.meta.url).pathname);
const map = JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'deploy-map.json'), 'utf8'));
const VIEWER = 'http://localhost:9774';
const GEN = 'http://45.61.58.125:3075/api/generate-room';
const ROOMS = [
  { roomType: 'living_room', angle: 'straight_on', cameraDistance: 8 },
  { roomType: 'office', angle: 'angled_left', cameraDistance: 11 },
  { roomType: 'dining_room', angle: 'straight_on', cameraDistance: 11 },
];
const CONC = 3;
// only standalone sites that have a deploy target AND a live /api/products
const domains = Object.keys(map);
async function topPattern(domain) {
  try {
    const r = await fetch(`https://${domain}/api/products?limit=8`, { signal: AbortSignal.timeout(10000) });
    if (!r.ok) return null;
    const j = await r.json();
    const items = (j.items || j.products || []).filter(p => p && p.image_url && /^https/.test(p.image_url));
    return items[0]?.image_url || null;
  } catch { return null; }
}
async function genRoom(b64, j) {
  const r = await fetch(GEN, { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ patternBase64: b64, roomType: j.roomType, angle: j.angle, cameraDistance: j.cameraDistance, patternWidth: 36, patternHeight: 36 }), signal: AbortSignal.timeout(90000) });
  const d = await r.json().catch(() => ({}));
  if (!d.success || !d.image) throw new Error(d.error || ('HTTP ' + r.status));
  return Buffer.from(d.image.replace(/^data:[^,]+,/, ''), 'base64');
}
async function isRedirect(domain) {
  try { const r = await fetch(`https://${domain}/`, { redirect: 'manual', signal: AbortSignal.timeout(10000) }); return r.status >= 300 && r.status < 400; } catch { return false; }
}
async function doSite(domain) {
  const outDir = path.join(DIR, 'viewer', 'static', 'rooms', domain);
  const done = fs.existsSync(outDir) && fs.readdirSync(outDir).filter(f => f.endsWith('.png')).length >= ROOMS.length;
  if (done) { console.log(`SKIP ${domain} (already has rooms)`); return; }
  if (await isRedirect(domain)) { console.log(`REDIR ${domain} (skip — hero never shown)`); return; }
  const pat = await topPattern(domain);
  if (!pat) { console.log(`NOPAT ${domain} (no product pattern)`); return; }
  let b64;
  try { b64 = Buffer.from(await (await fetch(pat, { signal: AbortSignal.timeout(20000) })).arrayBuffer()).toString('base64'); }
  catch { console.log(`PATFAIL ${domain}`); return; }
  fs.mkdirSync(outDir, { recursive: true });
  const urls = [];
  for (const j of ROOMS) {
    try {
      const buf = await genRoom(b64, j);
      const file = `${j.roomType}-${j.angle}.png`;
      const out = path.join(outDir, file);
      fs.writeFileSync(out, buf);
      try { execFileSync('sips', ['-Z', '2368', out]); } catch {}
      urls.push(`/static/rooms/${domain}/${file}`);
    } catch (e) { console.log(`  gen-fail ${domain}/${j.roomType}: ${String(e.message).slice(0,60)}`); }
  }
  if (urls.length) {
    await fetch(`${VIEWER}/api/hero-pool/set`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, images: urls }) });
    console.log(`OK ${domain} -> ${urls.length} rooms staged`);
  } else console.log(`EMPTY ${domain}`);
}
// pool runner
let i = 0; let ok = 0;
async function worker() { while (i < domains.length) { const d = domains[i++]; try { await doSite(d); } catch (e) { console.log(`ERR ${d}: ${e.message}`); } } }
console.log(`batch over ${domains.length} domains, conc ${CONC}`);
await Promise.all(Array.from({ length: CONC }, worker));
console.log('batch done');