starmusic-pc
1#!/usr/bin/env node
2/**
3 * 图标生成器:把源 PNG 转成 Windows 应用图标
4 *
5 * 用途:
6 * 1. 解析源图(仅依赖 Node 内置 zlib,无需 sharp/Pillow/ImageMagick)
7 * 2. 统一做「圆角方形」处理 —— 源图是对齐满幅的浅色底,直接当图标在任务栏/资源管理器里
8 * 会是一块生硬的白方块;圆角后视觉上更像一个现代应用图标,且四角透明可与深色任务栏融合
9 * 3. 生成 build/windows/icon.ico(多尺寸,PNG 条目,与 Wails 模板一致)
10 * 与 build/appicon.png(1024,供 NSIS 安装包与其它平台使用)
11 *
12 * 质量要点:
13 * - 缩放使用「面积平均」(可分离两趟:先横后竖)并基于**预乘 alpha**,
14 * 否则圆角处的半透明边缘会出现灰边/彩边
15 * - 面积平均天然带抗锯齿,非整数倍缩放(如 1024→24)也不会出现锯齿跳动
16 * - PNG 编码使用自适应滤波(5 种滤波器取绝对差之和最小者),压缩率与画质兼顾
17 *
18 * 用法:
19 * node scripts/gen-icons.cjs # 分析 + 生成
20 * node scripts/gen-icons.cjs --analyze # 仅分析源图
21 */
22
23const fs = require('fs')
24const path = require('path')
25const zlib = require('zlib')
26
27// ============================================================
28// 配置
29// ============================================================
30
31const SRC =
32 process.argv.includes('--src') && process.argv[process.argv.indexOf('--src') + 1]
33 ? process.argv[process.argv.indexOf('--src') + 1]
34 : 'C:/Users/admin/Documents/HBuilderProjects/starmusic/unpackage/dist/build/app-plus/static/icon/appstore.png'
35
36const PROJECT_ROOT = path.resolve(__dirname, '..')
37const OUT_ICO = path.join(PROJECT_ROOT, 'build', 'windows', 'icon.ico')
38const OUT_APPICON = path.join(PROJECT_ROOT, 'build', 'appicon.png')
39/** 预览图写到 .smoke 目录,避免混进构建产物 */
40const OUT_PREVIEW = path.join(PROJECT_ROOT, '.smoke', 'icon-preview.png')
41
42/** 圆角半径占边长的比例(0 表示不圆角)。22% 接近现代应用图标(iOS/Win11)的观感 */
43const RADIUS_RATIO = 0.22
44
45/**
46 * 背景清理阈值:与背景色距离 ≤ 该值的像素压成纯背景色(0 = 关闭)。
47 * 目的是消掉源图底部的柔和投影与噪点 —— 它们在 16~24px 下会退化成灰糊。
48 */
49const BG_CLEAN_THRESHOLD = Number(process.env.BG_CLEAN ?? 30)
50
51/** 过渡带宽度:阈值到 阈值+该值 之间线性收敛到背景色,避免出现硬边台阶 */
52const BG_CLEAN_FEATHER = 18
53
54/** ICO 内包含的尺寸:覆盖 100%~200% 各档 DPI 下任务栏/资源管理器用到的像素尺寸 */
55const ICO_SIZES = [256, 128, 96, 64, 48, 40, 32, 24, 20, 16]
56
57/** 圆角遮罩的超采样倍率(4 → 每像素 16 个采样点) */
58const MASK_SAMPLES = 4
59
60const ANALYZE_ONLY = process.argv.includes('--analyze')
61
62// ============================================================
63// PNG 解码(支持 8 位 灰/RGB/索引/RGBA,非隔行)
64// ============================================================
65
66function decodePNG(buf) {
67 if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a) {
68 throw new Error('不是有效的 PNG 文件')
69 }
70 let pos = 8
71 let width = 0
72 let height = 0
73 let bitDepth = 0
74 let colorType = 0
75 let interlace = 0
76 const idat = []
77 let palette = null
78 let trns = null
79
80 while (pos + 8 <= buf.length) {
81 const len = buf.readUInt32BE(pos)
82 const type = buf.toString('ascii', pos + 4, pos + 8)
83 const data = buf.subarray(pos + 8, pos + 8 + len)
84 if (type === 'IHDR') {
85 width = data.readUInt32BE(0)
86 height = data.readUInt32BE(4)
87 bitDepth = data[8]
88 colorType = data[9]
89 interlace = data[12]
90 } else if (type === 'IDAT') {
91 idat.push(data)
92 } else if (type === 'PLTE') {
93 palette = Buffer.from(data)
94 } else if (type === 'tRNS') {
95 trns = Buffer.from(data)
96 } else if (type === 'IEND') {
97 break
98 }
99 pos += 12 + len
100 }
101
102 if (!width || !height) throw new Error('PNG 缺少 IHDR')
103 if (bitDepth !== 8) throw new Error(`暂不支持 ${bitDepth} 位深,请提供 8 位深的 PNG`)
104 if (interlace !== 0) throw new Error('暂不支持 Adam7 隔行扫描,请另存为非隔行 PNG')
105
106 const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType]
107 if (!channels) throw new Error(`暂不支持的颜色类型 ${colorType}`)
108
109 const raw = zlib.inflateSync(Buffer.concat(idat))
110 const bpp = channels
111 const stride = width * bpp
112 const out = Buffer.alloc(height * stride)
113
114 // 反滤波(PNG 逐行滤波,类型 0~4)
115 let rp = 0
116 for (let y = 0; y < height; y++) {
117 const ft = raw[rp++]
118 const line = raw.subarray(rp, rp + stride)
119 rp += stride
120 const cur = out.subarray(y * stride, (y + 1) * stride)
121 const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : null
122 for (let x = 0; x < stride; x++) {
123 const a = x >= bpp ? cur[x - bpp] : 0
124 const b = prev ? prev[x] : 0
125 const c = prev && x >= bpp ? prev[x - bpp] : 0
126 let v = line[x]
127 switch (ft) {
128 case 0:
129 break
130 case 1:
131 v = (v + a) & 255
132 break
133 case 2:
134 v = (v + b) & 255
135 break
136 case 3:
137 v = (v + ((a + b) >> 1)) & 255
138 break
139 case 4: {
140 const p = a + b - c
141 const pa = Math.abs(p - a)
142 const pb = Math.abs(p - b)
143 const pc = Math.abs(p - c)
144 const pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c
145 v = (v + pr) & 255
146 break
147 }
148 default:
149 throw new Error(`未知的滤波类型 ${ft}(第 ${y} 行)`)
150 }
151 cur[x] = v
152 }
153 }
154
155 // 统一转成 RGBA
156 const rgba = new Uint8ClampedArray(width * height * 4)
157 for (let i = 0, n = width * height; i < n; i++) {
158 const s = i * bpp
159 const d = i * 4
160 switch (colorType) {
161 case 6:
162 rgba[d] = out[s]
163 rgba[d + 1] = out[s + 1]
164 rgba[d + 2] = out[s + 2]
165 rgba[d + 3] = out[s + 3]
166 break
167 case 2:
168 rgba[d] = out[s]
169 rgba[d + 1] = out[s + 1]
170 rgba[d + 2] = out[s + 2]
171 rgba[d + 3] = 255
172 break
173 case 0:
174 rgba[d] = rgba[d + 1] = rgba[d + 2] = out[s]
175 rgba[d + 3] = 255
176 break
177 case 4:
178 rgba[d] = rgba[d + 1] = rgba[d + 2] = out[s]
179 rgba[d + 3] = out[s + 1]
180 break
181 case 3: {
182 const idx = out[s] * 3
183 rgba[d] = palette[idx]
184 rgba[d + 1] = palette[idx + 1]
185 rgba[d + 2] = palette[idx + 2]
186 rgba[d + 3] = trns && out[s] < trns.length ? trns[out[s]] : 255
187 break
188 }
189 }
190 }
191
192 return { width, height, rgba }
193}
194
195// ============================================================
196// PNG 编码(自适应滤波)
197// ============================================================
198
199const CRC_TABLE = (() => {
200 const t = new Int32Array(256)
201 for (let n = 0; n < 256; n++) {
202 let c = n
203 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
204 t[n] = c
205 }
206 return t
207})()
208
209function crc32(buf) {
210 let c = -1
211 for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
212 return (c ^ -1) >>> 0
213}
214
215function chunk(type, data) {
216 const len = Buffer.alloc(4)
217 len.writeUInt32BE(data.length, 0)
218 const typeBuf = Buffer.from(type, 'ascii')
219 const crc = Buffer.alloc(4)
220 crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0)
221 return Buffer.concat([len, typeBuf, data, crc])
222}
223
224function paeth(a, b, c) {
225 const p = a + b - c
226 const pa = Math.abs(p - a)
227 const pb = Math.abs(p - b)
228 const pc = Math.abs(p - c)
229 return pa <= pb && pa <= pc ? a : pb <= pc ? b : c
230}
231
232function encodePNG(rgba, width, height) {
233 const stride = width * 4
234 const raw = Buffer.alloc(height * (stride + 1))
235
236 for (let y = 0; y < height; y++) {
237 const cur = rgba.subarray(y * stride, (y + 1) * stride)
238 const prev = y > 0 ? rgba.subarray((y - 1) * stride, y * stride) : null
239 const rowStart = y * (stride + 1)
240
241 // 依次尝试 5 种滤波,取「绝对差之和」最小者(PNG 通行启发式)
242 let bestFilter = 0
243 let bestScore = Infinity
244 let bestData = null
245 const candidates = []
246
247 for (let ft = 0; ft <= 4; ft++) {
248 const outRow = Buffer.alloc(stride)
249 let score = 0
250 for (let x = 0; x < stride; x++) {
251 const a = x >= 4 ? cur[x - 4] : 0
252 const b = prev ? prev[x] : 0
253 const c = prev && x >= 4 ? prev[x - 4] : 0
254 let v
255 switch (ft) {
256 case 0:
257 v = cur[x]
258 break
259 case 1:
260 v = cur[x] - a
261 break
262 case 2:
263 v = cur[x] - b
264 break
265 case 3:
266 v = cur[x] - ((a + b) >> 1)
267 break
268 default:
269 v = cur[x] - paeth(a, b, c)
270 break
271 }
272 v &= 255
273 outRow[x] = v
274 // 有符号视角的绝对差更能反映熵
275 score += v < 128 ? v : 256 - v
276 }
277 candidates.push(outRow)
278 if (score < bestScore) {
279 bestScore = score
280 bestFilter = ft
281 bestData = outRow
282 }
283 }
284
285 raw[rowStart] = bestFilter
286 bestData.copy(raw, rowStart + 1)
287 }
288
289 const ihdr = Buffer.alloc(13)
290 ihdr.writeUInt32BE(width, 0)
291 ihdr.writeUInt32BE(height, 4)
292 ihdr[8] = 8 // bit depth
293 ihdr[9] = 6 // RGBA
294 ihdr[10] = 0
295 ihdr[11] = 0
296 ihdr[12] = 0
297
298 return Buffer.concat([
299 Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
300 chunk('IHDR', ihdr),
301 chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
302 chunk('IEND', Buffer.alloc(0)),
303 ])
304}
305
306// ============================================================
307// 圆角遮罩
308// ============================================================
309
310/** 点是否落在圆角矩形内(标准 SDF 判定) */
311function insideRoundedRect(px, py, w, h, r) {
312 const cx = Math.min(Math.max(px, r), w - r)
313 const cy = Math.min(Math.max(py, r), h - r)
314 const dx = px - cx
315 const dy = py - cy
316 return dx * dx + dy * dy <= r * r
317}
318
319/** 生成 0~1 的圆角覆盖率遮罩(超采样抗锯齿) */
320function buildRoundedMask(w, h, radius) {
321 const mask = new Float32Array(w * h)
322 if (radius <= 0) {
323 mask.fill(1)
324 return mask
325 }
326 const total = MASK_SAMPLES * MASK_SAMPLES
327 for (let y = 0; y < h; y++) {
328 for (let x = 0; x < w; x++) {
329 let inside = 0
330 for (let sy = 0; sy < MASK_SAMPLES; sy++) {
331 const py = y + (sy + 0.5) / MASK_SAMPLES
332 for (let sx = 0; sx < MASK_SAMPLES; sx++) {
333 const px = x + (sx + 0.5) / MASK_SAMPLES
334 if (insideRoundedRect(px, py, w, h, radius)) inside++
335 }
336 }
337 mask[y * w + x] = inside / total
338 }
339 }
340 return mask
341}
342
343// ============================================================
344// 缩放(预乘 alpha 的面积平均,可分离两趟)
345// ============================================================
346
347function resample(src, sw, sh, dw, dh) {
348 // 横向:sw → dw
349 const tmp = new Float32Array(dw * sh * 4)
350 const scaleX = sw / dw
351 for (let y = 0; y < sh; y++) {
352 const rowOff = y * sw * 4
353 for (let x = 0; x < dw; x++) {
354 const x0 = x * scaleX
355 const x1 = (x + 1) * scaleX
356 const i0 = Math.floor(x0)
357 const i1 = Math.min(sw, Math.ceil(x1))
358 let r = 0
359 let g = 0
360 let b = 0
361 let a = 0
362 let wsum = 0
363 for (let i = i0; i < i1; i++) {
364 const cov = Math.min(i + 1, x1) - Math.max(i, x0)
365 if (cov <= 0) continue
366 const o = rowOff + i * 4
367 const al = src[o + 3] / 255
368 r += src[o] * al * cov
369 g += src[o + 1] * al * cov
370 b += src[o + 2] * al * cov
371 a += al * cov
372 wsum += cov
373 }
374 const t = (y * dw + x) * 4
375 if (wsum > 0) {
376 tmp[t] = r / wsum
377 tmp[t + 1] = g / wsum
378 tmp[t + 2] = b / wsum
379 tmp[t + 3] = a / wsum
380 }
381 }
382 }
383
384 // 纵向:sh → dh,并反预乘回 8 位
385 const out = new Uint8ClampedArray(dw * dh * 4)
386 const scaleY = sh / dh
387 for (let y = 0; y < dh; y++) {
388 const y0 = y * scaleY
389 const y1 = (y + 1) * scaleY
390 const j0 = Math.floor(y0)
391 const j1 = Math.min(sh, Math.ceil(y1))
392 for (let x = 0; x < dw; x++) {
393 let r = 0
394 let g = 0
395 let b = 0
396 let a = 0
397 let wsum = 0
398 for (let j = j0; j < j1; j++) {
399 const cov = Math.min(j + 1, y1) - Math.max(j, y0)
400 if (cov <= 0) continue
401 const o = (j * dw + x) * 4
402 r += tmp[o] * cov
403 g += tmp[o + 1] * cov
404 b += tmp[o + 2] * cov
405 a += tmp[o + 3] * cov
406 wsum += cov
407 }
408 const d = (y * dw + x) * 4
409 if (wsum > 0 && a / wsum > 0.0001) {
410 const av = a / wsum
411 out[d] = Math.round(r / wsum / av)
412 out[d + 1] = Math.round(g / wsum / av)
413 out[d + 2] = Math.round(b / wsum / av)
414 out[d + 3] = Math.round(av * 255)
415 } else {
416 out[d] = out[d + 1] = out[d + 2] = out[d + 3] = 0
417 }
418 }
419 }
420 return out
421}
422
423// ============================================================
424// ICO 打包(全部使用 PNG 条目,与 Wails 模板一致)
425// ============================================================
426
427function buildICO(entries) {
428 const header = Buffer.alloc(6)
429 header.writeUInt16LE(0, 0) // reserved
430 header.writeUInt16LE(1, 2) // type = icon
431 header.writeUInt16LE(entries.length, 4)
432
433 const dir = Buffer.alloc(16 * entries.length)
434 let offset = 6 + 16 * entries.length
435 entries.forEach((e, i) => {
436 const o = i * 16
437 dir[o] = e.size >= 256 ? 0 : e.size // 256 用 0 表示
438 dir[o + 1] = e.size >= 256 ? 0 : e.size
439 dir[o + 2] = 0 // 调色板数
440 dir[o + 3] = 0 // reserved
441 dir.writeUInt16LE(1, o + 4) // color planes
442 dir.writeUInt16LE(32, o + 6) // bits per pixel
443 dir.writeUInt32LE(e.png.length, o + 8)
444 dir.writeUInt32LE(offset, o + 12)
445 offset += e.png.length
446 })
447
448 return Buffer.concat([header, dir, ...entries.map((e) => e.png)])
449}
450
451/** 取四角像素均值作为背景色估计 */
452function estimateBackground(rgba, w, h) {
453 const px = (x, y) => {
454 const o = (y * w + x) * 4
455 return [rgba[o], rgba[o + 1], rgba[o + 2]]
456 }
457 const corners = [px(0, 0), px(w - 1, 0), px(0, h - 1), px(w - 1, h - 1)]
458 return [0, 1, 2].map((i) => Math.round(corners.reduce((s, c) => s + c[i], 0) / corners.length))
459}
460
461// ============================================================
462// 背景清理
463// ============================================================
464
465/**
466 * 把「接近背景色」的像素压成纯背景色。
467 *
468 * 为什么需要:源图底部有一层柔和投影、整体也带轻微噪点。这些微弱差异在 256px 下几乎看不出来,
469 * 但缩到 16~24px 时会退化成一层灰糊,削弱主体轮廓。阈值只覆盖「弱差异」区域,
470 * 笔画的抗锯齿边缘(与背景差异很大)不受影响。
471 *
472 * @param {Uint8ClampedArray} rgba
473 * @param {number[]} bg 背景色 [r,g,b]
474 * @param {number} threshold 距离阈值;<=0 表示关闭
475 * @param {number} feather 过渡上限:距离在 (threshold, feather] 之间的像素按比例向背景色收敛,避免出现硬边
476 */
477function cleanBackground(rgba, bg, threshold, feather) {
478 if (threshold <= 0) return rgba
479 const out = new Uint8ClampedArray(rgba.length)
480 const hi = Math.max(threshold + 1, feather)
481 for (let i = 0; i < rgba.length; i += 4) {
482 const r = rgba[i]
483 const g = rgba[i + 1]
484 const b = rgba[i + 2]
485 const d = Math.sqrt((r - bg[0]) ** 2 + (g - bg[1]) ** 2 + (b - bg[2]) ** 2)
486 if (d <= threshold) {
487 out[i] = bg[0]
488 out[i + 1] = bg[1]
489 out[i + 2] = bg[2]
490 } else if (d < hi) {
491 // 过渡带:线性插值到背景,避免阈值处出现生硬台阶
492 const t = (d - threshold) / (hi - threshold)
493 out[i] = Math.round(bg[0] + (r - bg[0]) * t)
494 out[i + 1] = Math.round(bg[1] + (g - bg[1]) * t)
495 out[i + 2] = Math.round(bg[2] + (b - bg[2]) * t)
496 } else {
497 out[i] = r
498 out[i + 1] = g
499 out[i + 2] = b
500 }
501 out[i + 3] = rgba[i + 3]
502 }
503 return out
504}
505
506// ============================================================
507// 预览图:把关键尺寸放大后排布在浅/深两种背景上,用于人工校验可读性
508// ============================================================
509
510/** 最近邻放大(保留像素块,便于判断小尺寸是否糊成一团) */
511function nearestScale(src, sw, sh, factor) {
512 const dw = sw * factor
513 const dh = sh * factor
514 const out = new Uint8ClampedArray(dw * dh * 4)
515 for (let y = 0; y < dh; y++) {
516 const sy = Math.min(sh - 1, Math.floor(y / factor))
517 for (let x = 0; x < dw; x++) {
518 const sx = Math.min(sw - 1, Math.floor(x / factor))
519 const s = (sy * sw + sx) * 4
520 const d = (y * dw + x) * 4
521 out[d] = src[s]
522 out[d + 1] = src[s + 1]
523 out[d + 2] = src[s + 2]
524 out[d + 3] = src[s + 3]
525 }
526 }
527 return { rgba: out, width: dw, height: dh }
528}
529
530/** 半尺寸最近邻(用于把 256 缩到 128 展示) */
531function halfScale(src, sw, sh) {
532 const dw = sw >> 1
533 const dh = sh >> 1
534 const out = new Uint8ClampedArray(dw * dh * 4)
535 for (let y = 0; y < dh; y++) {
536 for (let x = 0; x < dw; x++) {
537 const s = ((y * 2) * sw + x * 2) * 4
538 const d = (y * dw + x) * 4
539 out[d] = src[s]
540 out[d + 1] = src[s + 1]
541 out[d + 2] = src[s + 2]
542 out[d + 3] = src[s + 3]
543 }
544 }
545 return { rgba: out, width: dw, height: dh }
546}
547
548/** alpha 合成:把 src 叠加到纯色背景上 */
549function overSolid(src, bg) {
550 const out = new Uint8ClampedArray(src.length)
551 for (let i = 0; i < src.length; i += 4) {
552 const a = src[i + 3] / 255
553 out[i] = Math.round(src[i] * a + bg[0] * (1 - a))
554 out[i + 1] = Math.round(src[i + 1] * a + bg[1] * (1 - a))
555 out[i + 2] = Math.round(src[i + 2] * a + bg[2] * (1 - a))
556 out[i + 3] = 255
557 }
558 return out
559}
560
561/**
562 * 生成预览图:三列分别展示 16px(×8) / 32px(×4) / 256px(×0.5),
563 * 上行浅色背景、下行深色背景,用于同时校验两种使用场景。
564 */
565function buildPreview(masked, sw, sh) {
566 const CELL = 128
567 const PAD = 20
568 const cols = 3
569 const W = cols * CELL + (cols + 1) * PAD
570 const H = 2 * CELL + 3 * PAD
571 const canvas = new Uint8ClampedArray(W * H * 4)
572
573 const paintBg = (y0, y1, rgb) => {
574 for (let y = y0; y < y1; y++) {
575 for (let x = 0; x < W; x++) {
576 const o = (y * W + x) * 4
577 canvas[o] = rgb[0]
578 canvas[o + 1] = rgb[1]
579 canvas[o + 2] = rgb[2]
580 canvas[o + 3] = 255
581 }
582 }
583 }
584 // 上半浅灰、下半近黑(模拟 Windows 浅色/深色任务栏)
585 paintBg(0, Math.floor(H / 2), [242, 244, 246])
586 paintBg(Math.floor(H / 2), H, [26, 30, 36])
587
588 const blit = (src, dw, dh, x0, y0, bg) => {
589 const composited = overSolid(src, bg)
590 for (let y = 0; y < dh; y++) {
591 for (let x = 0; x < dw; x++) {
592 const ty = y0 + y
593 const tx = x0 + x
594 if (ty < 0 || ty >= H || tx < 0 || tx >= W) continue
595 const s = (y * dw + x) * 4
596 const d = (ty * W + tx) * 4
597 canvas[d] = composited[s]
598 canvas[d + 1] = composited[s + 1]
599 canvas[d + 2] = composited[s + 2]
600 canvas[d + 3] = 255
601 }
602 }
603 }
604
605 const rowBg = [
606 [242, 244, 246],
607 [26, 30, 36],
608 ]
609
610 // 各列:(目标展示尺寸, 缩放方式)
611 const plan = [
612 { draw: 16, mode: 'up', factor: 8 },
613 { draw: 32, mode: 'up', factor: 4 },
614 { draw: 256, mode: 'half' },
615 ]
616
617 plan.forEach((p, ci) => {
618 let rendered
619 if (p.mode === 'up') {
620 const base = resample(masked, sw, sh, p.draw, p.draw)
621 rendered = nearestScale(base, p.draw, p.draw, p.factor)
622 } else {
623 const base = resample(masked, sw, sh, p.draw, p.draw)
624 rendered = halfScale(base, p.draw, p.draw)
625 }
626 rowBg.forEach((bg, ri) => {
627 const x0 = PAD + ci * (CELL + PAD)
628 const y0 = PAD + ri * (CELL + PAD)
629 blit(rendered.rgba, rendered.width, rendered.height, x0, y0, bg)
630 })
631 })
632
633 return { rgba: canvas, width: W, height: H }
634}
635
636// ============================================================
637// 源图分析
638// ============================================================
639
640function analyze(rgba, w, h) {
641 const px = (x, y) => {
642 const o = (y * w + x) * 4
643 return [rgba[o], rgba[o + 1], rgba[o + 2], rgba[o + 3]]
644 }
645 const corners = {
646 左上: px(0, 0),
647 右上: px(w - 1, 0),
648 左下: px(0, h - 1),
649 右下: px(w - 1, h - 1),
650 }
651 console.log('--- 源图分析 ---')
652 for (const [k, v] of Object.entries(corners)) {
653 console.log(` ${k}角 rgba = ${v.join(',')}`)
654 }
655
656 // 四边中点的 alpha,判断是否满幅不透明
657 const edgeMid = {
658 上边: px(Math.floor(w / 2), 0),
659 下边: px(Math.floor(w / 2), h - 1),
660 左边: px(0, Math.floor(h / 2)),
661 右边: px(w - 1, Math.floor(h / 2)),
662 }
663 for (const [k, v] of Object.entries(edgeMid)) {
664 console.log(` ${k}中点 rgba = ${v.join(',')}`)
665 }
666
667 // 透明像素占比
668 let transparent = 0
669 let opaque = 0
670 for (let i = 0; i < w * h; i++) {
671 const a = rgba[i * 4 + 3]
672 if (a < 8) transparent++
673 else if (a > 247) opaque++
674 }
675 const total = w * h
676 console.log(
677 ` 完全透明像素 ${((transparent / total) * 100).toFixed(1)}% · 完全不透明 ${((opaque / total) * 100).toFixed(1)}%`
678 )
679 console.log(` 四角 alpha 全为 255 → 背景是**不透明**的,必须圆角裁切,否则图标会是硬边白方块`)
680
681 // 背景色取四角均值
682 const bg = estimateBackground(rgba, w, h)
683 console.log(` 推定背景色 rgb(${bg.join(',')})`)
684
685 // 内容包围盒(与背景色差异超过阈值的像素)
686 for (const threshold of [40, 70]) {
687 let x0 = w
688 let y0 = h
689 let x1 = -1
690 let y1 = -1
691 for (let y = 0; y < h; y++) {
692 for (let x = 0; x < w; x++) {
693 const o = (y * w + x) * 4
694 const d = Math.sqrt(
695 (rgba[o] - bg[0]) ** 2 + (rgba[o + 1] - bg[1]) ** 2 + (rgba[o + 2] - bg[2]) ** 2
696 )
697 if (d > threshold) {
698 if (x < x0) x0 = x
699 if (x > x1) x1 = x
700 if (y < y0) y0 = y
701 if (y > y1) y1 = y
702 }
703 }
704 }
705 if (x1 < 0) {
706 console.log(` 阈值 ${threshold}:未找到内容`)
707 continue
708 }
709 const cw = x1 - x0 + 1
710 const ch = y1 - y0 + 1
711 console.log(
712 ` 阈值 ${String(threshold).padStart(2)}:内容 ${cw}×${ch} @ (${x0},${y0}) ` +
713 `占画幅 ${((cw / w) * 100).toFixed(1)}% × ${((ch / h) * 100).toFixed(1)}% ` +
714 `四周留白 左${x0} 上${y0} 右${w - x1 - 1} 下${h - y1 - 1}`
715 )
716 }
717 console.log('')
718}
719
720// ============================================================
721// 主流程
722// ============================================================
723
724function main() {
725 if (!fs.existsSync(SRC)) throw new Error(`源文件不存在:${SRC}`)
726 console.log('源文件:' + SRC)
727
728 const t0 = Date.now()
729 const src = decodePNG(fs.readFileSync(SRC))
730 console.log(`已解码:${src.width}×${src.height},耗时 ${Date.now() - t0}ms\n`)
731
732 analyze(src.rgba, src.width, src.height)
733 if (ANALYZE_ONLY) return
734
735 // 1. 清掉柔和投影/噪点 → 再做圆角遮罩 → 最后缩放
736 const bg = estimateBackground(src.rgba, src.width, src.height)
737 const cleaned = cleanBackground(src.rgba, bg, BG_CLEAN_THRESHOLD, BG_CLEAN_THRESHOLD + BG_CLEAN_FEATHER)
738 console.log(
739 BG_CLEAN_THRESHOLD > 0
740 ? `背景清理:与 rgb(${bg.join(',')}) 距离 ≤ ${BG_CLEAN_THRESHOLD} 的像素压为纯背景色(过渡带 ${BG_CLEAN_FEATHER})`
741 : '背景清理:已关闭'
742 )
743
744 const radius = Math.round(Math.min(src.width, src.height) * RADIUS_RATIO)
745 console.log(`圆角半径:${radius}px(源尺寸的 ${(RADIUS_RATIO * 100).toFixed(0)}%)`)
746 const mask = buildRoundedMask(src.width, src.height, radius)
747 const masked = new Uint8ClampedArray(cleaned.length)
748 for (let i = 0; i < src.width * src.height; i++) {
749 const o = i * 4
750 masked[o] = cleaned[o]
751 masked[o + 1] = cleaned[o + 1]
752 masked[o + 2] = cleaned[o + 2]
753 masked[o + 3] = cleaned[o + 3] * mask[i]
754 }
755
756 // 2. 生成 ICO
757 const entries = []
758 for (const size of ICO_SIZES) {
759 const t = Date.now()
760 const scaled = resample(masked, src.width, src.height, size, size)
761 const png = encodePNG(scaled, size, size)
762 entries.push({ size, png })
763 console.log(` ${String(size).padStart(3)}×${String(size).padEnd(3)} PNG ${String(png.length).padStart(6)} B (${Date.now() - t}ms)`)
764 }
765 const ico = buildICO(entries)
766 fs.mkdirSync(path.dirname(OUT_ICO), { recursive: true })
767 fs.writeFileSync(OUT_ICO, ico)
768 console.log(`\n✔ 已写出 ${OUT_ICO} 共 ${entries.length} 档尺寸,${(ico.length / 1024).toFixed(1)} KB`)
769
770 // 3. 生成 appicon.png(1024,供 NSIS / 其它平台使用)
771 const appIconSize = 1024
772 const appScaled =
773 src.width === appIconSize
774 ? masked
775 : resample(masked, src.width, src.height, appIconSize, appIconSize)
776 const appPng = encodePNG(appScaled, appIconSize, appIconSize)
777 fs.mkdirSync(path.dirname(OUT_APPICON), { recursive: true })
778 fs.writeFileSync(OUT_APPICON, appPng)
779 console.log(`✔ 已写出 ${OUT_APPICON} 1024×1024 ${(appPng.length / 1024).toFixed(1)} KB`)
780
781 // 4. 预览图(浅色行 / 深色行各三档关键尺寸)
782 const preview = buildPreview(masked, src.width, src.height)
783 const previewPng = encodePNG(preview.rgba, preview.width, preview.height)
784 fs.mkdirSync(path.dirname(OUT_PREVIEW), { recursive: true })
785 fs.writeFileSync(OUT_PREVIEW, previewPng)
786 console.log(`✔ 已写出 ${OUT_PREVIEW} 预览:16px×8 / 32px×4 / 256px×0.5,上浅下深`)
787
788 console.log(`\n总耗时 ${Date.now() - t0}ms`)
789}
790
791main()