1. Wiki 形象圖「沒有同步」的真正原因
內容其實一直是一致的(md5 相同),但**頁面上看不到圖**:
Icon.md/Home.md 用了 HTML `<img src="icon.svg">`,而 Gitea **只改寫
Markdown 圖片語法**(`` → `/wiki/raw/icon.png`)。HTML 的 src
被瀏覽器解析成 `/wiki/icon.svg` → 303 轉址 → 破圖。
用公開的臨時庫實測四種寫法後確認,全部改成 Markdown 語法。
同時發現攤平規則過頭了:原本所有子目錄檔案都被壓成 `icon-portrait.svg`。
實測 `/wiki/raw/<資料夾>/<圖>` 是取得到的,所以**只有 .md 需要攤平**
(頁面必須在根層),圖片與附件保留資料夾結構。
2. icon/ 資料夾 + 高解析度
每次 `icon generate` 都會輸出並同步:
icon.svg / icon.png 512,Wiki 頁面與存取庫頭像
icon/portrait.svg 向量原稿
icon/portrait-512.png 高解析度
icon/portrait-1024.png 高解析度
柵格器加上每個圖形的 bounding box 裁剪,1024×1024(3× 超取樣)約 1.8 秒;
沒有裁剪的話要跑上億次點測試。
3. 更貼近原圖、更可愛
眼睛放大約 15%,虹膜改成四層(暗環→本色→下緣提亮→瞳孔)+雙高光,
眼線壓扁成細弧、外眼角加睫毛;頭髮加高光弧、髮絲與側髮反光;
臉型再圓一點、加下巴陰影與鼻子。
眉色改成隨髮色明暗調整:深髮往膚色提亮、淺髮比頭髮更深——否則不是和瀏海
連成黑帶,就是變成兩條灰槓。
selftest 168 項全綠(新增第 ⑱ 節:icon/ 多解析度、PNG 實際尺寸、只攤平 .md、
Wiki 頁面不得用 HTML img、bounding box 裁剪效能)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
987 lines
40 KiB
JavaScript
987 lines
40 KiB
JavaScript
// 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 0–360, s 0–1, l 0–1 → [r,g,b] 0–255 */
|
||
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 對比度(1–21)。 */
|
||
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.52–0.81
|
||
const light = 0.36 + (h[3] % 18) / 100; // 0.36–0.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 → PNG(8-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.006~0.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,
|
||
} = {}) {
|
||
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);
|
||
// icon/ 資料夾:向量原稿 + 多個解析度,Gitea 上點得開也拿得走
|
||
const dir = path.join(pl.personaDir(slug), "icon");
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(svg, "utf8") }];
|
||
pl.writeText(path.join(dir, "portrait.svg"), svg);
|
||
for (const px of RENDER_SIZES) {
|
||
const 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,
|
||
};
|
||
pl.writeJson(pl.configPath(slug), config);
|
||
return {
|
||
spec,
|
||
svg: iconSvgPath(slug),
|
||
png: iconPngPath(slug),
|
||
size,
|
||
renders,
|
||
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
|
||
};
|
||
}
|
||
|
||
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: "解碼照片(JPEG/WebP/PNG)與裁切都靠它",
|
||
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: "OpenCV(opencv-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 function listFaces(imagePath) {
|
||
const report = toolReport();
|
||
if (!report.ready) return { ok: false, report };
|
||
const args = [PORTRAIT_PY, "--input", imagePath, "--list"];
|
||
if (report.cascade) args.push("--cascade", cascadePath());
|
||
const proc = spawnSync(report.python, args, { encoding: "utf8" });
|
||
if (proc.status !== 0) return { ok: false, report, reason: String(proc.stderr).slice(0, 300) };
|
||
try {
|
||
return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report };
|
||
} catch {
|
||
return { ok: false, report, reason: "portrait.py 的輸出不是 JSON" };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 從參考圖裁出**大頭照**。這張是給 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 };
|
||
}
|
||
|