Files
hoelee-blog/scripts/og-gen/render_from_art.mjs
T
hoelee f391682fc3
Deploy / build (push) Successful in 19s
Round 7: zero-text frames, escaped/curly titles, plan-driven banner crops
The consultant's review of the shipped set found two defects, both ours, and both fixed
at the source rather than in the output:

1. The banner/thumb crop re-framing detected only the glowing core of the object. Column
   brightness measures 2-6 levels above the background on dark glass, so it boxed 8 % of
   the frame for an object group spanning 35 %+. The detector is now a background-plane
   deviation plus the largest connected components (`plan_banner_crops.py`, detector in
   `probe_band_crop.py`), 0 % false-positive on the frame's own negative space, stable
   across thresholds. The crop plan is frozen as `banner-plan.json` and consumed by the
   renderer; a thin subject is centred at 53.5 % of the banner width with the shortfall
   filled by the art's own edge pixels, one row at a time (a single averaged fill left a
   visible tonal step, tried and rejected). Measured on the delivered 72: 0 clipped,
   silhouette median 47.0 %, centre median 53.5 % (`verify_banner_crops.py`).

2. `art-meta.json` pinned two slugs to the hand-accepted sample frames — the very frames
   that carried the HUD readouts — which also kept them out of the crop plan. Pins removed.

Prompt side: 9 frames still printed glyphs at the consultant's bar (>=12 px and >=2
characters). Every offending noun is measured and now asserted against: a literal `marked
at 28` printed "28"; `storefront kiosk` printed "PRINATA"; `voucher card` printed
"Voucher"; `market booths` printed "10MARKET"; `glass button` printed "OC"; `gateway pylon`
printed "GATELY". 50 forbidden words, 0 hits across 72 captions, and all 9 frames
re-rendered clean.

Also: YAML escapes are honoured in the frontmatter reader and straight quotes become
curly ones (the ZH cards shipped a literal backslash), the evidence line is capped at 65
characters, and the light theme gives dark artwork a hairline border plus a soft shadow
(consultant option A) so it reads as a dark display case instead of a black slab.

OCR gate at the consultant's bar: 72/72 frames clean (16 carry sub-threshold single
glyphs, exempt as geometry noise).
2026-10-19 11:02:28 +08:00

