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