fix: Wiki 圖片其實是破圖(HTML img 不被改寫);形象圖加高解析度與五官細節
1. Wiki 形象圖「沒有同步」的真正原因
內容其實一直是一致的(md5 相同),但**頁面上看不到圖**:
Icon.md/Home.md 用了 HTML `<img src="icon.svg">`,而 Gitea **只改寫
Markdown 圖片語法**(`` → `/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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||||
"skills": "./skills"
|
"skills": "./skills"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,14 @@
|
|||||||
13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、
|
13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、
|
||||||
怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。
|
怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。
|
||||||
plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。
|
plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。
|
||||||
14. **Wiki 必須保存並同步形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的
|
14. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024)
|
||||||
**Icon** 頁展示兩種格式與來源。`icon generate` 推完會**回頭驗證**,`sync verify` 可隨時檢查。
|
都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**,
|
||||||
Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。
|
`sync verify` 可隨時檢查。兩個容易踩的坑:
|
||||||
|
* Wiki 頁面**只能用 Markdown 圖片語法** ``——Gitea 只改寫這種語法為
|
||||||
|
`/wiki/raw/...`;HTML `<img src="icon.png">` 不會被改寫,瀏覽器會解析成 `/wiki/icon.png`
|
||||||
|
而變成破圖(看起來就像「沒有同步」)。
|
||||||
|
* Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。
|
||||||
|
* 攤平只對 `.md` 做(頁面必須在根層);圖片等附件保留資料夾結構,`/wiki/raw/<資料夾>/<檔>` 讀得到。
|
||||||
|
|
||||||
## 慣例
|
## 慣例
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
|
|||||||
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
|
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
|
||||||
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
|
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
|
||||||
| **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 `<image>`、沒有 base64、沒有外連,底稿只留在 `.sync/` 不同步。來源網址與重繪依據存進 `config.json` 備查 |
|
| **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 `<image>`、沒有 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 `<img>` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,9 +161,20 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
|
|||||||
|
|
||||||
| 樣式 | 條件 | 長什麼樣 |
|
| 樣式 | 條件 | 長什麼樣 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像,五官俱全 |
|
| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像:漸層虹膜+雙高光+睫毛、髮絲與反光、腮紅與下巴陰影 |
|
||||||
| **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 |
|
| **`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 沒有 `<image>`、沒有 base64、沒有外連,每個像素都是畫出來的。
|
**找到的圖片只能當底稿**:產出的 SVG 沒有 `<image>`、沒有 base64、沒有外連,每個像素都是畫出來的。
|
||||||
|
|
||||||
裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級:
|
裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級:
|
||||||
@@ -378,7 +389,7 @@ node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是
|
|||||||
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
||||||
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
|
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
|
||||||
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
|
node scripts/persona.mjs sync status --session <id> # 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)、
|
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills/"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const AREAS = {
|
|||||||
"USER.md",
|
"USER.md",
|
||||||
"icon.svg",
|
"icon.svg",
|
||||||
"icon.png",
|
"icon.png",
|
||||||
|
"icon/",
|
||||||
"memory/INDEX.md",
|
"memory/INDEX.md",
|
||||||
"memory/long-term/",
|
"memory/long-term/",
|
||||||
"mindmap/semantic.mmd",
|
"mindmap/semantic.mmd",
|
||||||
@@ -316,6 +317,10 @@ const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relation
|
|||||||
*/
|
*/
|
||||||
export function wikiName(rel) {
|
export function wikiName(rel) {
|
||||||
if (!rel.includes("/")) return 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 parts = rel.split("/");
|
||||||
const file = parts.pop();
|
const file = parts.pop();
|
||||||
return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`;
|
return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`;
|
||||||
@@ -401,6 +406,19 @@ export function wikiIconPage(slug, code) {
|
|||||||
const root = pl.personaDir(slug);
|
const root = pl.personaDir(slug);
|
||||||
const hasSvg = fs.existsSync(path.join(root, "icon.svg"));
|
const hasSvg = fs.existsSync(path.join(root, "icon.svg"));
|
||||||
const hasPng = fs.existsSync(path.join(root, "icon.png"));
|
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 = {
|
const styleLabel = {
|
||||||
photo: "真實照片裁臉",
|
photo: "真實照片裁臉",
|
||||||
portrait: "向量人物形象(有臉),配色取自參考照片",
|
portrait: "向量人物形象(有臉),配色取自參考照片",
|
||||||
@@ -411,13 +429,16 @@ export function wikiIconPage(slug, code) {
|
|||||||
"",
|
"",
|
||||||
`\`${code}\` ${styleLabel}`,
|
`\`${code}\` ${styleLabel}`,
|
||||||
"",
|
"",
|
||||||
|
// Gitea 只會改寫 **Markdown 圖片語法** 的路徑(→ /wiki/raw/...);
|
||||||
|
// 用 HTML <img> 會被瀏覽器當成相對於頁面網址,變成 303 破圖。
|
||||||
...(hasSvg || hasPng
|
...(hasSvg || hasPng
|
||||||
? [
|
? [
|
||||||
"| SVG(向量,可無限放大) | PNG(點陣,Gitea 頭像用) |",
|
...(hasPng ? [``, ""] : []),
|
||||||
"| --- | --- |",
|
"| 格式 | 檔案 | 用途 |",
|
||||||
`| ${hasSvg ? '<img src="icon.svg" alt="icon.svg" width="200">' : "(缺)"} ` +
|
"| --- | --- | --- |",
|
||||||
`| ${hasPng ? '<img src="icon.png" alt="icon.png" width="200">' : "(缺)"} |`,
|
...(hasSvg ? [`| SVG | [icon.svg](icon.svg) | 向量,可無限放大 |`] : []),
|
||||||
`| [icon.svg](icon.svg) | [icon.png](icon.png) |`,
|
...(hasPng ? [`| PNG | [icon.png](icon.png) | 點陣,存取庫頭像 |`] : []),
|
||||||
|
...renders.map((r) => `| ${r.ext.toUpperCase()} | [icon/${r.name}](icon/${r.name}) | ${r.note} |`),
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
: ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]),
|
: ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]),
|
||||||
@@ -446,11 +467,11 @@ export function wikiHome(slug, code) {
|
|||||||
const ident = pl.identityFields(slug);
|
const ident = pl.identityFields(slug);
|
||||||
const longTerm = pl.longTermEntries(slug);
|
const longTerm = pl.longTermEntries(slug);
|
||||||
const relations = pl.loadRelations(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 = [
|
const lines = [
|
||||||
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
|
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
|
||||||
"",
|
"",
|
||||||
...(hasIcon ? [`<img src="icon.svg" alt="${code}" width="128" height="128">`, ""] : []),
|
...(hasIcon ? [``, ""] : []),
|
||||||
"> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;",
|
"> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;",
|
||||||
"> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。",
|
"> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。",
|
||||||
"",
|
"",
|
||||||
@@ -489,7 +510,7 @@ export function wikiHome(slug, code) {
|
|||||||
"",
|
"",
|
||||||
"- `Mindmap-semantic.mmd` — 心智圖(Mermaid)",
|
"- `Mindmap-semantic.mmd` — 心智圖(Mermaid)",
|
||||||
"- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料",
|
"- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料",
|
||||||
"- `_paths.json` — 攤平前的原始路徑對照(同步用,勿手改)",
|
"- `icon/` — 高解析度形象圖(多尺寸) - `_paths.json` — 攤平前的路徑對照(勿手改)",
|
||||||
"",
|
"",
|
||||||
"> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。",
|
"> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。",
|
||||||
"",
|
"",
|
||||||
|
|||||||
+72
-24
@@ -26,6 +26,8 @@ import * as pl from "./persona-lib.mjs";
|
|||||||
export const ICON_SVG = "icon.svg";
|
export const ICON_SVG = "icon.svg";
|
||||||
export const ICON_PNG = "icon.png";
|
export const ICON_PNG = "icon.png";
|
||||||
export const DEFAULT_SIZE = 512;
|
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 iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG);
|
||||||
export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG);
|
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.235, sideCy, 0.078, sideRy, hairDark));
|
||||||
out.push(ellipse(0.765, 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));
|
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") {
|
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.318, 0.410, 0.068, 0.140, hair));
|
||||||
out.push(ellipse(0.682, 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.250, 0.560, 0.032, 0.185, hair));
|
||||||
out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair));
|
out.push(ellipse(0.750, 0.560, 0.032, 0.185, hair));
|
||||||
|
|
||||||
// ── 眉毛(表情會影響傾斜)
|
// ── 眉毛(表情會影響傾斜)
|
||||||
const browY = f.expression === "bright" ? 0.430 : 0.436;
|
const browY = f.expression === "bright" ? 0.408 : 0.414;
|
||||||
const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開
|
// 眉毛要「看得到但不搶戲」:深髮角色往膚色提亮才不會和瀏海連成一片,
|
||||||
|
// 淺髮角色則要比頭髮更深,否則會變成兩條灰槓。
|
||||||
|
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;
|
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],
|
out.push(poly([[0.378, browY + browTilt], [0.458, browY], [0.458, browY + 0.014],
|
||||||
[0.372, browY + browTilt + 0.021]], brow));
|
[0.376, browY + browTilt + 0.014]], brow));
|
||||||
out.push(poly([[0.538, browY], [0.628, browY + browTilt], [0.628, browY + browTilt + 0.021],
|
out.push(poly([[0.542, browY], [0.622, browY + browTilt], [0.622, browY + browTilt + 0.014],
|
||||||
[0.538, browY + 0.021]], brow));
|
[0.540, browY + 0.014]], brow));
|
||||||
|
|
||||||
// ── 眼睛
|
// ── 眼睛
|
||||||
const eyeGeo = {
|
const eyeGeo = {
|
||||||
round: { rx: 0.058, ry: 0.070 },
|
round: { rx: 0.070, ry: 0.084 },
|
||||||
almond: { rx: 0.063, ry: 0.060 },
|
almond: { rx: 0.074, ry: 0.073 },
|
||||||
sharp: { rx: 0.066, ry: 0.049 },
|
sharp: { rx: 0.076, ry: 0.060 },
|
||||||
droopy: { rx: 0.060, ry: 0.065 },
|
droopy: { rx: 0.072, ry: 0.078 },
|
||||||
}[f.eyes] || { rx: 0.063, ry: 0.064 };
|
}[f.eyes] || { rx: 0.063, ry: 0.064 };
|
||||||
const eyeY = f.eyes === "droopy" ? 0.558 : 0.552;
|
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, 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.004, eyeGeo.rx * 0.80, eyeGeo.ry * 0.84, shade(eye, 0.30)));
|
||||||
out.push(ellipse(cx, eyeY + 0.011, eyeGeo.rx * 0.34, eyeGeo.ry * 0.42, shade(eye, 0.7)));
|
out.push(ellipse(cx, eyeY + 0.012, eyeGeo.rx * 0.76, eyeGeo.ry * 0.72, eye));
|
||||||
out.push(circle(hl, eyeY - 0.018, 0.015, [255, 255, 255]));
|
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(rect(cx - eyeGeo.rx, eyeY - eyeGeo.ry, eyeGeo.rx * 2, 0.014, hairDark));
|
out.push(ellipse(hl, eyeY - 0.019, 0.019, 0.016, [255, 255, 255]));
|
||||||
if (f.eyes === "sharp") {
|
out.push(circle(cx + dir * eyeGeo.rx * 0.38, eyeY + eyeGeo.ry * 0.42, 0.010, [255, 255, 255], 0.85));
|
||||||
out.push(poly([[cx + eyeGeo.rx * 0.6, eyeY - eyeGeo.ry],
|
// 上眼線 + 外眼角睫毛
|
||||||
[cx + eyeGeo.rx * 1.45, eyeY - eyeGeo.ry - 0.022],
|
out.push(ellipse(cx, eyeY - eyeGeo.ry * 0.94, eyeGeo.rx * 0.98, eyeGeo.ry * 0.13, lash));
|
||||||
[cx + eyeGeo.rx * 1.05, eyeY - eyeGeo.ry * 0.35]], hairDark));
|
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)));
|
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 SS = Math.max(1, Math.min(4, supersample));
|
||||||
const big = size * SS;
|
const big = size * SS;
|
||||||
const acc = Buffer.alloc(big * big * 4);
|
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;
|
const [g1, g2, g3] = spec.ring;
|
||||||
|
|
||||||
for (let py = 0; py < big; py += 1) {
|
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;
|
let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t;
|
||||||
// 圖形清單:由後往前疊,與 SVG 的繪製順序相同
|
// 圖形清單:由後往前疊,與 SVG 的繪製順序相同
|
||||||
for (const sh of shapes) {
|
for (const sh of shapes) {
|
||||||
|
if (u < sh.x0 || u > sh.x1 || v < sh.y0 || v > sh.y1) continue;
|
||||||
let hit;
|
let hit;
|
||||||
if (sh.type === "rect") {
|
if (sh.type === "rect") {
|
||||||
hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h;
|
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);
|
pl.writeText(iconSvgPath(slug), svg);
|
||||||
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
||||||
fs.writeFileSync(iconPngPath(slug), png);
|
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,之後重畫才會一致,也才查得到「這個顏色是哪來的」
|
// 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」
|
||||||
const config = pl.loadConfig(slug);
|
const config = pl.loadConfig(slug);
|
||||||
config.icon = {
|
config.icon = {
|
||||||
@@ -776,6 +823,7 @@ export function generateIcon(slug, {
|
|||||||
svg: iconSvgPath(slug),
|
svg: iconSvgPath(slug),
|
||||||
png: iconPngPath(slug),
|
png: iconPngPath(slug),
|
||||||
size,
|
size,
|
||||||
|
renders,
|
||||||
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
|
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1232,6 +1232,7 @@ commands.icon = async ({ flags, positional }) => {
|
|||||||
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
|
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
|
||||||
` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
|
` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
|
||||||
` ${res.png}(${(res.bytes.png / 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.style === "portrait"
|
||||||
? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)`
|
? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)`
|
||||||
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
|
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
|
||||||
|
|||||||
+37
-1
@@ -579,7 +579,8 @@ check("形象圖真的畫了五官(眼白/虹膜/瞳孔/嘴都在)", (
|
|||||||
const eyeWhite = shapes.filter((sh) => sh.fill.join() === "252,252,255").length;
|
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 iris = shapes.filter((sh) => sh.fill.join() === facePal.eye.join()).length;
|
||||||
const highlight = shapes.filter((sh) => sh.fill.join() === "255,255,255").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}`);
|
})(), `圖形數 ${ic.iconShapes(faceSpec).length}`);
|
||||||
check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)",
|
check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)",
|
||||||
faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join());
|
faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join());
|
||||||
@@ -678,6 +679,41 @@ check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", ((
|
|||||||
return a === b;
|
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 <img> 不會被改寫路徑)", (() => {
|
||||||
|
const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
|
||||||
|
const home = gt.wikiHome("GAMMA-01", "GAMMA-01");
|
||||||
|
return page.includes("![") && !page.includes("<img") && !home.includes("<img");
|
||||||
|
})(), gt.wikiIconPage("GAMMA-01", "GAMMA-01").split("\n").find((l) => 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(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||||
process.exit(failed ? 1 : 0);
|
process.exit(failed ? 1 : 0);
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSON
|
|||||||
|
|
||||||
- 兩種樣式都由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**(橢圓/矩形/多邊形),
|
- 兩種樣式都由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**(橢圓/矩形/多邊形),
|
||||||
兩邊必然一致;PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。
|
兩邊必然一致;PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。
|
||||||
|
- 每次產生會輸出:`icon.svg`/`icon.png`(512,頭像用)+ `icon/portrait.svg`、
|
||||||
|
`icon/portrait-512.png`、`icon/portrait-1024.png`(高解析度),全部同步到 Wiki。
|
||||||
- SVG **不會內嵌任何影像**——沒有 `<image>`、沒有 base64、沒有外連。
|
- SVG **不會內嵌任何影像**——沒有 `<image>`、沒有 base64、沒有外連。
|
||||||
- `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓,
|
- `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓,
|
||||||
確保字讀得到。
|
確保字讀得到。
|
||||||
|
|||||||
Reference in New Issue
Block a user