feat(branding): 原创应用图标——深墨夜色+猎人之月+玻璃笔记卡片+H字标(含 .ico/.png/SVG 源与生成脚本)

This commit is contained in:
hunter-dev
2026-09-26 00:07:43 +08:00
parent 0332c7b147
commit 3f2f6a4e37
12 changed files with 366 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
import { deflateSync } from 'node:zlib';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
const OUT = nodePath.join(__dirname, '..', 'build');
const SIZES = [16, 24, 32, 48, 64, 128, 256, 512];
// ---------- PNG encoder ----------
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) 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 typeBuf = Buffer.from(type, 'ascii');
const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function encodePNG(w, h, rgba) {
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 6; // bit depth 8, color type 6 (RGBA)
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
const stride = w * 4;
const raw = Buffer.alloc(h * (stride + 1));
for (let y = 0; y < h; y++) {
raw[y * (stride + 1)] = 0; // filter None
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
const idat = pngChunk('IDAT', deflateSync(raw, { level: 9 }));
return Buffer.concat([sig, pngChunk('IHDR', ihdr), idat, pngChunk('IEND', Buffer.alloc(0))]);
}
// ---------- ICO encoder ----------
function encodeICO(sizes, pngs) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(sizes.length, 4);
const entries = [];
let offset = 6 + 16 * sizes.length;
for (let i = 0; i < sizes.length; i++) {
const s = sizes[i];
const e = Buffer.alloc(16);
e[0] = s >= 256 ? 0 : s; e[1] = s >= 256 ? 0 : s;
e[2] = 0; e[3] = 0;
e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6);
e.writeUInt32LE(pngs[i].length, 8);
e.writeUInt32LE(offset, 12);
entries.push(e);
offset += pngs[i].length;
}
return Buffer.concat([header, ...entries, ...pngs]);
}
// ---------- Rasterizer ----------
function blend(buf, W, x, y, r, g, b, a) {
if (a <= 0.001) return;
x = Math.round(x); y = Math.round(y);
if (x < 0 || y < 0 || x >= W || y >= W) return;
const i = (y * W + x) * 4;
const da = buf[i + 3] / 255;
const oa = a + da * (1 - a);
if (oa <= 0) return;
buf[i] = Math.round((r * a + buf[i] * da * (1 - a)) / oa);
buf[i + 1] = Math.round((g * a + buf[i + 1] * da * (1 - a)) / oa);
buf[i + 2] = Math.round((b * a + buf[i + 2] * da * (1 - a)) / oa);
buf[i + 3] = Math.round(oa * 255);
}
function inRR(px, py, x, y, w, h, r) {
if (px < x || px > x + w || py < y || py > y + h) return false;
const cx = Math.max(x + r, Math.min(px, x + w - r));
const cy = Math.max(y + r, Math.min(py, y + h - r));
const dx = px - cx, dy = py - cy;
return dx * dx + dy * dy <= r * r;
}
function inCircle(px, py, cx, cy, r) {
const dx = px - cx, dy = py - cy;
return dx * dx + dy * dy <= r * r;
}
function lerp(a, b, t) { return a + (b - a) * t; }
function lerpColor(c1, c2, t) { return [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)]; }
// linear gradient stops: [[pos, [r,g,b], alpha], ...]
function vGradientColor(stops, y, y0, y1) {
const t = Math.min(1, Math.max(0, (y - y0) / (y1 - y0)));
for (let i = 0; i < stops.length - 1; i++) {
const a = stops[i], b = stops[i + 1];
if (t >= a[0] && t <= b[0]) {
const lt = (t - a[0]) / (b[0] - a[0] || 1);
return [lerp(a[1][0], b[1][0], lt), lerp(a[1][1], b[1][1], lt), lerp(a[1][2], b[1][2], lt), lerp(a[2], b[2], lt)];
}
}
const last = stops[stops.length - 1];
return [...last[1], last[2]];
}
// Box blur on alpha channel (for soft shadows)
function boxBlur(buf, W, radius) {
const H = W;
const tmp = new Float32Array(W * H);
for (let i = 0; i < W * H; i++) tmp[i] = buf[i * 4 + 3] / 255;
const src = tmp.slice();
const win = radius * 2 + 1;
// horizontal
for (let y = 0; y < H; y++) {
let acc = 0;
for (let x = -radius; x <= radius; x++) acc += src[Math.max(0, Math.min(W - 1, x)) + y * W];
for (let x = 0; x < W; x++) {
tmp[x + y * W] = acc / win;
const add = Math.min(W - 1, x + radius + 1), rem = Math.max(0, x - radius);
acc += src[add + y * W] - src[rem + y * W];
}
}
const src2 = tmp.slice();
for (let x = 0; x < W; x++) {
let acc = 0;
for (let y = -radius; y <= radius; y++) acc += src2[x + Math.max(0, Math.min(H - 1, y)) * W];
for (let y = 0; y < H; y++) {
tmp[x + y * W] = acc / win;
const add = Math.min(H - 1, y + radius + 1), rem = Math.max(0, y - radius);
acc += src2[x + add * W] - src2[x + rem * W];
}
}
for (let i = 0; i < W * H; i++) buf[i * 4 + 3] = Math.round(tmp[i] * 255);
}
// Affine: rotate around pivot
function rotPoint(px, py, px_, py_, cosA, sinA) {
const dx = px - px_, dy = py - py_;
return [px_ + dx * cosA - dy * sinA, py_ + dx * sinA + dy * cosA];
}
function render(size) {
const hi = Math.max(256, size * 2);
const k = hi / 512;
const W = hi;
const buf = new Uint8ClampedArray(W * W * 4);
// 循环坐标即缓冲区坐标(缓冲区 W=hi,循环 0..hi);k 只用于设计坐标→缓冲区换算
const blendAt = (x, y, r, g, b, a) => blend(buf, W, x, y, r, g, b, a);
const sA = (ang) => Math.sin(ang), cA = (ang) => Math.cos(ang);
// ---- Tile base (vertical gradient, rounded 116) ----
const tileStops = [[0, [0x1a, 0x1e, 0x2b], 1], [0.55, [0x16, 0x1a, 0x26], 1], [1, [0x10, 0x13, 0x1c], 1]];
for (let y = 0; y < hi; y++) {
const c = vGradientColor(tileStops, y / k, 0, 512);
for (let x = 0; x < hi; x++) {
if (inRR(x / k, y / k, 0, 0, 512, 512, 116)) blendAt(x, y, c[0], c[1], c[2], 1);
}
}
// ---- Top sheen (rounded top, white 0.09 -> 0) ----
for (let y = 0; y < hi; y++) {
const t = Math.min(1, Math.max(0, (y / k) / 260));
const a = 0.09 * (1 - t);
for (let x = 0; x < hi; x++) {
if (a > 0.002 && inRR(x / k, y / k, 0, 0, 512, 512, 116)) blendAt(x, y, 255, 255, 255, a);
}
}
// ---- Stars ----
const stars = [[96, 120, 2.6, 0.5], [150, 196, 1.7, 0.5], [420, 360, 2, 0.5], [356, 92, 1.6, 0.5], [88, 340, 1.8, 0.5], [452, 200, 2.2, 0.5]];
for (const [sx, sy, sr, sa] of stars) {
const r0 = Math.max(0, Math.floor((sy - sr - 1) * k)), r1 = Math.min(hi, Math.ceil((sy + sr + 1) * k));
const c0 = Math.max(0, Math.floor((sx - sr - 1) * k)), c1 = Math.min(hi, Math.ceil((sx + sr + 1) * k));
for (let y = r0; y < r1; y++) for (let x = c0; x < c1; x++) {
if (inCircle(x / k, y / k, sx, sy, sr)) blendAt(x, y, 231, 236, 255, sa);
}
}
// ---- Moon: glow + crescent ----
// glow
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
const dx = x / k - 404, dy = y / k - 116;
const d = Math.sqrt(dx * dx + dy * dy);
if (d <= 62) blendAt(x, y, 238, 242, 255, 0.22 * (1 - d / 62));
}
// crescent (rotate -18 deg around (404,116))
const cosA = cA(-18 * Math.PI / 180), sinA = sA(-18 * Math.PI / 180);
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
const [lx, ly] = rotPoint(x / k, y / k, 404, 116, cosA, sinA);
if (inCircle(lx, ly, 404, 116, 40)) {
const t = Math.min(1, Math.max(0, (ly - 76) / 80));
const mc = lerpColor([255, 255, 255], [201, 212, 242], t);
blendAt(x, y, mc[0], mc[1], mc[2], 1);
}
if (inCircle(lx, ly, 418, 106, 36)) blendAt(x, y, 0x1a, 0x1e, 0x2b, 1);
}
// ---- Card drop shadow (behind card, dark blurred, offset down-right) ----
const cardCos = cA(-6 * Math.PI / 180), cardSin = sA(-6 * Math.PI / 180);
const cardLocal = (x, y) => rotPoint(x / k, y / k, 256, 268, cardCos, cardSin);
const shadowBuf = new Uint8ClampedArray(W * W * 4);
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
const [lx, ly] = cardLocal(x, y);
if (inRR(lx + 4, ly + 12, 96, 150, 320, 236, 44)) {
const i = (y * W + x) * 4;
shadowBuf[i] = 0; shadowBuf[i + 1] = 0; shadowBuf[i + 2] = 0; shadowBuf[i + 3] = 255;
}
}
boxBlur(shadowBuf, W, Math.max(2, Math.round(18 * k / 4)));
for (let i = 0; i < W * W; i++) {
const a = shadowBuf[i * 4 + 3] / 255 * 0.5;
if (a > 0.002) {
const x = i % W, y = (i / W) | 0;
blend(buf, W, x, y, 0, 0, 0, a);
}
}
// ---- Card group (rotate -6 deg around 256,268) ----
// draw card (and inner bar + lines) via local coords
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
const [lx, ly] = cardLocal(x, y);
// card body: gradient white 0.16->0.07
if (inRR(lx, ly, 96, 150, 320, 236, 44)) {
const t = Math.min(1, Math.max(0, (ly - 150) / 236));
const a = 0.16 - 0.09 * t;
blendAt(x, y, 255, 255, 255, a);
}
// inner top light bar
if (inRR(lx, ly, 112, 164, 288, 30, 15)) blendAt(x, y, 255, 255, 255, 0.10);
// card border (ring): draw by testing just outside inner, inside outer
// note lines
if (inRR(lx, ly, 140, 338, 150, 11, 5.5)) blendAt(x, y, 255, 255, 255, 0.55);
if (inRR(lx, ly, 140, 360, 110, 11, 5.5)) blendAt(x, y, 255, 255, 255, 0.38);
// H monogram
const hStops = [[0, [0xf2, 0xf5, 0xff], 1], [1, [0xa7, 0xb8, 0xf0], 1]];
const hc = vGradientColor(hStops, ly, 210, 330);
if (inRR(lx, ly, 204, 210, 46, 120, 23) || inRR(lx, ly, 286, 210, 46, 120, 23) || inRR(lx, ly, 227, 254, 82, 42, 21)) {
blendAt(x, y, hc[0], hc[1], hc[2], 1);
}
}
// card border: stroke ring (approx) - draw after body
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
const [lx, ly] = cardLocal(x, y);
if (inRR(lx, ly, 96, 150, 320, 236, 44) && !inRR(lx, ly, 100, 154, 312, 228, 40)) {
blendAt(x, y, 255, 255, 255, 0.4);
}
}
// ---- Downsample hi -> size (box average, straight alpha) ----
if (size === hi) return Buffer.from(buf);
const out = Buffer.alloc(size * size * 4);
const ratio = hi / size;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let pr = 0, pg = 0, pb = 0, pa = 0;
const x0 = Math.floor(x * ratio), y0 = Math.floor(y * ratio);
const x1 = Math.min(hi, Math.ceil((x + 1) * ratio)), y1 = Math.min(hi, Math.ceil((y + 1) * ratio));
for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) {
const i = (yy * W + xx) * 4;
const aa = buf[i + 3] / 255;
if (aa > 0) { pr += buf[i] * aa; pg += buf[i + 1] * aa; pb += buf[i + 2] * aa; pa += aa; }
}
const o = (y * size + x) * 4;
if (pa > 0) { out[o] = Math.round(pr / pa); out[o + 1] = Math.round(pg / pa); out[o + 2] = Math.round(pb / pa); out[o + 3] = Math.round(pa * 255); }
}
}
return out;
}
// ---------- Main ----------
async function main() {
const pngs = [];
for (const s of SIZES) {
const rgba = render(s);
const png = encodePNG(s, s, rgba);
await fs.writeFile(nodePath.join(OUT, 'icon', s + '.png'), png);
pngs.push(png);
console.log('rendered', s + 'px', png.length, 'bytes');
}
const ico = encodeICO(SIZES, pngs);
await fs.writeFile(nodePath.join(OUT, 'icon.ico'), ico);
console.log('icon.ico', ico.length, 'bytes');
console.log('DONE');
}
await main();