#!/usr/bin/env node /** * render_from_art.mjs — the "one art, four targets" batch renderer. * * One base-art frame per post (zero text in the art, per the consultant's spec) * becomes the four assets the site actually loads: * * public/banners/.webp 1600x900 in-page hero ( source) * public/banners/. 1600x900 hero fallback ( src, frontmatter `banner`) * public/og/. 1200x630 social card (frontmatter `ogImage`) * public/og/thumb/-800.webp 800x420 list / bento card * public/og/thumb/-400.webp 400x210 small list tile * * The OG card is the only composited one: template-v2.2.html (the consultant's * frosted-bento layout) is filled from frontmatter + art-meta.json and rendered * in headless Chrome at 1200x630. The self-fitting headline script reports the * size it settled on through ("FIT 44px"); a render that does not settle * is a hard failure, never a silently-shrunk card. * * Inputs * scripts/og-gen/art-meta.json per-slug { art, evidence, ogTitle_en, ogTitle_zh } * src/content/posts/<slug>.md title / description / category / tags (EN) * src/content/posts/zh/<slug>.md title (ZH) — description falls back to EN * * Every slug needs an `evidence` string: it is editorial text shown in the card's * evidence bar, and inventing one from frontmatter is exactly the kind of made-up * specificity the blog avoids. Missing entries fail loudly with the list. * * Usage * node scripts/og-gen/render_from_art.mjs --only <slug>[,<slug>] [--out DIR] * node scripts/og-gen/render_from_art.mjs --all [--limit N] [--dry-run] * flags: --format jpg|png (default jpg, 84 KB vs 558 KB measured on a real card) * --art-dir DIR (default C:/AI/ideogram4/out) * --meta FILE (default scripts/og-gen/art-meta.json) * --out DIR (default the repo's public/ — use a temp dir to dry-run) * --quality N (default 85, JPEG/mozjpeg) */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve, basename } from 'node:path'; import sharp from 'sharp'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..', '..'); const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; const TPL = readFileSync(join(__dirname, 'template-v2.2.html'), 'utf8'); const AVATAR = 'file:///' + join(ROOT, 'public', 'images', 'lee-teong-hoe-640.jpg').replace(/\\/g, '/'); // ---------- args ---------- const argv = process.argv.slice(2); const flag = (name, dflt) => { const i = argv.indexOf('--' + name); return i === -1 ? dflt : argv[i + 1]; }; const has = (name) => argv.includes('--' + name); const FORMAT = flag('format', 'jpg').toLowerCase(); if (!['jpg', 'png'].includes(FORMAT)) throw new Error(`--format must be jpg or png, got ${FORMAT}`); const QUALITY = Number(flag('quality', '85')); const ART_DIR = flag('art-dir', 'C:/AI/ideogram4/out'); const META_FILE = resolve(flag('meta', join(__dirname, 'art-meta.json'))); const PLAN_FILE = resolve(flag('plan', join(__dirname, 'banner-plan.json'))); const OUT = resolve(flag('out', join(ROOT, 'public'))); const LIMIT = Number(flag('limit', '0')) || 0; const DRY = has('dry-run'); const OUT_BANNER = join(OUT, 'banners'); const OUT_OG = join(OUT, 'og'); const OUT_THUMB = join(OUT, 'og', 'thumb'); const GEN_DIR = join(ROOT, '.og', 'gen'); for (const d of [OUT_BANNER, OUT_OG, OUT_THUMB, GEN_DIR]) mkdirSync(d, { recursive: true }); // ---------- frontmatter ---------- // A frozen YAML subset — but it must still honour the escapes the post authors use. // A ZH title written as `title: "你的\"磁盘将满\"告警在说谎"` arrives with literal // backslashes unless they are unescaped here; that shipped as `你的\"磁盘将满\"…` on // the ZH card (caught by the design consultant, fixed 2026-10-19). function yamlUnescape(v) { return String(v).replace(/\\(["'\\/nrt])/g, (_, c) => ( { '"': '"', "'": "'", '\\': '\\', '/': '/', n: '\n', r: '\r', t: '\t' }[c] )); } // Typography: the card renders “弯引号”, never the straight `"` that Markdown source // uses. Pairwise, so an odd count still opens before it closes. function curlyQuotes(s) { let open = true; return String(s ?? '').replace(/"/g, () => { const q = open ? '\u201C' : '\u201D'; open = !open; return q; }); } // The evidence strip is one line by contract (the template ellipsises, which would // silently swallow the payload). Cap at 65 chars — the consultant's ruling — and // trim on a word/separator boundary so the line still reads as data. const EVIDENCE_MAX = 65; function capEvidence(s) { const t = String(s ?? '').trim(); if (t.length <= EVIDENCE_MAX) return t; const cut = t.slice(0, EVIDENCE_MAX - 1); const at = Math.max(cut.lastIndexOf(' '), cut.lastIndexOf('·'), cut.lastIndexOf(',')); return `${(at > 30 ? cut.slice(0, at) : cut).replace(/[\s·,\-–—]+$/, '')}\u2026`; } function frontmatter(file) { const raw = readFileSync(file, 'utf8'); const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!m) throw new Error(`no frontmatter delimiters in ${file}`); const fm = {}; for (const line of m[1].split(/\r?\n/)) { const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/); if (!kv) continue; let v = kv[2].trim(); if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = yamlUnescape(v.slice(1, -1)); else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1); fm[kv[1]] = v; } const tags = (fm.tags ?? '').replace(/^\[|\]$/g, '').split(',') .map((t) => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean); return { ...fm, tags }; } // ---------- title handling (identical to the approved sample renderer) ---------- function startTitleSize(title, isZh) { return isZh ? 50 : title.length <= 42 ? 48 : 44; } function formatSmartTitle(title, isZh) { if (!isZh || title.includes('<br>')) return title; const m = title.match(/^(.{6,30}?[:,,——])(.{5,})$/); return m ? `${m[1]}<br>${m[2]}` : title; } function glueNumbers(title) { return title.replace(/([\p{L}])\s+(\d)/gu, '$1\u00A0$2'); } const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); function run(cmd, args, opts = {}) { return execFileSync(cmd, args, { stdio: 'pipe', maxBuffer: 64 * 1024 * 1024, ...opts }); } async function renderCard(slug, art, meta, fmEn, fmZh, isZh) { const lang = isZh ? 'zh' : 'en'; const fm = isZh ? fmZh : fmEn; const override = isZh ? meta.ogTitle_zh : meta.ogTitle_en; const titleRaw = override || fm.title; if (!titleRaw) throw new Error(`${slug}: no ${lang} title`); const title = glueNumbers(curlyQuotes(formatSmartTitle(titleRaw, isZh))); // ZH description: use the ZH twin's own description when present, else EN. const desc = curlyQuotes((isZh ? fmZh.description : '') || fmEn.description || ''); const evidence = capEvidence(meta.evidence); if (String(meta.evidence ?? '').trim().length > EVIDENCE_MAX) { console.warn(` ! evidence trimmed to ${EVIDENCE_MAX} chars: ${evidence}`); } const start = startTitleSize(title, isZh); const tags = (meta.tags || fmEn.tags || []).slice(0, 3) .map((t) => `<span>${esc(String(t).toUpperCase())}</span>`).join(''); const html = TPL .replace(/\{\{LANG\}\}/g, isZh ? 'zh-CN' : 'en') .replace(/\{\{BG_IMAGE_SRC\}\}/g, 'file:///' + art.replace(/\\/g, '/')) .replace(/\{\{CATEGORY\}\}/g, esc(fmEn.category || '')) .replace(/\{\{TAGS_HTML\}\}/g, tags) .replace(/\{\{TITLE_SIZE\}\}/g, String(start)) .replace(/\{\{TITLE_HTML\}\}/g, title) .replace(/\{\{DESCRIPTION\}\}/g, esc(desc)) .replace(/\{\{EVIDENCE_SUMMARY\}\}/g, esc(evidence)) .replace(/\{\{AVATAR_PATH\}\}/g, AVATAR); const left = html.match(/\{\{[A-Z_]+\}\}/g); if (left) throw new Error(`${slug}/${lang}: unsubstituted placeholder(s) ${left}`); const tmp = join(GEN_DIR, `${slug}-${lang}.html`); writeFileSync(tmp, html, 'utf8'); const url = 'file:///' + tmp.replace(/\\/g, '/'); const dom = run(CHROME, ['--headless', '--dump-dom', '--virtual-time-budget=2500', '--disable-gpu', url]).toString('utf8'); const m = dom.match(/<title>([^<]*)<\/title>/); const fit = m ? m[1] : '(no title)'; if (!/^FIT \d+px/.test(fit)) throw new Error(`${slug}/${lang}: headline fitting did not settle (${fit})`); const shotPng = join(GEN_DIR, `${slug}-og-${lang}.png`); run(CHROME, ['--headless', `--screenshot=${shotPng}`, '--window-size=1200,630', '--hide-scrollbars', '--disable-gpu', '--force-device-scale-factor=1', '--virtual-time-budget=2500', url]); const outFile = join(OUT_OG, isZh ? `zh-${slug}.${FORMAT}` : `${slug}.${FORMAT}`); if (FORMAT === 'jpg') { await sharp(shotPng).jpeg({ quality: QUALITY, mozjpeg: true, progressive: true }).toFile(outFile); } else { await sharp(shotPng).png({ compressionLevel: 9, palette: true, quality: 90, colors: 256 }).toFile(outFile); } return { lang, fit, file: basename(outFile), px: start }; } // ---------- banner framing (consultant's option B, 2026-10-19) ---------- // The base frame reserves its left ~60 % for the card's frosted panel — correct on the // 1200x630 share card (where art is scaled 116 % and pushed right) and wrong on the // page hero, which carries no text: there the empty half reads as a dead black band // (measured: the subject on `your-disk-full-alert-is-lying` occupied ~21 % of the // banner width before this change). So the banner — and the list thumbs, which are cut // from the banner — re-frame around the subject instead of scaling the raw frame. // // The crop plan comes from `banner-plan.json`, computed by // `C:/AI/ideogram4/plan_banner_crops.py` (detector in `probe_band_crop.py`): deviation from // an estimated background plane + largest connected component, so dark glass and matte // metal count as subject while the 1 px blueprint grid does not. Column energy and // pixel-brightness tests were both tried and both failed — brightness misses the dark body // (it boxed the glow, 8 % of the frame, for an object group spanning 35 %+), and a per-row // background sample cannot describe a horizontal gradient, so it boxed background instead. // `subjectBand` below stays as the fallback for art that has no plan entry yet, and is // deliberately the same simple heuristic as before. // // The zoom is limited by height as well as width: a portrait subject (e.g. 118x469 px) // held at 47 % of a 16:9 width would need 335 px of source height and would be sliced // top and bottom. `zoom` is therefore min(width demand, height affordance) and the // rule guarantees the whole band stays inside the crop. const BANNER_W = 1600; const BANNER_H = 900; const SUBJECT_FRACTION = 0.47; // target: subject = 45-50 % of the banner width const SUBJECT_CENTER_X = 0.575; // visual centre, centre-right per the consultant const MAX_ZOOM = 2.6; // above this the frame is being upscaled, not framed const BAND_GAP = 8; // px of dark tolerance inside one subject band async function subjectBand(art) { const { data, info } = await sharp(art).greyscale().raw().toBuffer({ resolveWithObject: true }); const { width: W, height: H } = info; const colMax = new Uint8Array(W); const hist = new Uint32Array(256); for (let y = 0; y < H; y++) { const off = y * W; for (let x = 0; x < W; x++) { const v = data[off + x]; if (v > colMax[x]) colMax[x] = v; hist[v]++; } } // threshold = the value above which sits ~0.5 % of the frame (floored at 80) let acc = 0; let thr = 80; for (let v = 255; v >= 0; v--) { acc += hist[v]; if (acc > data.length * 0.005) { thr = Math.max(80, v + 1); break; } } const runs = []; let start = null; let gap = 0; for (let x = 0; x < W; x++) { if (colMax[x] >= thr) { if (start === null) start = x; gap = 0; } else if (start !== null) { gap++; if (gap > BAND_GAP) { runs.push([start, x - gap]); start = null; } } } if (start !== null) runs.push([start, W - 1]); if (!runs.length) return { W, H, x1: 0, x2: W - 1, y1: 0, y2: H - 1, thr, fallback: true }; const [x1, x2] = runs.reduce((a, b) => (b[1] - b[0] > a[1] - a[0] ? b : a)); let y1 = H; let y2 = -1; for (let y = 0; y < H; y++) { const off = y * W; for (let x = x1; x <= x2; x++) { if (data[off + x] >= thr) { if (y < y1) y1 = y; if (y > y2) y2 = y; break; } } } if (y2 < 0) return { W, H, x1, x2, y1: 0, y2: H - 1, thr, fallback: true }; return { W, H, x1, x2, y1, y2, thr, fallback: false }; } // The plan gives the window in source coordinates: scale the art to sw x sh, extend the // canvas with the art's own edge colour where the window overhangs, then extract. Extending // rather than clamping is what keeps a thin subject at 53.5 % of the width instead of // gluing it to the right edge. let PLAN = { frames: {} }; if (existsSync(PLAN_FILE)) { try { PLAN = JSON.parse(readFileSync(PLAN_FILE, 'utf8')); } catch (err) { console.warn(` ! ${basename(PLAN_FILE)} unreadable (${err.message}) — falling back to detection`); } } // Extends the canvas with the art's own edge colours, one row/column at a time: each // extended row takes that row's edge pixel, so the frame's vertical gradient (and its // vignette) survives, and the seam matches pixel-for-pixel. A single averaged fill colour // was tried first and left a visible tonal step down the right edge (caught in review). async function extendEdge(scaled, p, padL, padT, padR, padB) { const { sw, sh } = p; const [fr, fg, fb] = p.fill; const bg = { r: fr, g: fg, b: fb }; const stripCol = async (x, w) => sharp(scaled) .extract({ left: x, top: 0, width: 1, height: sh }) .resize({ width: w, height: sh, fit: 'fill' }).toBuffer(); const stripRow = async (img, y, h, w) => sharp(img) .extract({ left: 0, top: y, width: w, height: 1 }) .resize({ width: w, height: h, fit: 'fill' }).toBuffer(); let horiz = scaled; const cw = sw + padL + padR; if (padL || padR) { const parts = []; if (padL) parts.push({ input: await stripCol(0, padL), left: 0, top: 0 }); parts.push({ input: scaled, left: padL, top: 0 }); if (padR) parts.push({ input: await stripCol(sw - 1, padR), left: padL + sw, top: 0 }); horiz = await sharp({ create: { width: cw, height: sh, channels: 3, background: bg } }) .composite(parts).png().toBuffer(); } if (!padT && !padB) return horiz; const ch = sh + padT + padB; const parts = []; if (padT) parts.push({ input: await stripRow(horiz, 0, padT, cw), left: 0, top: 0 }); parts.push({ input: horiz, left: 0, top: padT }); if (padB) parts.push({ input: await stripRow(horiz, sh - 1, padB, cw), left: 0, top: padT + sh }); return sharp({ create: { width: cw, height: ch, channels: 3, background: bg } }) .composite(parts).png().toBuffer(); } // Returns { buf, zoom, fraction, band, binding, planned } — the banner pixels plus the // numbers to log, so a run states what it did instead of asking anyone to trust the picture. async function bannerFraming(art, slug) { const p = PLAN.frames && PLAN.frames[slug]; if (p) { const scaled = await sharp(art).resize({ width: p.sw, height: p.sh, fit: 'fill' }).toBuffer(); const padL = Math.max(0, -p.left); const padT = Math.max(0, -p.top); const padR = Math.max(0, p.left + BANNER_W - p.sw); const padB = Math.max(0, p.top + BANNER_H - p.sh); const grown = (padL || padT || padR || padB) ? await extendEdge(scaled, p, padL, padT, padR, padB) : scaled; const buf = await sharp(grown) .extract({ left: p.left + padL, top: p.top + padT, width: BANNER_W, height: BANNER_H }) .toBuffer(); return { buf, zoom: p.zoom, fraction: p.silhouette, band: `${p.band[0]},${p.band[1]}-${p.band[2]},${p.band[3]}`, binding: 'plan', ps: `${p.left},${p.top}`, extended: padL + padT + padR + padB, padEdges: `L${padL} T${padT} R${padR} B${padB}`, planned: true, }; } // fallback for art that has no plan entry yet (a brand-new post rendered before the // planner has seen its frame): the older column-energy heuristic const b = await subjectBand(art); const bw = Math.max(1, b.x2 - b.x1); const bh = Math.max(1, b.y2 - b.y1); const cx = (b.x1 + b.x2) / 2; const cy = (b.y1 + b.y2) / 2; const floor = BANNER_W / b.W; // never leave the canvas short const wantW = (SUBJECT_FRACTION * BANNER_W) / bw; // zoom that hits 47 % of width const allowH = (BANNER_H / bh) * 0.90; // zoom that keeps 5 % air top+bottom let zoom = Math.min(MAX_ZOOM, Math.max(floor, Math.min(wantW, allowH))); if (bh * zoom > BANNER_H) zoom = Math.max(floor, (BANNER_H / bh) * 0.96); const scaledW = Math.round(b.W * zoom); const scaledH = Math.round(b.H * zoom); const left = Math.round(Math.min(Math.max(cx * zoom - SUBJECT_CENTER_X * BANNER_W, 0), Math.max(0, scaledW - BANNER_W))); const top = Math.round(Math.min(Math.max(cy * zoom - BANNER_H / 2, 0), Math.max(0, scaledH - BANNER_H))); const buf = await sharp(art) .resize({ width: scaledW }) .extract({ left, top, width: BANNER_W, height: BANNER_H }) .toBuffer(); const bandFits = (b.y1 * zoom - top >= -1) && (b.y2 * zoom - top <= BANNER_H + 1); if (!bandFits) throw new Error(`banner framing would cut the subject band ${b.y1}-${b.y2}`); return { buf, zoom: Number(zoom.toFixed(2)), fraction: Number(((bw * zoom) / BANNER_W).toFixed(3)), band: `${b.x1},${b.y1}-${b.x2},${b.y2} (${bw}x${bh}px, thr ${b.thr})`, binding: wantW <= allowH ? 'width' : 'height', ps: `${left},${top}`, extended: 0, padEdges: '', planned: false, fallback: b.fallback, }; } async function renderSlug(slug, meta) { const art = resolve(meta.art || join(ART_DIR, `${slug}.png`)); if (!existsSync(art)) throw new Error(`${slug}: base art not found at ${art}`); const enPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); const zhPath = join(ROOT, 'src', 'content', 'posts', 'zh', `${slug}.md`); if (!existsSync(enPath)) throw new Error(`${slug}: no EN post at ${enPath}`); const fmEn = frontmatter(enPath); const fmZh = existsSync(zhPath) ? frontmatter(zhPath) : {}; // pure-art targets — banner first, then the thumbs derived from that same framing const bannerWebp = join(OUT_BANNER, `${slug}.webp`); const bannerFallback = join(OUT_BANNER, `${slug}.${FORMAT}`); const thumb800 = join(OUT_THUMB, `${slug}-800.webp`); const thumb400 = join(OUT_THUMB, `${slug}-400.webp`); let framing = null; if (!DRY) { framing = await bannerFraming(art, slug); await sharp(framing.buf).webp({ quality: 88 }).toFile(bannerWebp); if (FORMAT === 'jpg') { await sharp(framing.buf).jpeg({ quality: QUALITY, mozjpeg: true, progressive: true }).toFile(bannerFallback); } else { await sharp(framing.buf).png({ compressionLevel: 9, palette: true, quality: 90, colors: 256 }).toFile(bannerFallback); } await sharp(framing.buf).resize(800, 420, { fit: 'cover', position: 'center' }).webp({ quality: 86 }).toFile(thumb800); await sharp(framing.buf).resize(400, 210, { fit: 'cover', position: 'center' }).webp({ quality: 84 }).toFile(thumb400); } const cards = DRY ? [] : [await renderCard(slug, art, meta, fmEn, fmZh, false), await renderCard(slug, art, meta, fmEn, fmZh, true)]; return { slug, art, cards, framing }; } // ---------- main ---------- const META = JSON.parse(readFileSync(META_FILE, 'utf8')); const metaSlugs = Object.keys(META).filter((k) => !k.startsWith('_')); let targets; if (has('all')) { targets = metaSlugs; } else { const only = flag('only', ''); if (!only) { console.error('refusing to run: pass --only <slug[,slug]> or --all'); console.error(`known slugs in ${basename(META_FILE)} (${metaSlugs.length}): ${metaSlugs.join(', ')}`); process.exit(2); } targets = only.split(',').map((s) => s.trim()).filter(Boolean); } if (LIMIT) targets = targets.slice(0, LIMIT); const missingMeta = targets.filter((s) => !META[s]); const missingEvidence = targets.filter((s) => META[s] && !String(META[s].evidence || '').trim()); if (missingMeta.length) { console.error(`no art-meta entry (needs art + evidence) for: ${missingMeta.join(', ')}`); process.exit(2); } if (missingEvidence.length) { console.error(`art-meta entry has no evidence line for: ${missingEvidence.join(', ')}`); console.error('Write the evidence string deliberately — do not derive it from frontmatter.'); process.exit(2); } console.log(`format=${FORMAT} q=${QUALITY} out=${OUT} targets=${targets.length}${DRY ? ' (dry-run)' : ''}`); const rows = []; for (const slug of targets) { if (!DRY) console.log(`\n=== ${slug} ===`); rows.push(await renderSlug(slug, META[slug])); } for (const r of rows) { console.log(`${r.slug}`); console.log(` art ${r.art}`); for (const c of r.cards) console.log(` OG ${c.lang} start ${c.px}px -> ${c.fit} -> og/${c.file}`); if (r.framing) { console.log(` banner banners/${r.slug}.webp + .${FORMAT} band ${r.framing.band} ` + `-> x${r.framing.zoom} (${r.framing.binding}${r.framing.planned ? ', plan' : ' binds'}) ` + `-> subject at ${(r.framing.fraction * 100).toFixed(1)}% of ${BANNER_W}px` + `${r.framing.padEdges ? `, canvas extended ${r.framing.padEdges}` : ''}` + `${r.framing.planned ? '' : ' (no plan entry — detected on the fly)'}` + `${r.framing.fallback ? ' (FALLBACK framing — no subject band detected)' : ''}`); console.log(` thumbs og/thumb/${r.slug}-800.webp + -400.webp (cut from the banner)`); } } console.log(`\nDone: ${rows.length} slug(s) -> ${OUT}`);