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.
96 lines
4.0 KiB
JavaScript
96 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* balanced-fit probe: find the font size and explicit line breaks that give a
|
|
* headline N balanced lines (no runt first line, no widow last line) inside a
|
|
* fixed-width box — without relying on CSS `text-wrap: balance` support.
|
|
*
|
|
* Usage: node fit-title-probe.mjs
|
|
* Prints a table for the sample titles so the values can be baked into the renderer.
|
|
*/
|
|
import { execSync } from 'node:child_process';
|
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
|
const OUT = 'C:/Users/hoelee/Desktop/blog-og-image-redesign-brief/samples-v2';
|
|
mkdirSync(OUT, { recursive: true });
|
|
|
|
const TITLES = [
|
|
['en', 'Running Production Infrastructure Solo: 162 Containers Across Three Hosts'],
|
|
['en', 'Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.'],
|
|
['zh', '独自运维 162 个生产容器:两起故障改变了我验证数字的方式'],
|
|
['zh', 'Docker 报 Exit 0,内核早已 OOM 杀死了它'],
|
|
];
|
|
|
|
const probe = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
|
body{margin:0;font-family:"Space Grotesk","Plus Jakarta Sans","Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
|
#box{width:648px}
|
|
</style></head><body><div id="box"></div><script>
|
|
const AVAIL = 648, MAXL = 3;
|
|
const ctx = document.createElement('canvas').getContext('2d');
|
|
function w(s, px) { ctx.font = '800 ' + px + 'px "Space Grotesk","Plus Jakarta Sans","Segoe UI","PingFang SC","Microsoft YaHei",sans-serif'; return ctx.measureText(s).width; }
|
|
function isCJK(s){ return /[\\u4e00-\\u9fff]/.test(s); }
|
|
function toks(t, cjk){ return cjk ? Array.from(t) : t.split(/\\s+/).filter(Boolean); }
|
|
function join(a, cjk){ return cjk ? a.join('') : a.join(' '); }
|
|
|
|
function layout(text, cjk, px){
|
|
const tk = toks(text, cjk);
|
|
const total = w(join(tk, cjk), px);
|
|
let L = Math.max(1, Math.min(MAXL, Math.ceil(total / AVAIL)));
|
|
// try L and L+1, keep whichever balances better and fits
|
|
let best = null;
|
|
for (let n = L; n <= MAXL; n++) {
|
|
const target = total / n;
|
|
let lines = [], cur = [], curW = 0;
|
|
for (const t of tk) {
|
|
const add = w((cur.length && !cjk) ? ' ' + t : t, px);
|
|
if (cur.length && curW + add > target * 1.02 && lines.length < n - 1) {
|
|
lines.push(cur.join(cjk ? '' : ' ')); cur = [t]; curW = w(t, px);
|
|
} else { cur.push(t); curW += add; }
|
|
}
|
|
if (cur.length) lines.push(cur.join(cjk ? '' : ' '));
|
|
const widths = lines.map(s => w(s, px));
|
|
const maxW = Math.max(...widths);
|
|
if (maxW > AVAIL + 0.5) continue;
|
|
const minW = Math.min(...widths);
|
|
const score = maxW - minW; // smaller = more even
|
|
if (!best || score < best.score) best = { lines, widths, score, px, n };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function fit(text){
|
|
const cjk = isCJK(text);
|
|
for (let px = 52; px >= 26; px--) {
|
|
const r = layout(text, cjk, px);
|
|
if (r) return { px, lines: r.lines, widths: r.widths };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const cases = TITLES_JSON;
|
|
document.title = 'RESULT' + JSON.stringify(cases.map(([lang, t]) => ({ lang, t, out: fit(t) })));
|
|
</script></body></html>`;
|
|
|
|
const html = probe.replace('TITLES_JSON', JSON.stringify(TITLES));
|
|
const p = join(OUT, 'fit-probe.html');
|
|
writeFileSync(p, html, 'utf8');
|
|
|
|
const dom = execSync(
|
|
`"${CHROME}" --headless --dump-dom --virtual-time-budget=3000 --disable-gpu "file:///${p.replace(/\\/g, '/')}"`,
|
|
{ stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 }
|
|
).toString('utf8');
|
|
|
|
const m = dom.match(/<title>RESULT(.*?)<\/title>/s);
|
|
if (!m) {
|
|
console.error('probe did not return a result');
|
|
process.exit(1);
|
|
}
|
|
const rows = JSON.parse(m[1].replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'));
|
|
for (const r of rows) {
|
|
console.log(`\n[${r.lang}] ${r.t}`);
|
|
if (!r.out) { console.log(' NO FIT'); continue; }
|
|
console.log(` px=${r.out.px} lines=${r.out.lines.length} widths=[${r.out.widths.map(x => Math.round(x)).join(', ')}]`);
|
|
r.out.lines.forEach((l) => console.log(` | ${l}`));
|
|
}
|