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.
109 lines
4.0 KiB
JavaScript
109 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } 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';
|
|
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.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 个生产容器:<br>两起故障改变了我验证数字的方式'
|
|
},
|
|
{
|
|
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 杀死了它'
|
|
}
|
|
];
|
|
|
|
function calcTitleSize(title, isZh) {
|
|
const len = title.length;
|
|
if (isZh) {
|
|
if (len <= 18) return 50;
|
|
if (len <= 26) return 44;
|
|
return 38;
|
|
}
|
|
if (len <= 45) return 48;
|
|
if (len <= 65) return 42;
|
|
return 36;
|
|
}
|
|
|
|
async function renderCard(sample, isZh) {
|
|
const lang = isZh ? 'zh' : 'en';
|
|
const title = isZh ? sample.title_zh : sample.title_en;
|
|
const titleSize = calcTitleSize(title, isZh);
|
|
|
|
const tagsHtml = sample.tags.map(t => `<span>${t}</span>`).join('');
|
|
const bgUrl = 'file:///' + sample.bg.replace(/\\/g, '/');
|
|
|
|
let 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}}', titleSize)
|
|
.replace('{{TITLE_HTML}}', title)
|
|
.replace('{{EVIDENCE_SUMMARY}}', sample.evidence)
|
|
.replace('{{AVATAR_PATH}}', AVATAR_URL);
|
|
|
|
const tmpHtml = join(OUT_DIR, `temp_${sample.slug}_${lang}.html`);
|
|
writeFileSync(tmpHtml, html, 'utf8');
|
|
|
|
const outPng = join(OUT_DIR, `${sample.slug}_og_${lang}.png`);
|
|
const chromeCmd = `"${CHROME}" --headless --screenshot="${outPng}" --window-size=1200,630 --hide-scrollbars --disable-gpu "file:///${tmpHtml.replace(/\\/g, '/')}"`;
|
|
execSync(chromeCmd, { stdio: 'pipe' });
|
|
console.log(`Rendered OG card (${lang}): ${outPng}`);
|
|
}
|
|
|
|
async function processSample(sample) {
|
|
console.log(`\n=== Processing Sample: ${sample.slug} ===`);
|
|
|
|
// 1. 生成 1600x900 内页 Hero Banner (无文字纯视觉)
|
|
const bannerWebp = join(OUT_DIR, `${sample.slug}_banner_1600x900.webp`);
|
|
await sharp(sample.bg)
|
|
.resize(1600, 900, { fit: 'cover', position: 'center' })
|
|
.webp({ quality: 88 })
|
|
.toFile(bannerWebp);
|
|
console.log(`Generated Banner: ${bannerWebp}`);
|
|
|
|
// 2. 生成 800x420 列表缩略图 (聚焦画面右侧 3D 核心主体)
|
|
const thumbWebp = join(OUT_DIR, `${sample.slug}_thumb_800x420.webp`);
|
|
await sharp(sample.bg)
|
|
.resize(800, 420, { fit: 'cover', position: 'right' })
|
|
.webp({ quality: 86 })
|
|
.toFile(thumbWebp);
|
|
console.log(`Generated Thumbnail: ${thumbWebp}`);
|
|
|
|
// 3. 渲染 1200x630 英文版与中文版 OG 分享卡
|
|
await renderCard(sample, false);
|
|
await renderCard(sample, true);
|
|
}
|
|
|
|
for (const sample of SAMPLES) {
|
|
await processSample(sample);
|
|
}
|
|
|
|
console.log(`\nAll sample visual assets generated successfully in ${OUT_DIR}!`);
|