feat: 建立人格並補齊資料後產生圖示(SVG + PNG,零外部依賴)

`icon generate|show`:由**編號、Name、Emoji** 雜湊出配色與圖案,產出
512×512 的幾何徽章 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。

圖案(SVG 與 PNG 共用同一組單位座標與同一份 5×7 點陣字,**輸出的是同一張圖**):
  * 雙色對角漸層底
  * 5×5 左右對稱點陣紋(identicon 式)
  * 中央兩個字母=編號前兩字(ASUNA-01 → AS),依背景亮度自動選黑/白

同一個人格永遠得到同一張圖(純函數,無隨機);ASUNA-01 與 ASUNA-02 明顯不同。

為什麼自己畫:這台機器(及多數伺服器)沒有 rsvg/inkscape/imagemagick,
沒有影像函式庫,也沒有 emoji 字型,而本專案禁止 npm 依賴。所以
scripts/persona-icon.mjs 自己柵格化(3× 超取樣 + 盒式縮減當反鋸齒),
再用內建 zlib 手工組出 IHDR/IDAT/IEND 與 CRC32。
emoji 無法柵格化,因此不作為圖形,但仍參與配色雜湊。

時機:**資料補齊之後才產生**——配色與字母綁在最終身分上,太早跑會對不上。
persona-create 第 7 步、persona-anime 第 8 步都補了這個步驟;改過身分用 --force 重畫。

selftest 128 項全綠(新增第 ⑭ 節:PNG chunk 合法性、決定性、不同人格不同圖、
圖示屬 Wiki 區、匯出用 base64 帶走二進位、guest 不得重畫)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:03:45 +00:00
co-authored by Claude Opus 5
parent e986686d0e
commit d067907225
12 changed files with 580 additions and 11 deletions
+54
View File
@@ -15,6 +15,7 @@ import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs";
import * as gt from "./persona-gitea.mjs";
import * as ic from "./persona-icon.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
@@ -1115,6 +1116,54 @@ commands.code = async ({ flags, positional }) => {
die(`未知 action${action}(可用 show/assign/next`);
};
/**
* 由人格資料產生圖示(SVG + PNG)。**建立人格並補齊 IDENTITYSOUL 之後再跑**
* 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。
*/
commands.icon = async ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const action = positional[0] || "generate";
if (action === "show") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const spec = ic.iconSpec(slug);
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`"}`,
` 字母 ${spec.letters}|配色 ${JSON.stringify(spec.c1)}${JSON.stringify(spec.c2)}seed ${spec.seed}`,
` ${ic.iconSvgPath(slug)}`,
` ${ic.iconPngPath(slug)}`,
]);
return;
}
if (action !== "generate") die(`未知 action${action}(可用 generate/show`);
requireOwner(slug, session);
if (ic.hasIcon(slug) && !flags.force) {
die(`人格 \`${slug}\` 已經有圖示了。改過身分要重畫請加 --force。`);
}
const size = num(flags.size, ic.DEFAULT_SIZE);
if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 162048。");
const res = ic.generateIcon(slug, { size });
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.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`,
];
// 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
try {
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 區。");
} catch (err) {
lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`);
}
}
emit({ persona: slug, ...res }, flags.json, lines);
};
/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */
commands.sync = async ({ flags, positional }) => {
const session = requireSession(flags);
@@ -1261,6 +1310,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
圖示(建立人格並補齊資料後跑):
icon generate|show --session <id> [--size 512 --force --no-gitea]
由編號/名字/emoji 決定配色與字母,產出 icon.svg + icon.png(同一人格永遠同一張圖),
並設為 Gitea 存取庫頭像、同步到 Wiki 區。
編號與 Gitea(存取庫名稱 = 人格編號):
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
sync status|init|push|pull --session <id> [--area files|wiki|all --if-due --force --message --owner]