Files
persona/scripts/persona-icon.mjs
jiantw83andClaude Opus 5 929ccb3c05 feat: 從找圖開始優化——高解析度官方設定稿 → 去背 → 合成為形象圖
1. 找圖(icon search)
   從 Fandom API 撈角色頁的所有圖片,依「解析度 + 是不是官方設定稿」排序。
   官方設定稿(Full Body / Character Design / Avatar)是最好的來源:
     * 773×1056 起跳,遠勝角色資料庫的 230px 縮圖
     * **多半本來就是透明底 PNG**,去背幾乎免費、邊緣完美
   新增 icon measure:回報解析度、臉佔比、背景是透明/單色/有場景、去背難度。

2. 去背(icon cutout)→ icon/portrait-cutout.png
   三條路徑自動選:
     source-alpha       原圖已是透明底(官方設定稿常見)→ 完美
     plain-background   純白/單色底,色距去背 + 最大連通區 + 補洞 → 很好
     grabcut            有場景時用臉的位置當前景種子 → 普通
   實測記錄:把臉從 2026 主視覺裁下來再 GrabCut,結衣的黑髮會被整片當成背景切掉;
   換成官方設定稿之後這問題直接消失——所以「找對圖」比「去背演算法」更關鍵。

3. 合成(icon generate --from-cutout)
   自動裁成頭肩構圖再疊到角色配色的漸層底上。官方設定稿常是正反兩面並排,
   不裁會變成兩個人,所以 compose 預設 --crop head(--zoom 可調鬆緊)。
   產出 icon.svg(內嵌同一張 PNG,自成一體不外連)、icon.png,
   以及 icon/portrait.svg 與 512/1024 兩個解析度。

   向量重繪(--features)保留為「找不到可用官方圖」時的退路。

已更新兩個真實人格(皆為 cutout 樣式,Wiki 同步已驗證):
   ASUNA-01  Asuna's SAO Avatar Full Body(773×1056,透明底)— 對應 2026
             《Unanswered//butterfly》重述的早期艾恩葛朗特
   YUI-01    Yui's ALO Pixie Form Full Body(773×1056,透明底)— 現行 Unital Ring
             章的導航妖精形態

順手修掉兩個 bug:
   * 合成路徑下 svg 為 null,回傳時 Buffer.byteLength(null) 直接崩潰。
   * verifyArea 解析 git status --porcelain 時,因為 git() 會 trim 輸出,
     開頭空白已消失(" M x" → "M x"),正規式對不上,檔名前面多一個 M。

selftest 175 項全綠(新增第 ⑲ 節:找圖評分、去背三路徑、compose 預設裁頭肩、
去背圖在 icon/ 會同步、SVG 不外連、沒有去背圖時 --from-cutout 會擋下)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 03:43:45 +00:00

