From cd1f4754ab2736c060180de16599434d3e45fb0b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 03:20:29 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20Wiki=20=E5=9C=96=E7=89=87=E5=85=B6?= =?UTF-8?q?=E5=AF=A6=E6=98=AF=E7=A0=B4=E5=9C=96=EF=BC=88HTML=20img=20?= =?UTF-8?q?=E4=B8=8D=E8=A2=AB=E6=94=B9=E5=AF=AB=EF=BC=89=EF=BC=9B=E5=BD=A2?= =?UTF-8?q?=E8=B1=A1=E5=9C=96=E5=8A=A0=E9=AB=98=E8=A7=A3=E6=9E=90=E5=BA=A6?= =?UTF-8?q?=E8=88=87=E4=BA=94=E5=AE=98=E7=B4=B0=E7=AF=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Wiki 形象圖「沒有同步」的真正原因 內容其實一直是一致的(md5 相同),但**頁面上看不到圖**: Icon.md/Home.md 用了 HTML ``,而 Gitea **只改寫 Markdown 圖片語法**(`![](icon.png)` → `/wiki/raw/icon.png`)。HTML 的 src 被瀏覽器解析成 `/wiki/icon.svg` → 303 轉址 → 破圖。 用公開的臨時庫實測四種寫法後確認,全部改成 Markdown 語法。 同時發現攤平規則過頭了:原本所有子目錄檔案都被壓成 `icon-portrait.svg`。 實測 `/wiki/raw/<資料夾>/<圖>` 是取得到的,所以**只有 .md 需要攤平** (頁面必須在根層),圖片與附件保留資料夾結構。 2. icon/ 資料夾 + 高解析度 每次 `icon generate` 都會輸出並同步: icon.svg / icon.png 512,Wiki 頁面與存取庫頭像 icon/portrait.svg 向量原稿 icon/portrait-512.png 高解析度 icon/portrait-1024.png 高解析度 柵格器加上每個圖形的 bounding box 裁剪,1024×1024(3× 超取樣)約 1.8 秒; 沒有裁剪的話要跑上億次點測試。 3. 更貼近原圖、更可愛 眼睛放大約 15%,虹膜改成四層(暗環→本色→下緣提亮→瞳孔)+雙高光, 眼線壓扁成細弧、外眼角加睫毛;頭髮加高光弧、髮絲與側髮反光; 臉型再圓一點、加下巴陰影與鼻子。 眉色改成隨髮色明暗調整:深髮往膚色提亮、淺髮比頭髮更深——否則不是和瀏海 連成黑帶,就是變成兩條灰槓。 selftest 168 項全綠(新增第 ⑱ 節:icon/ 多解析度、PNG 實際尺寸、只攤平 .md、 Wiki 頁面不得用 HTML img、bounding box 裁剪效能)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 11 +++-- README.md | 17 +++++-- plugin.json | 2 +- scripts/persona-gitea.mjs | 37 +++++++++++--- scripts/persona-icon.mjs | 96 +++++++++++++++++++++++++++--------- scripts/persona.mjs | 1 + scripts/selftest.mjs | 38 +++++++++++++- skills/persona-icon/SKILL.md | 2 + 10 files changed, 166 insertions(+), 42 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 53fb8aa..b6e732d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 158b058..13d84c1 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 42e9ae4..9dac0c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,14 @@ 13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 -14. **Wiki 必須保存並同步形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的 - **Icon** 頁展示兩種格式與來源。`icon generate` 推完會**回頭驗證**,`sync verify` 可隨時檢查。 - Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 +14. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024) + 都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**, + `sync verify` 可隨時檢查。兩個容易踩的坑: + * Wiki 頁面**只能用 Markdown 圖片語法** `![](icon.png)`——Gitea 只改寫這種語法為 + `/wiki/raw/...`;HTML `` 不會被改寫,瀏覽器會解析成 `/wiki/icon.png` + 而變成破圖(看起來就像「沒有同步」)。 + * Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 + * 攤平只對 `.md` 做(頁面必須在根層);圖片等附件保留資料夾結構,`/wiki/raw/<資料夾>/<檔>` 讀得到。 ## 慣例 diff --git a/README.md b/README.md index 0931f2e..9edd9ae 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | | **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 ``、沒有 base64、沒有外連,底稿只留在 `.sync/` 不同步。來源網址與重繪依據存進 `config.json` 備查 | -| **13. Wiki 必須保存並同步形象圖** | `icon.svg` 與 `icon.png` 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示兩種格式與來源;`icon generate` 推完會**回頭驗證** Wiki 真的有這兩個檔案且與本機一致,另有 `sync verify` 可隨時檢查(不一致以非零結束)| +| **13. Wiki 必須保存並同步形象圖** | `icon.svg`、`icon.png` 與 **`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**(Gitea 只改寫這種語法為 `/wiki/raw/...`;HTML `` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` | --- @@ -161,9 +161,20 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 | 樣式 | 條件 | 長什麼樣 | | --- | --- | --- | -| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像,五官俱全 | +| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像:漸層虹膜+雙高光+睫毛、髮絲與反光、腮紅與下巴陰影 | | **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 | +輸出(每次 `icon generate` 都會全部更新): + +``` +icon.svg / icon.png 根目錄:Wiki 頁面與存取庫頭像用(512) +icon/portrait.svg 向量原稿,可無限放大 +icon/portrait-512.png 高解析度 +icon/portrait-1024.png 高解析度 +``` + +柵格器對每個圖形先算 bounding box 再逐點測試,1024×1024(3× 超取樣)約 1.8 秒。 + **找到的圖片只能當底稿**:產出的 SVG 沒有 ``、沒有 base64、沒有外連,每個像素都是畫出來的。 裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級: @@ -378,7 +389,7 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 161 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪與 Wiki 同步、hooks +node scripts/selftest.mjs # 168 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪、高解析輸出與 Wiki 同步、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/plugin.json b/plugin.json index d5c9bc2..c176f0d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index a87e9b6..2214218 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -93,6 +93,7 @@ export const AREAS = { "USER.md", "icon.svg", "icon.png", + "icon/", "memory/INDEX.md", "memory/long-term/", "mindmap/semantic.mmd", @@ -316,6 +317,10 @@ const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relation */ export function wikiName(rel) { if (!rel.includes("/")) return rel; + // 只有 .md 需要攤平——Gitea 只把「根目錄的 .md」當成頁面。 + // 其他附件(圖片、mmd、json)放子資料夾沒問題:實測 /wiki/raw/icon/portrait.svg 取得到, + // 而且 Markdown 圖片語法會被自動改寫成那個 raw 路徑。 + if (!/\.md$/i.test(rel)) return rel; const parts = rel.split("/"); const file = parts.pop(); return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`; @@ -401,6 +406,19 @@ export function wikiIconPage(slug, code) { const root = pl.personaDir(slug); const hasSvg = fs.existsSync(path.join(root, "icon.svg")); const hasPng = fs.existsSync(path.join(root, "icon.png")); + let renders = []; + try { + renders = fs.readdirSync(path.join(root, "icon")) + .filter((n) => /\.(svg|png)$/i.test(n)) + .sort() + .map((name) => ({ + name, + ext: name.split(".").pop(), + note: /(\d+)/.test(name) ? `${name.match(/(\d+)/)[1]}×${name.match(/(\d+)/)[1]} 高解析度` : "向量原稿", + })); + } catch { + renders = []; + } const styleLabel = { photo: "真實照片裁臉", portrait: "向量人物形象(有臉),配色取自參考照片", @@ -411,13 +429,16 @@ export function wikiIconPage(slug, code) { "", `\`${code}\` ${styleLabel}`, "", + // Gitea 只會改寫 **Markdown 圖片語法** 的路徑(→ /wiki/raw/...); + // 用 HTML 會被瀏覽器當成相對於頁面網址,變成 303 破圖。 ...(hasSvg || hasPng ? [ - "| SVG(向量,可無限放大) | PNG(點陣,Gitea 頭像用) |", - "| --- | --- |", - `| ${hasSvg ? 'icon.svg' : "(缺)"} ` + - `| ${hasPng ? 'icon.png' : "(缺)"} |`, - `| [icon.svg](icon.svg) | [icon.png](icon.png) |`, + ...(hasPng ? [`![${code} 形象圖](icon.png)`, ""] : []), + "| 格式 | 檔案 | 用途 |", + "| --- | --- | --- |", + ...(hasSvg ? [`| SVG | [icon.svg](icon.svg) | 向量,可無限放大 |`] : []), + ...(hasPng ? [`| PNG | [icon.png](icon.png) | 點陣,存取庫頭像 |`] : []), + ...renders.map((r) => `| ${r.ext.toUpperCase()} | [icon/${r.name}](icon/${r.name}) | ${r.note} |`), "", ] : ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]), @@ -446,11 +467,11 @@ export function wikiHome(slug, code) { const ident = pl.identityFields(slug); const longTerm = pl.longTermEntries(slug); const relations = pl.loadRelations(slug); - const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.svg")); + const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.png")); const lines = [ `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``, "", - ...(hasIcon ? [`${code}`, ""] : []), + ...(hasIcon ? [`![${code}](icon.png)`, ""] : []), "> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;", "> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。", "", @@ -489,7 +510,7 @@ export function wikiHome(slug, code) { "", "- `Mindmap-semantic.mmd` — 心智圖(Mermaid)", "- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料", - "- `_paths.json` — 攤平前的原始路徑對照(同步用,勿手改)", + "- `icon/` — 高解析度形象圖(多尺寸) - `_paths.json` — 攤平前的路徑對照(勿手改)", "", "> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。", "", diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 8bb4c29..2c69302 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -26,6 +26,8 @@ import * as pl from "./persona-lib.mjs"; export const ICON_SVG = "icon.svg"; export const ICON_PNG = "icon.png"; export const DEFAULT_SIZE = 512; +/** icon/ 資料夾裡要輸出的解析度(512 給頭像、1024 給高解析用途)。 */ +export const RENDER_SIZES = [512, 1024]; export const iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG); export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG); @@ -404,11 +406,14 @@ function portraitShapes(spec) { out.push(ellipse(0.235, sideCy, 0.078, sideRy, hairDark)); out.push(ellipse(0.765, sideCy, 0.078, sideRy, hairDark)); } + // 側髮的反光,讓長髮有圓柱感 + out.push(ellipse(0.221, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32)); + out.push(ellipse(0.779, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32)); // 呆毛 if (f.ahoge === "yes") out.push(poly([[0.482, 0.180], [0.548, 0.070], [0.540, 0.190]], hair)); // ── 臉 - out.push(ellipse(0.5, 0.500, 0.226, 0.256, skin)); + out.push(ellipse(0.5, 0.500, 0.234, 0.252, skin)); // ── 瀏海 if (f.fringe === "blunt") { @@ -426,41 +431,63 @@ function portraitShapes(spec) { out.push(ellipse(0.318, 0.410, 0.068, 0.140, hair)); out.push(ellipse(0.682, 0.410, 0.068, 0.140, hair)); } + // 瀏海高光:一道亮弧 + 幾綹髮絲,頭髮才不會是一塊死色 + out.push(ellipse(0.5, 0.230, 0.126, 0.022, tint(hair, 0.40), + luminance(hair) < 0.12 ? 0.13 : 0.24)); + for (const [sx, sy, sw] of [[0.386, 0.330, 0.013], [0.596, 0.330, 0.013]]) { + out.push(poly([[sx, sy - 0.075], [sx + sw, sy - 0.075], [sx + sw * 0.4, sy + 0.055]], + shade(hair, 0.16), 0.35)); + } + // 鬢髮(貼著臉頰的兩束) - out.push(ellipse(0.268, 0.545, 0.038, 0.150, hair)); - out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair)); + out.push(ellipse(0.250, 0.560, 0.032, 0.185, hair)); + out.push(ellipse(0.750, 0.560, 0.032, 0.185, hair)); // ── 眉毛(表情會影響傾斜) - const browY = f.expression === "bright" ? 0.430 : 0.436; - const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開 + const browY = f.expression === "bright" ? 0.408 : 0.414; + // 眉毛要「看得到但不搶戲」:深髮角色往膚色提亮才不會和瀏海連成一片, + // 淺髮角色則要比頭髮更深,否則會變成兩條灰槓。 + const brow = luminance(hair) < 0.12 ? mix(hairDark, skin, 0.16) : shade(hair, 0.48); const browTilt = { gentle: 0.006, bright: 0.010, calm: 0, neutral: 0 }[f.expression] ?? 0; - out.push(poly([[0.372, browY + browTilt], [0.462, browY], [0.462, browY + 0.021], - [0.372, browY + browTilt + 0.021]], brow)); - out.push(poly([[0.538, browY], [0.628, browY + browTilt], [0.628, browY + browTilt + 0.021], - [0.538, browY + 0.021]], brow)); + out.push(poly([[0.378, browY + browTilt], [0.458, browY], [0.458, browY + 0.014], + [0.376, browY + browTilt + 0.014]], brow)); + out.push(poly([[0.542, browY], [0.622, browY + browTilt], [0.622, browY + browTilt + 0.014], + [0.540, browY + 0.014]], brow)); // ── 眼睛 const eyeGeo = { - round: { rx: 0.058, ry: 0.070 }, - almond: { rx: 0.063, ry: 0.060 }, - sharp: { rx: 0.066, ry: 0.049 }, - droopy: { rx: 0.060, ry: 0.065 }, + round: { rx: 0.070, ry: 0.084 }, + almond: { rx: 0.074, ry: 0.073 }, + sharp: { rx: 0.076, ry: 0.060 }, + droopy: { rx: 0.072, ry: 0.078 }, }[f.eyes] || { rx: 0.063, ry: 0.064 }; const eyeY = f.eyes === "droopy" ? 0.558 : 0.552; - for (const [cx, hl] of [[0.415, 0.399], [0.585, 0.569]]) { + const lash = shade(hairDark, 0.35); + for (const [cx, hl, dir] of [[0.404, 0.385, -1], [0.596, 0.577, 1]]) { + // 眼白 → 虹膜(上深下淺的漸層感)→ 瞳孔 → 高光 ×2 → 眼線與睫毛 out.push(ellipse(cx, eyeY, eyeGeo.rx, eyeGeo.ry, [252, 252, 255])); - out.push(ellipse(cx, eyeY + 0.005, eyeGeo.rx * 0.76, eyeGeo.ry * 0.80, eye)); - out.push(ellipse(cx, eyeY + 0.011, eyeGeo.rx * 0.34, eyeGeo.ry * 0.42, shade(eye, 0.7))); - out.push(circle(hl, eyeY - 0.018, 0.015, [255, 255, 255])); - // 上眼線:眼型的關鍵 - out.push(rect(cx - eyeGeo.rx, eyeY - eyeGeo.ry, eyeGeo.rx * 2, 0.014, hairDark)); - if (f.eyes === "sharp") { - out.push(poly([[cx + eyeGeo.rx * 0.6, eyeY - eyeGeo.ry], - [cx + eyeGeo.rx * 1.45, eyeY - eyeGeo.ry - 0.022], - [cx + eyeGeo.rx * 1.05, eyeY - eyeGeo.ry * 0.35]], hairDark)); + out.push(ellipse(cx, eyeY + 0.004, eyeGeo.rx * 0.80, eyeGeo.ry * 0.84, shade(eye, 0.30))); + out.push(ellipse(cx, eyeY + 0.012, eyeGeo.rx * 0.76, eyeGeo.ry * 0.72, eye)); + out.push(ellipse(cx, eyeY + 0.024, eyeGeo.rx * 0.62, eyeGeo.ry * 0.42, tint(eye, 0.28))); + out.push(ellipse(cx, eyeY + 0.010, eyeGeo.rx * 0.32, eyeGeo.ry * 0.44, shade(eye, 0.78))); + out.push(ellipse(hl, eyeY - 0.019, 0.019, 0.016, [255, 255, 255])); + out.push(circle(cx + dir * eyeGeo.rx * 0.38, eyeY + eyeGeo.ry * 0.42, 0.010, [255, 255, 255], 0.85)); + // 上眼線 + 外眼角睫毛 + out.push(ellipse(cx, eyeY - eyeGeo.ry * 0.94, eyeGeo.rx * 0.98, eyeGeo.ry * 0.13, lash)); + out.push(poly([ + [cx + dir * eyeGeo.rx * 0.80, eyeY - eyeGeo.ry * 1.00], + [cx + dir * eyeGeo.rx * 1.22, eyeY - eyeGeo.ry * (f.eyes === "sharp" ? 1.24 : 1.08)], + [cx + dir * eyeGeo.rx * 1.02, eyeY - eyeGeo.ry * 0.66], + ], lash)); + if (f.eyes === "droopy") { + out.push(ellipse(cx + dir * eyeGeo.rx * 0.7, eyeY + eyeGeo.ry * 0.85, eyeGeo.rx * 0.5, + eyeGeo.ry * 0.16, lash, 0.75)); } } + // ── 下巴/脖子的陰影,臉才立體 + out.push(ellipse(0.5, 0.706, 0.104, 0.032, shade(skin, 0.14), 0.32)); + // ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子) out.push(ellipse(0.5, 0.638, 0.010, 0.007, shade(skin, 0.22))); @@ -664,7 +691,16 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { const SS = Math.max(1, Math.min(4, supersample)); const big = size * SS; const acc = Buffer.alloc(big * big * 4); - const shapes = iconShapes(spec); + // 先算每個圖形的外接矩形:不加這個,1024×3 超取樣要跑上億次點測試 + const shapes = iconShapes(spec).map((sh) => { + if (sh.type === "rect") return { ...sh, x0: sh.x, x1: sh.x + sh.w, y0: sh.y, y1: sh.y + sh.h }; + if (sh.type === "poly") { + const xs = sh.points.map((q) => q[0]); + const ys = sh.points.map((q) => q[1]); + return { ...sh, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) }; + } + return { ...sh, x0: sh.cx - sh.rx, x1: sh.cx + sh.rx, y0: sh.cy - sh.ry, y1: sh.cy + sh.ry }; + }); const [g1, g2, g3] = spec.ring; for (let py = 0; py < big; py += 1) { @@ -680,6 +716,7 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t; // 圖形清單:由後往前疊,與 SVG 的繪製順序相同 for (const sh of shapes) { + if (u < sh.x0 || u > sh.x1 || v < sh.y0 || v > sh.y1) continue; let hit; if (sh.type === "rect") { hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h; @@ -758,6 +795,16 @@ export function generateIcon(slug, { pl.writeText(iconSvgPath(slug), svg); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.writeFileSync(iconPngPath(slug), png); + // icon/ 資料夾:向量原稿 + 多個解析度,Gitea 上點得開也拿得走 + const dir = path.join(pl.personaDir(slug), "icon"); + fs.mkdirSync(dir, { recursive: true }); + const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(svg, "utf8") }]; + pl.writeText(path.join(dir, "portrait.svg"), svg); + for (const px of RENDER_SIZES) { + const buf = px === size ? png : renderPng(spec, px); + fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf); + renders.push({ name: `portrait-${px}.png`, bytes: buf.length }); + } // 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」 const config = pl.loadConfig(slug); config.icon = { @@ -776,6 +823,7 @@ export function generateIcon(slug, { svg: iconSvgPath(slug), png: iconPngPath(slug), size, + renders, bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, }; } diff --git a/scripts/persona.mjs b/scripts/persona.mjs index abe5698..244c242 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1232,6 +1232,7 @@ commands.icon = async ({ flags, positional }) => { `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, + ` icon/:${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`, res.spec.style === "portrait" ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)` : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 7e3390e..7ccacc1 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -579,7 +579,8 @@ check("形象圖真的畫了五官(眼白/虹膜/瞳孔/嘴都在)", ( const eyeWhite = shapes.filter((sh) => sh.fill.join() === "252,252,255").length; const iris = shapes.filter((sh) => sh.fill.join() === facePal.eye.join()).length; const highlight = shapes.filter((sh) => sh.fill.join() === "255,255,255").length; - return shapes.length >= 20 && eyeWhite === 2 && iris === 2 && highlight === 2; + // 兩隻眼睛:各有眼白、虹膜、至少一個高光;整體圖形數要夠(含頭髮/五官/衣服) + return shapes.length >= 30 && eyeWhite === 2 && iris >= 2 && highlight >= 2; })(), `圖形數 ${ic.iconShapes(faceSpec).length}`); check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)", faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join()); @@ -678,6 +679,41 @@ check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", (( return a === b; })()); +console.log("⑱ 高解析度輸出與 Wiki 圖片實際可讀"); +check("icon/ 資料夾輸出向量原稿與多個解析度", (() => { + cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64", + "--palette", "hair=#d9a45b,accent=#c0392b", "--source-url", "https://example.invalid/x.png"]); + const dir = path.join(pl.personaDir("GAMMA-01"), "icon"); + const names = fs.readdirSync(dir).sort(); + return names.includes("portrait.svg") && ic.RENDER_SIZES.every((px) => names.includes(`portrait-${px}.png`)); +})(), (() => { try { return fs.readdirSync(path.join(pl.personaDir("GAMMA-01"), "icon")).join(","); } catch { return "(無)"; } })()); +check("高解析度 PNG 的實際尺寸正確", (() => { + const buf = fs.readFileSync(path.join(pl.personaDir("GAMMA-01"), "icon", "portrait-1024.png")); + return buf.readUInt32BE(16) === 1024 && buf.readUInt32BE(20) === 1024; +})()); +check("icon/ 屬於 Wiki 區(會被同步)", covered("icon/portrait-1024.png")[0] === "wiki"); +check("Wiki 只攤平 .md,圖片保留資料夾結構", (() => { + // Gitea 只把「根目錄的 .md」當頁面,但 /wiki/raw/<資料夾>/<圖> 取得到(實測) + return gt.wikiName("memory/long-term/x.md") === "Memory-x.md" && + gt.wikiName("icon/portrait-1024.png") === "icon/portrait-1024.png" && + gt.wikiName("relations/graph.json") === "relations/graph.json"; +})(), `${gt.wikiName("icon/portrait-1024.png")} / ${gt.wikiName("memory/long-term/x.md")}`); +check("Wiki 頁面用 Markdown 圖片語法(HTML 不會被改寫路徑)", (() => { + const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); + const home = gt.wikiHome("GAMMA-01", "GAMMA-01"); + return page.includes("![") && !page.includes(" l.includes("img") || l.includes("!["))); +check("Icon 頁會列出 icon/ 裡的每個檔案", (() => { + const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); + return ic.RENDER_SIZES.every((px) => page.includes(`icon/portrait-${px}.png`)); +})()); +check("柵格器有做 bounding box 裁剪(1024 才跑得動)", (() => { + const spec = ic.iconSpec("GAMMA-01"); + const t0 = Date.now(); + ic.renderPng(spec, 512); + return Date.now() - t0 < 4000; // 沒有裁剪的話會慢好幾倍 +})()); + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md index 9e6b012..eccef59 100644 --- a/skills/persona-icon/SKILL.md +++ b/skills/persona-icon/SKILL.md @@ -140,6 +140,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session `、沒有 base64、沒有外連。 - `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓, 確保字讀得到。