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.
137 lines
5.6 KiB
JavaScript
137 lines
5.6 KiB
JavaScript
#!/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}`);
|