Files
goodbuddy/build/generate-icons.mjs
T
mesalogo ddcc6dd1d9 fix: align website branding and spacing
The website used redrawn brand marks, generated icons retained opaque white edges, and adjacent download and Runtime sections accumulated excessive vertical space. It now uses synchronized official theme icons with transparent antialiased edges and a consistent responsive section rhythm.

Release note: 官网与应用图标现统一使用官方 GoodBuddy 标识,并优化下载区与 Agent Runtime 区之间的页面间距。
2026-08-17 01:30:53 +08:00

411 lines
12 KiB
JavaScript

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import pngToIco from 'png-to-ico'
import { resize } from 'png-to-ico/lib/png.js'
import { PNG } from 'pngjs'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const lightSourcePath = join(
root,
'icons',
'ChatGPT_xQBGv24GYc.png'
)
const darkSourcePath = join(
root,
'icons',
'ChatGPT_qPkaIrLGsm.png'
)
const rendererAssetRoot = join(root, 'src', 'renderer', 'src', 'assets')
const websiteAssetRoot = join(root, 'sites', 'assets')
const lightTile = {
left: 40,
top: 32,
right: 704,
bottom: 696,
radius: 126,
edgeColor: [255, 255, 255]
}
const darkTile = {
left: 20,
top: 16,
right: 684,
bottom: 680,
radius: 126,
edgeColor: [15, 21, 31]
}
function cropSquare(source, size) {
const output = new PNG({ width: size, height: size })
PNG.bitblt(source, output, 0, 0, size, size, 0, 0)
return output
}
function scaleTile(tile, sourceSize, targetSize) {
const scale = targetSize / sourceSize
return {
left: tile.left * scale,
top: tile.top * scale,
right: tile.right * scale,
bottom: tile.bottom * scale,
radius: tile.radius * scale,
edgeColor: tile.edgeColor
}
}
function cropTile(source, tile) {
const width = Math.round(tile.right - tile.left)
const height = Math.round(tile.bottom - tile.top)
if (width !== height || width <= 0) {
throw new Error('图标卡片裁剪区域必须是有效正方形')
}
const output = new PNG({ width, height })
PNG.bitblt(
source,
output,
Math.round(tile.left),
Math.round(tile.top),
width,
height,
0,
0
)
return {
image: output,
tile: {
left: 0,
top: 0,
right: width,
bottom: height,
radius: tile.radius,
edgeColor: tile.edgeColor
}
}
}
function roundedRectangleDistance(x, y, tile) {
const centerX = (tile.left + tile.right) / 2
const centerY = (tile.top + tile.bottom) / 2
const halfWidth = (tile.right - tile.left) / 2
const halfHeight = (tile.bottom - tile.top) / 2
const offsetX = Math.abs(x - centerX) - (halfWidth - tile.radius)
const offsetY = Math.abs(y - centerY) - (halfHeight - tile.radius)
return (
Math.hypot(Math.max(offsetX, 0), Math.max(offsetY, 0)) +
Math.min(Math.max(offsetX, offsetY), 0) -
tile.radius
)
}
function applyRoundedTransparency(image, tile) {
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
const offset = pixelOffset(image, x, y)
const distance = roundedRectangleDistance(x + 0.5, y + 0.5, tile)
const coverage = Math.min(Math.max(0.5 - distance, 0), 1)
if (coverage >= 1) {
if (distance > -2 && image.data[offset + 3] < 255) {
image.data[offset] = tile.edgeColor[0]
image.data[offset + 1] = tile.edgeColor[1]
image.data[offset + 2] = tile.edgeColor[2]
}
continue
}
image.data[offset] = tile.edgeColor[0]
image.data[offset + 1] = tile.edgeColor[1]
image.data[offset + 2] = tile.edgeColor[2]
image.data[offset + 3] = Math.round(
image.data[offset + 3] * coverage
)
}
}
}
function createRoundedIcon(source, tile, size) {
const cropped = cropTile(source, tile)
applyRoundedTransparency(cropped.image, cropped.tile)
const output = resize(
cropped.image,
size,
size,
'bicubicInterpolation'
)
applyRoundedTransparency(
output,
scaleTile(cropped.tile, cropped.image.width, size)
)
return output
}
function pixelOffset(image, x, y) {
return (y * image.width + x) * 4
}
function repairLightCursor(image) {
for (let y = 246; y <= 284; y += 1) {
const leftOffset = pixelOffset(image, 632, y)
const rightOffset = pixelOffset(image, 670, y)
for (let x = 636; x <= 664; x += 1) {
const amount = (x - 632) / (670 - 632)
const targetOffset = pixelOffset(image, x, y)
for (let channel = 0; channel < 4; channel += 1) {
image.data[targetOffset + channel] = Math.round(
image.data[leftOffset + channel] * (1 - amount) +
image.data[rightOffset + channel] * amount
)
}
}
}
}
function isBrandColor(red, green, blue) {
return (
Math.max(red, green, blue) - Math.min(red, green, blue) > 36 &&
(green > 90 || blue > 100)
)
}
function createTaskbarIcon(source) {
const output = new PNG({ width: 640, height: 640 })
const bounds = {
left: 80,
top: 90,
right: 660,
bottom: 535
}
const offsetX = 30
const offsetY = 90
for (let y = bounds.top; y < bounds.bottom; y += 1) {
for (let x = bounds.left; x < bounds.right; x += 1) {
const sourceOffset = pixelOffset(source, x, y)
const red = source.data[sourceOffset]
const green = source.data[sourceOffset + 1]
const blue = source.data[sourceOffset + 2]
const maximum = Math.max(red, green, blue)
const minimum = Math.min(red, green, blue)
const insideFace =
Math.hypot(x - 244, y - 380) <= 96 ||
Math.hypot(x - 490, y - 380) <= 96
const keep =
isBrandColor(red, green, blue) ||
maximum < 110 ||
(insideFace && minimum > 210)
if (!keep) {
continue
}
const targetX = x - bounds.left + offsetX
const targetY = y - bounds.top + offsetY
const targetOffset = pixelOffset(output, targetX, targetY)
for (let channel = 0; channel < 4; channel += 1) {
output.data[targetOffset + channel] =
source.data[sourceOffset + channel]
}
}
}
return resize(output, 512, 512, 'bicubicInterpolation')
}
function assertTaskbarIcon(image) {
let visiblePixels = 0
let colorfulPixels = 0
for (let index = 0; index < image.data.length; index += 4) {
if (image.data[index + 3] < 16) {
continue
}
visiblePixels += 1
if (
isBrandColor(
image.data[index],
image.data[index + 1],
image.data[index + 2]
)
) {
colorfulPixels += 1
}
}
if (
image.data[pixelOffset(image, 0, 0) + 3] !== 0 ||
visiblePixels < 40_000 ||
colorfulPixels < 20_000
) {
throw new Error('Windows 任务栏图标生成失败')
}
}
function assertTransparentCorners(image, label) {
const corners = [
pixelOffset(image, 0, 0),
pixelOffset(image, image.width - 1, 0),
pixelOffset(image, 0, image.height - 1),
pixelOffset(image, image.width - 1, image.height - 1)
]
if (corners.some((offset) => image.data[offset + 3] !== 0)) {
throw new Error(`${label} 的圆角外侧必须透明`)
}
}
function assertDarkEdgeHasNoWhiteFringe(image) {
const edgeWidth = Math.max(4, Math.round(image.width * 0.08))
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
if (
x >= edgeWidth &&
x < image.width - edgeWidth &&
y >= edgeWidth &&
y < image.height - edgeWidth
) {
continue
}
const offset = pixelOffset(image, x, y)
const alpha = image.data[offset + 3]
if (
alpha > 0 &&
alpha < 255 &&
Math.max(
image.data[offset],
image.data[offset + 1],
image.data[offset + 2]
) > 96
) {
throw new Error(
`暗色图标的透明边缘仍包含白色像素:${x},${y} ` +
`rgba(${image.data[offset]},${image.data[offset + 1]},` +
`${image.data[offset + 2]},${alpha})`
)
}
}
}
}
function repairDarkCursor(dark, light) {
for (let y = 570; y <= 606; y += 1) {
const backgroundOffset = pixelOffset(dark, 640, y)
for (let x = 540; x <= 568; x += 1) {
const targetOffset = pixelOffset(dark, x, y)
if (
isBrandColor(
dark.data[targetOffset],
dark.data[targetOffset + 1],
dark.data[targetOffset + 2]
)
) {
continue
}
const lightOffset = pixelOffset(light, x + 20, y + 17)
if (
isBrandColor(
light.data[lightOffset],
light.data[lightOffset + 1],
light.data[lightOffset + 2]
)
) {
dark.data[targetOffset] = light.data[lightOffset]
dark.data[targetOffset + 1] = light.data[lightOffset + 1]
dark.data[targetOffset + 2] = light.data[lightOffset + 2]
dark.data[targetOffset + 3] = 255
continue
}
dark.data[targetOffset] = dark.data[backgroundOffset]
dark.data[targetOffset + 1] = dark.data[backgroundOffset + 1]
dark.data[targetOffset + 2] = dark.data[backgroundOffset + 2]
dark.data[targetOffset + 3] = 255
}
}
}
function assertCursorRemoved(light, dark) {
for (let y = 246; y <= 284; y += 1) {
for (let x = 636; x <= 664; x += 1) {
const offset = pixelOffset(light, x, y)
if (
Math.max(
light.data[offset],
light.data[offset + 1],
light.data[offset + 2]
) < 190
) {
throw new Error('亮色图标的鼠标指针修复失败')
}
}
}
for (let y = 570; y <= 606; y += 1) {
for (let x = 540; x <= 568; x += 1) {
const offset = pixelOffset(dark, x, y)
const channels = [
dark.data[offset],
dark.data[offset + 1],
dark.data[offset + 2]
]
if (
Math.max(...channels) - Math.min(...channels) < 20 &&
Math.max(...channels) > 80
) {
throw new Error('暗色图标的鼠标指针修复失败')
}
}
}
}
async function main() {
const lightSource = PNG.sync.read(await readFile(lightSourcePath))
const darkSource = PNG.sync.read(await readFile(darkSourcePath))
const lightSquare = cropSquare(lightSource, 744)
const darkSquare = cropSquare(darkSource, 718)
repairLightCursor(lightSquare)
repairDarkCursor(darkSquare, lightSquare)
assertCursorRemoved(lightSquare, darkSquare)
const taskbar = createTaskbarIcon(lightSquare)
const light = createRoundedIcon(lightSquare, lightTile, 512)
const dark = createRoundedIcon(darkSquare, darkTile, 512)
const rendererLight = createRoundedIcon(lightSquare, lightTile, 128)
const rendererDark = createRoundedIcon(darkSquare, darkTile, 128)
assertTaskbarIcon(taskbar)
assertTransparentCorners(light, '亮色图标')
assertTransparentCorners(dark, '暗色图标')
assertTransparentCorners(rendererLight, '亮色界面图标')
assertTransparentCorners(rendererDark, '暗色界面图标')
assertTransparentCorners(taskbar, '任务栏图标')
assertDarkEdgeHasNoWhiteFringe(dark)
assertDarkEdgeHasNoWhiteFringe(rendererDark)
const tray = resize(taskbar, 32, 32, 'bicubicInterpolation')
assertTransparentCorners(tray, '托盘图标')
const lightPng = PNG.sync.write(light)
const darkPng = PNG.sync.write(dark)
const taskbarPng = PNG.sync.write(taskbar)
const trayPng = PNG.sync.write(tray)
const rendererLightPng = PNG.sync.write(rendererLight)
const rendererDarkPng = PNG.sync.write(rendererDark)
await Promise.all([
mkdir(rendererAssetRoot, { recursive: true }),
mkdir(websiteAssetRoot, { recursive: true })
])
const outputs = [
[join(root, 'build', 'icon-light.png'), lightPng],
[join(root, 'build', 'icon-dark.png'), darkPng],
[join(root, 'build', 'icon.png'), lightPng],
[join(root, 'build', 'icon-taskbar.png'), taskbarPng],
[join(root, 'build', 'icon-tray.png'), trayPng],
[join(rendererAssetRoot, 'goodbuddy-light.png'), rendererLightPng],
[join(rendererAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng],
[join(websiteAssetRoot, 'goodbuddy-light.png'), rendererLightPng],
[join(websiteAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng]
]
await Promise.all(outputs.map(([path, contents]) => writeFile(path, contents)))
const lightIco = await pngToIco(lightPng)
const darkIco = await pngToIco(darkPng)
const taskbarIco = await pngToIco(taskbarPng)
await Promise.all([
writeFile(join(root, 'build', 'icon-light.ico'), lightIco),
writeFile(join(root, 'build', 'icon-dark.ico'), darkIco),
writeFile(join(root, 'build', 'icon.ico'), lightIco),
writeFile(join(root, 'build', 'icon-taskbar.ico'), taskbarIco)
])
}
await main()