feat: 形象圖改為「依人格資料重繪」,並驗證 Wiki 同步
1. 不再直接使用網路上找到的圖片
舊版把裁下來的官方美術當圖示(photo 樣式)。現在改成:
找圖 → `icon headshot` 裁出**大頭照當底稿** → AI 用 Read 親眼看過 →
讀出髮型/瀏海/眼型/表情/髮飾/領口等特徵 → **由本工具重新繪製**。
* 底稿寫在 <人格>/.sync/headshot.png,**不是圖示、不同步、不發佈**。
* 產出的 SVG 不得有 <image>/base64/外連(selftest 會擋)。
* 移除 photo 樣式與 photoSvg;config 裡殘留的舊樣式會被忽略而非退回徽章。
2. 重繪引擎:五官與造型可參數化
新增 --features:hairstyle(5) / length(4) / fringe(4) / eyes(4) /
expression(4) / accessory(5) / side / collar(4) / ahoge。
渲染器新增 polygon 圖元(呆毛、緞帶、V 領、銳利眼角),SVG 與自寫柵格器
仍共用同一份圖形清單,兩邊必然一致。
另外調了臉部比例:眉毛用「髮色偏膚色」避免深髮角色眉毛與瀏海連成黑帶、
加了鼻子(否則嘴會被看成鼻子)、眼與嘴的縱向配置重排。
3. Wiki 形象圖必須同步(並且會被驗證)
新增 `sync verify`(不一致以非零結束)與 verifyIconInWiki();
`icon generate` 推完 Wiki 會自動回頭確認 icon.svg + icon.png 真的在遠端
且與本機一致。
修掉一個會讓驗證永遠失敗的 bug:Wiki 首頁內嵌了 `最後同步 ${now}`,
每次產生都不同 → 永遠 dirty、每次 push 都多一個 commit。改用圖示的
generated_at。porcelain 解析也從固定位移改為正規式。
已重繪兩個真實人格(皆為 portrait 樣式,並通過 Wiki 同步驗證):
ASUNA-01 底稿=《Unanswered//butterfly》(2026) 主視覺左側;金蜜色極長直髮、
中分瀏海、呆毛、紅褐杏眼、沉靜神情、紅上衣 V 領
YUI-01 底稿=AniList 官方角色圖;藍黑極長直髮、齊瀏海、圓大棕眼、燦爛笑容,
髮飾與配色採現行 Unital Ring 導航妖精造型(淡粉洋裝、藍花)
selftest 161 項全綠(新增第 ⑰ 節:特徵解析、換髮型/眼型/表情會畫出不同的圖、
polygon 兩邊一致、舊樣式不污染、SVG 無內嵌影像、Wiki 首頁無時間戳)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+88
-43
@@ -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 <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 faces|headshot|generate|show --session <id>
|
||||
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 <YYYY-MM-DD>]
|
||||
**依人格資料重新繪製**人物頭像;不會把來源圖放進圖示。
|
||||
沒帶 --palette → 徽章樣式(配色由編號/名字/emoji 雜湊)。
|
||||
show 看目前的樣式、配色、特徵與來源
|
||||
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
|
||||
沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。
|
||||
帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」
|
||||
(必須同時帶 --source-url 存證)。
|
||||
帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象
|
||||
並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。
|
||||
|
||||
編號與 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]
|
||||
sync status|init|push|pull|verify --session <id> [--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),
|
||||
|
||||
Reference in New Issue
Block a user