Files
hoelee-blog/scripts/og-gen/render_sample_suite_v2.mjs
T
hoelee a41448bb4a 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.
2026-10-18 16:32:58 +08:00

137 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* render_sample_suite.mjs — render the "one art, four targets" sample set from two
* real Ideogram 4 base images (no GPU re-run needed).
*
* Template: scripts/og-gen/template-v2.1.html (outside consultant's v2.0 Bento spec:
* frosted Bento card, blueprint grid, 2-line description, full-width evidence bar).
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } 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 OUT_DIR = 'C:/Users/hoelee/Desktop/blog-og-image-redesign-brief/samples-v2';
mkdirSync(OUT_DIR, { recursive: true });
const AVATAR_FILE = join(ROOT, 'public', 'images', 'lee-teong-hoe-640.jpg');
const AVATAR_URL = 'file:///' + AVATAR_FILE.replace(/\\/g, '/');
const TPL_RAW = readFileSync(join(__dirname, 'template-v2.1.html'), 'utf8');
const SAMPLES = [
{
slug: 'running-production-infrastructure-solo',
bg: 'C:/AI/ideogram4/out/sample_prod_169_00001_.png',
category: 'case-studies',
tags: ['DOCKER', 'INFRASTRUCTURE', 'PROMETHEUS'],
evidence: '162 containers · 3 hosts · 78 compose stacks',
title_en: 'Running Production Infrastructure Solo: 162 Containers Across Three Hosts',
title_zh: '独自运维 3 台主机上的 162 个生产容器:两起故障改变了我验证数字的方式',
desc_en: 'One operator, three hosts, 162 containers in 78 compose stacks — and the two failures that changed how I verify my own numbers.',
desc_zh: '一个人、三台宿主、78 个 compose 栈里的 162 个容器,以及两起改变了我验证数字方式的真实故障。'
},
{
slug: 'docker-reported-exit-0-the-oom-killer-disagreed',
bg: 'C:/AI/ideogram4/out/sample_oom_169_00001_.png',
category: 'devops',
tags: ['DOCKER', 'DMESG', 'OOM-KILLER'],
evidence: 'exit 0 · dmesg: 6x cgroup OOM kills',
title_en: 'Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.',
title_zh: 'Docker 报告 Exit 0,内核却早已通过 OOM 杀死了它',
desc_en: 'A container kept stopping while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills.',
desc_zh: '容器不停意外停止,而 Docker 报的是 exit 0 / OOMKilled=false。真正的证据在 dmesg 里:六次 cgroup OOM 杀死。'
}
];
/** Consultant v2.0 sizing: larger, never collapses to 36px. */
function calcTitleSize(title, isZh) {
const len = title.replace(/<br\s*\/?>/gi, '').length;
if (isZh) {
if (len <= 20) return 48;
if (len <= 30) return 44;
return 40;
}
if (len <= 42) return 46;
if (len <= 65) return 42;
return 38;
}
/** Consultant v2.0: break Chinese titles at a colon/comma in the 7–18 char window
* so the second line is never an orphan tail like "杀死了它". */
function formatSmartTitle(title, isZh) {
if (!isZh || title.includes('<br>')) return title;
const m = title.match(/^(.{7,18}?[:,——])(.{5,})$/);
return m ? `${m[1]}<br>${m[2]}` : title;
}
async function renderCard(sample, isZh) {
const lang = isZh ? 'zh' : 'en';
const rawTitle = isZh ? sample.title_zh : sample.title_en;
const title = formatSmartTitle(rawTitle, isZh);
const titleSize = calcTitleSize(rawTitle, isZh);
const desc = isZh ? sample.desc_zh : sample.desc_en;
const tagsHtml = sample.tags.map((t) => `<span>${t}</span>`).join('');
const bgUrl = 'file:///' + sample.bg.replace(/\\/g, '/');
const html = TPL_RAW
.replace('{{LANG}}', isZh ? 'zh-CN' : 'en')
.replace('{{BG_IMAGE_SRC}}', bgUrl)
.replace('{{CATEGORY}}', sample.category)
.replace('{{TAGS_HTML}}', tagsHtml)
.replace('{{TITLE_SIZE}}', String(titleSize))
.replace('{{TITLE_HTML}}', title)
.replace('{{DESCRIPTION}}', desc)
.replace('{{EVIDENCE_SUMMARY}}', sample.evidence)
.replace('{{AVATAR_PATH}}', AVATAR_URL);
if (/\{\{[A-Z_]+\}\}/.test(html)) {
throw new Error(`unsubstituted placeholder: ${html.match(/\{\{[A-Z_]+\}\}/g)}`);
}
const tmpHtml = join(OUT_DIR, `temp_${sample.slug}_${lang}.html`);
writeFileSync(tmpHtml, html, 'utf8');
const outPng = join(OUT_DIR, `${sample.slug}_og_${lang}.png`);
execSync(
`"${CHROME}" --headless --screenshot="${outPng}" --window-size=1200,630 ` +
`--hide-scrollbars --disable-gpu --force-device-scale-factor=1 ` +
`"file:///${tmpHtml.replace(/\\/g, '/')}"`,
{ stdio: 'pipe' }
);
console.log(` OG (${lang}) ${titleSize}px -> ${outPng}`);
}
async function processSample(s) {
console.log(`\n=== ${s.slug} ===`);
// Hero banner 1600x900: consultant flagged the old crop left 2/3 black. Crop toward
// the 3D subject on the right so the art fills the frame instead of floating in a void.
const banner = join(OUT_DIR, `${s.slug}_banner_1600x900.webp`);
await sharp(s.bg)
.resize(1600, 900, { fit: 'cover', position: 'right' })
.webp({ quality: 88 })
.toFile(banner);
console.log(` banner -> ${banner}`);
// Listing thumbnail 800x420: pure art, right-anchored on the subject.
const thumb = join(OUT_DIR, `${s.slug}_thumb_800x420.webp`);
await sharp(s.bg)
.resize(800, 420, { fit: 'cover', position: 'right' })
.webp({ quality: 86 })
.toFile(thumb);
console.log(` thumb -> ${thumb}`);
await renderCard(s, false);
await renderCard(s, true);
}
for (const s of SAMPLES) await processSample(s);
console.log(`\nDone -> ${OUT_DIR}`);