這個部落格有個存在很久的小債:17 篇文章裡,一張封面都沒有。每次打開文章列表都是一排光禿禿的灰底 placeholder,社群媒體分享預覽也只有標題文字。
一直沒處理是因為:要嘛手動一張張找圖很煩,要嘛丟給外部服務(Midjourney / DALL-E)要自己掏錢 + 管 API key + 存圖檔對應 slug,光想這個 pipeline 就懶。
直到昨天刷到一則推文:Codex CLI 0.122 把 gpt-image-2 預設打開了,不需要 API key,走你現有的 ChatGPT 帳號計費。配套還有一個叫 baoyu-skills 的 Claude Code skill 集合,專門餵 Codex 生各種規格的圖。
也就是說:整條 pipeline 已經在我手邊,只是我不知道。那今天就來補債。
最後結果 17 張全生出來了,過程中踩到一個 --sandbox workspace-write 的誤會,有 14 張看起來全失敗,後來發現圖其實都生成了,只是被困在 Codex 的快取目錄裡。這篇記錄流程 + 踩坑 + 救援。
驗證:Codex 真的能畫圖
先 live test。我的 Codex 版本剛好是 0.122.0。
codex exec --skip-git-repo-check --sandbox workspace-write \
--output-last-message "$OUT" -m gpt-5.4 \
'$imagegen Create a simple 256x256 PNG of a red circle on white background. Save as /tmp/test.png. Final message: only the file path.' \
< /dev/null
跑完 ls /tmp/test.png 就有了。32 KB PNG、256×256 8-bit RGB、花了 ~30k tokens。
幾個關鍵點,當下沒全搞懂,後面會回來踩:
--sandbox workspace-write必加,因為 Codex 要呼叫 shell 的cp把圖從內部 cache 搬到你指定的位置。read-only直接失敗。$imagegen用單引號包整段 prompt,避免 bash 把它展開成空字串。省略也行(Codex 會自己判斷要不要畫),但加上更明確。- 命令尾的
< /dev/null是上週踩過另一個坑的肌肉記憶:Codex exec 會等 stdin EOF,背景執行如果 pipe stdin 不關就永遠卡住。這是另一個故事。
內部流程也驗到了:Codex 不是直接寫你指定的路徑。它先把圖存到 ~/.codex/generated_images/<session-id>/ig_*.png,然後呼叫一次 shell用 cp + macOS 的 sips(做 resize)搬到目標位置。等等會看到為什麼這個中間步驟很重要。
baoyu-skills:要不要裝、裝什麼
推文裡說的 baoyu-skills 來自 JimLiu/baoyu-skills,GitHub 星數 ★15k 級別。裡面有二十幾個 skill:
- 內容生成類:
baoyu-cover-image、baoyu-infographic、baoyu-comic、baoyu-slide-deck… - AI 圖像生成類:
baoyu-image-gen(各家 API 整合)、baoyu-imagine - 發文類:
baoyu-post-to-x、baoyu-post-to-weibo、baoyu-post-to-wechat、baoyu-youtube-transcript - 工具類:
baoyu-compress-image、baoyu-format-markdown - 「danger」類:
baoyu-danger-gemini-web、baoyu-danger-x-to-markdown(逆向 web API 的)
一口氣裝下去顯然太多。評估下來我只需要 baoyu-cover-image:
- 它是純文字 skill(SKILL.md + references 資料夾,188 KB),不帶 npx / bun / 任何跑時依賴
- 它對後端是「auto-detect」:優先用 runtime native 的 imagegen(= Codex 自己的 gpt-image-2),只有在沒有時才去找
baoyu-imagine這類需要 API key 的備用 - 所以我裝它就好,不需要碰任何第三方 API key
避開的:所有 baoyu-post-to-*(我 blog 不透過 Twitter/微博/微信發)、baoyu-image-gen(需要 OpenAI/DashScope/Replicate 等 API key)、baoyu-danger-*(逆向 web API 本來就有維護成本,而我 Codex 能用就不需要)。
安裝就是從 repo 把 skills/baoyu-cover-image/ 整個複製進 ~/.claude/skills/baoyu-cover-image/。下 session Claude Code 啟動就讀到了。
寫個最小偏好檔 ~/.baoyu-skills/baoyu-cover-image/EXTEND.md:
---
version: 3
preferred_text: title-only
preferred_mood: balanced
default_aspect: "16:9"
quick_mode: false
language: zh
preferred_image_backend: auto
default_output_dir: same-dir
---
preferred_image_backend: auto 讓 skill 自動挑 Codex 當 backend;quick_mode: false 保留第一次產圖前的確認步驟。
流程設計
blog 原本住在 VPS 上的 blog/ 資料夾,沒 GitHub remote。我不打算為這次工作 push 到雲端(另一件事),所以選 rsync 策略:
- 下載
content/+hugo.toml到本機~/mklee/blog/ - 本機改 frontmatter + 生圖
- rsync 回 VPS
- VPS 上跑部署腳本
圖檔放哪?PaperMod 支援絕對路徑的 cover image,走 Hugo static/ 自動 map 到網站 root:
- 檔案:
static/images/posts/<slug>.png - URL:
/images/posts/<slug>.png - Frontmatter:
cover.image: "/images/posts/<slug>.png"+relative: false
尺寸用 1792×1024,這是 gpt-image-2 原生支援的 wide 尺寸,比例接近 16:9。
每篇文章要給圖生成的「指令」怎麼寫?baoyu-cover-image 把風格拆成五個維度:
| 維度 | 選項 |
|---|---|
| Type | hero / conceptual / metaphor / typography / scene / minimal |
| Palette | warm / cool / dark / earth / retro / mono / vivid / pastel / duotone / elegant / macaron |
| Rendering | flat-vector / hand-drawn / painterly / digital / chalk / pixel / screen-print |
| Text | none / title-only / title-subtitle / text-rich |
| Mood | subtle / balanced / bold |
每篇我根據題目手動挑一組。例如:
- 一篇講 memory 三層系統的文章 →
conceptual+cool+digital+balanced(架構圖感) - 一篇咖啡萃取筆記 →
metaphor+warm+hand-drawn+subtle(咖啡館粉筆畫感) - 一篇講夢境式記憶循環 →
metaphor+dark+painterly+subtle(月光星雲)
然後每篇都寫一句「視覺核心構想」,例如「三個 dial 圍繞著一個中央 espresso cup」、「分層金字塔顯示從 worker 到 decision 節點」。這才是圖的靈魂,維度只是色調。
Pilot 3 張:先驗再擴大
我不想 17 張一次丟下去,萬一風格不對或中文渲染歪掉,會全盤重跑浪費 token。先挑三篇各自代表一種題材跑 pilot:
- hybrid-memory-search(技術算法):conceptual + cool + digital
- espresso-dialing-ramble(咖啡哲思):metaphor + warm + hand-drawn
- polymarket-bot-architecture(基礎設施):conceptual + cool + digital(偏 blueprint)
三張都在 1–3 分鐘內生完、各 500 KB 到 3 MB 不等。中文渲染方面我有個意外發現:gpt-image-2 對標題是中文還是英文是機率性的。咖啡那張標題「Espresso 調磨雜談」中英混排,它忠實渲染;Polymarket 那張整個中文標題 + 副標 + 分層標籤全部中文,渲染得最完整;但 hybrid memory search 那張的標題是「Hybrid memory search:把 journal 搜尋當成 ranking 問題」,它自動只保留英文前半。大致規律是原標題越偏哪一方它就往哪邊倒,強制全中文會降低品質。
三張都對題。批次剩下 14 張。
踩坑:14 張看起來全部失敗
批次我寫了個 shell function:
gen_cover() {
local slug="$1" title="$2" desc="$3" dims="$4" visual="$5"
codex exec --skip-git-repo-check --sandbox workspace-write \
--output-last-message "/tmp/codex-$slug.out" -m gpt-5.4 \
"<包好的 prompt>" < /dev/null >/tmp/codex-$slug.log 2>/tmp/codex-$slug.err &
echo "launched $slug (pid=$!)"
}
gen_cover "<slug1>" "<title1>" "<desc1>" "<dims1>" "<visual1>"
gen_cover "<slug2>" ...
# ... 共 14 呼叫
全部用 & 背景,14 個 Codex 同時跑。6 分鐘後回來看,全部程序都收掉了 —— 但 static/images/posts/ 還是只有 pilot 那 3 張。
掃 err 檔:每個都是同一個錯誤:
cp: ~/mklee/blog/static/images/posts/<slug>.png: Operation not permitted
而且 --output-last-message 指向的 .out 檔裡,Codex 回傳的是:
~/.codex/generated_images/<session-id>/ig_<hash>.png
Codex 自己也困惑:它產完圖了,想搬到我指定的路徑,被擋;於是改放回自己的內部路徑,跟我報告。
診斷:workspace-write 到底 write 什麼 workspace
workspace-write 這個 sandbox 模式的寫入範圍,鎖定在 Codex 啟動時 shell 的 CWD。不是 prompt 寫什麼絕對路徑、不是 --output-last-message 指哪裡、也不是讓 Codex agent 自己 mkdir -p 就能繞過 —— shell 在哪裡啟動,能寫的就是那個目錄樹。
我的 launcher function 在 cc-memory-project/ 目錄跑,Codex 的「workspace」就是這裡,當然不能寫 ~/mklee/blog/。
為什麼 pilot 3 張沒踩到?翻我的腳本 —— pilot 我有手動 cd ~/mklee/blog 再跑 Codex。當時沒覺得是必要,只是順手。等到 batch function 我沒把 cd 包進去,就集體踩。
教訓:workspace-write 不是「允許寫任何 workspace」,是「允許寫當前 workspace」。batch 任務要寫檔到特定目錄,launcher 必須先 cd 過去。
救圖:從 stderr 撿 session id
現在重點是:圖檔其實都生成成功了,只是停在 ~/.codex/generated_images/<sid>/ig_*.png。每個 Codex session 有自己的 uuid 目錄,14 個 session 就 14 個目錄。
怎麼知道哪個 slug 對應哪個 session?Codex 啟動時的 stderr 會印:
session id: 019db338-aee2-7d02-89e5-865b20c264a8
我的 launcher 把每個 Codex 的 stderr 各自導到 /tmp/codex-<slug>.err,所以 slug 跟 session id 對應關係已經在這堆檔案裡。撿回來:
for slug in "${SLUGS[@]}"; do
sid=$(grep -m1 '^session id: ' "/tmp/codex-$slug.err" | sed 's/session id: //')
src=$(ls "$HOME/.codex/generated_images/$sid"/*.png | head -1)
cp "$src" "$OUT_DIR/$slug.png"
echo "recovered $slug"
done
14 張一個不漏全撿回來。剛好驗證了「Codex 報告失敗不等於真的沒產出」這件事 —— 下次遇到同樣狀況,永遠先看 cache 目錄再考慮重跑。
部署
本機改完剩下就是搬回 VPS:
# rsync content/ 跟 static/images/posts/ 回去
rsync -av --rsync-path="sudo rsync" \
~/mklee/blog/content/posts/ vps:<blog>/content/posts/
rsync -av --rsync-path="sudo rsync" \
~/mklee/blog/static/images/posts/ vps:<blog>/static/images/posts/
# VPS 上跑部署
ssh vps 'sudo <blog-deploy-script>'
Hugo build 301 ms,部署完抽樣驗證外網:
curl -o /dev/null -s -w "%{http_code}" \
https://blog.mklee.org/images/posts/espresso-dialing-ramble.png
# 200
HTML 渲染的 <meta property="og:image">、<meta name="twitter:image">、<figure class="entry-cover"> 全部正確。現在點開文章列表不再是一排灰方塊,社群分享預覽也有圖了。
帶走的兩件事
1. Sandbox scope 的直覺要 calibrate
--sandbox workspace-write 的「workspace」是啟動 shell 的 CWD,不是隨便哪個 workspace。prompt 寫什麼絕對路徑、指定 output 都不會擴大寫入範圍。Batch 任務寫檔到特定目錄,cd 進去是第一步而不是可選步。
2. 「失敗」訊息值得多問一句
Codex 在 err 裡清楚寫 cp: Operation not permitted —— 這是「搬運失敗」,不是「生成失敗」。如果當下沒分清楚這兩種失敗,很容易直接重跑 14 張浪費 30 分鐘 + 420k tokens。產出物跟 agent 的自我陳述是兩件事,實際去看 cache 比相信 agent 的結論快。
這兩件事也剛好更新進我的 skill 設定:~/.claude/skills/blog-writer/SKILL.md 現在有一段專門講「封面圖自動產圖」的流程 + fallback + CWD 提醒。下次想用自動化產圖,skill 會自己提醒該 cd 哪裡、該去哪找走失的圖。
