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:
@@ -462,7 +462,7 @@ export function wikiHome(slug, code) {
|
||||
.map((k) => `| ${k} | ${ident[k]} |`),
|
||||
`| 長期記憶 | ${longTerm.length} 則 |`,
|
||||
`| 關係人 | ${relations.nodes.length} 位 |`,
|
||||
`| 最後同步 | ${pl.nowIso()} |`,
|
||||
`| 圖示更新 | ${pl.loadConfig(slug).icon?.generated_at || "—"} |`,
|
||||
"",
|
||||
"## 頁面",
|
||||
"",
|
||||
@@ -610,6 +610,56 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
|
||||
return { ok: true, area, code: theCode, changed: incoming, written };
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證某一區「本機 = 遠端」。
|
||||
* push 回報成功不等於遠端真的有東西(網路中斷、權限、非快轉都可能),
|
||||
* 形象圖這種一定要出現在 Wiki 的檔案更需要一個明確的檢查點。
|
||||
*/
|
||||
export async function verifyArea(slug, area, { code = null, owner = null } = {}) {
|
||||
const problem = giteaProblem();
|
||||
if (problem) return { ok: false, skipped: true, area, reason: problem };
|
||||
const theCode = code || personaCode(slug);
|
||||
if (!theCode) return { ok: false, skipped: true, area, reason: "沒有人格編號" };
|
||||
const theOwner = owner || (await resolveOwner());
|
||||
const { host } = giteaEnv();
|
||||
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
|
||||
if (!git(["fetch", "--quiet", "origin"], dir).ok) {
|
||||
return { ok: false, area, reason: "fetch 失敗(連不上遠端)" };
|
||||
}
|
||||
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
|
||||
const local = git(["rev-parse", "HEAD"], dir).stdout;
|
||||
const remote = git(["rev-parse", `origin/${branch}`], dir).stdout;
|
||||
// 再把工作副本疊上去,看看還有沒有沒推的差異
|
||||
if (area === "wiki") {
|
||||
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||||
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
|
||||
}
|
||||
const files = stageArea(slug, area, dir);
|
||||
const dirty = git(["status", "--porcelain"], dir)
|
||||
.stdout.split("\n").map((l) => l.replace(/^.{2}\s+/, "").trim()).filter(Boolean);
|
||||
git(["checkout", "--", "."], dir);
|
||||
git(["clean", "-qfd"], dir);
|
||||
return {
|
||||
ok: Boolean(local) && local === remote && dirty.length === 0,
|
||||
area,
|
||||
code: theCode,
|
||||
files: files.length,
|
||||
local,
|
||||
remote,
|
||||
pending: dirty,
|
||||
};
|
||||
}
|
||||
|
||||
/** 形象圖(SVG + PNG)是不是真的在 Wiki 上、而且和本機一致。 */
|
||||
export async function verifyIconInWiki(slug, opts = {}) {
|
||||
const res = await verifyArea(slug, "wiki", opts);
|
||||
if (res.skipped || !res.code) return res;
|
||||
const missing = ["icon.svg", "icon.png"].filter((f) => res.pending.includes(f));
|
||||
const dir = syncDir(slug, "wiki");
|
||||
const present = ["icon.svg", "icon.png"].filter((f) => fs.existsSync(path.join(dir, f)));
|
||||
return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 };
|
||||
}
|
||||
|
||||
/** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */
|
||||
export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) {
|
||||
const problem = giteaProblem();
|
||||
|
||||
+229
-96
@@ -184,7 +184,9 @@ export const paletteToString = (p) =>
|
||||
|
||||
export const STYLES = ["portrait", "badge"];
|
||||
|
||||
export function iconSpec(slug, { code = null, identity = null, palette = null, style = null } = {}) {
|
||||
export function iconSpec(slug, {
|
||||
code = null, identity = null, palette = null, style = null, features = null,
|
||||
} = {}) {
|
||||
const ident = identity || pl.identityFields(slug);
|
||||
const config = pl.loadConfig(slug);
|
||||
const theCode = code || config.code || slug;
|
||||
@@ -244,9 +246,11 @@ export function iconSpec(slug, { code = null, identity = null, palette = null, s
|
||||
dot: dot || ink,
|
||||
ringAlpha,
|
||||
dotAlpha,
|
||||
// 有調色盤(=看過參考照片)就畫人物形象;沒有的話只能畫徽章
|
||||
style: style || config.icon?.style || (pal ? "portrait" : "badge"),
|
||||
// 有調色盤(=看過大頭照)就重繪人物形象;沒有的話只能畫徽章
|
||||
// 只認得 STYLES 裡的樣式:config 可能殘留舊版本寫進去的值(例如已移除的 photo)
|
||||
style: [style, config.icon?.style].find((v) => STYLES.includes(v)) || (pal ? "portrait" : "badge"),
|
||||
palette: pal,
|
||||
features: features || parseFeatures(config.icon?.features || null),
|
||||
source: pal ? config.icon?.source || null : null,
|
||||
pattern,
|
||||
seed: h.subarray(0, 8).toString("hex"),
|
||||
@@ -291,6 +295,53 @@ const tint = (c, t) => mix(c, [255, 255, 255], t);
|
||||
const rect = (x, y, w, h, fill, alpha = 1) => ({ type: "rect", x, y, w, h, fill, alpha });
|
||||
const ellipse = (cx, cy, rx, ry, fill, alpha = 1) => ({ type: "ellipse", cx, cy, rx, ry, fill, alpha });
|
||||
const circle = (cx, cy, r, fill, alpha = 1) => ellipse(cx, cy, r, r, fill, alpha);
|
||||
/** 多邊形:畫髮尾、緞帶、V 領、呆毛這些橢圓做不出來的形狀。 */
|
||||
const poly = (points, fill, alpha = 1) => ({ type: "poly", points, fill, alpha });
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 五官與造型的特徵:這些決定「重新繪製」出來的人長什麼樣
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const FEATURE_SPEC = {
|
||||
hairstyle: ["straight", "twintails", "ponytail", "bob", "braid"],
|
||||
length: ["short", "medium", "long", "very-long"],
|
||||
fringe: ["blunt", "parted", "swept", "curtain"],
|
||||
eyes: ["round", "almond", "sharp", "droopy"],
|
||||
expression: ["gentle", "bright", "calm", "neutral"],
|
||||
accessory: ["none", "ribbon", "clip", "flower", "hairband"],
|
||||
side: ["left", "right"],
|
||||
collar: ["round", "v", "high", "sailor"],
|
||||
ahoge: ["no", "yes"],
|
||||
};
|
||||
|
||||
export const DEFAULT_FEATURES = {
|
||||
hairstyle: "straight",
|
||||
length: "long",
|
||||
fringe: "parted",
|
||||
eyes: "almond",
|
||||
expression: "gentle",
|
||||
accessory: "none",
|
||||
side: "right",
|
||||
collar: "round",
|
||||
ahoge: "no",
|
||||
};
|
||||
|
||||
/** `hairstyle=twintails,fringe=blunt,...` → 正規化過的特徵物件(不認得的值一律回退預設)。 */
|
||||
export function parseFeatures(raw) {
|
||||
const out = { ...DEFAULT_FEATURES };
|
||||
if (!raw) return out;
|
||||
for (const chunk of String(raw).split(",")) {
|
||||
const idx = chunk.indexOf("=");
|
||||
if (idx < 0) continue;
|
||||
const key = chunk.slice(0, idx).trim().toLowerCase();
|
||||
const value = chunk.slice(idx + 1).trim().toLowerCase();
|
||||
if (FEATURE_SPEC[key] && FEATURE_SPEC[key].includes(value)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const featuresToString = (f) =>
|
||||
Object.keys(DEFAULT_FEATURES).map((k) => `${k}=${f[k]}`).join(",");
|
||||
|
||||
/**
|
||||
* 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 SVG 與自寫柵格器畫得出一模一樣的結果。
|
||||
@@ -307,42 +358,148 @@ function portraitShapes(spec) {
|
||||
const skinShade = shade(skin, 0.12);
|
||||
const mouth = shade(cloth, 0.25);
|
||||
|
||||
return [
|
||||
// 肩膀與衣服(露出一截,看得出服裝主色)
|
||||
ellipse(0.5, 1.18, 0.47, 0.36, cloth),
|
||||
ellipse(0.5, 1.14, 0.175, 0.22, cloth2), // 領口/胸前配色
|
||||
// 脖子
|
||||
rect(0.442, 0.64, 0.116, 0.14, skinShade),
|
||||
// 後髮(長髮往下鋪到兩側)
|
||||
ellipse(0.5, 0.50, 0.315, 0.395, hairDark),
|
||||
ellipse(0.235, 0.78, 0.080, 0.27, hairDark),
|
||||
ellipse(0.765, 0.78, 0.080, 0.27, hairDark),
|
||||
// 臉
|
||||
ellipse(0.5, 0.505, 0.212, 0.248, skin),
|
||||
// 瀏海:中央一大片 + 兩側鬢髮,蓋住額頭
|
||||
ellipse(0.5, 0.335, 0.238, 0.158, hair),
|
||||
ellipse(0.312, 0.435, 0.072, 0.155, hair),
|
||||
ellipse(0.688, 0.435, 0.072, 0.155, hair),
|
||||
// 眉毛
|
||||
rect(0.362, 0.452, 0.095, 0.015, hairDark),
|
||||
rect(0.543, 0.452, 0.095, 0.015, hairDark),
|
||||
// 眼睛:眼白 → 虹膜 → 瞳孔 → 高光
|
||||
ellipse(0.415, 0.538, 0.060, 0.073, [252, 252, 255]),
|
||||
ellipse(0.585, 0.538, 0.060, 0.073, [252, 252, 255]),
|
||||
ellipse(0.415, 0.543, 0.046, 0.059, eye),
|
||||
ellipse(0.585, 0.543, 0.046, 0.059, eye),
|
||||
ellipse(0.415, 0.549, 0.021, 0.030, shade(eye, 0.7)),
|
||||
ellipse(0.585, 0.549, 0.021, 0.030, shade(eye, 0.7)),
|
||||
circle(0.399, 0.520, 0.015, [255, 255, 255]),
|
||||
circle(0.569, 0.520, 0.015, [255, 255, 255]),
|
||||
// 腮紅與嘴
|
||||
ellipse(0.330, 0.598, 0.048, 0.025, cloth, 0.28),
|
||||
ellipse(0.670, 0.598, 0.048, 0.025, cloth, 0.28),
|
||||
ellipse(0.5, 0.626, 0.026, 0.015, mouth),
|
||||
// 側邊髮飾(用第二配色,讓不同角色更好分辨)
|
||||
ellipse(0.762, 0.345, 0.056, 0.038, cloth2),
|
||||
ellipse(0.800, 0.375, 0.030, 0.052, cloth2),
|
||||
];
|
||||
const f = spec.features || DEFAULT_FEATURES;
|
||||
const out = [];
|
||||
const mirror = f.side === "left" ? -1 : 1;
|
||||
const sideX = (base) => 0.5 + (base - 0.5) * mirror;
|
||||
|
||||
// ── 頭髮長度:側髮鋪到哪裡
|
||||
const bottom = { short: 0.66, medium: 0.78, long: 0.90, "very-long": 1.02 }[f.length] || 0.90;
|
||||
const sideCy = (0.46 + bottom) / 2;
|
||||
const sideRy = (bottom - 0.46) / 2;
|
||||
|
||||
// ── 肩膀與衣服
|
||||
out.push(ellipse(0.5, 1.18, 0.47, 0.36, cloth));
|
||||
if (f.collar === "round") out.push(ellipse(0.5, 1.14, 0.175, 0.22, cloth2));
|
||||
else if (f.collar === "high") out.push(rect(0.30, 0.90, 0.40, 0.10, cloth2));
|
||||
else if (f.collar === "sailor") {
|
||||
out.push(poly([[0.30, 0.92], [0.50, 1.06], [0.70, 0.92], [0.70, 1.10], [0.30, 1.10]], cloth2));
|
||||
} else if (f.collar === "v") {
|
||||
out.push(poly([[0.428, 0.900], [0.572, 0.900], [0.500, 1.030]], skinShade));
|
||||
}
|
||||
// 脖子
|
||||
out.push(rect(0.442, 0.64, 0.116, 0.15, skinShade));
|
||||
|
||||
// ── 後髮
|
||||
out.push(ellipse(0.5, 0.50, 0.315, 0.395, hairDark));
|
||||
if (f.hairstyle === "twintails") {
|
||||
out.push(ellipse(sideX(0.185), sideCy + 0.03, 0.085, sideRy, hairDark));
|
||||
out.push(ellipse(sideX(0.815), sideCy + 0.03, 0.085, sideRy, hairDark));
|
||||
out.push(ellipse(sideX(0.255), 0.415, 0.040, 0.030, cloth2)); // 髮束
|
||||
out.push(ellipse(sideX(0.745), 0.415, 0.040, 0.030, cloth2));
|
||||
} else if (f.hairstyle === "ponytail") {
|
||||
out.push(ellipse(sideX(0.815), sideCy, 0.075, sideRy * 1.05, hairDark));
|
||||
out.push(ellipse(sideX(0.735), 0.40, 0.042, 0.032, cloth2));
|
||||
out.push(ellipse(0.245, 0.62, 0.062, 0.14, hairDark));
|
||||
} else if (f.hairstyle === "bob") {
|
||||
out.push(ellipse(0.215, 0.60, 0.090, 0.175, hairDark));
|
||||
out.push(ellipse(0.785, 0.60, 0.090, 0.175, hairDark));
|
||||
} else if (f.hairstyle === "braid") {
|
||||
out.push(ellipse(0.235, sideCy, 0.072, sideRy, hairDark));
|
||||
out.push(ellipse(0.765, sideCy, 0.072, sideRy, hairDark));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
out.push(ellipse(sideX(0.775), 0.60 + i * 0.11, 0.055, 0.048, shade(hair, 0.18)));
|
||||
}
|
||||
} else {
|
||||
out.push(ellipse(0.235, sideCy, 0.078, sideRy, hairDark));
|
||||
out.push(ellipse(0.765, sideCy, 0.078, sideRy, hairDark));
|
||||
}
|
||||
// 呆毛
|
||||
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));
|
||||
|
||||
// ── 瀏海
|
||||
if (f.fringe === "blunt") {
|
||||
out.push(ellipse(0.5, 0.300, 0.248, 0.150, hair));
|
||||
out.push(rect(0.252, 0.300, 0.496, 0.088, hair));
|
||||
} else if (f.fringe === "swept") {
|
||||
out.push(ellipse(sideX(0.560), 0.320, 0.235, 0.160, hair));
|
||||
out.push(poly([[sideX(0.30), 0.30], [sideX(0.78), 0.30], [sideX(0.30), 0.47]], hair));
|
||||
} else if (f.fringe === "curtain") {
|
||||
out.push(ellipse(0.5, 0.290, 0.235, 0.140, hair));
|
||||
out.push(ellipse(0.360, 0.400, 0.090, 0.155, hair));
|
||||
out.push(ellipse(0.640, 0.400, 0.090, 0.155, hair));
|
||||
} else {
|
||||
out.push(ellipse(0.5, 0.320, 0.244, 0.145, 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.268, 0.545, 0.038, 0.150, hair));
|
||||
out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair));
|
||||
|
||||
// ── 眉毛(表情會影響傾斜)
|
||||
const browY = f.expression === "bright" ? 0.430 : 0.436;
|
||||
const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開
|
||||
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],
|
||||
[0.372, browY + browTilt + 0.021]], brow));
|
||||
out.push(poly([[0.538, browY], [0.628, browY + browTilt], [0.628, browY + browTilt + 0.021],
|
||||
[0.538, browY + 0.021]], brow));
|
||||
|
||||
// ── 眼睛
|
||||
const eyeGeo = {
|
||||
round: { rx: 0.058, ry: 0.070 },
|
||||
almond: { rx: 0.063, ry: 0.060 },
|
||||
sharp: { rx: 0.066, ry: 0.049 },
|
||||
droopy: { rx: 0.060, ry: 0.065 },
|
||||
}[f.eyes] || { rx: 0.063, ry: 0.064 };
|
||||
const eyeY = f.eyes === "droopy" ? 0.558 : 0.552;
|
||||
for (const [cx, hl] of [[0.415, 0.399], [0.585, 0.569]]) {
|
||||
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.011, eyeGeo.rx * 0.34, eyeGeo.ry * 0.42, shade(eye, 0.7)));
|
||||
out.push(circle(hl, eyeY - 0.018, 0.015, [255, 255, 255]));
|
||||
// 上眼線:眼型的關鍵
|
||||
out.push(rect(cx - eyeGeo.rx, eyeY - eyeGeo.ry, eyeGeo.rx * 2, 0.014, hairDark));
|
||||
if (f.eyes === "sharp") {
|
||||
out.push(poly([[cx + eyeGeo.rx * 0.6, eyeY - eyeGeo.ry],
|
||||
[cx + eyeGeo.rx * 1.45, eyeY - eyeGeo.ry - 0.022],
|
||||
[cx + eyeGeo.rx * 1.05, eyeY - eyeGeo.ry * 0.35]], hairDark));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子)
|
||||
out.push(ellipse(0.5, 0.638, 0.010, 0.007, shade(skin, 0.22)));
|
||||
|
||||
// ── 腮紅與嘴(表情)
|
||||
if (f.expression !== "calm") {
|
||||
out.push(ellipse(0.318, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20));
|
||||
out.push(ellipse(0.682, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20));
|
||||
}
|
||||
if (f.expression === "bright") {
|
||||
out.push(ellipse(0.5, 0.690, 0.046, 0.022, mouth));
|
||||
out.push(rect(0.454, 0.676, 0.092, 0.009, shade(mouth, 0.35)));
|
||||
out.push(ellipse(0.5, 0.683, 0.034, 0.008, tint(mouth, 0.6)));
|
||||
} else if (f.expression === "calm" || f.expression === "neutral") {
|
||||
out.push(rect(0.480, 0.686, 0.040, 0.009, mouth));
|
||||
} else {
|
||||
out.push(ellipse(0.5, 0.688, 0.024, 0.013, mouth));
|
||||
}
|
||||
|
||||
// ── 髮飾
|
||||
const ax = sideX(0.775);
|
||||
if (f.accessory === "ribbon") {
|
||||
out.push(poly([[ax, 0.330], [ax + 0.085 * mirror, 0.288], [ax + 0.085 * mirror, 0.376]], cloth2));
|
||||
out.push(poly([[ax, 0.330], [ax - 0.075 * mirror, 0.292], [ax - 0.075 * mirror, 0.372]], cloth2));
|
||||
out.push(circle(ax, 0.332, 0.026, shade(cloth2, 0.18)));
|
||||
} else if (f.accessory === "clip") {
|
||||
out.push(rect(ax - 0.055, 0.318, 0.110, 0.026, cloth2));
|
||||
out.push(rect(ax - 0.055, 0.352, 0.075, 0.022, tint(cloth2, 0.25)));
|
||||
} else if (f.accessory === "flower") {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const a = (i / 5) * Math.PI * 2;
|
||||
out.push(circle(ax + Math.cos(a) * 0.042, 0.335 + Math.sin(a) * 0.042, 0.030, cloth2));
|
||||
}
|
||||
out.push(circle(ax, 0.335, 0.024, tint(cloth2, 0.45)));
|
||||
} else if (f.accessory === "hairband") {
|
||||
out.push(ellipse(0.5, 0.268, 0.246, 0.052, cloth2));
|
||||
out.push(ellipse(0.5, 0.300, 0.238, 0.052, hair));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */
|
||||
@@ -399,12 +556,16 @@ export function renderSvg(spec, size = DEFAULT_SIZE) {
|
||||
" </defs>",
|
||||
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
|
||||
` <g clip-path="url(#badge)">`,
|
||||
...iconShapes(spec).map((sh) =>
|
||||
sh.type === "rect"
|
||||
? ` <rect x="${u(sh.x)}" y="${u(sh.y)}" width="${u(sh.w)}" height="${u(sh.h)}"` +
|
||||
` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`
|
||||
: ` <ellipse cx="${u(sh.cx)}" cy="${u(sh.cy)}" rx="${u(sh.rx)}" ry="${u(sh.ry)}"` +
|
||||
` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`),
|
||||
...iconShapes(spec).map((sh) => {
|
||||
const paint = ` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`;
|
||||
if (sh.type === "rect") {
|
||||
return ` <rect x="${u(sh.x)}" y="${u(sh.y)}" width="${u(sh.w)}" height="${u(sh.h)}"${paint}`;
|
||||
}
|
||||
if (sh.type === "poly") {
|
||||
return ` <polygon points="${sh.points.map(([x, y]) => `${u(x)},${u(y)}`).join(" ")}"${paint}`;
|
||||
}
|
||||
return ` <ellipse cx="${u(sh.cx)}" cy="${u(sh.cy)}" rx="${u(sh.rx)}" ry="${u(sh.ry)}"${paint}`;
|
||||
}),
|
||||
" </g>",
|
||||
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
|
||||
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
|
||||
@@ -468,6 +629,17 @@ export function encodePng(rgba, width, height) {
|
||||
]);
|
||||
}
|
||||
|
||||
/** 點是否在多邊形內(crossing number;與 SVG 的 nonzero 對簡單多邊形結果一致)。 */
|
||||
function pointInPoly(x, y, pts) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) {
|
||||
const [xi, yi] = pts[i];
|
||||
const [xj, yj] = pts[j];
|
||||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** 內縮 inset 之後的圓角矩形內外判定(用來畫出與 SVG 描邊相同的環帶)。 */
|
||||
function insideInset(x, y, inset) {
|
||||
const span = 1 - 2 * inset;
|
||||
@@ -511,6 +683,8 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
let hit;
|
||||
if (sh.type === "rect") {
|
||||
hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h;
|
||||
} else if (sh.type === "poly") {
|
||||
hit = pointInPoly(u, v, sh.points);
|
||||
} else {
|
||||
const dx = (u - sh.cx) / sh.rx;
|
||||
const dy = (v - sh.cy) / sh.ry;
|
||||
@@ -576,28 +750,11 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
* 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。
|
||||
*/
|
||||
export function generateIcon(slug, {
|
||||
size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null,
|
||||
photo = null, pick = null, face = null,
|
||||
size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null,
|
||||
} = {}) {
|
||||
const spec = iconSpec(slug, { code, palette, style });
|
||||
let svg;
|
||||
let png;
|
||||
let photoResult = null;
|
||||
if (photo) {
|
||||
const tmp = path.join(pl.personaDir(slug), ".sync", "portrait.tmp.png");
|
||||
fs.mkdirSync(path.dirname(tmp), { recursive: true });
|
||||
photoResult = renderPhotoPng(photo, tmp, size, { pick, face });
|
||||
if (photoResult.ok) {
|
||||
png = fs.readFileSync(tmp);
|
||||
svg = photoSvg(spec, png, size);
|
||||
spec.style = "photo";
|
||||
}
|
||||
fs.rmSync(tmp, { force: true });
|
||||
}
|
||||
if (!png) {
|
||||
svg = renderSvg(spec, size);
|
||||
png = renderPng(spec, size);
|
||||
}
|
||||
const spec = iconSpec(slug, { code, palette, style, features });
|
||||
const svg = renderSvg(spec, size);
|
||||
const png = renderPng(spec, size);
|
||||
pl.writeText(iconSvgPath(slug), svg);
|
||||
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
||||
fs.writeFileSync(iconPngPath(slug), png);
|
||||
@@ -610,11 +767,8 @@ export function generateIcon(slug, {
|
||||
size,
|
||||
generated_at: pl.nowIso(),
|
||||
palette: spec.palette ? paletteToString(spec.palette) : null,
|
||||
features: featuresToString(spec.features),
|
||||
source: source || spec.source || null,
|
||||
crop: photoResult?.ok
|
||||
? { method: photoResult.info.method, face: photoResult.info.face,
|
||||
box: photoResult.info.box, faces_found: photoResult.info.faces_found }
|
||||
: null,
|
||||
};
|
||||
pl.writeJson(pl.configPath(slug), config);
|
||||
return {
|
||||
@@ -622,7 +776,6 @@ export function generateIcon(slug, {
|
||||
svg: iconSvgPath(slug),
|
||||
png: iconPngPath(slug),
|
||||
size,
|
||||
photo: photoResult,
|
||||
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
|
||||
};
|
||||
}
|
||||
@@ -757,8 +910,11 @@ export function listFaces(imagePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 用照片裁臉產生 icon.png(成功回傳結果,工具不足回 ok:false 與缺什麼)。 */
|
||||
export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) {
|
||||
/**
|
||||
* 從參考圖裁出**大頭照**。這張是給 AI 看的**參考**,不是圖示本身——
|
||||
* 圖示一律由 renderSvg/renderPng 依人格資料重新繪製。
|
||||
*/
|
||||
export function cropHeadshot(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) {
|
||||
const report = toolReport();
|
||||
if (!report.ready) return { ok: false, report };
|
||||
const args = [PORTRAIT_PY, "--input", imagePath, "--output", outPath, "--size", String(size),
|
||||
@@ -780,26 +936,3 @@ export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick =
|
||||
return { ok: true, report, info };
|
||||
}
|
||||
|
||||
/** 把裁好的 PNG 包成自成一體的 SVG(含圓角與瞳色外框)。 */
|
||||
export function photoSvg(spec, pngBuffer, size = DEFAULT_SIZE) {
|
||||
const S = size;
|
||||
const u = (v) => Math.round(v * S * 1000) / 1000;
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`,
|
||||
` width="${S}" height="${S}" viewBox="0 0 ${S} ${S}" role="img"`,
|
||||
` aria-label="人格 ${spec.code}${spec.name ? `(${spec.name})` : ""}的形象圖">`,
|
||||
` <title>${spec.code}${spec.name ? ` ${spec.name}` : ""}</title>`,
|
||||
" <defs>",
|
||||
` <clipPath id="badge"><rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}"/></clipPath>`,
|
||||
" </defs>",
|
||||
` <g clip-path="url(#badge)">`,
|
||||
` <image x="0" y="0" width="${S}" height="${S}" preserveAspectRatio="xMidYMid slice"`,
|
||||
` href="data:image/png;base64,${pngBuffer.toString("base64")}"/>`,
|
||||
" </g>",
|
||||
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
|
||||
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
|
||||
` fill="none" stroke="${hex(spec.ring)}" stroke-opacity="${spec.ringAlpha}" stroke-width="${u(0.012)}"/>`,
|
||||
"</svg>",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
+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),
|
||||
|
||||
@@ -615,6 +615,69 @@ check("Wiki 有專頁保存形象圖,並寫明來源", (() => {
|
||||
check("Wiki 的保留檔不會被同步流程刪掉",
|
||||
["Home.md", "Icon.md"].every((f) => gt.wikiHome && typeof gt.wikiIconPage === "function"));
|
||||
|
||||
console.log("⑰ 依人格資料重繪(不直接使用網路圖)與 Wiki 同步驗證");
|
||||
check("特徵解析:只吃認得的值,其餘回退預設", (() => {
|
||||
const f = ic.parseFeatures("hairstyle=twintails,eyes=round,accessory=flower,bogus=x,expression=???");
|
||||
return f.hairstyle === "twintails" && f.eyes === "round" && f.accessory === "flower" &&
|
||||
f.expression === ic.DEFAULT_FEATURES.expression && !("bogus" in f);
|
||||
})(), JSON.stringify(ic.parseFeatures("hairstyle=twintails,bogus=x")));
|
||||
const basePal = ic.parsePalette("hair=#d9a45b,eye=#9e5b3e,accent=#c0392b");
|
||||
const draw = (feat) => ic.renderPng(ic.iconSpec("GAMMA-01",
|
||||
{ palette: basePal, style: "portrait", features: ic.parseFeatures(feat) }), 32);
|
||||
check("換髮型會畫出不同的圖",
|
||||
Buffer.compare(draw("hairstyle=straight"), draw("hairstyle=twintails")) !== 0);
|
||||
check("換眼型會畫出不同的圖",
|
||||
Buffer.compare(draw("eyes=round"), draw("eyes=sharp")) !== 0);
|
||||
check("換表情會畫出不同的圖",
|
||||
Buffer.compare(draw("expression=calm"), draw("expression=bright")) !== 0);
|
||||
check("加髮飾/呆毛會多出圖形", (() => {
|
||||
const plain = ic.iconShapes(ic.iconSpec("GAMMA-01",
|
||||
{ palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=none,ahoge=no") }));
|
||||
const fancy = ic.iconShapes(ic.iconSpec("GAMMA-01",
|
||||
{ palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=flower,ahoge=yes") }));
|
||||
return fancy.length > plain.length + 3;
|
||||
})());
|
||||
check("多邊形在 SVG 與柵格器都畫得出來(呆毛用的是 polygon)", (() => {
|
||||
const spec = ic.iconSpec("GAMMA-01",
|
||||
{ palette: basePal, style: "portrait", features: ic.parseFeatures("ahoge=yes") });
|
||||
const shapes = ic.iconShapes(spec);
|
||||
const polys = shapes.filter((sh) => sh.type === "poly").length;
|
||||
const svg = ic.renderSvg(spec, 64);
|
||||
return polys > 0 && (svg.match(/<polygon /g) || []).length === polys;
|
||||
})());
|
||||
check("config 裡殘留的舊樣式不會讓形象圖退回徽章", (() => {
|
||||
const cfg = pl.loadConfig("GAMMA-01");
|
||||
cfg.icon = { ...(cfg.icon || {}), style: "photo" }; // 舊版本寫進去、現已移除的樣式
|
||||
pl.writeJson(pl.configPath("GAMMA-01"), cfg);
|
||||
return ic.iconSpec("GAMMA-01").style === "portrait";
|
||||
})(), ic.iconSpec("GAMMA-01").style);
|
||||
check("`icon headshot` 需要 --photo",
|
||||
cli(["icon", "headshot", "--session", S_CODE], { expectOk: false }).status !== 0);
|
||||
check("重繪不會把來源圖塞進圖示(SVG 沒有內嵌影像)", (() => {
|
||||
const svg = fs.readFileSync(ic.iconSvgPath("GAMMA-01"), "utf8");
|
||||
// xmlns 本來就有 http,所以只看「有沒有內嵌影像」
|
||||
return !svg.includes("<image") && !svg.includes("base64") && !/href=/.test(svg);
|
||||
})());
|
||||
check("特徵有寫進 config(重畫才會一致)", (() => {
|
||||
cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64",
|
||||
"--features", "hairstyle=twintails,accessory=ribbon"]);
|
||||
const feat = pl.loadConfig("GAMMA-01").icon?.features || "";
|
||||
return feat.includes("hairstyle=twintails") && feat.includes("accessory=ribbon");
|
||||
})(), pl.loadConfig("GAMMA-01").icon?.features);
|
||||
check("沒設定 Gitea 時 sync verify 是「略過」不是崩潰", (() => {
|
||||
const res = cli(["sync", "verify", "--session", S_CODE], { expectOk: false });
|
||||
return res.stderr.includes("尚未設定") || res.stdout.includes("略過");
|
||||
})());
|
||||
check("Wiki 形象圖頁同時列出 SVG 與 PNG", (() => {
|
||||
const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01");
|
||||
return page.includes("icon.svg") && page.includes("icon.png") && page.includes("SVG") && page.includes("PNG");
|
||||
})());
|
||||
check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", (() => {
|
||||
const a = gt.wikiHome("GAMMA-01", "GAMMA-01");
|
||||
const b = gt.wikiHome("GAMMA-01", "GAMMA-01");
|
||||
return a === b;
|
||||
})());
|
||||
|
||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user