diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8e3c725..53fb8aa 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "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 12534aa..158b058 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index c99307d..42e9ae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,15 +34,18 @@ **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 -12. **圖示必須是人物形象圖(盡量有臉)**,來源是該人格「最新一次登場」的官方視覺 - (`/jsc-persona:persona-icon`):上網找圖 → **用 Read 親眼看過** → 有 Pillow+OpenCV 就 - `--photo` 裁臉,否則畫有五官的向量人物;配色一律 `--palette` 搭配 `--source-url`(CLI 強制存證)。 - **沒看過圖就不准填顏色、也不准亂挑臉**(多角色的圖先 `icon faces` 再挑,挑完打開確認)。 +12. **圖示必須是重新繪製的人物形象圖(有臉)**:上網找出該人格「最新一次登場」的官方視覺 → + `icon headshot` 裁出**大頭照當底稿** → **用 Read 親眼看過** → 讀出髮型/瀏海/眼型/表情/ + 髮飾等特徵 → `icon generate --palette ... --features ...` **重新繪製**。 + **絕對不要把找到的圖片直接當圖示**(底稿只留在 `.sync/`,不同步、不發佈; + 產出的 SVG 不得有 ``/base64/外連)。 + **沒看過圖就不准填顏色或特徵、也不准亂挑臉**(多角色先 `icon faces` 再挑,挑完打開確認)。 13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 -14. **Wiki 必須保存形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的 **Icon** 頁 - 展示兩種格式並列出來源、樣式與裁切框。 +14. **Wiki 必須保存並同步形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的 + **Icon** 頁展示兩種格式與來源。`icon generate` 推完會**回頭驗證**,`sync verify` 可隨時檢查。 + Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 ## 慣例 diff --git a/README.md b/README.md index cf779e6..0931f2e 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | -| **12. 圖示是人物形象圖,取自最新登場的官方視覺** | `/jsc-persona:persona-icon` 上網找出該人格**最新一次登場**的官方視覺、**親眼看過**,有 Pillow+OpenCV 就直接**裁出臉**當形象圖;沒工具則畫**有五官的向量人物**(配色同樣取自那張圖),並印出安裝指令。來源網址與裁切框存進 `config.json` 備查 | -| **13. Wiki 保存形象圖** | `icon.svg` 與 `icon.png` 一起同步到 Wiki 區,另外自動產生一頁 **Icon** 同時展示兩種格式並列出來源、樣式與裁切框;PNG 同時設為存取庫頭像 | +| **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 ``、沒有 base64、沒有外連,底稿只留在 `.sync/` 不同步。來源網址與重繪依據存進 `config.json` 備查 | +| **13. Wiki 必須保存並同步形象圖** | `icon.svg` 與 `icon.png` 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示兩種格式與來源;`icon generate` 推完會**回頭驗證** Wiki 真的有這兩個檔案且與本機一致,另有 `sync verify` 可隨時檢查(不一致以非零結束)| --- @@ -150,13 +150,23 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 建立人格**並補齊 IDENTITY/SOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**, 依環境有無工具分三種樣式: +流程是 **找圖 → 裁大頭照當底稿 → 親眼看過 → 讀出特徵 → 重新繪製**: + +| 步驟 | 指令 | 產出 | +| --- | --- | --- | +| 1. 找臉 | `icon faces --photo <圖>` | 圖裡所有臉的座標(多角色務必先看) | +| 2. 裁底稿 | `icon headshot --photo <圖> --pick N` | `.sync/headshot.png`(**不是圖示、不同步**) | +| 3. 看 | Read 打開底稿 + 讀 IDENTITY/SOUL | 髮型、瀏海、眼型、表情、髮飾、領口 | +| 4. 重繪 | `icon generate --palette ... --features ...` | `icon.svg` + `icon.png` | + | 樣式 | 條件 | 長什麼樣 | | --- | --- | --- | -| **`photo`** | 有參考圖 **且** 有 Pillow+OpenCV | 從官方視覺**裁出的臉**(自動臉部偵測),圓角+瞳色外框 | -| **`portrait`** | 有參考圖但缺工具 | **有五官的向量人物**:髮型、瞳色、服裝色都取自那張圖 | +| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像,五官俱全 | | **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 | -缺工具時 CLI **會印出安裝指令**再退回 `portrait`,不會靜默降級: +**找到的圖片只能當底稿**:產出的 SVG 沒有 ``、沒有 base64、沒有外連,每個像素都是畫出來的。 + +裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級: ```bash python3 -m venv ~/.cache/jsc-persona/venv @@ -187,24 +197,23 @@ curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml \ - PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出 IHDR/IDAT/IEND 與 CRC32。這台機器沒有 rsvg/inkscape/imagemagick,也沒有影像函式庫, 而本專案禁止 npm 依賴——所以就自己畫。 -- **為什麼沒有 emoji、也不是把照片本身當圖示**:環境裡沒有任何影像解碼器 - (rsvg/imagemagick/Pillow/ffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG; - 而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。 - emoji 同理無法柵格化,但仍參與雜湊配色。 +- **為什麼不直接用找到的圖**:那是別人的美術作品。底稿只用來「看」,圖示由本工具重畫。 +- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化,環境裡連 emoji 字型都沒有; + emoji 仍參與雜湊配色。 - 圖示屬於低頻資料 → `icon.svg` 與 `icon.png` 都同步到 **Wiki 區**,並自動產生一頁 **Icon** 展示兩種格式與來源;PNG 同時設成 Gitea **存取庫頭像**。 ```bash -# 先看參考圖裡有幾張臉(多角色的主視覺務必先看) -node scripts/persona.mjs icon faces --session --photo "<圖片網址>" +node scripts/persona.mjs icon faces --session --photo "<圖片網址>" +node scripts/persona.mjs icon headshot --session --photo "<圖片網址>" --pick 1 -# 裁臉 + 取色(--palette 必須配 --source-url) node scripts/persona.mjs icon generate --session --force \ - --photo "<圖片網址>" --pick 1 \ - --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ - --source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>" + --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \ + --features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,collar=v,ahoge=yes" \ + --source-url "<底稿那張圖的網址>" --source-note "<作品(年份)+重繪依據>" -node scripts/persona.mjs icon show --session # 看樣式、配色、來源與裁切框 +node scripts/persona.mjs icon show --session # 樣式、配色、特徵、來源 +node scripts/persona.mjs sync verify --session --area wiki # 確認 Wiki 真的同步了 ``` ## 人格編號與 Gitea 儲存 @@ -369,7 +378,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 # 148 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、形象圖與裁臉、hooks +node scripts/selftest.mjs # 161 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪與 Wiki 同步、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/plugin.json b/plugin.json index f4b76bc..d5c9bc2 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 0f9d6aa..a87e9b6 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -462,7 +462,7 @@ export function wikiHome(slug, code) { .map((k) => `| ${k} | ${ident[k]} |`), `| 長期記憶 | ${longTerm.length} 則 |`, `| 關係人 | ${relations.nodes.length} 位 |`, - `| 最後同步 | ${pl.nowIso()} |`, + `| 圖示更新 | ${pl.loadConfig(slug).icon?.generated_at || "—"} |`, "", "## 頁面", "", @@ -610,6 +610,56 @@ export async function pullArea(slug, area, { code = null, owner = null, force = return { ok: true, area, code: theCode, changed: incoming, written }; } +/** + * 驗證某一區「本機 = 遠端」。 + * push 回報成功不等於遠端真的有東西(網路中斷、權限、非快轉都可能), + * 形象圖這種一定要出現在 Wiki 的檔案更需要一個明確的檢查點。 + */ +export async function verifyArea(slug, area, { code = null, owner = null } = {}) { + const problem = giteaProblem(); + if (problem) return { ok: false, skipped: true, area, reason: problem }; + const theCode = code || personaCode(slug); + if (!theCode) return { ok: false, skipped: true, area, reason: "沒有人格編號" }; + const theOwner = owner || (await resolveOwner()); + const { host } = giteaEnv(); + const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); + if (!git(["fetch", "--quiet", "origin"], dir).ok) { + return { ok: false, area, reason: "fetch 失敗(連不上遠端)" }; + } + const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; + const local = git(["rev-parse", "HEAD"], dir).stdout; + const remote = git(["rev-parse", `origin/${branch}`], dir).stdout; + // 再把工作副本疊上去,看看還有沒有沒推的差異 + if (area === "wiki") { + pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); + } + const files = stageArea(slug, area, dir); + const dirty = git(["status", "--porcelain"], dir) + .stdout.split("\n").map((l) => l.replace(/^.{2}\s+/, "").trim()).filter(Boolean); + git(["checkout", "--", "."], dir); + git(["clean", "-qfd"], dir); + return { + ok: Boolean(local) && local === remote && dirty.length === 0, + area, + code: theCode, + files: files.length, + local, + remote, + pending: dirty, + }; +} + +/** 形象圖(SVG + PNG)是不是真的在 Wiki 上、而且和本機一致。 */ +export async function verifyIconInWiki(slug, opts = {}) { + const res = await verifyArea(slug, "wiki", opts); + if (res.skipped || !res.code) return res; + const missing = ["icon.svg", "icon.png"].filter((f) => res.pending.includes(f)); + const dir = syncDir(slug, "wiki"); + const present = ["icon.svg", "icon.png"].filter((f) => fs.existsSync(path.join(dir, f))); + return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 }; +} + /** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */ export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) { const problem = giteaProblem(); diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 73fcd8f..8bb4c29 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -184,7 +184,9 @@ export const paletteToString = (p) => export const STYLES = ["portrait", "badge"]; -export function iconSpec(slug, { code = null, identity = null, palette = null, style = null } = {}) { +export function iconSpec(slug, { + code = null, identity = null, palette = null, style = null, features = null, +} = {}) { const ident = identity || pl.identityFields(slug); const config = pl.loadConfig(slug); const theCode = code || config.code || slug; @@ -244,9 +246,11 @@ export function iconSpec(slug, { code = null, identity = null, palette = null, s dot: dot || ink, ringAlpha, dotAlpha, - // 有調色盤(=看過參考照片)就畫人物形象;沒有的話只能畫徽章 - style: style || config.icon?.style || (pal ? "portrait" : "badge"), + // 有調色盤(=看過大頭照)就重繪人物形象;沒有的話只能畫徽章 + // 只認得 STYLES 裡的樣式:config 可能殘留舊版本寫進去的值(例如已移除的 photo) + style: [style, config.icon?.style].find((v) => STYLES.includes(v)) || (pal ? "portrait" : "badge"), palette: pal, + features: features || parseFeatures(config.icon?.features || null), source: pal ? config.icon?.source || null : null, pattern, seed: h.subarray(0, 8).toString("hex"), @@ -291,6 +295,53 @@ const tint = (c, t) => mix(c, [255, 255, 255], t); const rect = (x, y, w, h, fill, alpha = 1) => ({ type: "rect", x, y, w, h, fill, alpha }); const ellipse = (cx, cy, rx, ry, fill, alpha = 1) => ({ type: "ellipse", cx, cy, rx, ry, fill, alpha }); const circle = (cx, cy, r, fill, alpha = 1) => ellipse(cx, cy, r, r, fill, alpha); +/** 多邊形:畫髮尾、緞帶、V 領、呆毛這些橢圓做不出來的形狀。 */ +const poly = (points, fill, alpha = 1) => ({ type: "poly", points, fill, alpha }); + +// --------------------------------------------------------------------------- // +// 五官與造型的特徵:這些決定「重新繪製」出來的人長什麼樣 +// --------------------------------------------------------------------------- // + +export const FEATURE_SPEC = { + hairstyle: ["straight", "twintails", "ponytail", "bob", "braid"], + length: ["short", "medium", "long", "very-long"], + fringe: ["blunt", "parted", "swept", "curtain"], + eyes: ["round", "almond", "sharp", "droopy"], + expression: ["gentle", "bright", "calm", "neutral"], + accessory: ["none", "ribbon", "clip", "flower", "hairband"], + side: ["left", "right"], + collar: ["round", "v", "high", "sailor"], + ahoge: ["no", "yes"], +}; + +export const DEFAULT_FEATURES = { + hairstyle: "straight", + length: "long", + fringe: "parted", + eyes: "almond", + expression: "gentle", + accessory: "none", + side: "right", + collar: "round", + ahoge: "no", +}; + +/** `hairstyle=twintails,fringe=blunt,...` → 正規化過的特徵物件(不認得的值一律回退預設)。 */ +export function parseFeatures(raw) { + const out = { ...DEFAULT_FEATURES }; + if (!raw) return out; + for (const chunk of String(raw).split(",")) { + const idx = chunk.indexOf("="); + if (idx < 0) continue; + const key = chunk.slice(0, idx).trim().toLowerCase(); + const value = chunk.slice(idx + 1).trim().toLowerCase(); + if (FEATURE_SPEC[key] && FEATURE_SPEC[key].includes(value)) out[key] = value; + } + return out; +} + +export const featuresToString = (f) => + Object.keys(DEFAULT_FEATURES).map((k) => `${k}=${f[k]}`).join(","); /** * 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 SVG 與自寫柵格器畫得出一模一樣的結果。 @@ -307,42 +358,148 @@ function portraitShapes(spec) { const skinShade = shade(skin, 0.12); const mouth = shade(cloth, 0.25); - return [ - // 肩膀與衣服(露出一截,看得出服裝主色) - ellipse(0.5, 1.18, 0.47, 0.36, cloth), - ellipse(0.5, 1.14, 0.175, 0.22, cloth2), // 領口/胸前配色 - // 脖子 - rect(0.442, 0.64, 0.116, 0.14, skinShade), - // 後髮(長髮往下鋪到兩側) - ellipse(0.5, 0.50, 0.315, 0.395, hairDark), - ellipse(0.235, 0.78, 0.080, 0.27, hairDark), - ellipse(0.765, 0.78, 0.080, 0.27, hairDark), - // 臉 - ellipse(0.5, 0.505, 0.212, 0.248, skin), - // 瀏海:中央一大片 + 兩側鬢髮,蓋住額頭 - ellipse(0.5, 0.335, 0.238, 0.158, hair), - ellipse(0.312, 0.435, 0.072, 0.155, hair), - ellipse(0.688, 0.435, 0.072, 0.155, hair), - // 眉毛 - rect(0.362, 0.452, 0.095, 0.015, hairDark), - rect(0.543, 0.452, 0.095, 0.015, hairDark), - // 眼睛:眼白 → 虹膜 → 瞳孔 → 高光 - ellipse(0.415, 0.538, 0.060, 0.073, [252, 252, 255]), - ellipse(0.585, 0.538, 0.060, 0.073, [252, 252, 255]), - ellipse(0.415, 0.543, 0.046, 0.059, eye), - ellipse(0.585, 0.543, 0.046, 0.059, eye), - ellipse(0.415, 0.549, 0.021, 0.030, shade(eye, 0.7)), - ellipse(0.585, 0.549, 0.021, 0.030, shade(eye, 0.7)), - circle(0.399, 0.520, 0.015, [255, 255, 255]), - circle(0.569, 0.520, 0.015, [255, 255, 255]), - // 腮紅與嘴 - ellipse(0.330, 0.598, 0.048, 0.025, cloth, 0.28), - ellipse(0.670, 0.598, 0.048, 0.025, cloth, 0.28), - ellipse(0.5, 0.626, 0.026, 0.015, mouth), - // 側邊髮飾(用第二配色,讓不同角色更好分辨) - ellipse(0.762, 0.345, 0.056, 0.038, cloth2), - ellipse(0.800, 0.375, 0.030, 0.052, cloth2), - ]; + const f = spec.features || DEFAULT_FEATURES; + const out = []; + const mirror = f.side === "left" ? -1 : 1; + const sideX = (base) => 0.5 + (base - 0.5) * mirror; + + // ── 頭髮長度:側髮鋪到哪裡 + const bottom = { short: 0.66, medium: 0.78, long: 0.90, "very-long": 1.02 }[f.length] || 0.90; + const sideCy = (0.46 + bottom) / 2; + const sideRy = (bottom - 0.46) / 2; + + // ── 肩膀與衣服 + out.push(ellipse(0.5, 1.18, 0.47, 0.36, cloth)); + if (f.collar === "round") out.push(ellipse(0.5, 1.14, 0.175, 0.22, cloth2)); + else if (f.collar === "high") out.push(rect(0.30, 0.90, 0.40, 0.10, cloth2)); + else if (f.collar === "sailor") { + out.push(poly([[0.30, 0.92], [0.50, 1.06], [0.70, 0.92], [0.70, 1.10], [0.30, 1.10]], cloth2)); + } else if (f.collar === "v") { + out.push(poly([[0.428, 0.900], [0.572, 0.900], [0.500, 1.030]], skinShade)); + } + // 脖子 + out.push(rect(0.442, 0.64, 0.116, 0.15, skinShade)); + + // ── 後髮 + out.push(ellipse(0.5, 0.50, 0.315, 0.395, hairDark)); + if (f.hairstyle === "twintails") { + out.push(ellipse(sideX(0.185), sideCy + 0.03, 0.085, sideRy, hairDark)); + out.push(ellipse(sideX(0.815), sideCy + 0.03, 0.085, sideRy, hairDark)); + out.push(ellipse(sideX(0.255), 0.415, 0.040, 0.030, cloth2)); // 髮束 + out.push(ellipse(sideX(0.745), 0.415, 0.040, 0.030, cloth2)); + } else if (f.hairstyle === "ponytail") { + out.push(ellipse(sideX(0.815), sideCy, 0.075, sideRy * 1.05, hairDark)); + out.push(ellipse(sideX(0.735), 0.40, 0.042, 0.032, cloth2)); + out.push(ellipse(0.245, 0.62, 0.062, 0.14, hairDark)); + } else if (f.hairstyle === "bob") { + out.push(ellipse(0.215, 0.60, 0.090, 0.175, hairDark)); + out.push(ellipse(0.785, 0.60, 0.090, 0.175, hairDark)); + } else if (f.hairstyle === "braid") { + out.push(ellipse(0.235, sideCy, 0.072, sideRy, hairDark)); + out.push(ellipse(0.765, sideCy, 0.072, sideRy, hairDark)); + for (let i = 0; i < 3; i += 1) { + out.push(ellipse(sideX(0.775), 0.60 + i * 0.11, 0.055, 0.048, shade(hair, 0.18))); + } + } else { + out.push(ellipse(0.235, sideCy, 0.078, sideRy, hairDark)); + out.push(ellipse(0.765, sideCy, 0.078, sideRy, hairDark)); + } + // 呆毛 + 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)); + + // ── 瀏海 + if (f.fringe === "blunt") { + out.push(ellipse(0.5, 0.300, 0.248, 0.150, hair)); + out.push(rect(0.252, 0.300, 0.496, 0.088, hair)); + } else if (f.fringe === "swept") { + out.push(ellipse(sideX(0.560), 0.320, 0.235, 0.160, hair)); + out.push(poly([[sideX(0.30), 0.30], [sideX(0.78), 0.30], [sideX(0.30), 0.47]], hair)); + } else if (f.fringe === "curtain") { + out.push(ellipse(0.5, 0.290, 0.235, 0.140, hair)); + out.push(ellipse(0.360, 0.400, 0.090, 0.155, hair)); + out.push(ellipse(0.640, 0.400, 0.090, 0.155, hair)); + } else { + out.push(ellipse(0.5, 0.320, 0.244, 0.145, hair)); + 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.268, 0.545, 0.038, 0.150, hair)); + out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair)); + + // ── 眉毛(表情會影響傾斜) + const browY = f.expression === "bright" ? 0.430 : 0.436; + const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開 + 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)); + + // ── 眼睛 + 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 }, + }[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]]) { + 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(0.5, 0.638, 0.010, 0.007, shade(skin, 0.22))); + + // ── 腮紅與嘴(表情) + if (f.expression !== "calm") { + out.push(ellipse(0.318, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20)); + out.push(ellipse(0.682, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20)); + } + if (f.expression === "bright") { + out.push(ellipse(0.5, 0.690, 0.046, 0.022, mouth)); + out.push(rect(0.454, 0.676, 0.092, 0.009, shade(mouth, 0.35))); + out.push(ellipse(0.5, 0.683, 0.034, 0.008, tint(mouth, 0.6))); + } else if (f.expression === "calm" || f.expression === "neutral") { + out.push(rect(0.480, 0.686, 0.040, 0.009, mouth)); + } else { + out.push(ellipse(0.5, 0.688, 0.024, 0.013, mouth)); + } + + // ── 髮飾 + const ax = sideX(0.775); + if (f.accessory === "ribbon") { + out.push(poly([[ax, 0.330], [ax + 0.085 * mirror, 0.288], [ax + 0.085 * mirror, 0.376]], cloth2)); + out.push(poly([[ax, 0.330], [ax - 0.075 * mirror, 0.292], [ax - 0.075 * mirror, 0.372]], cloth2)); + out.push(circle(ax, 0.332, 0.026, shade(cloth2, 0.18))); + } else if (f.accessory === "clip") { + out.push(rect(ax - 0.055, 0.318, 0.110, 0.026, cloth2)); + out.push(rect(ax - 0.055, 0.352, 0.075, 0.022, tint(cloth2, 0.25))); + } else if (f.accessory === "flower") { + for (let i = 0; i < 5; i += 1) { + const a = (i / 5) * Math.PI * 2; + out.push(circle(ax + Math.cos(a) * 0.042, 0.335 + Math.sin(a) * 0.042, 0.030, cloth2)); + } + out.push(circle(ax, 0.335, 0.024, tint(cloth2, 0.45))); + } else if (f.accessory === "hairband") { + out.push(ellipse(0.5, 0.268, 0.246, 0.052, cloth2)); + out.push(ellipse(0.5, 0.300, 0.238, 0.052, hair)); + } + + return out; } /** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */ @@ -399,12 +556,16 @@ export function renderSvg(spec, size = DEFAULT_SIZE) { " ", ` `, ` `, - ...iconShapes(spec).map((sh) => - sh.type === "rect" - ? ` ` - : ` `), + ...iconShapes(spec).map((sh) => { + const paint = ` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`; + if (sh.type === "rect") { + return ` ", ` y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + /** 內縮 inset 之後的圓角矩形內外判定(用來畫出與 SVG 描邊相同的環帶)。 */ function insideInset(x, y, inset) { const span = 1 - 2 * inset; @@ -511,6 +683,8 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { let hit; if (sh.type === "rect") { hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h; + } else if (sh.type === "poly") { + hit = pointInPoly(u, v, sh.points); } else { const dx = (u - sh.cx) / sh.rx; const dy = (v - sh.cy) / sh.ry; @@ -576,28 +750,11 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { * 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。 */ export function generateIcon(slug, { - size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, - photo = null, pick = null, face = null, + size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null, } = {}) { - const spec = iconSpec(slug, { code, palette, style }); - let svg; - let png; - let photoResult = null; - if (photo) { - const tmp = path.join(pl.personaDir(slug), ".sync", "portrait.tmp.png"); - fs.mkdirSync(path.dirname(tmp), { recursive: true }); - photoResult = renderPhotoPng(photo, tmp, size, { pick, face }); - if (photoResult.ok) { - png = fs.readFileSync(tmp); - svg = photoSvg(spec, png, size); - spec.style = "photo"; - } - fs.rmSync(tmp, { force: true }); - } - if (!png) { - svg = renderSvg(spec, size); - png = renderPng(spec, size); - } + const spec = iconSpec(slug, { code, palette, style, features }); + const svg = renderSvg(spec, size); + const png = renderPng(spec, size); pl.writeText(iconSvgPath(slug), svg); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.writeFileSync(iconPngPath(slug), png); @@ -610,11 +767,8 @@ export function generateIcon(slug, { size, generated_at: pl.nowIso(), palette: spec.palette ? paletteToString(spec.palette) : null, + features: featuresToString(spec.features), source: source || spec.source || null, - crop: photoResult?.ok - ? { method: photoResult.info.method, face: photoResult.info.face, - box: photoResult.info.box, faces_found: photoResult.info.faces_found } - : null, }; pl.writeJson(pl.configPath(slug), config); return { @@ -622,7 +776,6 @@ export function generateIcon(slug, { svg: iconSvgPath(slug), png: iconPngPath(slug), size, - photo: photoResult, bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, }; } @@ -757,8 +910,11 @@ export function listFaces(imagePath) { } } -/** 用照片裁臉產生 icon.png(成功回傳結果,工具不足回 ok:false 與缺什麼)。 */ -export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) { +/** + * 從參考圖裁出**大頭照**。這張是給 AI 看的**參考**,不是圖示本身—— + * 圖示一律由 renderSvg/renderPng 依人格資料重新繪製。 + */ +export function cropHeadshot(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) { const report = toolReport(); if (!report.ready) return { ok: false, report }; const args = [PORTRAIT_PY, "--input", imagePath, "--output", outPath, "--size", String(size), @@ -780,26 +936,3 @@ export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick = return { ok: true, report, info }; } -/** 把裁好的 PNG 包成自成一體的 SVG(含圓角與瞳色外框)。 */ -export function photoSvg(spec, pngBuffer, size = DEFAULT_SIZE) { - const S = size; - const u = (v) => Math.round(v * S * 1000) / 1000; - return [ - ``, - ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, - " ", - ` `, - " ", - ` `, - ` `, - " ", - ` `, - "", - "", - ].join("\n"); -} diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 46a87dc..abe5698 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1133,12 +1133,45 @@ commands.icon = async ({ flags, positional }) => { ` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` + `:${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`, ...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []), + ...(spec.style === "portrait" ? [` 特徵:${ic.featuresToString(spec.features)}`] : []), ...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []), ` ${ic.iconSvgPath(slug)}`, ` ${ic.iconPngPath(slug)}`, ]); return; } + if (action === "headshot") { + // 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。 + requireOwner(slug, session); + const want = str(flags.photo); + if (!want) die("需要 `--photo <圖片路徑或網址>`(該人格最新登場的官方視覺)。"); + let file; + try { + file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); + } catch (err) { + die(err.message); + } + const out = path.join(pl.personaDir(slug), ".sync", "headshot.png"); + const cropped = ic.cropHeadshot(file, out, num(flags.size, 384), { + pick: str(flags.pick) || null, + face: str(flags.face) || null, + }); + if (!cropped.ok) { + emit(cropped, flags.json, [ + `✖ 裁不出大頭照:${cropped.reason || "工具不足"}`, + ...ic.installHintLines(cropped.report || ic.toolReport()), + ]); + process.exit(1); + } + emit({ persona: slug, file: out, ...cropped.info }, flags.json, [ + `✔ 大頭照已裁出:${out}`, + ` 偵測方式 ${cropped.info.method}|原圖 ${cropped.info.source_size.join("×")}|` + + `找到 ${cropped.info.faces_found} 張臉|裁切框 ${JSON.stringify(cropped.info.box)}`, + " ⚠ 這張是**參考底稿**,不是圖示,也不會同步到 Gitea。", + " 請用 Read 打開它,確認是本人,再依看到的髮型/眼型/配件下 `icon generate --features ...`。", + ]); + return; + } if (action === "faces") { requireMember(slug, session, Boolean(flags["as-guest"])); const want = str(flags.photo); @@ -1164,7 +1197,7 @@ commands.icon = async ({ flags, positional }) => { ]); return; } - if (action !== "generate") die(`\u672a\u77e5 action\uff1a${action}\uff08\u53ef\u7528 generate/show/faces\uff09`); + if (action !== "generate") die(`未知 action:${action}(可用 generate/show/faces/headshot)`); requireOwner(slug, session); if (ic.hasIcon(slug) && !flags.force) { die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); @@ -1193,40 +1226,22 @@ commands.icon = async ({ flags, positional }) => { : null; const style = str(flags.style) || null; if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`); - // 參考照片:給了就試著裁臉;工具不足會退回向量人物形象並印出安裝指令 - let photo = null; - if (flags.photo || (flags["from-source"] && source?.url)) { - const want = str(flags.photo) || source.url; - try { - photo = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); - } catch (err) { - die(`${err.message}`); - } - } - const res = ic.generateIcon(slug, { - size, palette, source, style, photo, - pick: str(flags.pick) || null, - face: str(flags.face) || null, - }); + const features = flags.features ? ic.parseFeatures(str(flags.features)) : null; + const res = ic.generateIcon(slug, { size, palette, source, style, features }); const lines = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, - res.spec.style === "photo" - ? ` 形象圖:真實照片裁臉(偵測方式 ${res.photo?.info?.method},裁切框 ${JSON.stringify(res.photo?.info?.box)})` - : res.spec.style === "portrait" - ? ` 形象圖:向量人物(有臉)|配色取自參考照片:${ic.paletteToString(res.spec.palette)}` - : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, + res.spec.style === "portrait" + ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)` + : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, + ...(res.spec.style === "portrait" + ? [` 配色:${ic.paletteToString(res.spec.palette)}`, + ` 特徵:${ic.featuresToString(res.spec.features)}`] + : []), ...(source?.url || res.spec.source?.url ? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`] : []), - // 缺工具就把安裝方式講清楚,而不是默默降級 - ...(photo && res.photo && !res.photo.ok - ? [ - ...(res.photo.reason ? [` ⚠ 照片裁臉失敗:${res.photo.reason}`] : []), - ...ic.installHintLines(res.photo.report || ic.toolReport()), - ] - : []), ]; // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像 if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { @@ -1234,8 +1249,16 @@ commands.icon = async ({ flags, positional }) => { const owner = await gt.resolveOwner(); const okAvatar = await gt.setRepoAvatar(owner, gt.personaCode(slug), fs.readFileSync(res.png)); if (okAvatar) lines.push(" 📦 已設為 Gitea 存取庫頭像。"); - const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 產生人格圖示 ${res.spec.letters}` }); - if (pushed.ok && pushed.changed) lines.push(" ↑ 圖示已同步到 Wiki 區。"); + const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 重繪人格形象圖 ${res.spec.code}` }); + if (pushed.ok && pushed.changed) lines.push(" ↑ 形象圖已同步到 Wiki 區。"); + // 推完一定要回頭確認 Wiki 真的有這兩個檔案,且與本機一致 + const check = await gt.verifyIconInWiki(slug); + lines.push( + check.ok + ? ` ✔ Wiki 已保存形象圖並與本機一致(${check.icon_present.join(" + ")})` + : ` ✖ Wiki 形象圖驗證未通過:${check.reason || `缺少或未同步 ${(check.icon_pending || []).join(", ") || "?"}`}` + + " → 跑 `sync push --area wiki` 再 `sync verify --area wiki`。", + ); } catch (err) { lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`); } @@ -1301,6 +1324,27 @@ commands.sync = async ({ flags, positional }) => { : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); return; } + if (action === "verify") { + const out = []; + for (const key of areas) { + try { + out.push(await gt.verifyArea(slug, key)); + } catch (err) { + out.push({ area: key, ok: false, reason: err.message }); + } + } + const bad = out.filter((r) => !r.ok && !r.skipped); + emit({ persona: slug, results: out, ok: bad.length === 0 }, flags.json, out.map((r) => + r.skipped + ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})` + : r.ok + ? `✔ ${gt.AREAS[r.area].label}:本機與 Gitea 一致(${r.files} 個檔案)` + : `✖ ${gt.AREAS[r.area].label}:不一致` + + (r.pending?.length ? `,還沒推上去的檔案:${r.pending.slice(0, 8).join(", ")}` : "") + + (r.reason ? `(${r.reason})` : ""))); + if (bad.length) process.exit(1); + return; + } if (action === "pull") { const out = []; for (const key of areas) { @@ -1324,7 +1368,7 @@ commands.sync = async ({ flags, positional }) => { : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); return; } - die(`未知 action:${action}(可用 init/push/pull/status)`); + die(`未知 action:${action}(可用 init/push/pull/status/verify)`); }; commands.gc = ({ flags }) => { @@ -1390,22 +1434,23 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) 圖示(建立人格並補齊資料後跑): - icon generate|show|faces --session [--size 512 --force --no-gitea] - [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."] - [--source-url <參考照片網址> --source-note <說明> --source-date ] - [--photo <圖片路徑或網址> | --from-source] [--style portrait|badge] - [--pick largest|leftmost|rightmost|<索引> | --face x,y,w,h] - faces 會列出參考照片裡偵測到的臉——多角色的圖務必先看過再用 --pick 指定。 + icon faces|headshot|generate|show --session + faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看) + headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384] + 裁出**參考用大頭照**到 .sync/headshot.png(不是圖示、不同步) + generate [--size 512 --force --no-gitea --style portrait|badge] + [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.."] + [--features "hairstyle=..,length=..,fringe=..,eyes=..,expression=..,accessory=..,side=..,collar=..,ahoge=.."] + [--source-url <來源網址> --source-note <說明> --source-date ] + **依人格資料重新繪製**人物頭像;不會把來源圖放進圖示。 + 沒帶 --palette → 徽章樣式(配色由編號/名字/emoji 雜湊)。 + show 看目前的樣式、配色、特徵與來源 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 - 沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。 - 帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」 - (必須同時帶 --source-url 存證)。 - 帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象 - 並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。 編號與 Gitea(存取庫名稱 = 人格編號): code show|assign|next --session [--romaji <英文名> --code --rename --force --public] - sync status|init|push|pull --session [--area files|wiki|all --if-due --force --message --owner] + sync status|init|push|pull|verify --session [--area files|wiki|all --if-due --force --message --owner] + verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步) 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER), diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 2990a46..7e3390e 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -615,6 +615,69 @@ check("Wiki 有專頁保存形象圖,並寫明來源", (() => { check("Wiki 的保留檔不會被同步流程刪掉", ["Home.md", "Icon.md"].every((f) => gt.wikiHome && typeof gt.wikiIconPage === "function")); +console.log("⑰ 依人格資料重繪(不直接使用網路圖)與 Wiki 同步驗證"); +check("特徵解析:只吃認得的值,其餘回退預設", (() => { + const f = ic.parseFeatures("hairstyle=twintails,eyes=round,accessory=flower,bogus=x,expression=???"); + return f.hairstyle === "twintails" && f.eyes === "round" && f.accessory === "flower" && + f.expression === ic.DEFAULT_FEATURES.expression && !("bogus" in f); +})(), JSON.stringify(ic.parseFeatures("hairstyle=twintails,bogus=x"))); +const basePal = ic.parsePalette("hair=#d9a45b,eye=#9e5b3e,accent=#c0392b"); +const draw = (feat) => ic.renderPng(ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures(feat) }), 32); +check("換髮型會畫出不同的圖", + Buffer.compare(draw("hairstyle=straight"), draw("hairstyle=twintails")) !== 0); +check("換眼型會畫出不同的圖", + Buffer.compare(draw("eyes=round"), draw("eyes=sharp")) !== 0); +check("換表情會畫出不同的圖", + Buffer.compare(draw("expression=calm"), draw("expression=bright")) !== 0); +check("加髮飾/呆毛會多出圖形", (() => { + const plain = ic.iconShapes(ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=none,ahoge=no") })); + const fancy = ic.iconShapes(ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=flower,ahoge=yes") })); + return fancy.length > plain.length + 3; +})()); +check("多邊形在 SVG 與柵格器都畫得出來(呆毛用的是 polygon)", (() => { + const spec = ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures("ahoge=yes") }); + const shapes = ic.iconShapes(spec); + const polys = shapes.filter((sh) => sh.type === "poly").length; + const svg = ic.renderSvg(spec, 64); + return polys > 0 && (svg.match(/ { + const cfg = pl.loadConfig("GAMMA-01"); + cfg.icon = { ...(cfg.icon || {}), style: "photo" }; // 舊版本寫進去、現已移除的樣式 + pl.writeJson(pl.configPath("GAMMA-01"), cfg); + return ic.iconSpec("GAMMA-01").style === "portrait"; +})(), ic.iconSpec("GAMMA-01").style); +check("`icon headshot` 需要 --photo", + cli(["icon", "headshot", "--session", S_CODE], { expectOk: false }).status !== 0); +check("重繪不會把來源圖塞進圖示(SVG 沒有內嵌影像)", (() => { + const svg = fs.readFileSync(ic.iconSvgPath("GAMMA-01"), "utf8"); + // xmlns 本來就有 http,所以只看「有沒有內嵌影像」 + return !svg.includes(" { + cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64", + "--features", "hairstyle=twintails,accessory=ribbon"]); + const feat = pl.loadConfig("GAMMA-01").icon?.features || ""; + return feat.includes("hairstyle=twintails") && feat.includes("accessory=ribbon"); +})(), pl.loadConfig("GAMMA-01").icon?.features); +check("沒設定 Gitea 時 sync verify 是「略過」不是崩潰", (() => { + const res = cli(["sync", "verify", "--session", S_CODE], { expectOk: false }); + return res.stderr.includes("尚未設定") || res.stdout.includes("略過"); +})()); +check("Wiki 形象圖頁同時列出 SVG 與 PNG", (() => { + const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); + return page.includes("icon.svg") && page.includes("icon.png") && page.includes("SVG") && page.includes("PNG"); +})()); +check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", (() => { + const a = gt.wikiHome("GAMMA-01", "GAMMA-01"); + const b = gt.wikiHome("GAMMA-01", "GAMMA-01"); + return a === b; +})()); + 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 6df60b9..9e6b012 100644 --- a/skills/persona-icon/SKILL.md +++ b/skills/persona-icon/SKILL.md @@ -1,13 +1,17 @@ --- name: persona-icon -description: 產生或更新人格的形象圖(icon.svg + icon.png,盡量看得到臉):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖,接著優先用臉部偵測從圖上裁出臉當頭像;缺少裁圖工具時改畫有五官的向量人物形象並提示如何安裝工具,完全沒有參考圖才退回編號徽章。來源網址與裁切框會存進 config 備查,圖示同時同步到 Gitea Wiki 與存取庫頭像。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 +description: 產生或更新人格的形象圖(icon.svg + icon.png,人物頭像、看得到臉):先上網找出該人格「最新一次登場」的官方視覺,裁出**大頭照當底稿**,親眼看過之後萃取髮色/瞳色/服裝色與髮型、眼型、表情、配件等特徵,再**由本工具依人格資料重新繪製**——絕不把找到的圖片直接當圖示。來源網址、底稿裁切框與重繪依據都會存進 config 備查,圖示同步到 Gitea Wiki(SVG + PNG)並設為存取庫頭像,且會回頭驗證同步結果。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 --- # 🎨 persona-icon — 依最新造型產生人格形象圖(有臉) **CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) **時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。 -**目標**:圖示要是**人物形象圖,盡量看得到臉**;退而求其次才是抽象徽章。 +**目標**:圖示要是**人物形象圖,看得到臉**;退而求其次才是抽象徽章。 + +> **鐵則:找到的圖片只能當底稿,不能當圖示。** +> 網路上的角色圖是別人的美術作品。這裡的做法是「裁出大頭照 → 看清楚 → +> **依人格資料重新繪製**」,產出的每一個像素都是本工具畫的。 --- @@ -58,27 +62,22 @@ description: 產生或更新人格的形象圖(icon.svg + icon.png,盡量看 | `secondary` | 服裝的第二色/配件色 | | `light` | 最亮的部位(白袖、皮膚亮面) | -### 4. 裁出臉(有工具的話,這是首選) - -**圖示必須是人物形象,盡量看得到臉。** 環境裡如果有 Pillow(+OpenCV 動漫臉模型), -就直接從參考圖裁臉: +### 4. 裁出大頭照當底稿 ```bash # 先看看圖裡有幾張臉——多角色的主視覺一定要先看 node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon faces --session --photo "<圖片網址或路徑>" -# 挑一張(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force \ - --photo "<圖片網址或路徑>" --pick <索引> \ - --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.." \ - --source-url "<那張圖的網址>" --source-note "<作品(年份)+造型描述>" +# 裁出底稿(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session --photo "<圖片網址或路徑>" --pick <索引> ``` -**多角色的圖務必自己看過再挑**:`icon faces` 只會告訴你座標,哪張臉是本人要由你判斷 -(把裁出來的圖用 Read 打開確認一次最保險)。挑錯就是別人的臉。 +底稿寫到 `<人格>/.sync/headshot.png`,**不是圖示、不會同步、不會發佈**。 -工具不齊時 CLI 會**印出安裝指令**並自動退回第 5 步的向量形象——不會靜默失敗。 -需要的工具: +**多角色的圖務必自己看過再挑**:`icon faces` 只給座標,哪張臉是本人要由你判斷。 +裁完**一定要用 Read 打開確認是本人**——挑錯就是別人的臉。 + +工具不齊時 CLI 會**印出安裝指令**——不會靜默失敗。需要的工具: | 工具 | 做什麼 | 怎麼裝(免 sudo) | | --- | --- | --- | @@ -89,17 +88,36 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session OpenCV **5.x 拿掉了 `CascadeClassifier`**,一定要裝 4.x。 > venv 放在 `~/.cache/jsc-persona/venv` 會被自動偵測到;也可以用 `PERSONA_PYTHON` 指定別的。 -### 5. 沒有工具時:向量人物形象(一樣有臉) +### 5. 看著底稿,把特徵讀出來 + +用 Read 打開 `.sync/headshot.png`,**同時**讀這個人格的 `IDENTITY.md`/`SOUL.md`/canon 記憶。 +形象要同時對得起「他長什麼樣」與「他是誰」: + +| 特徵 | 可選值 | 從哪裡判斷 | +| --- | --- | --- | +| `hairstyle` | straight / twintails / ponytail / bob / braid | 底稿的髮型 | +| `length` | short / medium / long / very-long | 底稿的髮長 | +| `fringe` | blunt / parted / swept / curtain | 底稿的瀏海 | +| `eyes` | round / almond / sharp / droopy | 底稿的眼型 | +| `expression` | gentle / bright / calm / neutral | **SOUL 的 Vibe** + 底稿的神情 | +| `accessory` | none / ribbon / clip / flower / hairband | 底稿或最新造型的髮飾 | +| `side` | left / right | 髮飾在哪一側 | +| `collar` | round / v / high / sailor | 服裝領口 | +| `ahoge` | yes / no | 有沒有呆毛 | + +### 6. 重新繪製 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ - --session --force \ - --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ - --source-url "<你剛才看的那張圖的網址>" \ - --source-note "<作品名(年份)+造型描述:髮色、瞳色、服裝>" \ +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force \ + --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \ + --features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,accessory=none,collar=v,ahoge=yes" \ + --source-url "<底稿那張圖的網址>" \ + --source-note "<作品(年份)+底稿是誰+重繪依據:髮型、眼型、神情、服裝>" \ --source-date ``` +**畫完一定要用 Read 打開產出的 `icon.png` 看一眼**,不像就調特徵重畫(`--force`)。 + - **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。 - 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。 - 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像、同步到 Wiki 區, @@ -108,7 +126,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ 找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette`, 用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。 -### 6. 回報 +### 7. 回報 一句話說明:用了哪部作品哪一年的視覺、取到什麼配色、圖示放哪裡。 把來源網址附上,讓使用者可以自己核對。 @@ -117,25 +135,34 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ | 樣式 | 什麼時候用 | 長什麼樣 | | --- | --- | --- | -| `photo` | 有參考圖 **且**工具齊全(最佳) | 從官方視覺**裁出的臉**,圓角+瞳色外框 | -| `portrait` | 有參考圖但沒工具 | 向量人物形象:髮型、瞳色、服裝色都取自照片,**有五官** | +| `portrait` | 有底稿(有調色盤)→ **預設** | 依特徵重繪的人物頭像:髮型、瀏海、眼型、表情、髮飾、領口全部照特徵畫 | | `badge` | 完全沒有參考圖(原創人格) | 雙色漸層徽章 + 編號前兩個字母 | -- `portrait` 與 `badge` 由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**,兩邊必然一致; - PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。 -- `photo` 的 SVG 會把裁好的 PNG 以 base64 內嵌,一樣自成一體(不外連)。 +- 兩種樣式都由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**(橢圓/矩形/多邊形), + 兩邊必然一致;PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。 +- SVG **不會內嵌任何影像**——沒有 ``、沒有 base64、沒有外連。 - `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓, - 確保字讀得到——顏色仍然是照片來的,只是收斂色階。 + 確保字讀得到。 ## 界線 -裁出來的是**角色臉部的縮圖**,用途是人格身分辨識(頭像)。原始美術作品的權利屬於原作者, -存取庫預設私有;不要把它當成可以散布的素材,也不要重製整張原圖。 +- **找到的圖片只能當底稿**:裁出來的大頭照留在 `.sync/`,不進圖示、不同步、不發佈。 + 圖示的每個像素都是本工具依特徵畫出來的。 +- 底稿的來源網址會記在 `config.json`,方便日後核對,但不重製原圖。 + +## Wiki 必須同步 + +`icon generate` 推完 Wiki 會**自動回頭驗證**,確認 `icon.svg` 與 `icon.png` 真的在 Wiki 上 +而且與本機一致;不一致會直接報錯。也可以手動查: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync verify --session --area wiki +``` ## 邊界 - 只能對「本 session 已載入的人格」產生圖示。 - 已有圖示時要重畫必須加 `--force`。 - 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。 -- `.sync/reference.*` 是下載回來的參考圖快取,可以安全刪除。 +- `.sync/reference.*` 與 `.sync/headshot.png` 是底稿快取,可以安全刪除(不會同步出去)。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。