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.
This commit is contained in:
hoelee committed 2026-10-18 16:32:58 +08:00
1 parent c09aeccc9c
commit a41448bb4a
12 files changed
+2932 -1

No files matched your search

+43 -1
View File
@@ -41,4 +41,46 @@ No entry → a generic default terminal.
- PNG (not JPG) is intentional: for flat UI/terminal art, PNG is smaller and
sharper than JPG (see commit history).
- The `logo-square.png` referenced lives at `public/logo-square.png`.
- The `logo-square.png` referenced lives at `public/logo-square.png`.
---
## v2 pipeline ("one art, four targets") — `render_from_art.mjs`
The consultant-approved redesign replaces the generated terminal panels with **one
zero-text base-art frame per post**, composited into four assets:
| Target | Path | Size |
|---|---|---|
| hero banner | `public/banners/<slug>.webp` (+ `.jpg` fallback from frontmatter) | 1600×900 |
| social card | `public/og/<slug>.jpg` (EN), `public/og/zh-<slug>.jpg` (ZH) | 1200×630 |
| bento/list thumb | `public/og/thumb/<slug>-800.webp` | 800×420 |
| small thumb | `public/og/thumb/<slug>-400.webp` | 400×210 |
The card is `template-v2.2.html` (frosted bento + blueprint grid + full-width evidence
bar) filled from the post's own frontmatter plus `art-meta.json`; the self-fitting
headline script reports `FIT <n>px` through `<title>`, and a render that does not
settle fails instead of shipping a silently-shrunken card.
```bash
# metadata first: art path (default <art-dir>/<slug>.png) + a HAND-WRITTEN evidence line
$EDITOR scripts/og-gen/art-meta.json
node scripts/og-gen/render_from_art.mjs --only <slug>[,<slug>] --out /tmp/og-check
node scripts/og-gen/render_from_art.mjs --all # writes into public/
```
- `--format jpg` (default) or `png`. **JPEG is the default because it is measured**:
a composited card renders 558 KB as PNG and lands at **84 KB at mozjpeg q85**
(256-colour PNG = 240 KB). The default site card already ships as JPG for the
same reason.
- `evidence` is **required and never derived** — it is editorial text, and inventing
one from the description is the "invented specificity" failure the blog avoids.
A missing entry aborts with the slug list.
- `ogTitle_en` / `ogTitle_zh` shorten only the card's headline (the article keeps
its real title) — the consultant's answer to the English headline-rag problem.
- Reproducibility check: re-rendering the two sample posts produced banners and
800px thumbs **byte-identical** to the consultant's accepted delivery
(sha256 16e6a7b0…, f3b29f38…, c4e28291…, 8095ec6c…).
- ⚠ Changing `ogImage` from `.png` to `.jpg` also needs `PostList.astro`'s thumb
helper updated (`/\.(png|jpe?g)$/`), or the listing derives
`…-800.webp` from `slug.jpg` and every thumb 404s.
+12
View File
@@ -0,0 +1,12 @@
{
"_about": "Inputs for scripts/og-gen/render_from_art.mjs ('one art, four targets'). One entry per post: `art` = the zero-text base frame (default <art-dir>/<slug>.png), `evidence` = the short factual line shown in the card's evidence bar (REQUIRED, written by hand — never derived), optional `ogTitle_en` / `ogTitle_zh` = shortened display titles for the card only (consultant question 1, option C: the article keeps its real title), optional `tags` = override the three badge tags. Cards render from the post's own frontmatter (title/description/category) unless an ogTitle override is present.",
"_status": "2 of 72 posts have base art (the two consultant sample frames, reused). The remaining 70 need a GPU run from prompts_catalog.json before the batch can render.",
"running-production-infrastructure-solo": {
"art": "C:/AI/ideogram4/out/sample_prod_169_00001_.png",
"evidence": "162 containers · 3 hosts · 78 compose stacks"
},
"docker-reported-exit-0-the-oom-killer-disagreed": {
"art": "C:/AI/ideogram4/out/sample_oom_169_00001_.png",
"evidence": "exit 0 · dmesg: 6x cgroup OOM kills"
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/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}`));
}
+198
View File
@@ -0,0 +1,198 @@
import os, glob, yaml, json
posts = sorted(glob.glob('src/content/posts/*.md'))
ACCENTS = {
'devops': 'cyan and vibrant emerald green (#4ade80)',
'engineering': 'royal cobalt blue (#295cff) with crisp ice-white',
'ai': 'neon-cyan and electric violet-magenta',
'web3': 'translucent emerald (#10b981) and prism-split cyan',
'tutorials': 'electric cyan and soft ultraviolet spectrum',
'case-studies': 'emerald green (#4ade80) status nodes and gold telemetry',
'notes': 'warm amber-orange warning glow (#fbbf24)'
}
SPECIFIC_METAPHORS = {
'running-production-infrastructure-solo': (
'Three massive interconnected obsidian server monoliths, surrounded by dense floating micro-glass container matrices representing 162 modular services, routed by glowing fiber optic pipelines into a single master control console',
'162 containers · 3 hosts · 78 stacks'
),
'loop-engineering-without-a-coding-agent': (
'Eleven concentric circular obsidian gear loops rotating in mechanical harmony, eight solid pure crystal clockwork rings alongside three subtle neural pulse rings, operating autonomously without human intervention',
'11 cron jobs · 8 zero-AI loops'
),
'one-prometheus-for-unraid-synology-and-a-vps': (
'A single radiant central telemetry beacon sending precision laser monitoring vectors to three distinct hexagonal platform nodes, illuminating transparent storage volumes with holographic metrics',
'3 hosts · 1 prometheus · 0 false panels'
),
'self-healing-digital-goods-entitlements': (
'A multi-stage floating acrylic workflow pathway with five interlocking diamond gates, automatically self-repairing broken link fragments with glowing golden solder',
'5 workflows · NocoDB + n8n + AList'
),
'docker-reported-exit-0-the-oom-killer-disagreed': (
'A translucent glass server cube displaying a deceptive green heartbeat light, while a razor-sharp crimson kernel laser beam slices through its bottom core, scattering fractured memory crystals',
'exit 0 · dmesg: 6x cgroup OOM kills'
),
'my-backup-was-using-someone-elses-api-quota': (
'A massive high-throughput data conduit bottlenecked at a narrow glowing amber hourglass valve, only releasing single sparse droplets into a dark repository vault below',
'1 TB restic seed · shared rclone quota'
),
'calibrating-intel-quick-sync-hevc-quality': (
'A 3D topographic luminous video waveform mountain dropping abruptly off a 90-degree vertical cliff edge marked at 28, flanked by two perfectly identical overlapping optical calibration prisms',
'ICQ 18-22 vs 28 cliff · QSV measured'
),
'i-let-an-agent-manage-my-shopee-vouchers': (
'An intricate rotating glass agent decision loop, where an unexpected stray orange pulse vector veers off the blueprint track, materializing an unauthorized glowing geometric voucher card in mid-air',
'mode=propose gate · RM140 voucher unapproved'
),
'why-your-cdp-clicks-silently-fail': (
'A high-precision robotic cursor pointer clicking onto a translucent glass UI button that has already shifted away due to an invisible kinetic wave, triggering zero DOM resonance',
'scroll-behavior:smooth · rect expired'
),
'space-reclamation-wasnt-stuck-it-was-waiting': (
'A massive btrfs filesystem disk cylinder slowly draining fragmented blue blocks into an obsidian reclamation pit, guided by a precision holographic timer dial count down',
'121 GiB returned · scheduled 09:29'
),
'five-step-ai-roadmap-what-i-actually-run': (
'A five-tier architectural staircase in dark graphite, with the middle tiers brightly illuminated with active hardware racks while speculative top tiers remain skeletal wireframes',
'whisper + mem0 in production · step 4 skipped'
)
}
def derive_metaphor(title, desc, cat, tags):
title_l = title.lower()
desc_l = desc.lower()
if any(k in title_l for k in ['backup', 'restic', 'rclone']):
return (
'A secure armored cryptographic data vault connected by glowing braided conduit cables, archiving shimmering memory slabs into deep subterranean storage partitions',
'automated backup verification · zero-loss'
)
if any(k in title_l for k in ['container', 'docker', 'compose']):
return (
'Modular glowing cubic container blocks neatly docked into an industrial server chassis, synchronized by high-speed optic interconnects',
'containerized topology · isolated runtime'
)
if any(k in title_l for k in ['disk', 'smartctl', 'btrfs', 'nas', 'storage', 'raid']):
return (
'A precision laser diagnostic sensor scanning a floating high-density magnetic storage platter, revealing microscopic sector heatmaps',
'storage diagnostics · SMART telemetry'
)
if any(k in title_l for k in ['agent', 'llm', 'ai', 'gpt', 'token']):
return (
'Concentric acrylic rings of an autonomous neural decision core, pulsing with directed cyan synaptic data packets along vector paths',
'autonomous loop · constrained execution'
)
if any(k in title_l for k in ['dns', 'mail', 'postfix', 'mailcow', 'email']):
return (
'An intricate routing matrix of optical network fiber switches, filtering and directing packet envelopes through cryptographic security barriers',
'mail protocol routing · DKIM / SPF handshake'
)
if any(k in title_l for k in ['video', 'transcode', 'ffmpeg', 'hevc', 'audio', 'song']):
return (
'A cascading optical prism slicing raw video wavebands into ultra-compact high-efficiency spectral ribbons on a dark reflective surface',
'HEVC hardware pipeline · lossless fidelity'
)
if any(k in title_l for k in ['traefik', 'proxy', 'ssl', 'tls', 'nginx', 'cloudflare']):
return (
'An impenetrable dark-glass gateway perimeter routing colored ingress traffic streams through dynamic cryptographic reverse-proxy channels',
'reverse proxy routing · dynamic ingress'
)
if any(k in title_l for k in ['codeigniter', 'php', 'database', 'mysql', 'commit', 'churn', 'lines']):
return (
'Interlocking precision architectural pillars and modular computational foundations, supporting a clean crystalline software framework',
'verification gates · deterministic code'
)
# Generic high-tech fallbacks by category
if cat == 'devops':
return (
'Sleek industrial rackmount hardware with glowing diagnostic bus cables, routing high-throughput telemetry streams across redundant server clusters',
'production DevOps · verified metrics'
)
elif cat == 'ai':
return (
'An elegant geometric lattice core radiating structured synaptic energy pulses across an organized neural topological network',
'AI automation · deterministic controls'
)
elif cat == 'web3':
return (
'An isometric floating crystalline polyhedral block with precision laser-etched mathematical vectors and internal emerald prism reflections',
'on-chain verification · cryptographic math'
)
elif cat == 'tutorials':
return (
'A clean modular educational workbench of floating holographic schematic layers and precision calibration instruments',
'step-by-step telemetry · reproducible setup'
)
elif cat == 'case-studies':
return (
'An expansive architectural blueprint of enterprise private cloud nodes interconnected by luminous data pathways and telemetry readouts',
'enterprise delivery · real-world production'
)
else: # notes
return (
'A minimalist diagnostic probe examining a micro-structural hardware flaw illuminated by a subtle amber pinpoint warning light',
'root-cause analysis · production evidence'
)
catalog = []
for p in posts:
slug = os.path.basename(p)[:-3]
with open(p, 'r', encoding='utf-8') as f:
content = f.read()
parts = content.split('---')
if len(parts) < 3: continue
try: data = yaml.safe_load(parts[1])
except: continue
if data.get('draft'): continue
title = data.get('title', slug)
desc = data.get('description', '')
cat = data.get('category', 'engineering')
tags = data.get('tags', [])[:3]
accent = ACCENTS.get(cat, 'royal cobalt blue (#295cff)')
if slug in SPECIFIC_METAPHORS:
unique_elem, evidence = SPECIFIC_METAPHORS[slug]
else:
unique_elem, evidence = derive_metaphor(title, desc, cat, tags)
prompt = (
f"{unique_elem}, styled as a modern dark cyber-bento 3D concept and technical blueprint schematic. "
f"Sleek matte obsidian hardware, frosted acrylic glass surfaces, subtle ultra-thin blueprint coordinate crosshairs "
f"and vector topology lines in the background. Strictly off-center composition: the 3D focal subject is positioned "
f"exclusively in the right 38% of the canvas, leaving vast, clean, uncluttered dark negative space across the entire "
f"left 62% for UI overlay. Deep midnight slate background (#0b0f19 to #111827), volumetric rim lighting in royal blue (#4d82ff) "
f"with {accent} highlights, raytraced ambient occlusion, macro depth of field, 8k resolution, "
f"no text, no typography, no letters, no numbers, no HUD labels, no watermark."
)
catalog.append({
'slug': slug,
'title': title,
'category': cat,
'tags': tags,
'accent': accent,
'unique_elements': unique_elem,
'evidence_summary': evidence,
'prompt': prompt
})
os.makedirs('scripts/og-gen', exist_ok=True)
with open('scripts/og-gen/prompts_catalog.json', 'w', encoding='utf-8') as f:
json.dump(catalog, f, indent=2, ensure_ascii=False)
print(f"Generated complete prompts catalog for {len(catalog)} posts in scripts/og-gen/prompts_catalog.json")
out_md = "C:/Users/hoelee/Desktop/blog-og-image-redesign-brief/03-72-Posts-Visual-Prompts-Catalog.md"
with open(out_md, 'w', encoding='utf-8') as f:
f.write("# 72 篇技术文章专属视觉意象与 Prompt 全景目录 (Prompts Catalog)\n\n")
f.write("> 由 Hermes Agent 根据外部设计顾问定调公式自动提取生成:固定品牌底座 (60%) + 单篇专属独特隐喻 (40%)\n\n")
f.write("| # | 分类 | 文章 Slug 与标题 | 单篇专属物理隐喻与动作状态 | 硬核证据条摘要 |\n")
f.write("|---|---|---|---|---|\n")
for i, it in enumerate(catalog, 1):
f.write(f"| {i} | `{it['category']}` | **{it['title']}**<br>`{it['slug']}` | {it['unique_elements']} | `${it['evidence_summary']}` |\n")
print(f"Readable summary written to {out_md}")
File diff suppressed because it is too large. Load diff
+232
View File
@@ -0,0 +1,232 @@
#!/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 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 ----------
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 = v.slice(1, -1);
else if (v.startsWith("'") && v.endsWith("'")) 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(formatSmartTitle(titleRaw, isZh));
// ZH description: use the ZH twin's own description when present, else EN.
const desc = (isZh ? fmZh.description : '') || fmEn.description || '';
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(meta.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 };
}
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
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`);
if (!DRY) {
await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' }).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);
} else {
await sharp(art).resize(1600, 900, { fit: 'cover', position: 'right' })
.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);
}
const cards = DRY ? [] : [await renderCard(slug, art, meta, fmEn, fmZh, false), await renderCard(slug, art, meta, fmEn, fmZh, true)];
return { slug, art, cards };
}
// ---------- 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 (!DRY) console.log(` banner banners/${r.slug}.webp + .${FORMAT} thumbs -800/-400.webp`);
}
console.log(`\nDone: ${rows.length} slug(s) -> ${OUT}`);
+108
View File
@@ -0,0 +1,108 @@
#!/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}!`);
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* render_sample_suite.mjs — render the "one art, four targets" sample set from two
* real Ideogram 4 base images (no GPU re-run needed).
*
* Template: scripts/og-gen/template-v2.1.html (outside consultant's v2.0 Bento spec:
* frosted Bento card, blueprint grid, 2-line description, full-width evidence bar).
*/
import { readFileSync, writeFileSync, mkdirSync } 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-v2';
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.1.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 个生产容器:两起故障改变了我验证数字的方式',
desc_en: 'One operator, three hosts, 162 containers in 78 compose stacks — and the two failures that changed how I verify my own numbers.',
desc_zh: '一个人、三台宿主、78 个 compose 栈里的 162 个容器,以及两起改变了我验证数字方式的真实故障。'
},
{
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 杀死了它',
desc_en: 'A container kept stopping while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills.',
desc_zh: '容器不停意外停止,而 Docker 报的是 exit 0 / OOMKilled=false。真正的证据在 dmesg 里:六次 cgroup OOM 杀死。'
}
];
/** Consultant v2.0 sizing: larger, never collapses to 36px. */
function calcTitleSize(title, isZh) {
const len = title.replace(/<br\s*\/?>/gi, '').length;
if (isZh) {
if (len <= 20) return 48;
if (len <= 30) return 44;
return 40;
}
if (len <= 42) return 46;
if (len <= 65) return 42;
return 38;
}
/** Consultant v2.0: break Chinese titles at a colon/comma in the 7–18 char window
* so the second line is never an orphan tail like "杀死了它". */
function formatSmartTitle(title, isZh) {
if (!isZh || title.includes('<br>')) return title;
const m = title.match(/^(.{7,18}?[:,——])(.{5,})$/);
return m ? `${m[1]}<br>${m[2]}` : title;
}
async function renderCard(sample, isZh) {
const lang = isZh ? 'zh' : 'en';
const rawTitle = isZh ? sample.title_zh : sample.title_en;
const title = formatSmartTitle(rawTitle, isZh);
const titleSize = calcTitleSize(rawTitle, isZh);
const desc = isZh ? sample.desc_zh : sample.desc_en;
const tagsHtml = sample.tags.map((t) => `<span>${t}</span>`).join('');
const bgUrl = 'file:///' + sample.bg.replace(/\\/g, '/');
const 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}}', String(titleSize))
.replace('{{TITLE_HTML}}', title)
.replace('{{DESCRIPTION}}', desc)
.replace('{{EVIDENCE_SUMMARY}}', sample.evidence)
.replace('{{AVATAR_PATH}}', AVATAR_URL);
if (/\{\{[A-Z_]+\}\}/.test(html)) {
throw new Error(`unsubstituted placeholder: ${html.match(/\{\{[A-Z_]+\}\}/g)}`);
}
const tmpHtml = join(OUT_DIR, `temp_${sample.slug}_${lang}.html`);
writeFileSync(tmpHtml, html, 'utf8');
const outPng = join(OUT_DIR, `${sample.slug}_og_${lang}.png`);
execSync(
`"${CHROME}" --headless --screenshot="${outPng}" --window-size=1200,630 ` +
`--hide-scrollbars --disable-gpu --force-device-scale-factor=1 ` +
`"file:///${tmpHtml.replace(/\\/g, '/')}"`,
{ stdio: 'pipe' }
);
console.log(` OG (${lang}) ${titleSize}px -> ${outPng}`);
}
async function processSample(s) {
console.log(`\n=== ${s.slug} ===`);
// Hero banner 1600x900: consultant flagged the old crop left 2/3 black. Crop toward
// the 3D subject on the right so the art fills the frame instead of floating in a void.
const banner = join(OUT_DIR, `${s.slug}_banner_1600x900.webp`);
await sharp(s.bg)
.resize(1600, 900, { fit: 'cover', position: 'right' })
.webp({ quality: 88 })
.toFile(banner);
console.log(` banner -> ${banner}`);
// Listing thumbnail 800x420: pure art, right-anchored on the subject.
const thumb = join(OUT_DIR, `${s.slug}_thumb_800x420.webp`);
await sharp(s.bg)
.resize(800, 420, { fit: 'cover', position: 'right' })
.webp({ quality: 86 })
.toFile(thumb);
console.log(` thumb -> ${thumb}`);
await renderCard(s, false);
await renderCard(s, true);
}
for (const s of SAMPLES) await processSample(s);
console.log(`\nDone -> ${OUT_DIR}`);
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* render_sample_suite_v3.mjs — "one art, four targets" sample set from two real
* Ideogram 4 base images (no GPU re-run needed).
*
* Template: scripts/og-gen/template-v2.2.html
* - outside consultant's v2.0 Bento spec (frosted card, blueprint grid, 2-line
* description, full-width evidence bar)
* - plus a self-fitting headline script: shrinks font until the title fits
* <=3 lines with no widow line, so long Chinese/English titles never leave a
* single orphan character on the last row.
*
* Chrome runs with --virtual-time-budget so the fitting script has settled before
* the screenshot; the chosen size is read back from <title> as proof it ran.
*/
import { readFileSync, writeFileSync, mkdirSync } 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-v2';
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.2.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: '独自运维 162 个生产容器:两起故障改变了我验证数字的方式',
desc_en: 'One operator, three hosts, 162 containers in 78 compose stacks — and the two failures that changed how I verify my own numbers.',
desc_zh: '一个人、三台宿主、78 个 compose 栈里的 162 个容器,以及两起改变了我验证数字方式的真实故障。'
},
{
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 杀死了它',
desc_en: 'A container kept stopping while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills.',
desc_zh: '容器不停意外停止,而 Docker 报的是 exit 0 / OOMKilled=false。真正的证据在 dmesg 里:六次 cgroup OOM。'
}
];
function startTitleSize(title, isZh) {
return isZh ? 50 : title.length <= 42 ? 48 : 44;
}
/** Consultant v2.0: CJK natural wrapping splits compounds ("两|起故障"). Break at a
* colon/comma instead — the self-fitting loop then sizes each segment to one line. */
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;
}
/** Keep a word and the number right after it on one line ("Exit 0." must never
* break as "Exit / 0."), so numeric evidence stays intact. */
function glueNumbers(title) {
return title.replace(/([\p{L}])\s+(\d)/gu, '$1\u00A0$2');
}
function renderCard(sample, isZh) {
const lang = isZh ? 'zh' : 'en';
const title = glueNumbers(formatSmartTitle(isZh ? sample.title_zh : sample.title_en, isZh));
const desc = isZh ? sample.desc_zh : sample.desc_en;
const startPx = startTitleSize(title, isZh);
const tagsHtml = sample.tags.map((t) => `<span>${t}</span>`).join('');
const bgUrl = 'file:///' + sample.bg.replace(/\\/g, '/');
const html = TPL_RAW
.replace(/\{\{LANG\}\}/g, isZh ? 'zh-CN' : 'en')
.replace(/\{\{BG_IMAGE_SRC\}\}/g, bgUrl)
.replace(/\{\{CATEGORY\}\}/g, sample.category)
.replace(/\{\{TAGS_HTML\}\}/g, tagsHtml)
.replace(/\{\{TITLE_SIZE\}\}/g, String(startPx))
.replace(/\{\{TITLE_HTML\}\}/g, title)
.replace(/\{\{DESCRIPTION\}\}/g, desc)
.replace(/\{\{EVIDENCE_SUMMARY\}\}/g, sample.evidence)
.replace(/\{\{AVATAR_PATH\}\}/g, AVATAR_URL);
if (/\{\{[A-Z_]+\}\}/.test(html)) {
throw new Error(`unsubstituted placeholder: ${html.match(/\{\{[A-Z_]+\}\}/g)}`);
}
const tmpHtml = join(OUT_DIR, `temp_${sample.slug}_${lang}.html`);
writeFileSync(tmpHtml, html, 'utf8');
const fileUrl = 'file:///' + tmpHtml.replace(/\\/g, '/');
const dom = execSync(
`"${CHROME}" --headless --dump-dom --virtual-time-budget=2500 --disable-gpu "${fileUrl}"`,
{ stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 }
).toString('utf8');
const m = dom.match(/<title>([^<]*)<\/title>/);
const fit = m ? m[1] : '(no title)';
if (!/^FIT \d+px/.test(fit)) throw new Error(`fitting script did not settle: ${fit}`);
const outPng = join(OUT_DIR, `${sample.slug}_og_${lang}.png`);
execSync(
`"${CHROME}" --headless --screenshot="${outPng}" --window-size=1200,630 ` +
`--hide-scrollbars --disable-gpu --force-device-scale-factor=1 ` +
`--virtual-time-budget=2500 "${fileUrl}"`,
{ stdio: 'pipe' }
);
console.log(` OG (${lang}) start ${startPx}px -> ${fit}`);
}
async function processSample(s) {
console.log(`\n=== ${s.slug} ===`);
const banner = join(OUT_DIR, `${s.slug}_banner_1600x900.webp`);
await sharp(s.bg).resize(1600, 900, { fit: 'cover', position: 'right' })
.webp({ quality: 88 }).toFile(banner);
const thumb = join(OUT_DIR, `${s.slug}_thumb_800x420.webp`);
await sharp(s.bg).resize(800, 420, { fit: 'cover', position: 'right' })
.webp({ quality: 86 }).toFile(thumb);
renderCard(s, false);
renderCard(s, true);
}
for (const s of SAMPLES) await processSample(s);
console.log(`\nDone -> ${OUT_DIR}`);
+340
View File
@@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="{{LANG}}">
<head>
<meta charset="UTF-8">
<style>
:root {
--bg-base: #090d16;
--primary: #4d82ff;
--primary-bright: #6d8dff;
--accent-green: #3fb950;
--accent-red: #ff7b72;
--text-main: #f8fafc;
--text-sub: #cbd5e1;
--text-muted: #94a3b8;
--text-dim: #64748b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1200px;
height: 630px;
overflow: hidden;
background: var(--bg-base);
font-family: "Space Grotesk", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: var(--text-main);
-webkit-font-smoothing: antialiased;
}
.canvas {
position: relative;
width: 1200px;
height: 630px;
display: flex;
align-items: center;
padding: 44px 52px;
overflow: hidden;
}
/* 1. 底层:将 AI 3D 底图适度放大 18% 并向中轴线微移,消除中间与右上角空洞 */
.bg-artwork {
position: absolute;
top: -9%;
right: -4%;
width: 116%;
height: 118%;
object-fit: cover;
object-position: right center;
z-index: 1;
}
/* 2. 中层 A:柔和过渡遮罩(告别死纯黑,保留底层微光与空间感) */
.scrim-overlay {
position: absolute;
inset: 0;
z-index: 2;
background:
radial-gradient(circle at 18% 22%, rgba(77, 130, 255, 0.14) 0%, transparent 48%),
radial-gradient(circle at 80% 50%, rgba(9, 13, 22, 0.0) 0%, rgba(9, 13, 22, 0.55) 58%, rgba(9, 13, 22, 0.90) 82%),
linear-gradient(90deg, rgba(9, 13, 22, 0.84) 0%, rgba(9, 13, 22, 0.78) 42%, rgba(9, 13, 22, 0.28) 68%, rgba(9, 13, 22, 0.05) 100%);
}
/* 3. 中层 B:CSS 编程级蓝图坐标网格(确保左侧绝不死黑单调) */
.blueprint-grid {
position: absolute;
inset: 0;
z-index: 2;
background-image:
linear-gradient(to right, rgba(109, 141, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(109, 141, 255, 0.05) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 55%, transparent 85%);
-webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 55%, transparent 85%);
}
/* 外框蓝图装饰线与十字准星 */
.blueprint-accents {
position: absolute;
inset: 20px;
border: 1px solid rgba(109, 141, 255, 0.10);
border-radius: 16px;
pointer-events: none;
z-index: 3;
}
.crosshair {
position: absolute;
width: 15px;
height: 15px;
opacity: 0.45;
}
.crosshair::before, .crosshair::after {
content: "";
position: absolute;
background: var(--primary-bright);
}
.crosshair::before { top: 7px; left: 0; width: 15px; height: 1px; }
.crosshair::after { left: 7px; top: 0; width: 1px; height: 15px; }
.ch-tr { top: 22px; right: 26px; }
.ch-br { bottom: 22px; right: 26px; }
/* 4. 顶层:左侧半透明磨砂亚克力 Bento 容器卡 */
.bento-card {
position: relative;
z-index: 4;
width: 736px;
height: 536px;
padding: 38px 44px;
border-radius: 18px;
background: linear-gradient(145deg, rgba(21, 29, 46, 0.82) 0%, rgba(13, 18, 30, 0.74) 100%);
border: 1px solid rgba(109, 141, 255, 0.24);
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* Bento 卡片顶部蓝色微光装饰条 */
.bento-card::before {
content: "";
position: absolute;
top: -1px;
left: 44px;
width: 96px;
height: 2px;
background: linear-gradient(90deg, var(--primary-bright), transparent);
}
/* --- 第一段 (Top):分类徽章 + 核心技术标签 --- */
.top-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.badge-group {
display: flex;
align-items: center;
gap: 12px;
}
.cat-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 999px;
background: rgba(77, 130, 255, 0.2);
border: 1px solid rgba(109, 141, 255, 0.5);
color: #a5c0ff;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 13px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.cat-badge .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
}
.top-tags {
display: flex;
align-items: center;
gap: 8px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 13px;
}
.top-tags span {
background: rgba(15, 21, 35, 0.75);
border: 1px solid rgba(56, 72, 99, 0.7);
padding: 4px 10px;
border-radius: 6px;
color: var(--text-muted);
}
/* --- 第二段 (Center):大标题 + 2行摘要 + 100%等宽终端证据条 --- */
.center-block {
display: flex;
flex-direction: column;
gap: 16px;
margin: auto 0;
}
.title {
font-weight: 800;
font-size: {{TITLE_SIZE}}px;
line-height: 1.22;
letter-spacing: -0.02em;
color: #ffffff;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.5);
}
/* 新增:2 行文章核心摘要,彻底消除纵向空洞 */
.desc {
font-size: 18px;
line-height: 1.5;
color: var(--text-sub);
font-weight: 400;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 100% 撑满 Bento 容器的终端证据栏,消除右侧阶梯空缺 */
.evidence-strip {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-top: 4px;
padding: 13px 18px;
border-radius: 10px;
background: rgba(9, 13, 22, 0.85);
border: 1px solid rgba(77, 130, 255, 0.28);
border-left: 3px solid var(--primary-bright);
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 15.5px;
line-height: 1.4;
color: #e2e8f0;
}
.evidence-strip .prompt {
color: var(--accent-green);
font-weight: 700;
}
.evidence-strip .text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- 第三段 (Bottom):个人品牌锁定 --- */
.bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.author-lockup {
display: flex;
align-items: center;
gap: 14px;
}
.author-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--primary-bright);
}
.author-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.author-name {
font-size: 17px;
font-weight: 700;
color: #ffffff;
}
.author-name .handle {
font-weight: 500;
color: var(--text-muted);
font-size: 14px;
margin-left: 6px;
}
.author-role {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
letter-spacing: 0.5px;
color: var(--primary-bright);
}
.domain-pill {
font-family: "JetBrains Mono", monospace;
font-size: 14px;
font-weight: 600;
color: #cbd5e1;
background: rgba(77, 130, 255, 0.12);
border: 1px solid rgba(109, 141, 255, 0.3);
padding: 6px 14px;
border-radius: 8px;
}
</style>
</head>
<body>
<div class="canvas">
<!-- 1. Ideogram 4 背景图(自动放大 18% 并居中偏右) -->
<img class="bg-artwork" src="{{BG_IMAGE_SRC}}" alt="">
<!-- 2. 柔和渐变遮罩 + 编程级蓝图网格 + 准星 -->
<div class="scrim-overlay"></div>
<div class="blueprint-grid"></div>
<div class="blueprint-accents">
<div class="crosshair ch-tr"></div>
<div class="crosshair ch-br"></div>
</div>
<!-- 3. 左侧半透明磨砂 Bento 容器 -->
<div class="bento-card">
<!-- Top -->
<div class="top-row">
<div class="badge-group">
<div class="cat-badge"><span class="dot"></span>{{CATEGORY}}</div>
<div class="top-tags">{{TAGS_HTML}}</div>
</div>
</div>
<!-- Center -->
<div class="center-block">
<h1 class="title">{{TITLE_HTML}}</h1>
<p class="desc">{{DESCRIPTION}}</p>
<div class="evidence-strip">
<span class="prompt">$</span>
<span class="text">{{EVIDENCE_SUMMARY}}</span>
</div>
</div>
<!-- Bottom -->
<div class="bottom-row">
<div class="author-lockup">
<img class="author-avatar" src="{{AVATAR_PATH}}" alt="Lee Teong Hoe">
<div class="author-meta">
<div class="author-name">Lee Teong Hoe <span class="handle">· Mr Hoelee</span></div>
<div class="author-role">BUILD · OPERATE · DOCUMENT</div>
</div>
</div>
<div class="domain-pill">blog.hoelee.com</div>
</div>
</div>
</div>
</body>
</html>
+324
View File
@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="{{LANG}}">
<head>
<meta charset="UTF-8">
<style>
:root {
--bg-base: #090d16;
--primary: #4d82ff;
--primary-bright: #6d8dff;
--accent-green: #3fb950;
--accent-red: #ff7b72;
--text-main: #f8fafc;
--text-sub: #cbd5e1;
--text-muted: #94a3b8;
--text-dim: #64748b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1200px;
height: 630px;
overflow: hidden;
background: var(--bg-base);
font-family: "Space Grotesk", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: var(--text-main);
-webkit-font-smoothing: antialiased;
}
.canvas {
position: relative;
width: 1200px;
height: 630px;
display: flex;
align-items: center;
padding: 44px 52px;
overflow: hidden;
}
.bg-artwork {
position: absolute;
top: -9%;
right: -4%;
width: 116%;
height: 118%;
object-fit: cover;
object-position: right center;
z-index: 1;
}
.scrim-overlay {
position: absolute;
inset: 0;
z-index: 2;
background:
radial-gradient(circle at 18% 22%, rgba(77, 130, 255, 0.14) 0%, transparent 48%),
radial-gradient(circle at 80% 50%, rgba(9, 13, 22, 0.0) 0%, rgba(9, 13, 22, 0.55) 58%, rgba(9, 13, 22, 0.90) 82%),
linear-gradient(90deg, rgba(9, 13, 22, 0.84) 0%, rgba(9, 13, 22, 0.78) 42%, rgba(9, 13, 22, 0.28) 68%, rgba(9, 13, 22, 0.05) 100%);
}
.blueprint-grid {
position: absolute;
inset: 0;
z-index: 2;
background-image:
linear-gradient(to right, rgba(109, 141, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(109, 141, 255, 0.05) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 55%, transparent 85%);
-webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 55%, transparent 85%);
}
.blueprint-accents {
position: absolute;
inset: 20px;
border: 1px solid rgba(109, 141, 255, 0.10);
border-radius: 16px;
pointer-events: none;
z-index: 3;
}
.crosshair { position: absolute; width: 15px; height: 15px; opacity: 0.45; }
.crosshair::before, .crosshair::after {
content: ""; position: absolute; background: var(--primary-bright);
}
.crosshair::before { top: 7px; left: 0; width: 15px; height: 1px; }
.crosshair::after { left: 7px; top: 0; width: 1px; height: 15px; }
.ch-tr { top: 22px; right: 26px; }
.ch-br { bottom: 22px; right: 26px; }
.bento-card {
position: relative;
z-index: 4;
width: 736px;
height: 536px;
padding: 34px 44px 30px;
border-radius: 18px;
background: linear-gradient(145deg, rgba(21, 29, 46, 0.82) 0%, rgba(13, 18, 30, 0.74) 100%);
border: 1px solid rgba(109, 141, 255, 0.24);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
flex-direction: column;
}
.bento-card::before {
content: "";
position: absolute;
top: -1px; left: 44px;
width: 96px; height: 2px;
background: linear-gradient(90deg, var(--primary-bright), transparent);
}
.top-row { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; }
.badge-group { display: flex; align-items: center; gap: 12px; }
.cat-badge {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 999px;
background: rgba(77, 130, 255, 0.2);
border: 1px solid rgba(109, 141, 255, 0.5);
color: #a5c0ff;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 13px; font-weight: 700;
letter-spacing: 1px; text-transform: uppercase;
}
.cat-badge .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
}
.top-tags {
display: flex; align-items: center; gap: 8px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 13px;
}
.top-tags span {
background: rgba(15, 21, 35, 0.75);
border: 1px solid rgba(56, 72, 99, 0.7);
padding: 4px 10px; border-radius: 6px;
color: var(--text-muted);
}
.center-block {
display: flex;
flex-direction: column;
gap: 14px;
flex: 1 1 auto;
justify-content: center;
min-height: 0;
padding: 8px 0;
}
.title {
font-weight: 800;
font-size: 44px;
line-height: 1.24;
letter-spacing: -0.02em;
color: #ffffff;
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.5);
text-wrap: balance;
}
.desc {
font-size: 17px;
line-height: 1.5;
color: var(--text-sub);
font-weight: 400;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.evidence-strip {
display: flex; align-items: center; gap: 12px;
width: 100%;
padding: 12px 18px;
border-radius: 10px;
background: rgba(9, 13, 22, 0.85);
border: 1px solid rgba(77, 130, 255, 0.28);
border-left: 3px solid var(--primary-bright);
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 15px; line-height: 1.4;
color: #e2e8f0;
}
.evidence-strip .prompt { color: var(--accent-green); font-weight: 700; }
.evidence-strip .text {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bottom-row {
display: flex; align-items: center; justify-content: space-between;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
flex: 0 0 auto;
}
.author-lockup { display: flex; align-items: center; gap: 14px; }
.author-avatar {
width: 44px; height: 44px; border-radius: 50%;
object-fit: cover;
border: 2px solid var(--primary-bright);
}
.author-meta { display: flex; flex-direction: column; gap: 2px; }
.author-name { font-size: 17px; font-weight: 700; color: #ffffff; }
.author-name .handle {
font-weight: 500; color: var(--text-muted);
font-size: 14px; margin-left: 6px;
}
.author-role {
font-family: "JetBrains Mono", monospace;
font-size: 12px; letter-spacing: 0.5px;
color: var(--primary-bright);
}
.domain-pill {
font-family: "JetBrains Mono", monospace;
font-size: 14px; font-weight: 600;
color: #cbd5e1;
background: rgba(77, 130, 255, 0.12);
border: 1px solid rgba(109, 141, 255, 0.3);
padding: 6px 14px; border-radius: 8px;
}
</style>
</head>
<body>
<div class="canvas">
<img class="bg-artwork" src="{{BG_IMAGE_SRC}}" alt="">
<div class="scrim-overlay"></div>
<div class="blueprint-grid"></div>
<div class="blueprint-accents">
<div class="crosshair ch-tr"></div>
<div class="crosshair ch-br"></div>
</div>
<div class="bento-card" id="bento">
<div class="top-row">
<div class="badge-group">
<div class="cat-badge"><span class="dot"></span>{{CATEGORY}}</div>
<div class="top-tags">{{TAGS_HTML}}</div>
</div>
</div>
<div class="center-block">
<h1 class="title" id="title">{{TITLE_HTML}}</h1>
<p class="desc" id="desc">{{DESCRIPTION}}</p>
<div class="evidence-strip">
<span class="prompt">$</span>
<span class="text">{{EVIDENCE_SUMMARY}}</span>
</div>
</div>
<div class="bottom-row">
<div class="author-lockup">
<img class="author-avatar" src="{{AVATAR_PATH}}" alt="Lee Teong Hoe">
<div class="author-meta">
<div class="author-name">Lee Teong Hoe <span class="handle">· Mr Hoelee</span></div>
<div class="author-role">BUILD · OPERATE · DOCUMENT</div>
</div>
</div>
<div class="domain-pill">blog.hoelee.com</div>
</div>
</div>
</div>
<script>
/* Auto-fit the headline: shrink until the block fits maxLines with no widow line.
Runs before the screenshot (Chrome is invoked with --virtual-time-budget), so the
captured PNG is always the settled state — never a mid-layout frame. */
(function () {
var MAX_PX = {{TITLE_SIZE}};
var MIN_PX = 26;
var MAX_LINES = 3;
var WIDOW_RATIO = 0.40;
var title = document.getElementById('title');
var bento = document.getElementById('bento');
var block = title.parentElement;
function lineRects(el) {
var range = document.createRange();
var rects = [];
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (!n.textContent.trim()) continue;
range.selectNodeContents(n);
var rs = range.getClientRects();
for (var i = 0; i < rs.length; i++) if (rs[i].width > 1) rects.push(rs[i]);
}
var rows = {};
rects.forEach(function (r) {
var key = Math.round((r.top + r.height / 2) / 6);
if (!rows[key]) rows[key] = { left: r.left, right: r.right, top: r.top };
else {
rows[key].left = Math.min(rows[key].left, r.left);
rows[key].right = Math.max(rows[key].right, r.right);
}
});
return Object.keys(rows).sort(function (a, b) { return rows[a].top - rows[b].top; })
.map(function (k) { return rows[k]; });
}
function fits(px) {
title.style.fontSize = px + 'px';
var avail = block.clientWidth;
var rows = lineRects(title);
if (rows.length === 0) return false;
if (rows.length > MAX_LINES) return false;
var last = rows[rows.length - 1];
var lastW = last.right - last.left;
if (rows.length > 1 && lastW < avail * WIDOW_RATIO) return false;
// Whole card must still fit its fixed height.
if (bento.scrollHeight > bento.clientHeight + 1) return false;
return true;
}
for (var px = MAX_PX; px >= MIN_PX; px--) {
if (fits(px)) {
document.title = 'FIT ' + px + 'px';
return;
}
}
title.style.fontSize = MIN_PX + 'px';
document.title = 'FIT ' + MIN_PX + 'px (floor)';
})();
</script>
</body>
</html>
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="{{LANG}}">
<head>
<meta charset="UTF-8">
<style>
:root {
--bg-base: #0b0f19;
--primary: #4d82ff;
--primary-bright: #6d8dff;
--accent-green: #3fb950;
--accent-red: #ff7b72;
--accent-amber: #fbbf24;
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--text-dim: #6b7c99;
--border-glass: rgba(109, 141, 255, 0.22);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1200px;
height: 630px;
overflow: hidden;
background: var(--bg-base);
font-family: "Space Grotesk", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: var(--text-main);
-webkit-font-smoothing: antialiased;
}
.canvas {
position: relative;
width: 1200px;
height: 630px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 52px 64px;
}
/* 1. 底层:Ideogram 4 生成的单篇专属背景底图 */
.bg-artwork {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: right center;
z-index: 1;
}
/* 2. 中层:强制暗色渐变遮罩 (Programmatic Scrim) + 蓝图网格装饰 */
.scrim-overlay {
position: absolute;
inset: 0;
z-index: 2;
background:
radial-gradient(circle at 82% 50%, rgba(11, 15, 25, 0.05) 0%, rgba(11, 15, 25, 0.65) 55%, rgba(11, 15, 25, 0.96) 78%),
linear-gradient(90deg, #0b0f19 0%, #0b0f19 38%, rgba(11, 15, 25, 0.94) 54%, rgba(11, 15, 25, 0.35) 74%, rgba(11, 15, 25, 0.08) 100%);
}
/* 延续 sample-new-default-og.jpg 的微弱蓝图几何线与十字准星 */
.blueprint-accents {
position: absolute;
inset: 24px;
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 12px;
pointer-events: none;
z-index: 3;
}
.crosshair {
position: absolute;
width: 15px;
height: 15px;
opacity: 0.35;
}
.crosshair::before, .crosshair::after {
content: "";
position: absolute;
background: var(--primary-bright);
}
.crosshair::before { top: 7px; left: 0; width: 15px; height: 1px; }
.crosshair::after { left: 7px; top: 0; width: 1px; height: 15px; }
.ch-tr { top: 28px; right: 32px; }
.ch-br { bottom: 28px; right: 32px; }
/* 3. 顶层:左侧 65% 安全区三段式排版容器 */
.safe-zone {
position: relative;
z-index: 4;
width: 740px; /* 严格控制在左侧 ~62% 安全区内 */
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* --- 第一段 (Top):分类徽章 + 核心技术标签 --- */
.top-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: nowrap;
}
.cat-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 999px;
background: rgba(77, 130, 255, 0.16);
border: 1px solid rgba(109, 141, 255, 0.45);
color: #93b4ff;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 14px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
}
.cat-badge .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
}
.top-tags {
display: flex;
align-items: center;
gap: 10px;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 14px;
color: var(--text-dim);
}
.top-tags span {
background: rgba(28, 36, 51, 0.75);
border: 1px solid rgba(42, 54, 74, 0.8);
padding: 4px 10px;
border-radius: 6px;
color: var(--text-muted);
}
/* --- 第二段 (Center):主标题 + 硬核证据摘要条 --- */
.center-block {
display: flex;
flex-direction: column;
gap: 20px;
margin: auto 0;
padding: 12px 0;
}
.title {
font-weight: 800;
font-size: {{TITLE_SIZE}}px;
line-height: 1.18;
letter-spacing: -0.02em;
color: #ffffff;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
}
.title .accent {
color: var(--primary-bright);
}
.brand-divider {
width: 56px;
height: 4px;
border-radius: 2px;
background: var(--primary-bright);
}
/* 浓缩版证据胶囊:保留旧版 Terminal 的硬核感,但更紧凑现代 */
.evidence-strip {
display: inline-flex;
align-items: center;
gap: 12px;
width: fit-content;
max-width: 720px;
padding: 12px 18px;
border-radius: 8px;
background: rgba(21, 27, 38, 0.82);
border: 1px solid rgba(45, 58, 80, 0.9);
backdrop-filter: blur(8px);
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 16px;
line-height: 1.4;
color: #d1d5db;
}
.evidence-strip .prompt {
color: var(--primary-bright);
font-weight: 700;
}
.evidence-strip .text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- 第三段 (Bottom):个人品牌锁定 (与 sample-new-default-og.jpg 呼应) --- */
.bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.author-lockup {
display: flex;
align-items: center;
gap: 14px;
}
.author-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(109, 141, 255, 0.6);
}
.author-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.author-name {
font-size: 18px;
font-weight: 700;
color: #ffffff;
letter-spacing: 0.2px;
}
.author-name .handle {
font-weight: 500;
color: var(--text-muted);
font-size: 15px;
margin-left: 6px;
}
.author-role {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
color: var(--primary-bright);
}
.domain-pill {
font-family: "JetBrains Mono", monospace;
font-size: 15px;
color: var(--text-muted);
background: rgba(15, 20, 28, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 14px;
border-radius: 6px;
}
</style>
</head>
<body>
<div class="canvas">
<!-- 1. Ideogram 4 背景图 -->
<img class="bg-artwork" src="{{BG_IMAGE_SRC}}" alt="">
<!-- 2. 渐变遮罩与蓝图准星 -->
<div class="scrim-overlay"></div>
<div class="blueprint-accents">
<div class="crosshair ch-tr"></div>
<div class="crosshair ch-br"></div>
</div>
<!-- 3. 左侧 65% 三段式黄金排版区 -->
<div class="safe-zone">
<!-- Top -->
<div class="top-row">
<div class="cat-badge"><span class="dot"></span>{{CATEGORY}}</div>
<div class="top-tags">{{TAGS_HTML}}</div>
</div>
<!-- Center -->
<div class="center-block">
<h1 class="title">{{TITLE_HTML}}</h1>
<div class="brand-divider"></div>
<div class="evidence-strip">
<span class="prompt">$</span>
<span class="text">{{EVIDENCE_SUMMARY}}</span>
</div>
</div>
<!-- Bottom -->
<div class="bottom-row">
<div class="author-lockup">
<img class="author-avatar" src="{{AVATAR_PATH}}" alt="Lee Teong Hoe">
<div class="author-meta">
<div class="author-name">Lee Teong Hoe <span class="handle">· Mr Hoelee</span></div>
<div class="author-role">BUILD · OPERATE · DOCUMENT</div>
</div>
</div>
<div class="domain-pill">blog.hoelee.com</div>
</div>
</div>
</div>
</body>
</html>