← back to Feature Harvest

build-thumbs.js

119 lines

#!/usr/bin/env node
/*
 * build-thumbs.js — generate static-render preview thumbnails for the APP items
 * (source dw-app / wpb-tool) by screenshotting their HTML via headless Chrome,
 * then downscaling with sips (shrink-only, never upscale).
 *
 * Output: data/thumbs/<safeid>.jpg  +  data/thumbs/manifest.json {id:{file,ok}}
 * Items with no usable HTML, or whose render is near-blank, get ok:false → the
 * UI shows a deterministic gradient poster instead (never a broken image).
 *
 * Usage: node build-thumbs.js [--only <substr>] [--limit N]
 */
const fs = require('fs');
const path = require('path');
const { execFile, execFileSync } = require('child_process');

const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ROOT = __dirname;
const THUMBS = path.join(ROOT, 'data', 'thumbs');
const TMP = path.join(THUMBS, '_tmp');
const ITEMS = path.join(ROOT, 'data', 'items.json');
const BLANK_BYTES = 5500;          // jpeg smaller than this ≈ blank/login/empty
const WIDTH = 480;                 // thumb width cap (shrink-only)
const POOL = 3;                    // concurrent chrome instances

const argOnly = (() => { const i = process.argv.indexOf('--only'); return i > -1 ? process.argv[i + 1] : null; })();
const argLimit = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? +process.argv[i + 1] : Infinity; })();

fs.mkdirSync(TMP, { recursive: true });
const items = JSON.parse(fs.readFileSync(ITEMS, 'utf8'))
  .filter((i) => (i.source === 'dw-app' || i.source === 'wpb-tool') && i.path);

function resolveHtml(p) {
  try {
    const st = fs.statSync(p);
    if (st.isFile()) return p.endsWith('.html') ? p : null;
    for (const c of ['public/index.html', 'index.html']) {
      const f = path.join(p, c);
      if (fs.existsSync(f)) return f;
    }
    // first html anywhere shallow
    const hit = (function find(dir, d) {
      if (d < 0) return null;
      for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
        if (e.name.startsWith('.') || e.name === 'node_modules') continue;
        const fp = path.join(dir, e.name);
        if (e.isFile() && e.name.endsWith('.html')) return fp;
        if (e.isDirectory()) { const r = find(fp, d - 1); if (r) return r; }
      }
      return null;
    })(p, 1);
    return hit;
  } catch { return null; }
}
const safe = (id) => id.replace(/[^a-z0-9]+/gi, '_').slice(0, 80);

function shot(html, outPng) {
  return new Promise((resolve) => {
    const args = [
      '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
      '--force-color-profile=srgb', '--window-size=1280,860',
      '--run-all-compositor-stages-before-draw', '--virtual-time-budget=4500',
      '--default-background-color=ffffffff',
      `--screenshot=${outPng}`, `file://${html}`,
    ];
    const ch = execFile(CHROME, args, { timeout: 20000 }, () => {
      resolve(fs.existsSync(outPng));
    });
    ch.on('error', () => resolve(false));
  });
}
function thumb(png, jpg) {
  // sips: clamp width to WIDTH (only if larger), export jpeg. Returns bytes or 0.
  try {
    const w = parseInt(execFileSync('sips', ['-g', 'pixelWidth', png]).toString().match(/pixelWidth: (\d+)/)?.[1] || '0', 10);
    const args = w > WIDTH ? ['--resampleWidth', String(WIDTH)] : [];
    execFileSync('sips', [...args, '-s', 'format', 'jpeg', '-s', 'formatOptions', '70', png, '--out', jpg],
      { stdio: 'ignore' });
    return fs.existsSync(jpg) ? fs.statSync(jpg).size : 0;
  } catch { return 0; }
}

async function run() {
  let pool = items.filter((i) => !argOnly || (i.title + i.path).toLowerCase().includes(argOnly.toLowerCase()));
  pool = pool.slice(0, argLimit);
  const manifest = {};
  let done = 0;
  async function one(it) {
    const html = resolveHtml(it.path);
    const out = { file: `${safe(it.id)}.jpg`, ok: false };
    if (html) {
      const png = path.join(TMP, `${safe(it.id)}.png`);
      const jpg = path.join(THUMBS, out.file);
      if (await shot(html, png)) {
        const bytes = thumb(png, jpg);
        out.ok = bytes >= BLANK_BYTES;
        if (!out.ok && fs.existsSync(jpg)) fs.unlinkSync(jpg);
      }
      try { fs.existsSync(png) && fs.unlinkSync(png); } catch {}
    }
    manifest[it.id] = out;
    done++;
    process.stdout.write(`\r  ${done}/${pool.length}  ${out.ok ? '✓' : '·'} ${it.title.slice(0, 34).padEnd(34)}`);
  }
  // simple concurrency pool
  const queue = [...pool];
  await Promise.all(Array.from({ length: POOL }, async () => {
    while (queue.length) await one(queue.shift());
  }));
  // merge with any prior manifest (so partial --only runs don't wipe others)
  const mpath = path.join(THUMBS, 'manifest.json');
  let prior = {}; try { prior = JSON.parse(fs.readFileSync(mpath, 'utf8')); } catch {}
  fs.writeFileSync(mpath, JSON.stringify({ ...prior, ...manifest }, null, 2));
  const okN = Object.values(manifest).filter((m) => m.ok).length;
  console.log(`\n  thumbnails: ${okN}/${pool.length} real renders, ${pool.length - okN} → gradient fallback`);
  try { fs.rmdirSync(TMP, { recursive: true }); } catch {}
}
run();