Round 7: zero-text frames, escaped/curly titles, plan-driven banner crops
Deploy / build (push) Successful in 19s
Deploy / build (push) Successful in 19s
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).
This commit is contained in:
1 parent
576a43a034
commit
f391682fc3
513 files changed
+2223
-174
No files matched your search
@@ -59,6 +59,7 @@ if (!['jpg', 'png'].includes(FORMAT)) throw new Error(`--format must be jpg or p
|
||||
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');
|
||||
@@ -70,6 +71,39 @@ 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---/);
|
||||
@@ -79,8 +113,8 @@ function frontmatter(file) {
|
||||
const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
let v = kv[2].trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
else if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
|
||||
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(',')
|
||||
@@ -112,9 +146,13 @@ async function renderCard(slug, art, meta, fmEn, fmZh, isZh) {
|
||||
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(formatSmartTitle(titleRaw, isZh));
|
||||
const title = glueNumbers(curlyQuotes(formatSmartTitle(titleRaw, isZh)));
|
||||
// ZH description: use the ZH twin's own description when present, else EN.
|
||||
const desc = (isZh ? fmZh.description : '') || fmEn.description || '';
|
||||
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('');
|
||||
@@ -127,7 +165,7 @@ async function renderCard(slug, art, meta, fmEn, fmZh, isZh) {
|
||||
.replace(/\{\{TITLE_SIZE\}\}/g, String(start))
|
||||
.replace(/\{\{TITLE_HTML\}\}/g, title)
|
||||
.replace(/\{\{DESCRIPTION\}\}/g, esc(desc))
|
||||
.replace(/\{\{EVIDENCE_SUMMARY\}\}/g, esc(meta.evidence))
|
||||
.replace(/\{\{EVIDENCE_SUMMARY\}\}/g, esc(evidence))
|
||||
.replace(/\{\{AVATAR_PATH\}\}/g, AVATAR);
|
||||
|
||||
const left = html.match(/\{\{[A-Z_]+\}\}/g);
|
||||
@@ -156,6 +194,190 @@ async function renderCard(slug, art, meta, fmEn, fmZh, isZh) {
|
||||
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}`);
|
||||
@@ -165,26 +387,26 @@ async function renderSlug(slug, meta) {
|
||||
const fmEn = frontmatter(enPath);
|
||||
const fmZh = existsSync(zhPath) ? frontmatter(zhPath) : {};
|
||||
|
||||
// pure-art targets
|
||||
// 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) {
|
||||
await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' }).webp({ quality: 88 }).toFile(bannerWebp);
|
||||
framing = await bannerFraming(art, slug);
|
||||
await sharp(framing.buf).webp({ quality: 88 }).toFile(bannerWebp);
|
||||
if (FORMAT === 'jpg') {
|
||||
await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' })
|
||||
.jpeg({ quality: QUALITY, mozjpeg: true, progressive: true }).toFile(bannerFallback);
|
||||
await sharp(framing.buf).jpeg({ quality: QUALITY, mozjpeg: true, progressive: true }).toFile(bannerFallback);
|
||||
} else {
|
||||
await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' })
|
||||
.png({ compressionLevel: 9, palette: true, quality: 90, colors: 256 }).toFile(bannerFallback);
|
||||
await sharp(framing.buf).png({ compressionLevel: 9, palette: true, quality: 90, colors: 256 }).toFile(bannerFallback);
|
||||
}
|
||||
await sharp(art).resize(800, 420, { fit: 'cover', position: 'right' }).webp({ quality: 86 }).toFile(thumb800);
|
||||
await sharp(art).resize(400, 210, { fit: 'cover', position: 'right' }).webp({ quality: 84 }).toFile(thumb400);
|
||||
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 };
|
||||
return { slug, art, cards, framing };
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
@@ -227,6 +449,14 @@ 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 (!DRY) console.log(` banner banners/${r.slug}.webp + .${FORMAT} thumbs -800/-400.webp`);
|
||||
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}`);
|
||||
Reference in new issue
Block a user