feat: 圖示配色取自人格「最新一次登場」的官方視覺
新增 skill persona-icon:上網查出該人格最新的官方視覺 → 下載 →
**用 Read 親眼看過** → 取髮色/瞳色/服裝色 → 用那組配色繪製圖示,
來源網址與造型描述一併寫進 state/config.json 備查。
CLI:
icon generate --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."
--source-url <網址> [--source-note <說明> --source-date <日期>]
* --palette 必須配 --source-url(配色要有出處,CLI 強制)。
* 配色與來源存進 config.icon,之後不帶 --palette 重畫會沿用,不會變回雜湊色。
* icon show 會列出調色盤與來源網址。
繪製:漸層=髮色→服裝主色、外框=瞳色、點陣紋=亮色、中央仍是編號前兩字。
一深一淺的極端配色(藍黑髮+淡粉洋裝)會把較亮端往較暗端壓到對比 ≥ 3.2,
確保字讀得到——顏色仍是照片來的,只是收斂色階。
為什麼不是把照片本身當圖示:
1) 環境裡沒有任何影像解碼器(rsvg/imagemagick/Pillow/ffmpeg 都沒有),
JPEG/WebP 讀不進來,無法轉成 PNG;
2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
取配色是有依據又不搬運原圖的做法,這個取捨寫進了 README 與 skill。
已套用到兩個真實人格:
ASUNA-01 取自《Unanswered//butterfly》(2026) 官方主視覺(金栗髮/紅褐瞳/紅衣粉裙)
YUI-01 取自 AniList 官方角色圖(藍黑髮/暖棕瞳)+ Unital Ring 導航妖精造型
selftest 136 項全綠(新增第 ⑮ 節:調色盤解析、缺來源會被擋、格式錯誤會被擋、
來源寫入 config、重畫沿用配色、極端配色的對比保證)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+140
-24
@@ -103,30 +103,127 @@ function luminance([r, g, b]) {
|
||||
// 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export function iconSpec(slug, { code = null, identity = null } = {}) {
|
||||
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"];
|
||||
|
||||
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];
|
||||
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 } = {}) {
|
||||
const ident = identity || pl.identityFields(slug);
|
||||
const theCode = code || pl.loadConfig(slug).code || 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();
|
||||
|
||||
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
|
||||
const c1 = hslToRgb(hue, sat, light);
|
||||
const c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
|
||||
|
||||
// 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)
|
||||
// 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 onDark = luminance(c1) < 0.32 || luminance(c2) < 0.32;
|
||||
const ink = onDark ? [255, 255, 255] : [16, 18, 24];
|
||||
|
||||
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,
|
||||
@@ -137,6 +234,12 @@ export function iconSpec(slug, { code = null, identity = null } = {}) {
|
||||
c1,
|
||||
c2,
|
||||
ink,
|
||||
ring: ring || ink,
|
||||
dot: dot || ink,
|
||||
ringAlpha,
|
||||
dotAlpha,
|
||||
palette: pal,
|
||||
source: pal ? config.icon?.source || null : null,
|
||||
pattern,
|
||||
seed: h.subarray(0, 8).toString("hex"),
|
||||
};
|
||||
@@ -208,7 +311,7 @@ export function renderSvg(spec, size = DEFAULT_SIZE) {
|
||||
" </linearGradient>",
|
||||
" </defs>",
|
||||
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
|
||||
` <g fill="${hex(spec.ink)}" opacity="0.14">`,
|
||||
` <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)}">`,
|
||||
@@ -216,7 +319,7 @@ export function renderSvg(spec, size = DEFAULT_SIZE) {
|
||||
" </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.ink)}" stroke-opacity="0.18" stroke-width="${u(0.012)}"/>`,
|
||||
` fill="none" stroke="${hex(spec.ring)}" stroke-opacity="${spec.ringAlpha}" stroke-width="${u(0.012)}"/>`,
|
||||
"</svg>",
|
||||
"",
|
||||
];
|
||||
@@ -303,6 +406,8 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
const circles = dots(spec);
|
||||
const rects = glyphRects(spec);
|
||||
const [i1, i2, i3] = spec.ink;
|
||||
const [d1, d2, d3] = spec.dot;
|
||||
const [g1, g2, g3] = spec.ring;
|
||||
|
||||
for (let py = 0; py < big; py += 1) {
|
||||
const v = (py + 0.5) / big;
|
||||
@@ -315,23 +420,23 @@ 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;
|
||||
// 紋:圓點(14% 不透明度)
|
||||
// 紋:圓點
|
||||
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 += (i1 - r) * 0.14;
|
||||
g += (i2 - g) * 0.14;
|
||||
b += (i3 - b) * 0.14;
|
||||
r += (d1 - r) * spec.dotAlpha;
|
||||
g += (d2 - g) * spec.dotAlpha;
|
||||
b += (d3 - b) * spec.dotAlpha;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 邊框:與 SVG 的 stroke 完全相同的環帶
|
||||
//(SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018)
|
||||
if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
|
||||
r += (i1 - r) * 0.18;
|
||||
g += (i2 - g) * 0.18;
|
||||
b += (i3 - b) * 0.18;
|
||||
r += (g1 - r) * spec.ringAlpha;
|
||||
g += (g2 - g) * spec.ringAlpha;
|
||||
b += (g3 - b) * spec.ringAlpha;
|
||||
}
|
||||
// 字:實心方塊
|
||||
for (const rc of rects) {
|
||||
@@ -384,13 +489,24 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
// 產生並寫檔
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export function generateIcon(slug, { size = DEFAULT_SIZE, code = null } = {}) {
|
||||
const spec = iconSpec(slug, { code });
|
||||
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);
|
||||
pl.writeText(iconSvgPath(slug), svg);
|
||||
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
||||
fs.writeFileSync(iconPngPath(slug), png);
|
||||
// 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」
|
||||
const config = pl.loadConfig(slug);
|
||||
config.icon = {
|
||||
...(config.icon || {}),
|
||||
letters: spec.letters,
|
||||
size,
|
||||
generated_at: pl.nowIso(),
|
||||
palette: spec.palette ? paletteToString(spec.palette) : null,
|
||||
source: source || spec.source || null,
|
||||
};
|
||||
pl.writeJson(pl.configPath(slug), config);
|
||||
return {
|
||||
spec,
|
||||
svg: iconSvgPath(slug),
|
||||
|
||||
+38
-6
@@ -1127,9 +1127,13 @@ commands.icon = async ({ flags, positional }) => {
|
||||
if (action === "show") {
|
||||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
const spec = ic.iconSpec(slug);
|
||||
const src = spec.source || {};
|
||||
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
|
||||
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`)"}`,
|
||||
` 字母 ${spec.letters}|配色 ${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`,
|
||||
` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` +
|
||||
`:${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`,
|
||||
...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []),
|
||||
...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []),
|
||||
` ${ic.iconSvgPath(slug)}`,
|
||||
` ${ic.iconPngPath(slug)}`,
|
||||
]);
|
||||
@@ -1138,16 +1142,41 @@ commands.icon = async ({ flags, positional }) => {
|
||||
if (action !== "generate") die(`未知 action:${action}(可用 generate/show)`);
|
||||
requireOwner(slug, session);
|
||||
if (ic.hasIcon(slug) && !flags.force) {
|
||||
die(`人格 \`${slug}\` 已經有圖示了。改過身分要重畫請加 --force。`);
|
||||
die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
|
||||
}
|
||||
const size = num(flags.size, ic.DEFAULT_SIZE);
|
||||
if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 16–2048。");
|
||||
const res = ic.generateIcon(slug, { size });
|
||||
let palette = null;
|
||||
if (flags.palette) {
|
||||
palette = ic.parsePalette(str(flags.palette));
|
||||
if (!palette) {
|
||||
die(
|
||||
"`--palette` 格式錯誤。要 `hair=#rrggbb,eye=#rrggbb,accent=#rrggbb,secondary=#rrggbb,light=#rrggbb`," +
|
||||
"其中 hair 與 accent 必填(顏色請取自你實際看過的參考照片)。",
|
||||
);
|
||||
}
|
||||
if (!str(flags["source-url"])) {
|
||||
die("用 `--palette` 就必須帶 `--source-url`:配色是從哪張圖取的要留得下來(可查證)。");
|
||||
}
|
||||
}
|
||||
const source = str(flags["source-url"])
|
||||
? {
|
||||
url: str(flags["source-url"]),
|
||||
note: str(flags["source-note"]) || null,
|
||||
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
|
||||
}
|
||||
: null;
|
||||
const res = ic.generateIcon(slug, { size, palette, source });
|
||||
const lines = [
|
||||
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
|
||||
` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
|
||||
` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`,
|
||||
` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`,
|
||||
res.spec.palette
|
||||
? ` 字母 ${res.spec.letters}|配色取自參考照片:${ic.paletteToString(res.spec.palette)}`
|
||||
: ` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`,
|
||||
...(source?.url || res.spec.source?.url
|
||||
? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
|
||||
: []),
|
||||
];
|
||||
// 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像
|
||||
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
|
||||
@@ -1312,8 +1341,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
|
||||
圖示(建立人格並補齊資料後跑):
|
||||
icon generate|show --session <id> [--size 512 --force --no-gitea]
|
||||
由編號/名字/emoji 決定配色與字母,產出 icon.svg + icon.png(同一人格永遠同一張圖),
|
||||
並設為 Gitea 存取庫頭像、同步到 Wiki 區。
|
||||
[--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."]
|
||||
[--source-url <參考照片網址> --source-note <說明> --source-date <YYYY-MM-DD>]
|
||||
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
|
||||
沒帶 --palette → 配色由編號/名字/emoji 雜湊而來。
|
||||
帶了 --palette → 用「你實際看過的參考照片」萃取的顏色(必須同時帶 --source-url 存證)。
|
||||
|
||||
編號與 Gitea(存取庫名稱 = 人格編號):
|
||||
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
|
||||
|
||||
@@ -519,6 +519,55 @@ check("guest 只能看不能重畫圖示",
|
||||
guard({ session_id: S_HOST, agent_id: "guest-9", agent_type: "jsc-persona:persona-guest", tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs icon generate --persona beta --session ${S_HOST} --as-guest` } }) === "deny");
|
||||
|
||||
console.log("⑮ 依參考照片配色的圖示");
|
||||
const PAL = "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3";
|
||||
const SRC = "https://example.invalid/key-visual.png";
|
||||
check("調色盤解析:hair 與 accent 必填、支援 #abc 縮寫", (() => {
|
||||
const ok = ic.parsePalette(PAL);
|
||||
const short = ic.parsePalette("hair=#abc,accent=#123456");
|
||||
return ok?.hair?.join() === "217,164,91" && ok.eye.join() === "158,91,62" &&
|
||||
short?.hair?.join() === "170,187,204" &&
|
||||
ic.parsePalette("eye=#ffffff") === null && ic.parsePalette("garbage") === null;
|
||||
})());
|
||||
check("`--palette` 沒帶 `--source-url` 會被擋(配色要有出處)",
|
||||
cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", PAL], { expectOk: false }).status !== 0);
|
||||
check("格式錯誤的 `--palette` 會被擋",
|
||||
cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", "hair=紅色",
|
||||
"--source-url", SRC], { expectOk: false }).status !== 0);
|
||||
cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64",
|
||||
"--palette", PAL, "--source-url", SRC, "--source-note", "測試用主視覺", "--source-date", "2026-07-30"]);
|
||||
const palSpec = ic.iconSpec("GAMMA-01");
|
||||
check("圖示改用照片配色(瞳色當外框、亮色當紋路)",
|
||||
palSpec.palette !== null && palSpec.ring.join() === "158,91,62" && palSpec.dot.join() === "242,235,227");
|
||||
check("來源網址與說明寫進 config(可查證)", (() => {
|
||||
const icon = pl.loadConfig("GAMMA-01").icon || {};
|
||||
return icon.palette === PAL && icon.source?.url === SRC &&
|
||||
icon.source?.note === "測試用主視覺" && icon.source?.date === "2026-07-30";
|
||||
})(), JSON.stringify(pl.loadConfig("GAMMA-01").icon));
|
||||
check("照片配色與雜湊配色畫出來不一樣", (() => {
|
||||
const withPal = ic.renderPng(palSpec, 32);
|
||||
const hashOnly = ic.renderPng({ ...palSpec, palette: null, c1: [40, 120, 184], c2: [200, 102, 214],
|
||||
ring: palSpec.ink, dot: palSpec.ink, ringAlpha: 0.18, dotAlpha: 0.14 }, 32);
|
||||
return Buffer.compare(withPal, hashOnly) !== 0;
|
||||
})());
|
||||
check("不帶 --palette 重畫會沿用已存的配色(不會變回雜湊色)", (() => {
|
||||
cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64"]);
|
||||
const again = ic.iconSpec("GAMMA-01");
|
||||
return again.palette !== null && ic.paletteToString(again.palette) === PAL;
|
||||
})());
|
||||
check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => {
|
||||
// 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉
|
||||
const spec = ic.iconSpec("GAMMA-01", {
|
||||
palette: ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3"),
|
||||
});
|
||||
const lum = (c) => {
|
||||
const f = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
|
||||
};
|
||||
const ratio = (a, b) => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05);
|
||||
return Math.min(ratio(spec.ink, spec.c1), ratio(spec.ink, spec.c2)) >= 3;
|
||||
})());
|
||||
|
||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user