這個部落格有個存在很久的小債: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,然後呼叫一次 shellcp + macOS 的 sips(做 resize)搬到目標位置。等等會看到為什麼這個中間步驟很重要。

baoyu-skills:要不要裝、裝什麼

推文裡說的 baoyu-skills 來自 JimLiu/baoyu-skills,GitHub 星數 ★15k 級別。裡面有二十幾個 skill:

  • 內容生成類baoyu-cover-imagebaoyu-infographicbaoyu-comicbaoyu-slide-deck
  • AI 圖像生成類baoyu-image-gen(各家 API 整合)、baoyu-imagine
  • 發文類baoyu-post-to-xbaoyu-post-to-weibobaoyu-post-to-wechatbaoyu-youtube-transcript
  • 工具類baoyu-compress-imagebaoyu-format-markdown
  • 「danger」類baoyu-danger-gemini-webbaoyu-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 策略

  1. 下載 content/ + hugo.toml 到本機 ~/mklee/blog/
  2. 本機改 frontmatter + 生圖
  3. rsync 回 VPS
  4. 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 把風格拆成五個維度:

維度選項
Typehero / conceptual / metaphor / typography / scene / minimal
Palettewarm / cool / dark / earth / retro / mono / vivid / pastel / duotone / elegant / macaron
Renderingflat-vector / hand-drawn / painterly / digital / chalk / pixel / screen-print
Textnone / title-only / title-subtitle / text-rich
Moodsubtle / 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 哪裡、該去哪找走失的圖。