1090 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// persona-icon.mjs — 由人格資料產生圖示(SVG + PNG),零外部依賴
//
// 為什麼是「幾何圖形 + 字母」而不是 emoji:
// 這台機器(以及大部分伺服器)沒有 rsvg/inkscape/imagemagick,也沒有 emoji 字型,
// 而本專案的規則是只用 Node 內建模組。把 emoji 畫進 PNG 需要字型柵格化,做不到。
// 所以圖案只用「我能在 SVG 與自寫柵格器裡畫出完全相同結果」的元素:
// 圓角矩形、線性漸層、圓點、以及 5×7 點陣字母。**兩種格式輸出的是同一張圖。**
// emoji 仍然參與雜湊,所以它會影響配色。
//
// 圖案(512×512 圓角方形徽章):
// 底:由人格編號雜湊出的雙色對角漸層
// 紋:5×5 左右對稱的圓點(identicon 式,每個人格都不一樣)
// 字:編號英文名的前兩個字母(ASUNA-01 → AS),5×7 點陣,自動選黑或白以確保對比
//
// 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import zlib from "node:zlib";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs";
export const ICON_SVG = "icon.svg";
export const ICON_PNG = "icon.png";
export const DEFAULT_SIZE = 512;
/** icon/ 資料夾裡要輸出的解析度(512 給頭像、1024 給高解析用途)。 */
export const RENDER_SIZES = [512, 1024];
export const iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG);
export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG);
// --------------------------------------------------------------------------- //
// 5×7 點陣字(SVG 與 PNG 共用同一份資料,兩邊才會長得一模一樣)
// --------------------------------------------------------------------------- //
const FONT = {
A: "01110,10001,10001,11111,10001,10001,10001",
B: "11110,10001,10001,11110,10001,10001,11110",
C: "01111,10000,10000,10000,10000,10000,01111",
D: "11110,10001,10001,10001,10001,10001,11110",
E: "11111,10000,10000,11110,10000,10000,11111",
F: "11111,10000,10000,11110,10000,10000,10000",
G: "01110,10001,10000,10111,10001,10001,01111",
H: "10001,10001,10001,11111,10001,10001,10001",
I: "11111,00100,00100,00100,00100,00100,11111",
J: "00111,00010,00010,00010,00010,10010,01100",
K: "10001,10010,10100,11000,10100,10010,10001",
L: "10000,10000,10000,10000,10000,10000,11111",
M: "10001,11011,10101,10101,10001,10001,10001",
N: "10001,11001,10101,10011,10001,10001,10001",
O: "01110,10001,10001,10001,10001,10001,01110",
P: "11110,10001,10001,11110,10000,10000,10000",
Q: "01110,10001,10001,10001,10101,10010,01101",
R: "11110,10001,10001,11110,10100,10010,10001",
S: "01111,10000,10000,01110,00001,00001,11110",
T: "11111,00100,00100,00100,00100,00100,00100",
U: "10001,10001,10001,10001,10001,10001,01110",
V: "10001,10001,10001,10001,10001,01010,00100",
W: "10001,10001,10001,10101,10101,11011,10001",
X: "10001,10001,01010,00100,01010,10001,10001",
Y: "10001,10001,01010,00100,00100,00100,00100",
Z: "11111,00001,00010,00100,01000,10000,11111",
0: "01110,10001,10011,10101,11001,10001,01110",
1: "00100,01100,00100,00100,00100,00100,01110",
2: "01110,10001,00001,00010,00100,01000,11111",
3: "11111,00010,00100,00010,00001,10001,01110",
4: "00010,00110,01010,10010,11111,00010,00010",
5: "11111,10000,11110,00001,00001,10001,01110",
6: "00110,01000,10000,11110,10001,10001,01110",
7: "11111,00001,00010,00100,01000,01000,01000",
8: "01110,10001,10001,01110,10001,10001,01110",
9: "01110,10001,10001,01111,00001,00010,01100",
};
const glyph = (ch) => (FONT[ch] || FONT.O).split(",").map((row) => row.split("").map(Number));
// --------------------------------------------------------------------------- //
// 顏色
// --------------------------------------------------------------------------- //
/** h 0360, s 01, l 01 → [r,g,b] 0255 */
export function hslToRgb(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = (((h % 360) + 360) % 360) / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
const [r1, g1, b1] =
hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x]
: hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
const m = l - c / 2;
return [r1 + m, g1 + m, b1 + m].map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255));
}
const hex = ([r, g, b]) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
/** 相對亮度(sRGB → 線性),用來決定字要黑還是白。 */
function luminance([r, g, b]) {
const lin = [r, g, b].map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
// --------------------------------------------------------------------------- //
// 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖
// --------------------------------------------------------------------------- //
const WHITE = [255, 255, 255];
const BLACK = [16, 18, 24];
/** WCAG 對比度(121)。 */
function contrast(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
/** 字要黑還是白:取「對兩端漸層色的最差對比」最好的那個。 */
function pickInk(c1, c2) {
const worst = (ink) => Math.min(contrast(ink, c1), contrast(ink, c2));
return worst(WHITE) >= worst(BLACK) ? WHITE : BLACK;
}
/**
* 照片取來的兩個顏色可能一深一淺(例:藍黑髮 + 淡粉洋裝),
* 那樣不論字用黑或白,都會有一端糊掉。這裡把較亮的一端往較暗的一端壓,
* 直到最差對比達標——顏色仍然是照片來的,只是收斂色階。
*/
function harmonize(c1, c2, target = 3.2) {
let a = c1;
let b = c2;
for (let step = 0; step < 6; step += 1) {
const ink = pickInk(a, b);
if (Math.min(contrast(ink, a), contrast(ink, b)) >= target) break;
if (luminance(a) > luminance(b)) a = mix(a, b, 0.18);
else b = mix(b, a, 0.18);
}
return [a, b];
}
// --------------------------------------------------------------------------- //
// 取自照片的配色
// --------------------------------------------------------------------------- //
//
// 「依照片產生圖示」在這裡的做法:**由 AI 上網找到該人格最新的官方視覺、看過那張圖、
// 萃取出五個代表色**,再交給這支程式繪製。不是把原圖裁進圖示裡——
// 1) 沒有任何影像解碼器可用(見檔頭),JPEG/WebP 根本讀不進來;
// 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
// 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。
export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light", "skin"];
export function parseHexColor(value) {
const m = String(value ?? "").trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i);
if (!m) return null;
const hexStr = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
return [0, 2, 4].map((i) => parseInt(hexStr.slice(i, i + 2), 16));
}
/** `hair=#d9a45b,eye=#9e5b3e,...` → { hair:[r,g,b], ... };缺 hair/accent 視為無效。 */
export function parsePalette(raw) {
if (!raw) return null;
const 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 rgb = parseHexColor(chunk.slice(idx + 1));
if (PALETTE_KEYS.includes(key) && rgb) out[key] = rgb;
}
if (!out.hair || !out.accent) return null;
out.eye ??= out.accent;
out.secondary ??= out.accent;
out.light ??= [246, 240, 236];
out.skin ??= mix(out.light, [242, 201, 168], 0.65); // 沒指定就用偏暖的膚色
return out;
}
export const paletteToString = (p) =>
PALETTE_KEYS.filter((k) => p?.[k]).map((k) => `${k}=${hex(p[k])}`).join(",");
export const STYLES = ["portrait", "badge"];
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;
const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`;
const h = crypto.createHash("sha256").update(seedText).digest();
// 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)——不論哪種配色都保留這個專屬紋路
const pattern = [];
for (let y = 0; y < 5; y += 1) {
const row = [];
for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1);
pattern.push([...row, row[1], row[0]]);
}
const letters = String(theCode).replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 2) || "P";
const pal = palette || (config.icon?.palette ? parsePalette(config.icon.palette) : null);
let c1;
let c2;
let ring;
let dot;
let ringAlpha;
let dotAlpha;
if (pal) {
// 照片配色:髮色 → 主服裝色的漸層,瞳色當外框,亮色當紋路
c1 = pal.hair;
c2 = pal.accent;
ring = pal.eye;
dot = pal.light;
ringAlpha = 0.55;
dotAlpha = 0.2;
} else {
// 雜湊配色:沒有參考照片時的預設
const hue = (h[0] * 360) / 256;
const hue2 = (hue + 40 + (h[1] % 80)) % 360;
const sat = 0.52 + (h[2] % 30) / 100; // 0.520.81
const light = 0.36 + (h[3] % 18) / 100; // 0.360.53
c1 = hslToRgb(hue, sat, light);
c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
ring = null;
dot = null;
ringAlpha = 0.18;
dotAlpha = 0.14;
}
[c1, c2] = harmonize(c1, c2);
const ink = pickInk(c1, c2);
return {
persona: slug,
code: theCode,
name: ident.Name || slug,
emoji: ident.Emoji || "",
letters,
c1,
c2,
ink,
ring: ring || ink,
dot: dot || ink,
ringAlpha,
dotAlpha,
// 有調色盤(=看過大頭照)就重繪人物形象;沒有的話只能畫徽章
// 只認得 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"),
};
}
// --------------------------------------------------------------------------- //
// 幾何:SVG 與柵格器共用同一組座標(單位為 0–1,最後乘上 size)
// --------------------------------------------------------------------------- //
const GEO = {
radius: 0.22, // 圓角半徑
patternInset: 0.12,
patternCell: 0.152,
dotRadius: 0.038,
glyphCell: 1 / 16,
};
/** 回傳圖案中所有圓點(單位座標)。 */
function dots(spec) {
const out = [];
for (let y = 0; y < 5; y += 1) {
for (let x = 0; x < 5; x += 1) {
if (!spec.pattern[y][x]) continue;
out.push({
cx: GEO.patternInset + GEO.patternCell * (x + 0.5),
cy: GEO.patternInset + GEO.patternCell * (y + 0.5),
r: GEO.dotRadius,
});
}
}
return out;
}
// --------------------------------------------------------------------------- //
// 圖形清單:SVG 與柵格器都從這份清單畫,兩邊不可能長得不一樣
// --------------------------------------------------------------------------- //
const shade = (c, t) => mix(c, [0, 0, 0], t);
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 與自寫柵格器畫得出一模一樣的結果。
* 顏色一律來自參考照片萃取的調色盤:髮色、瞳色、服裝色、膚色。
*/
function portraitShapes(spec) {
const p = spec.palette || {};
const hair = p.hair || spec.c1;
const eye = p.eye || spec.ring;
const cloth = p.accent || spec.c2;
const cloth2 = p.secondary || cloth;
const skin = p.skin || [244, 214, 187];
const hairDark = shade(hair, 0.32);
const skinShade = shade(skin, 0.12);
const mouth = shade(cloth, 0.25);
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));
}
// 側髮的反光,讓長髮有圓柱感
out.push(ellipse(0.221, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32));
out.push(ellipse(0.779, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32));
// 呆毛
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.234, 0.252, 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.5, 0.230, 0.126, 0.022, tint(hair, 0.40),
luminance(hair) < 0.12 ? 0.13 : 0.24));
for (const [sx, sy, sw] of [[0.386, 0.330, 0.013], [0.596, 0.330, 0.013]]) {
out.push(poly([[sx, sy - 0.075], [sx + sw, sy - 0.075], [sx + sw * 0.4, sy + 0.055]],
shade(hair, 0.16), 0.35));
}
// 鬢髮(貼著臉頰的兩束)
out.push(ellipse(0.250, 0.560, 0.032, 0.185, hair));
out.push(ellipse(0.750, 0.560, 0.032, 0.185, hair));
// ── 眉毛(表情會影響傾斜)
const browY = f.expression === "bright" ? 0.408 : 0.414;
// 眉毛要「看得到但不搶戲」:深髮角色往膚色提亮才不會和瀏海連成一片,
// 淺髮角色則要比頭髮更深,否則會變成兩條灰槓。
const brow = luminance(hair) < 0.12 ? mix(hairDark, skin, 0.16) : shade(hair, 0.48);
const browTilt = { gentle: 0.006, bright: 0.010, calm: 0, neutral: 0 }[f.expression] ?? 0;
out.push(poly([[0.378, browY + browTilt], [0.458, browY], [0.458, browY + 0.014],
[0.376, browY + browTilt + 0.014]], brow));
out.push(poly([[0.542, browY], [0.622, browY + browTilt], [0.622, browY + browTilt + 0.014],
[0.540, browY + 0.014]], brow));
// ── 眼睛
const eyeGeo = {
round: { rx: 0.070, ry: 0.084 },
almond: { rx: 0.074, ry: 0.073 },
sharp: { rx: 0.076, ry: 0.060 },
droopy: { rx: 0.072, ry: 0.078 },
}[f.eyes] || { rx: 0.063, ry: 0.064 };
const eyeY = f.eyes === "droopy" ? 0.558 : 0.552;
const lash = shade(hairDark, 0.35);
for (const [cx, hl, dir] of [[0.404, 0.385, -1], [0.596, 0.577, 1]]) {
// 眼白 → 虹膜(上深下淺的漸層感)→ 瞳孔 → 高光 ×2 → 眼線與睫毛
out.push(ellipse(cx, eyeY, eyeGeo.rx, eyeGeo.ry, [252, 252, 255]));
out.push(ellipse(cx, eyeY + 0.004, eyeGeo.rx * 0.80, eyeGeo.ry * 0.84, shade(eye, 0.30)));
out.push(ellipse(cx, eyeY + 0.012, eyeGeo.rx * 0.76, eyeGeo.ry * 0.72, eye));
out.push(ellipse(cx, eyeY + 0.024, eyeGeo.rx * 0.62, eyeGeo.ry * 0.42, tint(eye, 0.28)));
out.push(ellipse(cx, eyeY + 0.010, eyeGeo.rx * 0.32, eyeGeo.ry * 0.44, shade(eye, 0.78)));
out.push(ellipse(hl, eyeY - 0.019, 0.019, 0.016, [255, 255, 255]));
out.push(circle(cx + dir * eyeGeo.rx * 0.38, eyeY + eyeGeo.ry * 0.42, 0.010, [255, 255, 255], 0.85));
// 上眼線 + 外眼角睫毛
out.push(ellipse(cx, eyeY - eyeGeo.ry * 0.94, eyeGeo.rx * 0.98, eyeGeo.ry * 0.13, lash));
out.push(poly([
[cx + dir * eyeGeo.rx * 0.80, eyeY - eyeGeo.ry * 1.00],
[cx + dir * eyeGeo.rx * 1.22, eyeY - eyeGeo.ry * (f.eyes === "sharp" ? 1.24 : 1.08)],
[cx + dir * eyeGeo.rx * 1.02, eyeY - eyeGeo.ry * 0.66],
], lash));
if (f.eyes === "droopy") {
out.push(ellipse(cx + dir * eyeGeo.rx * 0.7, eyeY + eyeGeo.ry * 0.85, eyeGeo.rx * 0.5,
eyeGeo.ry * 0.16, lash, 0.75));
}
}
// ── 下巴/脖子的陰影,臉才立體
out.push(ellipse(0.5, 0.706, 0.104, 0.032, shade(skin, 0.14), 0.32));
// ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子)
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;
}
/** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */
function badgeShapes(spec) {
return [
...dots(spec).map((d) => circle(d.cx, d.cy, d.r, spec.dot, spec.dotAlpha)),
...glyphRects(spec).map((r) => rect(r.x, r.y, r.w, r.h, spec.ink)),
];
}
export function iconShapes(spec) {
return spec.style === "portrait" ? portraitShapes(spec) : badgeShapes(spec);
}
/** 回傳字母的所有方塊(單位座標)。 */
function glyphRects(spec) {
const cell = GEO.glyphCell;
const chars = [...spec.letters];
const width = chars.length * 5 * cell + (chars.length - 1) * cell;
const left = 0.5 - width / 2;
const top = 0.5 - (7 * cell) / 2;
const out = [];
chars.forEach((ch, i) => {
const g = glyph(ch);
const ox = left + i * 6 * cell;
for (let y = 0; y < 7; y += 1) {
for (let x = 0; x < 5; x += 1) {
if (g[y][x]) out.push({ x: ox + x * cell, y: top + y * cell, w: cell, h: cell });
}
}
});
return out;
}
// --------------------------------------------------------------------------- //
// SVG
// --------------------------------------------------------------------------- //
export function renderSvg(spec, size = DEFAULT_SIZE) {
const S = size;
const u = (v) => Math.round(v * S * 1000) / 1000;
const lines = [
`<svg xmlns="http://www.w3.org/2000/svg" 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>",
` <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">`,
` <stop offset="0" stop-color="${hex(spec.c1)}"/>`,
` <stop offset="1" stop-color="${hex(spec.c2)}"/>`,
" </linearGradient>",
` <clipPath id="badge">`,
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}"/>`,
" </clipPath>",
" </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) => {
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)}"`,
` fill="none" stroke="${hex(spec.ring)}" stroke-opacity="${spec.ringAlpha}" stroke-width="${u(0.012)}"/>`,
"</svg>",
"",
];
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// PNG:自己柵格化 + 自己編碼(zlib 是內建的,不需要任何影像函式庫)
// --------------------------------------------------------------------------- //
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
/** RGBA buffer → PNG8-bit RGBA、無交錯、filter 0)。 */
export function encodePng(rgba, width, height) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y += 1) {
raw[y * (stride + 1)] = 0; // filter type: None
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", ihdr),
pngChunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
/** 點是否在多邊形內(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;
return insideRounded((x - inset) / span, (y - inset) / span, (GEO.radius - inset) / span);
}
/** 圓角矩形的內外判定(單位座標)。 */
function insideRounded(x, y, r) {
if (x < 0 || y < 0 || x > 1 || y > 1) return false;
const cx = Math.min(Math.max(x, r), 1 - r);
const cy = Math.min(Math.max(y, r), 1 - r);
const dx = x - cx;
const dy = y - cy;
return dx * dx + dy * dy <= r * r;
}
/**
* 畫出與 SVG 完全相同的圖案。
* 用 3× 超取樣再做盒式縮減當作反鋸齒——沒有第三方繪圖庫,這是最省事又夠好的做法。
*/
export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
const SS = Math.max(1, Math.min(4, supersample));
const big = size * SS;
const acc = Buffer.alloc(big * big * 4);
// 先算每個圖形的外接矩形:不加這個,1024×3 超取樣要跑上億次點測試
const shapes = iconShapes(spec).map((sh) => {
if (sh.type === "rect") return { ...sh, x0: sh.x, x1: sh.x + sh.w, y0: sh.y, y1: sh.y + sh.h };
if (sh.type === "poly") {
const xs = sh.points.map((q) => q[0]);
const ys = sh.points.map((q) => q[1]);
return { ...sh, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
}
return { ...sh, x0: sh.cx - sh.rx, x1: sh.cx + sh.rx, y0: sh.cy - sh.ry, y1: sh.cy + sh.ry };
});
const [g1, g2, g3] = spec.ring;
for (let py = 0; py < big; py += 1) {
const v = (py + 0.5) / big;
for (let px = 0; px < big; px += 1) {
const u = (px + 0.5) / big;
const o = (py * big + px) * 4;
if (!insideRounded(u, v, GEO.radius)) continue; // 圓角外=透明
// 底:對角線性漸層(與 SVG 的 x1,y1=0,0 → x2,y2=1,1 相同)
const t = Math.min(1, Math.max(0, (u + v) / 2));
let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t;
let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t;
let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t;
// 圖形清單:由後往前疊,與 SVG 的繪製順序相同
for (const sh of shapes) {
if (u < sh.x0 || u > sh.x1 || v < sh.y0 || v > sh.y1) continue;
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;
hit = dx * dx + dy * dy <= 1;
}
if (!hit) continue;
const a = sh.alpha;
r += (sh.fill[0] - r) * a;
g += (sh.fill[1] - g) * a;
b += (sh.fill[2] - b) * a;
}
// 邊框:與 SVG 的 stroke 完全相同的環帶
//SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.0060.018
if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
r += (g1 - r) * spec.ringAlpha;
g += (g2 - g) * spec.ringAlpha;
b += (g3 - b) * spec.ringAlpha;
}
acc[o] = Math.round(r);
acc[o + 1] = Math.round(g);
acc[o + 2] = Math.round(b);
acc[o + 3] = 255;
}
}
if (SS === 1) return encodePng(acc, size, size);
// 盒式縮減(連 alpha 一起平均,圓角邊緣才會平滑)
const out = Buffer.alloc(size * size * 4);
const n = SS * SS;
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let sy = 0; sy < SS; sy += 1) {
const row = (y * SS + sy) * big;
for (let sx = 0; sx < SS; sx += 1) {
const o = (row + x * SS + sx) * 4;
const av = acc[o + 3];
r += acc[o] * av;
g += acc[o + 1] * av;
b += acc[o + 2] * av;
a += av;
}
}
const o = (y * size + x) * 4;
out[o] = a ? Math.round(r / a) : 0;
out[o + 1] = a ? Math.round(g / a) : 0;
out[o + 2] = a ? Math.round(b / a) : 0;
out[o + 3] = Math.round(a / n);
}
}
return encodePng(out, size, size);
}
// --------------------------------------------------------------------------- //
// 產生並寫檔
// --------------------------------------------------------------------------- //
/**
* 產生圖示。有 `photo` 且工具齊全 → 用真實照片裁臉;否則畫向量人物形象(一樣有臉)。
* 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。
*/
export function generateIcon(slug, {
size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null,
cutout = null, pick = null, face = null, zoom = 2.15,
} = {}) {
const spec = iconSpec(slug, { code, palette, style, features });
// 有去背圖就用它合成(官方原圖,解析度高);沒有就回到向量重繪
let compose = null;
if (cutout && fs.existsSync(cutout)) {
const bg = spec.palette
? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}`
: `${hex(spec.c1)},${hex(spec.c2)}`;
compose = composeIcon(cutout, iconPngPath(slug), { size, bg, pick, face, zoom });
if (compose.ok) spec.style = "cutout";
}
const svg = compose?.ok ? null : renderSvg(spec, size);
const png = compose?.ok ? fs.readFileSync(iconPngPath(slug)) : renderPng(spec, size);
const dir = path.join(pl.personaDir(slug), "icon");
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
if (!compose?.ok) fs.writeFileSync(iconPngPath(slug), png);
// SVG:向量樣式直接輸出;去背合成則包一層自成一體的 SVG(base64 內嵌,不外連)
const finalSvg = svg || wrapPngSvg(spec, png, size);
pl.writeText(iconSvgPath(slug), finalSvg);
// icon/ 資料夾:原稿 + 多個解析度,Gitea 上點得開也拿得走
const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(finalSvg, "utf8") }];
pl.writeText(path.join(dir, "portrait.svg"), finalSvg);
for (const px of RENDER_SIZES) {
let buf;
if (compose?.ok) {
const target = path.join(dir, `portrait-${px}.png`);
const res = composeIcon(cutout, target, {
size: px,
bg: spec.palette
? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}`
: `${hex(spec.c1)},${hex(spec.c2)}`,
pick, face, zoom,
});
buf = res.ok ? fs.readFileSync(target) : png;
if (!res.ok) fs.writeFileSync(target, buf);
} else {
buf = px === size ? png : renderPng(spec, px);
fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf);
}
renders.push({ name: `portrait-${px}.png`, bytes: buf.length });
}
// 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」
const config = pl.loadConfig(slug);
config.icon = {
...(config.icon || {}),
letters: spec.letters,
style: spec.style,
size,
generated_at: pl.nowIso(),
palette: spec.palette ? paletteToString(spec.palette) : null,
features: featuresToString(spec.features),
source: source || spec.source || null,
cutout: compose?.ok ? { file: CUTOUT_PNG, compose: compose.person || null } : null,
};
pl.writeJson(pl.configPath(slug), config);
return {
spec,
svg: iconSvgPath(slug),
png: iconPngPath(slug),
size,
renders,
bytes: { svg: Buffer.byteLength(finalSvg, "utf8"), png: png.length },
};
}
/** 去背合成的圖示:SVG 用 base64 內嵌同一張 PNG,兩種格式看到的是同一張圖,且不外連。 */
export function wrapPngSvg(spec, pngBuffer, size = DEFAULT_SIZE) {
const S = size;
return [
`<svg xmlns="http://www.w3.org/2000/svg" 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>`,
` <image x="0" y="0" width="${S}" height="${S}"`,
` xlink:href="data:image/png;base64,${pngBuffer.toString("base64")}"`,
` href="data:image/png;base64,${pngBuffer.toString("base64")}"/>`,
"</svg>",
"",
].join("\n");
}
export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug));
// --------------------------------------------------------------------------- //
// 照片裁臉(選用的加值路徑)
// --------------------------------------------------------------------------- //
//
// 本體零依賴,但如果環境裡有 Pillow(+可選的 OpenCV 動漫臉偵測),
// 圖示就能直接用真實照片裁出的臉。缺工具時 `toolReport()` 會給出安裝指令。
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const PORTRAIT_PY = path.join(HERE, "portrait.py");
export const CASCADE_URL =
"https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml";
export function cascadePath() {
if (process.env.PERSONA_ANIME_CASCADE) return process.env.PERSONA_ANIME_CASCADE;
return path.join(os.homedir(), ".cache", "jsc-persona", "lbpcascade_animeface.xml");
}
/** 找一個能用的 python(優先吃 PERSONA_PYTHON,其次 venv,最後系統 python3)。 */
export function pythonPath() {
const candidates = [
process.env.PERSONA_PYTHON,
path.join(os.homedir(), ".cache", "jsc-persona", "venv", "bin", "python3"),
"python3",
].filter(Boolean);
for (const bin of candidates) {
const probe = spawnSync(bin, ["-c", "import sys;print(sys.version_info[0])"], { encoding: "utf8" });
if (probe.status === 0) return bin;
}
return null;
}
const pyHas = (bin, mod) =>
spawnSync(bin, ["-c", `import ${mod}`], { encoding: "utf8" }).status === 0;
/** 目前有哪些工具、缺什麼、怎麼補。 */
export function toolReport() {
const python = pythonPath();
const pillow = Boolean(python) && pyHas(python, "PIL");
const cv2 = Boolean(python) && pyHas(python, "cv2");
const cascade = fs.existsSync(cascadePath());
const venv = path.join(os.homedir(), ".cache", "jsc-persona", "venv");
const missing = [];
if (!python) {
missing.push({
what: "python3",
why: "解碼照片(JPEGWebPPNG)與裁切都靠它",
how: "sudo apt-get install -y python3 python3-venv",
});
}
if (!pillow) {
missing.push({
what: "Pillow",
why: "沒有它就無法把照片解碼成像素,也就無法裁臉",
how: `python3 -m venv ${venv} && ${venv}/bin/pip install -q pillow`,
});
}
if (!cv2) {
missing.push({
what: "OpenCVopencv-python-headless",
why: "自動找出臉的位置;沒有它就只能用「上方中央」的經驗法則裁切",
how: `${venv}/bin/pip install -q opencv-python-headless`,
});
}
if (!cascade) {
missing.push({
what: "動漫臉偵測模型 lbpcascade_animeface.xml",
why: "OpenCV 內建的模型認不出動漫臉,要這個才準",
how:
`mkdir -p ${path.dirname(cascadePath())} && curl -sL -o ${cascadePath()} ${CASCADE_URL}`,
});
}
return {
python,
pillow,
cv2,
cascade,
ready: Boolean(python && pillow),
faceDetection: Boolean(python && pillow && cv2),
animeFaceDetection: Boolean(python && pillow && cv2 && cascade),
missing,
venv,
};
}
/** 缺工具時要印給使用者看的提示(一行 what/why,一行指令)。 */
export function installHintLines(report) {
if (!report.missing.length) return [];
const lines = [" ⚠ 少了這些工具,暫時無法用真實照片裁臉(先用向量人物形象代替):"];
for (const m of report.missing) {
lines.push(` • ${m.what}${m.why}`);
lines.push(` ${m.how}`);
}
lines.push(" 裝好之後重跑 `icon generate --photo <圖片> --force` 就會換成照片裁臉版。");
return lines;
}
/** 取得參考照片:URL 就抓下來,本機路徑就直接用。 */
export async function fetchPhoto(source, destDir) {
if (/^https?:\/\//i.test(source)) {
const res = await fetch(source, { headers: { "User-Agent": "jsc-persona/icon" } });
if (!res.ok) throw new Error(`下載參考照片失敗(HTTP ${res.status}):${source}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(destDir, { recursive: true });
const ext = (source.split("?")[0].match(/\.(png|jpe?g|webp|gif)$/i)?.[1] || "img").toLowerCase();
const file = path.join(destDir, `reference.${ext}`);
fs.writeFileSync(file, buf);
return file;
}
const abs = path.resolve(source);
if (!fs.existsSync(abs)) throw new Error(`找不到參考照片:${abs}`);
return abs;
}
export const CUTOUT_PNG = "icon/portrait-cutout.png";
export const cutoutPath = (slug) => path.join(pl.personaDir(slug), "icon", "portrait-cutout.png");
/** 呼叫 portrait.py 的共用包裝。 */
function runPortrait(args) {
const report = toolReport();
if (!report.ready) return { ok: false, report };
const full = [PORTRAIT_PY, ...args];
if (report.cascade && !args.includes("--cascade")) full.push("--cascade", cascadePath());
const proc = spawnSync(report.python, full, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
if (proc.status !== 0) {
return { ok: false, report, reason: String(proc.stderr || proc.stdout).trim().slice(0, 300) };
}
try {
return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report };
} catch {
return { ok: false, report, reason: "portrait.py 的輸出不是 JSON" };
}
}
/** 列出參考圖裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */
export const listFaces = (imagePath) => runPortrait(["--mode", "faces", "--input", imagePath]);
/** 量測一張候選圖:解析度、臉多大、背景好不好去。 */
export const measureImage = (imagePath) => runPortrait(["--mode", "measure", "--input", imagePath]);
/** 去背,輸出透明 PNG。優先沿用原圖既有的 alpha(官方人設圖多半就是透明底)。 */
export const cutoutImage = (imagePath, outPath, { pick = null, face = null } = {}) =>
runPortrait([
"--mode", "cutout", "--input", imagePath, "--output", outPath,
...(face ? ["--face", face] : pick ? ["--pick", pick] : []),
]);
/** 把去背圖裁成頭肩、疊到圓角漸層底上,產出最終圖示。 */
export const composeIcon = (cutout, outPath, { size = DEFAULT_SIZE, bg = null, pick = null,
face = null, zoom = 2.15, crop = "head" } = {}) =>
runPortrait([
"--mode", "compose", "--input", cutout, "--output", outPath,
"--size", String(size), "--radius", String(GEO.radius), "--zoom", String(zoom), "--crop", crop,
...(bg ? ["--bg", bg] : []),
...(face ? ["--face", face] : pick ? ["--pick", pick] : []),
]);
/**
* 從 Fandom wiki 找出這個角色的高解析度官方圖。
* 官方人設圖(Full Body / Character Design)通常是透明底或白底,去背幾乎免費,
* 而且解析度遠高於角色資料庫的縮圖——這是「找圖」這一步最該優先的來源。
*/
export async function wikiImageCandidates(wiki, page, { limit = 60 } = {}) {
const url = `https://${wiki}.fandom.com/api.php?action=query&generator=images` +
`&titles=${encodeURIComponent(page)}&gimlimit=${limit}&prop=imageinfo&iiprop=url|size&format=json`;
const res = await fetch(url, { headers: { "User-Agent": "jsc-persona/icon" } });
if (!res.ok) throw new Error(`Fandom API 失敗(HTTP ${res.status}`);
const json = await res.json();
const pages = json?.query?.pages || {};
const rows = Object.values(pages)
.map((entry) => ({ title: entry.title, info: entry.imageinfo?.[0] }))
.filter((r) => r.info?.width)
.map((r) => {
const px = r.info.width * r.info.height;
// 「官方設定稿」的關鍵字:這類圖解析度高、背景乾淨,最適合當形象圖
const settei = /full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title);
return {
title: r.title,
url: String(r.info.url).split("/revision")[0],
width: r.info.width,
height: r.info.height,
pixels: px,
official_sheet: settei,
score: Math.round(Math.log2(px) * 10) / 10 + (settei ? 8 : 0),
};
})
.sort((a, b) => b.score - a.score);
return rows;
}
/**
* 從參考圖裁出**大頭照**。這張是給 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),
"--radius", String(GEO.radius)];
if (report.cascade) args.push("--cascade", cascadePath());
if (face) args.push("--face", face);
else if (pick) args.push("--pick", pick);
const proc = spawnSync(report.python, args, { encoding: "utf8" });
if (proc.status !== 0) {
return { ok: false, report, reason: (proc.stderr || proc.stdout || "").trim().slice(0, 300) };
}
let info = null;
try {
info = JSON.parse(String(proc.stdout).trim().split("\n").pop());
} catch {
info = null;
}
if (!info?.ok) return { ok: false, report, reason: info?.reason || "portrait.py 沒有回報成功" };
return { ok: true, report, info };
}