feat(og-gen): check in the v2 art pipeline and its batch renderer

The consultant-approved "one art, four targets" work existed only in the
working tree. Commit it so the 72-post rollout is reproducible from the repo
instead of from one session's scratch files.

- template-v2.html / v2.1 / v2.2: the iteration the consultant reviewed, plus
  the accepted frosted-bento layout (736x536 card, blueprint grid, full-width
  evidence bar, self-fitting headline script that reports "FIT <n>px").
- render_sample_suite{,_v2,_v3}.mjs + fit-title-probe.mjs: how the two sample
  posts were rendered for rounds 2-4 (kept for provenance).
- generate_prompts_catalog.py + prompts_catalog.json: the 72 per-post prompts
  and their visual metaphors, extracted from the posts themselves.
- render_from_art.mjs (new): the batch renderer. One zero-text base frame per
  post -> banner 1600x900 webp (+jpg fallback), social card 1200x630 (EN and
  zh-<slug>), thumbs 800x420 and 400x210. Cards are filled from frontmatter +
  art-meta.json; a missing evidence line or a headline that never settles is a
  hard failure, never a silently-degraded card.
- art-meta.json: the per-slug input contract. Two slugs wired; 70 still need
  base art (GPU) and a hand-written evidence line.
- README: documents the pipeline, the measured format choice (558 KB PNG ->
  84 KB mozjpeg q85) and the PostList thumb-helper trap when ogImage moves to
  .jpg.

Proof the renderer reproduces the accepted output: re-rendering the two sample
posts yields banner + 800px thumb files BYTE-IDENTICAL to the consultant's
delivery (sha256 16e6a7b0, f3b29f38, c4e28291, 8095ec6c), and all four cards
report FIT, 2-3 title lines, 2 description lines, 0 overflow in x and y.
This commit is contained in:
hoelee committed 2026-10-18 16:32:58 +08:00
1 parent c09aeccc9c
commit a41448bb4a
12 files changed
+2932 -1

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
#!/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/<slug>.webp 1600x900 in-page hero (<picture> source)
* public/banners/<slug>.<fmt> 1600x900 hero fallback (<img> src, frontmatter `banner`)
* public/og/<slug>.<fmt> 1200x630 social card (frontmatter `ogImage`)
* public/og/thumb/<slug>-800.webp 800x420 list / bento card
* public/og/thumb/<slug>-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 <title> ("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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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}`);