Files
hoelee-blog/scripts/og-gen/fit-title-probe.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

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(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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}`));
}