#!/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 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 ---------- 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 = v.slice(1, -1); else if (v.startsWith("'") && v.endsWith("'")) 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(formatSmartTitle(titleRaw, isZh)); // ZH description: use the ZH twin's own description when present, else EN. const desc = (isZh ? fmZh.description : '') || fmEn.description || ''; 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(meta.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 }; } 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 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`); if (!DRY) { await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' }).webp({ quality: 88 }).toFile(bannerWebp); if (FORMAT === 'jpg') { await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' }) .jpeg({ quality: QUALITY, mozjpeg: true, progressive: true }).toFile(bannerFallback); } else { await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' }) .png({ compressionLevel: 9, palette: true, quality: 90, colors: 256 }).toFile(bannerFallback); } await sharp(art).resize(800, 420, { fit: 'cover', position: 'right' }).webp({ quality: 86 }).toFile(thumb800); await sharp(art).resize(400, 210, { fit: 'cover', position: 'right' }).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 }; } // ---------- 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 (!DRY) console.log(` banner banners/${r.slug}.webp + .${FORMAT} thumbs -800/-400.webp`); } console.log(`\nDone: ${rows.length} slug(s) -> ${OUT}`);