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:
1 parent
c09aeccc9c
commit
a41448bb4a
12 files changed
+2932
-1
No files matched your search
@@ -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, '&').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}`);
|
||||
Reference in new issue
Block a user