463 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* render_from_art.mjs — the "one art, four targets" batch renderer.
*
* One base-art frame per post (zero text in the art, per the consultant's spec)
* becomes the four assets the site actually loads:
*
* public/banners/<slug>.webp 1600x900 in-page hero (<picture> source)
* public/banners/<slug>.<fmt> 1600x900 hero fallback (<img> src, frontmatter `banner`)
* public/og/<slug>.<fmt> 1200x630 social card (frontmatter `ogImage`)
* public/og/thumb/<slug>-800.webp 800x420 list / bento card
* public/og/thumb/<slug>-400.webp 400x210 small list tile
*
* The OG card is the only composited one: template-v2.2.html (the consultant's
* frosted-bento layout) is filled from frontmatter + art-meta.json and rendered
* in headless Chrome at 1200x630. The self-fitting headline script reports the
* size it settled on through <title> ("FIT 44px"); a render that does not settle
* is a hard failure, never a silently-shrunk card.
*
* Inputs
* scripts/og-gen/art-meta.json per-slug { art, evidence, ogTitle_en, ogTitle_zh }
* src/content/posts/<slug>.md title / description / category / tags (EN)
* src/content/posts/zh/<slug>.md title (ZH) — description falls back to EN
*
* Every slug needs an `evidence` string: it is editorial text shown in the card's
* evidence bar, and inventing one from frontmatter is exactly the kind of made-up
* specificity the blog avoids. Missing entries fail loudly with the list.
*
* Usage
* node scripts/og-gen/render_from_art.mjs --only <slug>[,<slug>] [--out DIR]
* node scripts/og-gen/render_from_art.mjs --all [--limit N] [--dry-run]
* flags: --format jpg|png (default jpg, 84 KB vs 558 KB measured on a real card)
* --art-dir DIR (default C:/AI/ideogram4/out)
* --meta FILE (default scripts/og-gen/art-meta.json)
* --out DIR (default the repo's public/ — use a temp dir to dry-run)
* --quality N (default 85, JPEG/mozjpeg)
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve, basename } 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 TPL = readFileSync(join(__dirname, 'template-v2.2.html'), 'utf8');
const AVATAR = 'file:///' + join(ROOT, 'public', 'images', 'lee-teong-hoe-640.jpg').replace(/\\/g, '/');
// ---------- args ----------
const argv = process.argv.slice(2);
const flag = (name, dflt) => {
const i = argv.indexOf('--' + name);
return i === -1 ? dflt : argv[i + 1];
};
const has = (name) => argv.includes('--' + name);
const FORMAT = flag('format', 'jpg').toLowerCase();
if (!['jpg', 'png'].includes(FORMAT)) throw new Error(`--format must be jpg or png, got ${FORMAT}`);
const QUALITY = Number(flag('quality', '85'));
const ART_DIR = flag('art-dir', 'C:/AI/ideogram4/out');
const META_FILE = resolve(flag('meta', join(__dirname, 'art-meta.json')));
const PLAN_FILE = resolve(flag('plan', join(__dirname, 'banner-plan.json')));
const OUT = resolve(flag('out', join(ROOT, 'public')));
const LIMIT = Number(flag('limit', '0')) || 0;
const DRY = has('dry-run');
const OUT_BANNER = join(OUT, 'banners');
const OUT_OG = join(OUT, 'og');
const OUT_THUMB = join(OUT, 'og', 'thumb');
const GEN_DIR = join(ROOT, '.og', 'gen');
for (const d of [OUT_BANNER, OUT_OG, OUT_THUMB, GEN_DIR]) mkdirSync(d, { recursive: true });
// ---------- frontmatter ----------
// A frozen YAML subset — but it must still honour the escapes the post authors use.
// A ZH title written as `title: "你的\"磁盘将满\"告警在说谎"` arrives with literal
// backslashes unless they are unescaped here; that shipped as `你的\"磁盘将满\"…` on
// the ZH card (caught by the design consultant, fixed 2026-10-19).
function yamlUnescape(v) {
return String(v).replace(/\\(["'\\/nrt])/g, (_, c) => (
{ '"': '"', "'": "'", '\\': '\\', '/': '/', n: '\n', r: '\r', t: '\t' }[c]
));
}
// Typography: the card renders “弯引号”, never the straight `"` that Markdown source
// uses. Pairwise, so an odd count still opens before it closes.
function curlyQuotes(s) {
let open = true;
return String(s ?? '').replace(/"/g, () => {
const q = open ? '\u201C' : '\u201D';
open = !open;
return q;
});
}
// The evidence strip is one line by contract (the template ellipsises, which would
// silently swallow the payload). Cap at 65 chars — the consultant's ruling — and
// trim on a word/separator boundary so the line still reads as data.
const EVIDENCE_MAX = 65;
function capEvidence(s) {
const t = String(s ?? '').trim();
if (t.length <= EVIDENCE_MAX) return t;
const cut = t.slice(0, EVIDENCE_MAX - 1);
const at = Math.max(cut.lastIndexOf(' '), cut.lastIndexOf('·'), cut.lastIndexOf(','));
return `${(at > 30 ? cut.slice(0, at) : cut).replace(/[\s·,\-–—]+$/, '')}\u2026`;
}
function frontmatter(file) {
const raw = readFileSync(file, 'utf8');
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) throw new Error(`no frontmatter delimiters in ${file}`);
const fm = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
if (!kv) continue;
let v = kv[2].trim();
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = yamlUnescape(v.slice(1, -1));
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
fm[kv[1]] = v;
}
const tags = (fm.tags ?? '').replace(/^\[|\]$/g, '').split(',')
.map((t) => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
return { ...fm, tags };
}
// ---------- title handling (identical to the approved sample renderer) ----------
function startTitleSize(title, isZh) {
return isZh ? 50 : title.length <= 42 ? 48 : 44;
}
function formatSmartTitle(title, isZh) {
if (!isZh || title.includes('<br>')) return title;
const m = title.match(/^(.{6,30}?[:,,——])(.{5,})$/);
return m ? `${m[1]}<br>${m[2]}` : title;
}
function glueNumbers(title) {
return title.replace(/([\p{L}])\s+(\d)/gu, '$1\u00A0$2');
}
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
function run(cmd, args, opts = {}) {
return execFileSync(cmd, args, { stdio: 'pipe', maxBuffer: 64 * 1024 * 1024, ...opts });
}
async function renderCard(slug, art, meta, fmEn, fmZh, isZh) {
const lang = isZh ? 'zh' : 'en';
const fm = isZh ? fmZh : fmEn;
const override = isZh ? meta.ogTitle_zh : meta.ogTitle_en;
const titleRaw = override || fm.title;
if (!titleRaw) throw new Error(`${slug}: no ${lang} title`);
const title = glueNumbers(curlyQuotes(formatSmartTitle(titleRaw, isZh)));
// ZH description: use the ZH twin's own description when present, else EN.
const desc = curlyQuotes((isZh ? fmZh.description : '') || fmEn.description || '');
const evidence = capEvidence(meta.evidence);
if (String(meta.evidence ?? '').trim().length > EVIDENCE_MAX) {
console.warn(` ! evidence trimmed to ${EVIDENCE_MAX} chars: ${evidence}`);
}
const start = startTitleSize(title, isZh);
const tags = (meta.tags || fmEn.tags || []).slice(0, 3)
.map((t) => `<span>${esc(String(t).toUpperCase())}</span>`).join('');
const html = TPL
.replace(/\{\{LANG\}\}/g, isZh ? 'zh-CN' : 'en')
.replace(/\{\{BG_IMAGE_SRC\}\}/g, 'file:///' + art.replace(/\\/g, '/'))
.replace(/\{\{CATEGORY\}\}/g, esc(fmEn.category || ''))
.replace(/\{\{TAGS_HTML\}\}/g, tags)
.replace(/\{\{TITLE_SIZE\}\}/g, String(start))
.replace(/\{\{TITLE_HTML\}\}/g, title)
.replace(/\{\{DESCRIPTION\}\}/g, esc(desc))
.replace(/\{\{EVIDENCE_SUMMARY\}\}/g, esc(evidence))
.replace(/\{\{AVATAR_PATH\}\}/g, AVATAR);
const left = html.match(/\{\{[A-Z_]+\}\}/g);
if (left) throw new Error(`${slug}/${lang}: unsubstituted placeholder(s) ${left}`);
const tmp = join(GEN_DIR, `${slug}-${lang}.html`);
writeFileSync(tmp, html, 'utf8');
const url = 'file:///' + tmp.replace(/\\/g, '/');
const dom = run(CHROME, ['--headless', '--dump-dom', '--virtual-time-budget=2500', '--disable-gpu', url]).toString('utf8');
const m = dom.match(/<title>([^<]*)<\/title>/);
const fit = m ? m[1] : '(no title)';
if (!/^FIT \d+px/.test(fit)) throw new Error(`${slug}/${lang}: headline fitting did not settle (${fit})`);
const shotPng = join(GEN_DIR, `${slug}-og-${lang}.png`);
run(CHROME, ['--headless', `--screenshot=${shotPng}`, '--window-size=1200,630',
'--hide-scrollbars', '--disable-gpu', '--force-device-scale-factor=1',
'--virtual-time-budget=2500', url]);
const outFile = join(OUT_OG, isZh ? `zh-${slug}.${FORMAT}` : `${slug}.${FORMAT}`);
if (FORMAT === 'jpg') {
await sharp(shotPng).jpeg({ quality: QUALITY, mozjpeg: true, progressive: true }).toFile(outFile);
} else {
await sharp(shotPng).png({ compressionLevel: 9, palette: true, quality: 90, colors: 256 }).toFile(outFile);
}
return { lang, fit, file: basename(outFile), px: start };
}
// ---------- banner framing (consultant's option B, 2026-10-19) ----------
// The base frame reserves its left ~60 % for the card's frosted panel — correct on the
// 1200x630 share card (where art is scaled 116 % and pushed right) and wrong on the
// page hero, which carries no text: there the empty half reads as a dead black band
// (measured: the subject on `your-disk-full-alert-is-lying` occupied ~21 % of the
// banner width before this change). So the banner — and the list thumbs, which are cut
// from the banner — re-frame around the subject instead of scaling the raw frame.
//
// The crop plan comes from `banner-plan.json`, computed by
// `C:/AI/ideogram4/plan_banner_crops.py` (detector in `probe_band_crop.py`): deviation from
// an estimated background plane + largest connected component, so dark glass and matte
// metal count as subject while the 1 px blueprint grid does not. Column energy and
// pixel-brightness tests were both tried and both failed — brightness misses the dark body
// (it boxed the glow, 8 % of the frame, for an object group spanning 35 %+), and a per-row
// background sample cannot describe a horizontal gradient, so it boxed background instead.
// `subjectBand` below stays as the fallback for art that has no plan entry yet, and is
// deliberately the same simple heuristic as before.
//
// The zoom is limited by height as well as width: a portrait subject (e.g. 118x469 px)
// held at 47 % of a 16:9 width would need 335 px of source height and would be sliced
// top and bottom. `zoom` is therefore min(width demand, height affordance) and the
// rule guarantees the whole band stays inside the crop.
const BANNER_W = 1600;
const BANNER_H = 900;
const SUBJECT_FRACTION = 0.47; // target: subject = 45-50 % of the banner width
const SUBJECT_CENTER_X = 0.575; // visual centre, centre-right per the consultant
const MAX_ZOOM = 2.6; // above this the frame is being upscaled, not framed
const BAND_GAP = 8; // px of dark tolerance inside one subject band
async function subjectBand(art) {
const { data, info } = await sharp(art).greyscale().raw().toBuffer({ resolveWithObject: true });
const { width: W, height: H } = info;
const colMax = new Uint8Array(W);
const hist = new Uint32Array(256);
for (let y = 0; y < H; y++) {
const off = y * W;
for (let x = 0; x < W; x++) {
const v = data[off + x];
if (v > colMax[x]) colMax[x] = v;
hist[v]++;
}
}
// threshold = the value above which sits ~0.5 % of the frame (floored at 80)
let acc = 0;
let thr = 80;
for (let v = 255; v >= 0; v--) {
acc += hist[v];
if (acc > data.length * 0.005) { thr = Math.max(80, v + 1); break; }
}
const runs = [];
let start = null;
let gap = 0;
for (let x = 0; x < W; x++) {
if (colMax[x] >= thr) { if (start === null) start = x; gap = 0; }
else if (start !== null) {
gap++;
if (gap > BAND_GAP) { runs.push([start, x - gap]); start = null; }
}
}
if (start !== null) runs.push([start, W - 1]);
if (!runs.length) return { W, H, x1: 0, x2: W - 1, y1: 0, y2: H - 1, thr, fallback: true };
const [x1, x2] = runs.reduce((a, b) => (b[1] - b[0] > a[1] - a[0] ? b : a));
let y1 = H;
let y2 = -1;
for (let y = 0; y < H; y++) {
const off = y * W;
for (let x = x1; x <= x2; x++) {
if (data[off + x] >= thr) { if (y < y1) y1 = y; if (y > y2) y2 = y; break; }
}
}
if (y2 < 0) return { W, H, x1, x2, y1: 0, y2: H - 1, thr, fallback: true };
return { W, H, x1, x2, y1, y2, thr, fallback: false };
}
// The plan gives the window in source coordinates: scale the art to sw x sh, extend the
// canvas with the art's own edge colour where the window overhangs, then extract. Extending
// rather than clamping is what keeps a thin subject at 53.5 % of the width instead of
// gluing it to the right edge.
let PLAN = { frames: {} };
if (existsSync(PLAN_FILE)) {
try { PLAN = JSON.parse(readFileSync(PLAN_FILE, 'utf8')); } catch (err) {
console.warn(` ! ${basename(PLAN_FILE)} unreadable (${err.message}) — falling back to detection`);
}
}
// Extends the canvas with the art's own edge colours, one row/column at a time: each
// extended row takes that row's edge pixel, so the frame's vertical gradient (and its
// vignette) survives, and the seam matches pixel-for-pixel. A single averaged fill colour
// was tried first and left a visible tonal step down the right edge (caught in review).
async function extendEdge(scaled, p, padL, padT, padR, padB) {
const { sw, sh } = p;
const [fr, fg, fb] = p.fill;
const bg = { r: fr, g: fg, b: fb };
const stripCol = async (x, w) => sharp(scaled)
.extract({ left: x, top: 0, width: 1, height: sh })
.resize({ width: w, height: sh, fit: 'fill' }).toBuffer();
const stripRow = async (img, y, h, w) => sharp(img)
.extract({ left: 0, top: y, width: w, height: 1 })
.resize({ width: w, height: h, fit: 'fill' }).toBuffer();
let horiz = scaled;
const cw = sw + padL + padR;
if (padL || padR) {
const parts = [];
if (padL) parts.push({ input: await stripCol(0, padL), left: 0, top: 0 });
parts.push({ input: scaled, left: padL, top: 0 });
if (padR) parts.push({ input: await stripCol(sw - 1, padR), left: padL + sw, top: 0 });
horiz = await sharp({ create: { width: cw, height: sh, channels: 3, background: bg } })
.composite(parts).png().toBuffer();
}
if (!padT && !padB) return horiz;
const ch = sh + padT + padB;
const parts = [];
if (padT) parts.push({ input: await stripRow(horiz, 0, padT, cw), left: 0, top: 0 });
parts.push({ input: horiz, left: 0, top: padT });
if (padB) parts.push({ input: await stripRow(horiz, sh - 1, padB, cw), left: 0, top: padT + sh });
return sharp({ create: { width: cw, height: ch, channels: 3, background: bg } })
.composite(parts).png().toBuffer();
}
// Returns { buf, zoom, fraction, band, binding, planned } — the banner pixels plus the
// numbers to log, so a run states what it did instead of asking anyone to trust the picture.
async function bannerFraming(art, slug) {
const p = PLAN.frames && PLAN.frames[slug];
if (p) {
const scaled = await sharp(art).resize({ width: p.sw, height: p.sh, fit: 'fill' }).toBuffer();
const padL = Math.max(0, -p.left);
const padT = Math.max(0, -p.top);
const padR = Math.max(0, p.left + BANNER_W - p.sw);
const padB = Math.max(0, p.top + BANNER_H - p.sh);
const grown = (padL || padT || padR || padB)
? await extendEdge(scaled, p, padL, padT, padR, padB)
: scaled;
const buf = await sharp(grown)
.extract({ left: p.left + padL, top: p.top + padT, width: BANNER_W, height: BANNER_H })
.toBuffer();
return {
buf,
zoom: p.zoom,
fraction: p.silhouette,
band: `${p.band[0]},${p.band[1]}-${p.band[2]},${p.band[3]}`,
binding: 'plan',
ps: `${p.left},${p.top}`,
extended: padL + padT + padR + padB,
padEdges: `L${padL} T${padT} R${padR} B${padB}`,
planned: true,
};
}
// fallback for art that has no plan entry yet (a brand-new post rendered before the
// planner has seen its frame): the older column-energy heuristic
const b = await subjectBand(art);
const bw = Math.max(1, b.x2 - b.x1);
const bh = Math.max(1, b.y2 - b.y1);
const cx = (b.x1 + b.x2) / 2;
const cy = (b.y1 + b.y2) / 2;
const floor = BANNER_W / b.W; // never leave the canvas short
const wantW = (SUBJECT_FRACTION * BANNER_W) / bw; // zoom that hits 47 % of width
const allowH = (BANNER_H / bh) * 0.90; // zoom that keeps 5 % air top+bottom
let zoom = Math.min(MAX_ZOOM, Math.max(floor, Math.min(wantW, allowH)));
if (bh * zoom > BANNER_H) zoom = Math.max(floor, (BANNER_H / bh) * 0.96);
const scaledW = Math.round(b.W * zoom);
const scaledH = Math.round(b.H * zoom);
const left = Math.round(Math.min(Math.max(cx * zoom - SUBJECT_CENTER_X * BANNER_W, 0), Math.max(0, scaledW - BANNER_W)));
const top = Math.round(Math.min(Math.max(cy * zoom - BANNER_H / 2, 0), Math.max(0, scaledH - BANNER_H)));
const buf = await sharp(art)
.resize({ width: scaledW })
.extract({ left, top, width: BANNER_W, height: BANNER_H })
.toBuffer();
const bandFits = (b.y1 * zoom - top >= -1) && (b.y2 * zoom - top <= BANNER_H + 1);
if (!bandFits) throw new Error(`banner framing would cut the subject band ${b.y1}-${b.y2}`);
return {
buf,
zoom: Number(zoom.toFixed(2)),
fraction: Number(((bw * zoom) / BANNER_W).toFixed(3)),
band: `${b.x1},${b.y1}-${b.x2},${b.y2} (${bw}x${bh}px, thr ${b.thr})`,
binding: wantW <= allowH ? 'width' : 'height',
ps: `${left},${top}`,
extended: 0,
padEdges: '',
planned: false,
fallback: b.fallback,
};
}
async function renderSlug(slug, meta) {
const art = resolve(meta.art || join(ART_DIR, `${slug}.png`));
if (!existsSync(art)) throw new Error(`${slug}: base art not found at ${art}`);
const enPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
const zhPath = join(ROOT, 'src', 'content', 'posts', 'zh', `${slug}.md`);
if (!existsSync(enPath)) throw new Error(`${slug}: no EN post at ${enPath}`);
const fmEn = frontmatter(enPath);
const fmZh = existsSync(zhPath) ? frontmatter(zhPath) : {};
// pure-art targets — banner first, then the thumbs derived from that same framing
const bannerWebp = join(OUT_BANNER, `${slug}.webp`);
const bannerFallback = join(OUT_BANNER, `${slug}.${FORMAT}`);
const thumb800 = join(OUT_THUMB, `${slug}-800.webp`);
const thumb400 = join(OUT_THUMB, `${slug}-400.webp`);
let framing = null;
if (!DRY) {
framing = await bannerFraming(art, slug);
await sharp(framing.buf).webp({ quality: 88 }).toFile(bannerWebp);
if (FORMAT === 'jpg') {
await sharp(framing.buf).jpeg({ quality: QUALITY, mozjpeg: true, progressive: true }).toFile(bannerFallback);
} else {
await sharp(framing.buf).png({ compressionLevel: 9, palette: true, quality: 90, colors: 256 }).toFile(bannerFallback);
}
await sharp(framing.buf).resize(800, 420, { fit: 'cover', position: 'center' }).webp({ quality: 86 }).toFile(thumb800);
await sharp(framing.buf).resize(400, 210, { fit: 'cover', position: 'center' }).webp({ quality: 84 }).toFile(thumb400);
}
const cards = DRY ? [] : [await renderCard(slug, art, meta, fmEn, fmZh, false), await renderCard(slug, art, meta, fmEn, fmZh, true)];
return { slug, art, cards, framing };
}
// ---------- main ----------
const META = JSON.parse(readFileSync(META_FILE, 'utf8'));
const metaSlugs = Object.keys(META).filter((k) => !k.startsWith('_'));
let targets;
if (has('all')) {
targets = metaSlugs;
} else {
const only = flag('only', '');
if (!only) {
console.error('refusing to run: pass --only <slug[,slug]> or --all');
console.error(`known slugs in ${basename(META_FILE)} (${metaSlugs.length}): ${metaSlugs.join(', ')}`);
process.exit(2);
}
targets = only.split(',').map((s) => s.trim()).filter(Boolean);
}
if (LIMIT) targets = targets.slice(0, LIMIT);
const missingMeta = targets.filter((s) => !META[s]);
const missingEvidence = targets.filter((s) => META[s] && !String(META[s].evidence || '').trim());
if (missingMeta.length) {
console.error(`no art-meta entry (needs art + evidence) for: ${missingMeta.join(', ')}`);
process.exit(2);
}
if (missingEvidence.length) {
console.error(`art-meta entry has no evidence line for: ${missingEvidence.join(', ')}`);
console.error('Write the evidence string deliberately — do not derive it from frontmatter.');
process.exit(2);
}
console.log(`format=${FORMAT} q=${QUALITY} out=${OUT} targets=${targets.length}${DRY ? ' (dry-run)' : ''}`);
const rows = [];
for (const slug of targets) {
if (!DRY) console.log(`\n=== ${slug} ===`);
rows.push(await renderSlug(slug, META[slug]));
}
for (const r of rows) {
console.log(`${r.slug}`);
console.log(` art ${r.art}`);
for (const c of r.cards) console.log(` OG ${c.lang} start ${c.px}px -> ${c.fit} -> og/${c.file}`);
if (r.framing) {
console.log(` banner banners/${r.slug}.webp + .${FORMAT} band ${r.framing.band} `
+ `-> x${r.framing.zoom} (${r.framing.binding}${r.framing.planned ? ', plan' : ' binds'}) `
+ `-> subject at ${(r.framing.fraction * 100).toFixed(1)}% of ${BANNER_W}px`
+ `${r.framing.padEdges ? `, canvas extended ${r.framing.padEdges}` : ''}`
+ `${r.framing.planned ? '' : ' (no plan entry — detected on the fly)'}`
+ `${r.framing.fallback ? ' (FALLBACK framing — no subject band detected)' : ''}`);
console.log(` thumbs og/thumb/${r.slug}-800.webp + -400.webp (cut from the banner)`);
}
}
console.log(`\nDone: ${rows.length} slug(s) -> ${OUT}`);