fix(art): give both 2026-10-20 posts their artwork, and gate the build on it
Deploy / build (push) Successful in 34s

The YuE2 and subtitles posts shipped text-only: render_from_art.mjs only READS
ogImage/banner, and after the 2026-10-19 bulk migration no script had to write
them, so both pages advertised /og-default.jpg and rendered no hero banner.

- new metaphors (in build_captions.py, frozen here via sync_prompts_catalog.py),
  captions, art-meta evidence, banner plan and the five derived files per slug
- frontmatter: ogImage + banner in EN and ZH for both posts
- scripts/check-assets.mjs is now step 1 of npm run build, so CI fails when a
  published post lacks a language-correct og card, banner, banner .webp or
  400/800 list thumb. It reported 16 problems on the unfixed tree, all here.
This commit is contained in:
hoelee committed 2026-10-20 23:37:52 +08:00
1 parent 557f312bc7
commit 0dae8b0daa
26 files changed
+229 -2

No files matched your search

+6
View File
@@ -43,6 +43,12 @@ Read this **before** touching theme, layout, typography, color, or SEO markup. P
| Real photos (author, hardware) | **WebP q82** + JPEG fallback | Continuous-tone images are the opposite case from the flat art above. |
Regenerate all derivatives after adding a post: `uv run --with pillow python scripts/optimize-images.py`.
- **Every published post must point at its own art.** `scripts/check-assets.mjs` runs as the first
step of `npm run build` (so CI fails, not just warns) and rejects a post whose `ogImage` or
`banner` is missing or misnamed, or whose banner `.webp` / `-400` / `-800` thumbs are absent.
The renderers only *read* frontmatter — nothing writes these two fields for a new post, which is
how two 2026-10-20 posts shipped on `/og-default.jpg` with no hero banner.
- **Declare `og:image:width/height` explicitly** — mis-declared dimensions get preview cards rejected by WhatsApp/Telegram/LinkedIn.
- **Favicon**: reference all sizes (`.ico` + 32×32 + 192×192 PNG + apple-touch 180×180). "Favicon not showing" is usually browser cache, not a missing asset — `curl` the live files before "fixing".
+7
View File
@@ -258,8 +258,15 @@ No topics were killed by the user in this sweep.
- Verified with headless Chrome by injecting the built `<section class="related">` + built CSS into a probe at 1264 / 884 / 500 px: `gridTemplateColumns` = 3 / 2 / 2 columns, visible cards 6 / 6 / 4, `scrollWidth == clientWidth` everywhere (no overflow), 6/6 images decoded.
- ⚠ Headless `--window-size=390` is silently clamped to **500** on Windows — the right-hand column looks clipped in the screenshot, but that is the capture, not the layout. Verify <500px by putting the probe in a 390px `<iframe>` and screenshotting that.
- ⚠ `--virtual-time-budget` can expire before lazy images paint (5 of 6 cards came out as empty `var(--border)` rectangles at budget 6000). Use ≥20000 before declaring an image broken.
- **Tag pages now carry the same chrome as `/posts/`** (`/tags/<tag>/` and `/zh/tags/<tag>/` used to pass `chrome={false}` and show only a count line): the title/tag filter input, the category chips and the tag-chip strip. Category chips and the row filter stay scoped to the posts on the page, so their counts match the rows; the tag strip is built from the whole language collection (`chipPosts`) because those chips *navigate* — page-scoped counts there would lie — and the tag being viewed gets `is-active` + `aria-current`. Measured at 1264/500px: gaps 8px/22px between bar → category chips → tag chips → list, `scrollWidth < clientWidth`, 14 tag chips, 4 category chips on /tags/docker/.
- Bytes: 6 × ~65 KB JPEG ≈ 390 KB, `loading="lazy"` and below the fold. The `/og/thumb/` WebPs (5.8 / 16.1 KB) are keyed by base slug with **no ZH variant**, so they cannot be used here without first emitting `/og/thumb/zh-<slug>-{400,800}.webp`.
### Phase H — Two posts shipped without art (2026-10-20), and the gate that stops it recurring
- ⚠ **Cause**: the two posts from 2026-10-20 (`yue2-on-a-12gb-gpu-songs-that-lost-their-endings`, `generating-subtitles-for-songs-without-timestamps`, EN+ZH) were published text-complete only. `render_from_art.mjs` **reads** `ogImage`/`banner` but nothing **writes** them, and after the 2026-10-19 bulk migration no script had to — so both pages advertised `/og-default.jpg` (generic card in every share preview and in the new related grid) and rendered no hero banner. The pipeline itself was fine and up to date: `python scripts/og-gen/sync_prompts_catalog.py --check` named exactly these two slugs (`missing=[...]`, 73 metaphors vs 75 posts), which is the check nobody ran before pushing.
- ✅ Art generated with the current one-command flow (`C:/AI/ideogram4/post_art.py --slug … --metaphor … --evidence …`), same v2 semantic style as the other 73: no-text 1360×768 frame → 1600×900 banner (jpg+webp) → EN card → ZH card → 400/800 thumbs.
- ✅ **New gate**: `scripts/check-assets.mjs` runs as step 1 of `npm run build`, so a post that is text-complete but not image-complete now fails the Gitea Actions build instead of reaching nginx. It checks, per published post: `ogImage` present and language-correct (EN `/og/<slug>.jpg`, ZH `/og/zh-<slug>.jpg`), `banner` present (`/banners/<slug>.jpg`, shared by both languages), and the existence of banner `.jpg` + `.webp` + `-400` + `-800` thumbs. First run against the unfixed tree reported 16 problems, all on these two slugs and nothing else — it catches this bug class and is silent on the other 73 posts.
- Frontmatter for the two posts now carries all four fields; nothing else about the posts changed.
### Phase A — Identity (DONE)
- ✅ Real headshot in the About hero, the author card on every post, the home hero (EN + ZH) and JSON-LD `Person.image`; no 404 for any derivative.
- ✅ One branding line ("Full-Stack Developer & DevOps Engineer") aligned across `SITE.title`, meta description, JSON-LD and the homepage hero.
+1 -1
View File
@@ -5,7 +5,7 @@
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build && pagefind --site dist",
"build": "node scripts/check-assets.mjs && astro build && pagefind --site dist",
"preview": "astro preview",
"astro": "astro"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Publish gate: every published post must carry its own artwork.
*
* Why this exists: the "one art, four targets" pipeline writes five files per
* slug (banner .jpg/.webp, EN card, ZH card, two list thumbs) but the two
* frontmatter fields that *point* at them (`ogImage`, `banner`) are added by
* hand — they were bulk-migrated for the first 72 posts and nothing wrote them
* for posts added later. Two posts shipped on 2026-10-20 with an empty
* `ogImage`, so their pages advertised /og-default.jpg and rendered no hero
* banner, and every related-post card showed the generic default. Nothing in
* the build noticed. This script is that notice.
*
* Runs as the first step of `npm run build`, so a post that is text-complete
* but not image-complete cannot reach nginx.
*
* Rules checked per non-draft post:
* - `ogImage` present, language-correct (EN /og/<slug>.jpg, ZH /og/zh-<slug>.jpg)
* - `banner` present (/banners/<slug>.jpg) — the same banner serves both languages
* - every referenced file exists in public/, plus the derived banner .webp and
* the two list thumbs the post list and related grid link to
*
* Usage: node scripts/check-assets.mjs (exit 1 with a list on any problem)
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { basename, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const POSTS = join(ROOT, 'src', 'content', 'posts');
const PUBLIC = join(ROOT, 'public');
/** Minimal frontmatter reader: top-level `key: value` scalars only, which is all we need. */
function frontmatter(file) {
const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) return null;
const out = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
if (!kv) continue;
let value = kv[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
out[kv[1]] = value;
}
return out;
}
const problems = [];
const notes = [];
/** Resolve a site-absolute path (/og/x.jpg) to a file on disk under public/. */
const publicFile = (urlPath) => join(PUBLIC, urlPath.replace(/^\//, ''));
function requireFile(urlPath, label) {
if (!existsSync(publicFile(urlPath))) problems.push(`${label}: missing file ${urlPath}`);
}
const files = [
...readdirSync(POSTS)
.filter((f) => f.endsWith('.md'))
.map((f) => ({ dir: POSTS, file: f, lang: 'en' })),
...(existsSync(join(POSTS, 'zh'))
? readdirSync(join(POSTS, 'zh'))
.filter((f) => f.endsWith('.md'))
.map((f) => ({ dir: join(POSTS, 'zh'), file: f, lang: 'zh' }))
: []),
];
let checked = 0;
for (const { dir, file, lang } of files) {
const path = join(dir, file);
const slug = basename(file, '.md');
const fm = frontmatter(path);
if (!fm) {
problems.push(`${file}: no frontmatter block`);
continue;
}
if (fm.draft === 'true') continue;
checked++;
const where = lang === 'zh' ? `zh/${file}` : file;
const expectedOg = lang === 'zh' ? `/og/zh-${slug}.jpg` : `/og/${slug}.jpg`;
const expectedBanner = `/banners/${slug}.jpg`;
if (!fm.ogImage) {
problems.push(`${where}: no og:image in frontmatter (expected ogImage: "${expectedOg}")`);
} else if (fm.ogImage !== expectedOg) {
problems.push(`${where}: ogImage is "${fm.ogImage}", expected "${expectedOg}"`);
} else {
requireFile(fm.ogImage, where);
}
if (!fm.banner) {
problems.push(`${where}: no banner in frontmatter (expected banner: "${expectedBanner}")`);
} else if (fm.banner !== expectedBanner) {
problems.push(`${where}: banner is "${fm.banner}", expected "${expectedBanner}"`);
} else {
requireFile(fm.banner, where);
requireFile(fm.banner.replace(/\.(png|jpe?g)$/, '.webp'), where);
}
// The post list serves 400w/800w WebP thumbs keyed by BASE slug (a ZH card is
// zh-<slug>.jpg while its thumb is <slug>-<w>.webp), so check both languages here.
for (const w of [400, 800]) requireFile(`/og/thumb/${slug}-${w}.webp`, where);
}
// Every post should have a twin, and both should agree it exists.
const enSlugs = new Set(files.filter((f) => f.lang === 'en').map((f) => basename(f.file, '.md')));
const zhSlugs = new Set(files.filter((f) => f.lang === 'zh').map((f) => basename(f.file, '.md')));
for (const s of enSlugs) if (!zhSlugs.has(s)) notes.push(`no ZH twin for ${s}`);
for (const s of zhSlugs) if (!enSlugs.has(s)) notes.push(`no EN twin for zh/${s}`);
if (problems.length) {
console.error(`assets: ${problems.length} problem(s) across ${checked} published posts\n`);
for (const p of problems) console.error(` ✗ ${p}`);
if (notes.length) {
console.error('');
for (const n of notes) console.error(` ! ${n}`);
}
console.error(
'\nFix: cd C:/AI/ideogram4 && uv run --with pillow python post_art.py --slug <slug> --metaphor "..." --evidence "..."' +
'\nthen add ogImage + banner to BOTH language files (see docs/design-guide.md, image policy).'
);
process.exit(1);
}
console.log(`assets: ${checked} published posts OK (og card + banner + webp + 2 thumbs each)`);
for (const n of notes) console.log(` ! ${n}`);
+6
View File
@@ -221,5 +221,11 @@
},
"your-disk-full-alert-is-lying": {
"evidence": "3% free = 500 GB · alert fires wrong way"
},
"yue2-on-a-12gb-gpu-songs-that-lost-their-endings": {
"evidence": "12 takes - all 240.000 s - cap now 300 s"
},
"generating-subtitles-for-songs-without-timestamps": {
"evidence": "18 songs - 342 lines - 0.96 anchor coverage"
}
}
+48
View File
@@ -388,6 +388,30 @@
768
]
},
"generating-subtitles-for-songs-without-timestamps": {
"sw": 1877,
"sh": 1060,
"left": 545,
"top": 70,
"fill": [
11,
18,
25
],
"zoom": 1.38,
"silhouette": 0.47,
"cut": 0.0,
"band": [
743,
119,
1288,
635
],
"source_size": [
1360,
768
]
},
"hardening-a-tor-onion-service": {
"sw": 1952,
"sh": 1102,
@@ -1755,6 +1779,30 @@
1360,
768
]
},
"yue2-on-a-12gb-gpu-songs-that-lost-their-endings": {
"sw": 1887,
"sh": 1066,
"left": 556,
"top": 77,
"fill": [
10,
14,
22
],
"zoom": 1.387,
"silhouette": 0.47,
"cut": 0.0,
"band": [
747,
116,
1289,
643
],
"source_size": [
1360,
768
]
}
}
}
@@ -0,0 +1 @@
{"high_level_description":"A dark-mode 3D cyber-bento product render of a single piece of industrial hardware, photographed on a seamless dark backdrop. The main high-detail 3D focal subject is positioned on the right side of the canvas, leaving clean dark negative space across the left side.","style_description":{"aesthetics":"modern dark cyber-bento, industrial precision, sleek machined surfaces, raytraced ambient occlusion","lighting":"volumetric studio rim lighting in royal cobalt blue (#4d82ff) with electric cyan (#22d3ee) calibration light and soft ultraviolet spill and subtle cyan glow","medium":"3d_render","art_style":"octane render, matte obsidian hardware, frosted acrylic glass, precise machined surface detail","color_palette":["#0B0F19","#111827","#1E293B","#22D3EE","#7C5CFF","#4D82FF"]},"compositional_deconstruction":{"background":"Deep midnight obsidian slate background (#0b0f19 to #111827) fading into pure clean negative space across the entire left 60% of the horizontal canvas, with a soft rim vignette and faint volumetric haze.","elements":[{"type":"obj","bbox":[150,550,850,945],"desc":"A translucent glass rod of trapped sound resting on a dark machined spindle, a thin brass feeler tracing along its length and pressing evenly spaced notches into the glass, each notch filling with pale light just behind the moving feeler. Clean unbranded matte surfaces, blank panels, no printed markings. Razor-sharp detail, no text, no letters, no watermark."}]}}
@@ -0,0 +1 @@
{"high_level_description":"A dark-mode 3D cyber-bento product render of a single piece of industrial hardware, photographed on a seamless dark backdrop. The main high-detail 3D focal subject is positioned on the right side of the canvas, leaving clean dark negative space across the left side.","style_description":{"aesthetics":"modern dark cyber-bento, industrial precision, sleek machined surfaces, raytraced ambient occlusion","lighting":"volumetric studio rim lighting in royal cobalt blue (#4d82ff) with neon cyan (#22d3ee) and electric violet-magenta (#a855f7) synaptic glow and subtle cyan glow","medium":"3d_render","art_style":"octane render, matte obsidian hardware, frosted acrylic glass, precise machined surface detail","color_palette":["#0B0F19","#111827","#1E293B","#22D3EE","#A855F7","#4D82FF"]},"compositional_deconstruction":{"background":"Deep midnight obsidian slate background (#0b0f19 to #111827) fading into pure clean negative space across the entire left 60% of the horizontal canvas, with a soft rim vignette and faint volumetric haze.","elements":[{"type":"obj","bbox":[150,550,850,945],"desc":"A row of slender dark glass cylinders standing upright on a machined rail, a single horizontal blade bar sweeping across them and cutting every one at exactly the same height, the severed upper segments laid out in a shallow tray beside the rail. Clean unbranded matte surfaces, blank panels, no printed markings. Razor-sharp detail, no text, no letters, no watermark."}]}}
+3 -1
View File
@@ -15,6 +15,7 @@
"every-player-stuttered-the-file-was-fine": "A perfectly aligned dark film canister whose internal sprocket teeth sit one notch out of phase, casting a doubled ghost shadow onto a translucent glass slab",
"five-step-ai-roadmap-what-i-actually-run": "A five-tier architectural staircase in dark graphite, the middle tiers brightly illuminated with active hardware racks while the speculative top tiers remain skeletal wireframes",
"fully-on-chain-svg-nfts": "A crystalline art plaque sealed completely inside a transparent contract block, its imagery carved directly into the block's inner facets rather than mounted on any external frame",
"generating-subtitles-for-songs-without-timestamps": "A translucent glass rod of trapped sound resting on a dark machined spindle, a thin brass feeler tracing along its length and pressing evenly spaced notches into the glass, each notch filling with pale light just behind the moving feeler",
"hardening-a-tor-onion-service": "A matte black multi-shell relay tower of stacked concentric domes, each layer locked by a brass ring, with a single narrow sealed ingress slit glowing soft violet",
"hello-world": "A minimal dark workbench holding one glowing filament cursor hovering above a blank obsidian slate, first light spilling from a small pulsing pinpoint of light at its corner",
"how-i-built-the-digikedai-telegram-bot": "A folded paper-plane shaped obsidian drone docked at a glowing delivery dock module, tethered by a luminous thread to a private gateway pylon behind it",
@@ -71,5 +72,6 @@
"why-your-cdp-clicks-silently-fail": "A high-precision robotic cursor pointer clicking onto a translucent glass pad that has already shifted away on an invisible kinetic wave, triggering no resonance",
"why-your-grafana-dashboard-shows-no-data": "A wide wall of forty-one dark telemetry panels all reading blank, one small self-referential filter disc visibly stuck against its own inlet",
"why-your-headless-browser-cant-scrape-everything": "Two identical dark vault chambers side by side, one sealed shut behind a wall of blank grey panels while the other stands open with warm light spilling from inside",
"your-disk-full-alert-is-lying": "A tall nearly empty barrel of enormous capacity with a tiny amber fill line at its base, while a thin illuminated arc above it strains against its stop"
"your-disk-full-alert-is-lying": "A tall nearly empty barrel of enormous capacity with a tiny amber fill line at its base, while a thin illuminated arc above it strains against its stop",
"yue2-on-a-12gb-gpu-songs-that-lost-their-endings": "A row of slender dark glass tubes standing upright in a black rack on a machined rail, a single horizontal blade bar sweeping across them and cutting every one at the same height, the severed upper segments laid out in a shallow tray beside the rail"
}
+14
View File
@@ -239,6 +239,13 @@
"prompt": "A crystalline art plaque sealed completely inside a transparent contract block, its imagery carved directly into the block's inner facets rather than mounted on any external frame",
"caption": "captions/fully-on-chain-svg-nfts.json"
},
{
"slug": "generating-subtitles-for-songs-without-timestamps",
"unique_elements": "A translucent glass rod of trapped sound resting on a dark machined spindle, a thin brass feeler tracing along its length and pressing evenly spaced notches into the glass, each notch filling with pale light just behind the moving feeler",
"prompt": "A translucent glass rod of trapped sound resting on a dark machined spindle, a thin brass feeler tracing along its length and pressing evenly spaced notches into the glass, each notch filling with pale light just behind the moving feeler",
"evidence_summary": "18 songs - 342 lines - 0.96 anchor coverage",
"caption": "captions/generating-subtitles-for-songs-without-timestamps.json"
},
{
"slug": "hardening-a-tor-onion-service",
"title": "Hardening a Tor Onion Service: What Actually Matters",
@@ -1083,5 +1090,12 @@
"evidence_summary": "3% free = 500 GB · alert fires wrong way",
"prompt": "A tall nearly empty barrel of enormous capacity with a tiny amber fill line at its base, while a thin illuminated arc above it strains against its stop",
"caption": "captions/your-disk-full-alert-is-lying.json"
},
{
"slug": "yue2-on-a-12gb-gpu-songs-that-lost-their-endings",
"unique_elements": "A row of slender dark glass tubes standing upright in a black rack on a machined rail, a single horizontal blade bar sweeping across them and cutting every one at the same height, the severed upper segments laid out in a shallow tray beside the rail",
"prompt": "A row of slender dark glass tubes standing upright in a black rack on a machined rail, a single horizontal blade bar sweeping across them and cutting every one at the same height, the severed upper segments laid out in a shallow tray beside the rail",
"evidence_summary": "12 takes - all 240.000 s - cap now 300 s",
"caption": "captions/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.json"
}
]
@@ -4,6 +4,8 @@ description: "AI music tools give you audio and nothing else — no line timings
pubDate: 2026-10-20
category: tutorials
tags: ["subtitles", "lrc", "srt", "whisper", "forced-alignment", "ai-music", "python"]
ogImage: "/og/generating-subtitles-for-songs-without-timestamps.jpg"
banner: "/banners/generating-subtitles-for-songs-without-timestamps.jpg"
draft: false
---
Every AI music tool I have tried returns the same shape of output: an audio
@@ -4,6 +4,8 @@ description: "Twelve Mandarin songs came out exactly 240.000 seconds long with t
pubDate: 2026-10-20
category: ai
tags: ["yue2", "comfyui", "ai-music", "local-ai", "gpu", "vram", "mandarin"]
ogImage: "/og/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
banner: "/banners/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
draft: false
---
YuE2 is a 3B open-weights music model that sings Mandarin from a style prompt
@@ -4,6 +4,8 @@ description: "AI 音乐工具只给你音频 —— 没有逐行时间,没有
pubDate: 2026-10-20
category: tutorials
tags: ["subtitles", "lrc", "srt", "whisper", "forced-alignment", "ai-music", "python"]
ogImage: "/og/zh-generating-subtitles-for-songs-without-timestamps.jpg"
banner: "/banners/generating-subtitles-for-songs-without-timestamps.jpg"
draft: false
---
我试过的每一个 AI 音乐工具,交出来的东西形状都一样:一个音频文件。没有一个会告诉你**每一句是什么时候唱的**。只想听的话没问题,但只要你想把歌词放到屏幕上 —— 卡拉 OK 的那一行、视频字幕、播放器下面的滚动框 —— 就完全不够用。
@@ -4,6 +4,8 @@ description: "12 首中文歌时长全都恰好 240.000 秒,尾奏不见了。
pubDate: 2026-10-20
category: ai
tags: ["yue2", "comfyui", "ai-music", "local-ai", "gpu", "vram", "mandarin"]
ogImage: "/og/zh-yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
banner: "/banners/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
draft: false
---
YuE2 是一个 30 亿参数的开源音乐模型,能按风格提示词和歌词唱中文。我把它放在自己那台 12GB 显存的 RTX 3060 上用 ComfyUI 本地跑,一次排了 12 首原创歌 —— 结果第一批出来,**每一首的时长都恰好是 240.000 秒**。这个数字不是巧合,而且是个坏消息。