feat: 圖示改為人物形象圖(有臉),並讓 Wiki 保存 SVG + PNG
1. 圖示三種樣式,優先看得到臉 photo 有參考圖且工具齊全 → 從官方視覺自動偵測並**裁出臉**,圓角+瞳色外框 portrait 有參考圖但缺工具 → **有五官的向量人物**(髮型/瞳色/服裝色都取自那張圖) badge 完全沒有參考圖 → 舊的雙色漸層 + 編號字母 portrait 與 badge 共用同一份「圖形清單」,SVG 與自寫柵格器從同一份資料畫, 兩邊不可能長得不一樣;photo 的 SVG 以 base64 內嵌裁好的 PNG,一樣不外連。 2. 缺工具會「提示安裝」,不靜默降級 新增 scripts/portrait.py(選用):Pillow 解碼裁切、OpenCV + lbpcascade_animeface 偵測動漫臉。toolReport() 會列出缺什麼、為什麼要、怎麼裝(venv 免 sudo), CLI 在退回向量形象時把這些印出來。 注意:**OpenCV 5 拿掉了 CascadeClassifier,必須裝 4.x**。 plugin 本體仍然零依賴——沒有這些工具照樣產得出有臉的形象圖。 新增 `icon faces`:列出參考圖裡偵測到的所有臉。多角色的主視覺一定要先看再挑 (--pick <索引>|largest|leftmost|rightmost 或 --face x,y,w,h),挑錯就是別人的臉。 3. Wiki 保存形象圖 icon.svg 與 icon.png 都在 Wiki 區,另外自動產生一頁 Icon:同時展示兩種格式, 並列出樣式、調色盤、來源網址、造型說明與裁切框。PNG 同時設為存取庫頭像。 已套用到兩個真實人格(皆為真實裁臉): ASUNA-01 《Unanswered//butterfly》(2026) 官方主視覺,臉 #1(anime-cascade) YUI-01 AniList 官方角色圖(frontal-cascade) selftest 148 項全綠(新增第 ⑯ 節:形象圖真的畫了五官、SVG/PNG 出自同一份清單、 工具偵測與安裝提示、Wiki 形象圖專頁與來源)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+319
-33
@@ -15,9 +15,12 @@
|
||||
// 同一個人格永遠得到同一張圖(純函數 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";
|
||||
@@ -148,7 +151,7 @@ function harmonize(c1, c2, target = 3.2) {
|
||||
// 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
|
||||
// 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。
|
||||
|
||||
export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light"];
|
||||
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);
|
||||
@@ -172,13 +175,16 @@ export function parsePalette(raw) {
|
||||
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 function iconSpec(slug, { code = null, identity = null, palette = null } = {}) {
|
||||
export const STYLES = ["portrait", "badge"];
|
||||
|
||||
export function iconSpec(slug, { code = null, identity = null, palette = null, style = null } = {}) {
|
||||
const ident = identity || pl.identityFields(slug);
|
||||
const config = pl.loadConfig(slug);
|
||||
const theCode = code || config.code || slug;
|
||||
@@ -238,6 +244,8 @@ export function iconSpec(slug, { code = null, identity = null, palette = null }
|
||||
dot: dot || ink,
|
||||
ringAlpha,
|
||||
dotAlpha,
|
||||
// 有調色盤(=看過參考照片)就畫人物形象;沒有的話只能畫徽章
|
||||
style: style || config.icon?.style || (pal ? "portrait" : "badge"),
|
||||
palette: pal,
|
||||
source: pal ? config.icon?.source || null : null,
|
||||
pattern,
|
||||
@@ -273,6 +281,82 @@ function dots(spec) {
|
||||
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);
|
||||
|
||||
/**
|
||||
* 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 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);
|
||||
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
/** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */
|
||||
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;
|
||||
@@ -309,13 +393,18 @@ export function renderSvg(spec, size = DEFAULT_SIZE) {
|
||||
` <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 fill="${hex(spec.dot)}" opacity="${spec.dotAlpha}">`,
|
||||
...dots(spec).map((d) => ` <circle cx="${u(d.cx)}" cy="${u(d.cy)}" r="${u(d.r)}"/>`),
|
||||
" </g>",
|
||||
` <g fill="${hex(spec.ink)}">`,
|
||||
...glyphRects(spec).map((r) => ` <rect x="${u(r.x)}" y="${u(r.y)}" width="${u(r.w)}" height="${u(r.h)}"/>`),
|
||||
` <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}"` : ""}/>`),
|
||||
" </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)}"`,
|
||||
@@ -403,10 +492,7 @@ 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);
|
||||
const circles = dots(spec);
|
||||
const rects = glyphRects(spec);
|
||||
const [i1, i2, i3] = spec.ink;
|
||||
const [d1, d2, d3] = spec.dot;
|
||||
const shapes = iconShapes(spec);
|
||||
const [g1, g2, g3] = spec.ring;
|
||||
|
||||
for (let py = 0; py < big; py += 1) {
|
||||
@@ -420,16 +506,21 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
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;
|
||||
// 紋:圓點
|
||||
for (const d of circles) {
|
||||
const dx = u - d.cx;
|
||||
const dy = v - d.cy;
|
||||
if (dx * dx + dy * dy <= d.r * d.r) {
|
||||
r += (d1 - r) * spec.dotAlpha;
|
||||
g += (d2 - g) * spec.dotAlpha;
|
||||
b += (d3 - b) * spec.dotAlpha;
|
||||
break;
|
||||
// 圖形清單:由後往前疊,與 SVG 的繪製順序相同
|
||||
for (const sh of shapes) {
|
||||
let hit;
|
||||
if (sh.type === "rect") {
|
||||
hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h;
|
||||
} 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)
|
||||
@@ -438,15 +529,6 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
g += (g2 - g) * spec.ringAlpha;
|
||||
b += (g3 - b) * spec.ringAlpha;
|
||||
}
|
||||
// 字:實心方塊
|
||||
for (const rc of rects) {
|
||||
if (u >= rc.x && u < rc.x + rc.w && v >= rc.y && v < rc.y + rc.h) {
|
||||
r = i1;
|
||||
g = i2;
|
||||
b = i3;
|
||||
break;
|
||||
}
|
||||
}
|
||||
acc[o] = Math.round(r);
|
||||
acc[o + 1] = Math.round(g);
|
||||
acc[o + 2] = Math.round(b);
|
||||
@@ -489,10 +571,33 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
// 產生並寫檔
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null } = {}) {
|
||||
const spec = iconSpec(slug, { code, palette });
|
||||
const svg = renderSvg(spec, size);
|
||||
const png = renderPng(spec, size);
|
||||
/**
|
||||
* 產生圖示。有 `photo` 且工具齊全 → 用真實照片裁臉;否則畫向量人物形象(一樣有臉)。
|
||||
* 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。
|
||||
*/
|
||||
export function generateIcon(slug, {
|
||||
size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null,
|
||||
photo = null, pick = null, face = 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);
|
||||
}
|
||||
pl.writeText(iconSvgPath(slug), svg);
|
||||
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
||||
fs.writeFileSync(iconPngPath(slug), png);
|
||||
@@ -501,10 +606,15 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette =
|
||||
config.icon = {
|
||||
...(config.icon || {}),
|
||||
letters: spec.letters,
|
||||
style: spec.style,
|
||||
size,
|
||||
generated_at: pl.nowIso(),
|
||||
palette: spec.palette ? paletteToString(spec.palette) : null,
|
||||
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 {
|
||||
@@ -512,8 +622,184 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette =
|
||||
svg: iconSvgPath(slug),
|
||||
png: iconPngPath(slug),
|
||||
size,
|
||||
photo: photoResult,
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 用照片裁臉產生 icon.png(成功回傳結果,工具不足回 ok:false 與缺什麼)。 */
|
||||
export function renderPhotoPng(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 };
|
||||
}
|
||||
|
||||
/** 把裁好的 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user