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:
2026-07-30 02:39:58 +00:00
co-authored by Claude Opus 5
parent 1884a9c3d7
commit 15e4eea8ac
11 changed files with 769 additions and 91 deletions
+65 -9
View File
@@ -51,7 +51,7 @@ function emit(payload, asJson, lines) {
const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat",
"if-due", "no-gitea", "public", "rename",
"if-due", "no-gitea", "public", "rename", "from-source",
]);
function parseArgs(argv) {
@@ -1139,7 +1139,32 @@ commands.icon = async ({ flags, positional }) => {
]);
return;
}
if (action !== "generate") die(`未知 action${action}(可用 generate/show`);
if (action === "faces") {
requireMember(slug, session, Boolean(flags["as-guest"]));
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 found = ic.listFaces(file);
if (!found.ok) {
emit(found, flags.json, [
`\u2716 \u7121\u6cd5\u5075\u6e2c\u81c9\uff1a${found.reason || "\u5de5\u5177\u4e0d\u8db3"}`,
...ic.installHintLines(found.report || ic.toolReport()),
]);
return;
}
emit(found, flags.json, [
`\u53c3\u8003\u7167\u7247 ${found.size[0]}\u00d7${found.size[1]}\uff5c\u5075\u6e2c\u65b9\u5f0f ${found.method || "\uff08\u7121\uff09"}\uff5c\u627e\u5230 ${found.faces.length} \u5f35\u81c9\uff1a`,
...found.faces.map((f) => ` #${f.index} ${f.w}\u00d7${f.h} @(${f.x},${f.y})\u3000\u4e2d\u5fc3 ${f.center.join(",")}`),
" \u591a\u89d2\u8272\u7684\u5716\u8acb\u5148\u770b\u904e\u539f\u5716\u518d\u6311\uff1a`icon generate --photo <\u5716> --pick <\u7d22\u5f15>`\uff08\u6216 `--face x,y,w,h`\uff09\u3002",
]);
return;
}
if (action !== "generate") die(`\u672a\u77e5 action\uff1a${action}\uff08\u53ef\u7528 generate/show/faces\uff09`);
requireOwner(slug, session);
if (ic.hasIcon(slug) && !flags.force) {
die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
@@ -1166,17 +1191,42 @@ commands.icon = async ({ flags, positional }) => {
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
}
: null;
const res = ic.generateIcon(slug, { size, palette, source });
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 lines = [
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
` ${res.svg}${(res.bytes.svg / 1024).toFixed(1)} KB`,
` ${res.png}${(res.bytes.png / 1024).toFixed(1)} KB`,
res.spec.palette
? ` 字母 ${res.spec.letters}|配色取自參考照片:${ic.paletteToString(res.spec.palette)}`
: ` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`,
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 決定`,
...(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)) {
@@ -1340,12 +1390,18 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
圖示(建立人格並補齊資料後跑):
icon generate|show --session <id> [--size 512 --force --no-gitea]
icon generate|show|faces --session <id> [--size 512 --force --no-gitea]
[--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."]
[--source-url <參考照片網址> --source-note <說明> --source-date <YYYY-MM-DD>]
[--photo <圖片路徑或網址> | --from-source] [--style portrait|badge]
[--pick largest|leftmost|rightmost|<索引> | --face x,y,w,h]
faces 會列出參考照片裡偵測到的臉——多角色的圖務必先看過再用 --pick 指定。
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
沒帶 --palette → 配色由編號/名字/emoji 雜湊而來。
帶了 --palette → 用「你實際看過的參考照片」萃取的顏色(必須同時帶 --source-url 存證)。
沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。
帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」
(必須同時帶 --source-url 存證)。
帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象
並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。
編號與 Gitea(存取庫名稱 = 人格編號):
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]