feat: 圖示改為人物形象圖(有臉),並讓 Wiki 保存 SVG + PNG
1. 圖示三種樣式,優先看得到臉 photo 有參考圖且工具齊全 → 從官方視覺自動偵測並**裁出臉**,圓角+瞳色外框 portrait 有參考圖但缺工具 → **有五官的向量人物**(髮型/瞳色/服裝色都取自那張圖) badge 完全沒有參考圖 → 舊的雙色漸層 + 編號字母 portrait 與 badge 共用同一份「圖形清單」,SVG 與自寫柵格器從同一份資料畫, 兩邊不可能長得不一樣;photo 的 SVG 以 base64 內嵌裁好的 PNG,一樣不外連。 2. 缺工具會「提示安裝」,不靜默降級 新增 scripts/portrait.py(選用):Pillow 解碼裁切、OpenCV + lbpcascade_animeface 偵測動漫臉。toolReport() 會列出缺什麼、為什麼要、怎麼裝(venv 免 sudo), CLI 在退回向量形象時把這些印出來。 注意:**OpenCV 5 拿掉了 CascadeClassifier,必須裝 4.x**。 plugin 本體仍然零依賴——沒有這些工具照樣產得出有臉的形象圖。 新增 `icon faces`:列出參考圖裡偵測到的所有臉。多角色的主視覺一定要先看再挑 (--pick <索引>|largest|leftmost|rightmost 或 --face x,y,w,h),挑錯就是別人的臉。 3. Wiki 保存形象圖 icon.svg 與 icon.png 都在 Wiki 區,另外自動產生一頁 Icon:同時展示兩種格式, 並列出樣式、調色盤、來源網址、造型說明與裁切框。PNG 同時設為存取庫頭像。 已套用到兩個真實人格(皆為真實裁臉): ASUNA-01 《Unanswered//butterfly》(2026) 官方主視覺,臉 #1(anime-cascade) YUI-01 AniList 官方角色圖(frontal-cascade) selftest 148 項全綠(新增第 ⑯ 節:形象圖真的畫了五官、SVG/PNG 出自同一份清單、 工具偵測與安裝提示、Wiki 形象圖專頁與來源)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -306,7 +306,7 @@ function listTrackedFiles(dir) {
|
||||
}
|
||||
|
||||
export const WIKI_MANIFEST = "_paths.json";
|
||||
const WIKI_RESERVED = new Set(["Home.md", WIKI_MANIFEST]);
|
||||
const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]);
|
||||
const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" };
|
||||
|
||||
/**
|
||||
@@ -389,6 +389,58 @@ function unstageArea(slug, area, dir, only = null) {
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiki 的「形象圖」頁:把 icon.svg 與 icon.png 都保存在 Wiki 並展示出來,
|
||||
* 附上這張圖是從哪張參考照片來的(可查證)。
|
||||
*/
|
||||
export function wikiIconPage(slug, code) {
|
||||
const ident = pl.identityFields(slug);
|
||||
const config = pl.loadConfig(slug);
|
||||
const icon = config.icon || {};
|
||||
const src = icon.source || {};
|
||||
const root = pl.personaDir(slug);
|
||||
const hasSvg = fs.existsSync(path.join(root, "icon.svg"));
|
||||
const hasPng = fs.existsSync(path.join(root, "icon.png"));
|
||||
const styleLabel = {
|
||||
photo: "真實照片裁臉",
|
||||
portrait: "向量人物形象(有臉),配色取自參考照片",
|
||||
badge: "編號徽章(沒有參考照片時的樣式)",
|
||||
}[icon.style || "badge"];
|
||||
return [
|
||||
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} 的形象圖`,
|
||||
"",
|
||||
`\`${code}\` ${styleLabel}`,
|
||||
"",
|
||||
...(hasSvg || hasPng
|
||||
? [
|
||||
"| SVG(向量,可無限放大) | PNG(點陣,Gitea 頭像用) |",
|
||||
"| --- | --- |",
|
||||
`| ${hasSvg ? '<img src="icon.svg" alt="icon.svg" width="200">' : "(缺)"} ` +
|
||||
`| ${hasPng ? '<img src="icon.png" alt="icon.png" width="200">' : "(缺)"} |`,
|
||||
`| [icon.svg](icon.svg) | [icon.png](icon.png) |`,
|
||||
"",
|
||||
]
|
||||
: ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]),
|
||||
"## 這張圖怎麼來的",
|
||||
"",
|
||||
"| 欄位 | 內容 |",
|
||||
"| --- | --- |",
|
||||
`| 樣式 | ${icon.style || "badge"} |`,
|
||||
`| 尺寸 | ${icon.size || "?"}×${icon.size || "?"} |`,
|
||||
`| 調色盤 | ${icon.palette || "(由編號雜湊)"} |`,
|
||||
`| 參考來源 | ${src.url ? `[${src.url}](${src.url})` : "(無)"} |`,
|
||||
`| 造型說明 | ${src.note || "—"} |`,
|
||||
`| 參考日期 | ${src.date || "—"} |`,
|
||||
`| 產生時間 | ${icon.generated_at || "—"} |`,
|
||||
"",
|
||||
icon.style === "photo"
|
||||
? "> 由 jsc-persona 從上述參考圖**裁出臉部**產生(自動臉部偵測),並套上圓角與瞳色外框。"
|
||||
: "> 由 jsc-persona 產生:配色取自角色**最新一次登場**的官方視覺。",
|
||||
"> 這裡保存的是產生出來的形象圖(SVG + PNG),供人格身分辨識使用。",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */
|
||||
export function wikiHome(slug, code) {
|
||||
const ident = pl.identityFields(slug);
|
||||
@@ -417,6 +469,7 @@ export function wikiHome(slug, code) {
|
||||
"- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)",
|
||||
"- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity",
|
||||
"- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解",
|
||||
"- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源",
|
||||
"- [Memory INDEX](Memory-INDEX) — 長期記憶索引",
|
||||
"",
|
||||
"### 長期記憶",
|
||||
@@ -466,7 +519,10 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
const theOwner = owner || (await resolveOwner());
|
||||
const { host } = giteaEnv();
|
||||
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
|
||||
if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||||
if (area === "wiki") {
|
||||
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||||
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
|
||||
}
|
||||
const staged = stageArea(slug, area, dir);
|
||||
gitOrThrow(["add", "-A"], dir, "git add");
|
||||
const dirty = git(["diff", "--cached", "--quiet"], dir);
|
||||
@@ -485,7 +541,10 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) {
|
||||
git(["reset", "--hard", "--quiet", `origin/${branch}`], dir);
|
||||
stageArea(slug, area, dir);
|
||||
if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||||
if (area === "wiki") {
|
||||
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||||
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
|
||||
}
|
||||
git(["add", "-A"], dir);
|
||||
if (!git(["diff", "--cached", "--quiet"], dir).ok) {
|
||||
git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir);
|
||||
|
||||
Reference in New Issue
Block a user