[object Object]

← back to Feature Harvest

feature-harvest: add static-render preview thumbnails for app items (headless-Chrome screenshots + gradient-poster fallback)

71db8fdbe29ae6e3d083cbc0f2db2257ade50a0f · 2026-06-26 08:56:19 -0700 · Steve

Files touched

Diff

commit 71db8fdbe29ae6e3d083cbc0f2db2257ade50a0f
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 26 08:56:19 2026 -0700

    feature-harvest: add static-render preview thumbnails for app items (headless-Chrome screenshots + gradient-poster fallback)
---
 .gitignore        |   1 +
 build-thumbs.js   | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json      |   3 +-
 public/index.html |  19 +++++++++
 server.js         |  21 +++++++++-
 5 files changed, 160 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index e7f791a..92e9bd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ build/
 .next/
 data/wins-snapshot.json
 data/items.json
+data/thumbs/
diff --git a/build-thumbs.js b/build-thumbs.js
new file mode 100644
index 0000000..17c7c08
--- /dev/null
+++ b/build-thumbs.js
@@ -0,0 +1,118 @@
+#!/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();
diff --git a/package.json b/package.json
index 046e33d..0f9dd1b 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
   "description": "Interactive review deck — triage a year of shipped apps/features into a DW or WPB shortlist.",
   "scripts": {
     "build": "node build-corpus.js",
-    "start": "node server.js"
+    "start": "node server.js",
+    "thumbs": "node build-thumbs.js"
   }
 }
diff --git a/public/index.html b/public/index.html
index 7eb8a82..ba82730 100644
--- a/public/index.html
+++ b/public/index.html
@@ -65,6 +65,12 @@
   .vt-wpb{background:rgba(176,124,240,.18);color:var(--wpb)}
   .vt-both{background:rgba(61,220,151,.18);color:var(--both)}
   .vt-skip{background:rgba(90,97,114,.25);color:var(--skip)}
+  /* preview (app items only): gradient poster background + screenshot on top */
+  .preview{position:relative;width:100%;aspect-ratio:16/10;overflow:hidden;
+    border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:center}
+  .preview .thumb{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top}
+  .poster-label{position:relative;z-index:1;color:#fff;font-weight:700;font-size:17px;
+    text-align:center;padding:16px;text-shadow:0 1px 6px rgba(0,0,0,.45);letter-spacing:.3px}
   .card .body{padding:16px}
   .card h2{margin:0 0 4px;font-size:20px;line-height:1.25}
   .meta{color:var(--mut);font-size:12.5px;margin-bottom:12px}
@@ -99,6 +105,8 @@
     margin-bottom:8px;cursor:grab;font-size:13px}
   .mini:hover{border-color:var(--gold)}
   .mini .mt{font-weight:600;line-height:1.3}
+  .mini .preview{aspect-ratio:16/9;border:0;border-radius:7px;margin-bottom:7px}
+  .mini .poster-label{font-size:11px;padding:7px}
   .mini .ms{color:var(--mut);font-size:11px;margin-top:3px;display:flex;gap:6px;align-items:center}
   .col.drop{outline:2px dashed var(--gold);outline-offset:-4px}
   .grid-board[data-cols]{}
@@ -183,6 +191,15 @@ function buildProjectFacet(){
 
 // ---- helpers ----
 function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+function hueFromId(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return (h>>>0)%360;}
+function previewHTML(it){
+  if(it.source!=='dw-app' && it.source!=='wpb-tool') return '';   // app items only
+  const h=hueFromId(it.id);
+  const grad=`linear-gradient(135deg,hsl(${h} 52% 33%),hsl(${(h+45)%360} 58% 19%))`;
+  const img = it.thumb ? `<img class="thumb" loading="lazy" src="/thumb/${esc(it.thumb)}" alt="${esc(it.title)} preview" onerror="this.remove()">` : '';
+  const label = it.thumb ? '' : `<span class="poster-label">${esc(it.title)}</span>`;
+  return `<div class="preview" style="background:${grad}">${img}${label}</div>`;
+}
 function whenStr(it){
   if(!it.ts) return it.date||'';
   return new Date(it.ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
@@ -274,6 +291,7 @@ function cardHTML(it, big){
   const val = it.value ? `<div class="value"><b>Value:</b> ${esc(it.value)}</div>` : '';
   return `
   <div class="card" data-id="${esc(it.id)}">
+    ${previewHTML(it)}
     <div class="top">
       <span class="src ${it.source}">${SRC_LABEL[it.source]||it.source}</span>
       ${tag}
@@ -346,6 +364,7 @@ function renderBoard(){
 }
 function miniHTML(it){
   return `<div class="mini" data-id="${esc(it.id)}" title="${esc(it.title)}">
+    ${previewHTML(it)}
     <div class="mt">${esc(it.title)}</div>
     <div class="ms"><span class="src ${it.source}" style="font-size:9px;padding:1px 5px">${(SRC_LABEL[it.source]||it.source)}</span> ${esc(it.project||'')}</div>
   </div>`;
diff --git a/server.js b/server.js
index d668f87..02930d0 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,8 @@ const DATA = path.join(ROOT, 'data');
 const SNAP = path.join(DATA, 'wins-snapshot.json');
 const ITEMS = path.join(DATA, 'items.json');
 const VERDICTS = path.join(DATA, 'verdicts.json');
+const THUMBS = path.join(DATA, 'thumbs');
+const THUMB_MANIFEST = path.join(THUMBS, 'manifest.json');
 const CNCP_WINS = 'http://127.0.0.1:3333/api/wins';
 
 const USER = process.env.FH_USER || 'admin';
@@ -54,7 +56,15 @@ function fetchJSON(url, cb) {
 
 // ---- corpus + verdicts --------------------------------------------
 function loadWins() { return readJSON(SNAP, []); }
-function loadItems() { return readJSON(ITEMS, loadWins()); }
+function loadItems() {
+  const items = readJSON(ITEMS, loadWins());
+  const man = readJSON(THUMB_MANIFEST, {});
+  for (const it of items) {
+    const m = man[it.id];
+    it.thumb = m && m.ok ? m.file : null;   // null → UI shows gradient poster
+  }
+  return items;
+}
 function loadVerdicts() { return readJSON(VERDICTS, {}); }
 if (!fs.existsSync(VERDICTS)) writeJSON(VERDICTS, {});
 
@@ -130,6 +140,15 @@ const server = http.createServer((req, res) => {
   if (u.pathname === '/api/export') {
     return send(res, 200, exportMarkdown(), 'text/markdown; charset=utf-8');
   }
+  if (u.pathname.startsWith('/thumb/')) {
+    const file = path.basename(u.pathname.slice('/thumb/'.length)); // basename → no traversal
+    const fp = path.join(THUMBS, file);
+    if (file.endsWith('.jpg') && fs.existsSync(fp)) {
+      res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=300' });
+      return fs.createReadStream(fp).pipe(res);
+    }
+    return send(res, 404, { error: 'no thumb' });
+  }
   send(res, 404, { error: 'not found' });
 });
 

← fae9473 feature-harvest: interactive DW/WPB review deck over a year  ·  back to Feature Harvest  ·  feature-harvest: in-UI 'Rebuild thumbnails' button with asyn a537317 →