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:
1 parent
c09aeccc9c
commit
a41448bb4a
12 files changed
+2932
-1
No files matched your search
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'));
|
||||
for (const r of rows) {
|
||||
console.log(`\n[${r.lang}] ${r.t}`);
|
||||
if (!r.out) { console.log(' NO FIT'); continue; }
|
||||
console.log(` px=${r.out.px} lines=${r.out.lines.length} widths=[${r.out.widths.map(x => Math.round(x)).join(', ')}]`);
|
||||
r.out.lines.forEach((l) => console.log(` | ${l}`));
|
||||
}
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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}`);
|
||||
@@ -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}!`);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user