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:
@@ -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