FreeLLMAPI / desktop /scripts /gen-icons.mjs
Nryn215's picture
Upload folder using huggingface_hub
077865a verified
Raw
History Blame Contribute Delete
6.12 kB
// Generates the desktop icons from the brand mark (client/public/favicon.svg
// design: dot inside a broken routing ring) without any image dependencies β€”
// pure-Node PNG encoding (zlib IDAT + hand-rolled CRC32), 4x supersampled.
//
// Outputs:
// assets/trayTemplate.png 16x16 black-on-transparent (macOS template)
// assets/trayTemplate@2x.png 32x32
// assets/appicon_1024.png full-color app icon (icns built via iconutil)
import { deflateSync } from 'node:zlib';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const assets = path.resolve(__dirname, '../assets');
fs.mkdirSync(assets, { recursive: true });
// ── 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 (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(rgba, w, h) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const raw = Buffer.alloc((w * 4 + 1) * h);
for (let y = 0; y < h; y++) {
raw[y * (w * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
// ── rasterizer ─────────────────────────────────────────────────────────────
// Renders with 4x supersampling. `shapes` is an ordered list painted
// back-to-front; each returns coverage [0..1] at a sample point plus a color.
function render(size, shapes) {
const SS = 4;
const S = size * SS;
const img = Buffer.alloc(size * size * 4);
for (let py = 0; py < size; py++) {
for (let px = 0; px < size; px++) {
let r = 0, g = 0, b = 0, a = 0;
for (let sy = 0; sy < SS; sy++) {
for (let sx = 0; sx < SS; sx++) {
const x = (px * SS + sx + 0.5) / S; // normalized [0,1]
const y = (py * SS + sy + 0.5) / S;
// composite shapes front-to-back at this sample
let sr = 0, sg = 0, sb = 0, sa = 0;
for (const sh of shapes) {
const cov = sh.cover(x, y);
if (cov <= 0) continue;
const [cr, cg, cb, ca] = sh.color;
const srcA = ca * cov;
sr = cr * srcA + sr * (1 - srcA);
sg = cg * srcA + sg * (1 - srcA);
sb = cb * srcA + sb * (1 - srcA);
sa = srcA + sa * (1 - srcA);
}
r += sr; g += sg; b += sb; a += sa;
}
}
const n = SS * SS;
const idx = (py * size + px) * 4;
const alpha = a / n;
img[idx] = alpha > 0 ? Math.round((r / n / alpha) * 255) : 0;
img[idx + 1] = alpha > 0 ? Math.round((g / n / alpha) * 255) : 0;
img[idx + 2] = alpha > 0 ? Math.round((b / n / alpha) * 255) : 0;
img[idx + 3] = Math.round(alpha * 255);
}
}
return encodePng(img, size, size);
}
const dist = (x, y, cx, cy) => Math.hypot(x - cx, y - cy);
// Ring with a gap. gapCenter/gapWidth in degrees, standard math orientation.
function ringCover(x, y, cx, cy, radius, stroke, gapCenterDeg, gapWidthDeg) {
const d = Math.abs(dist(x, y, cx, cy) - radius);
if (d > stroke / 2) return 0;
let ang = (Math.atan2(cy - y, x - cx) * 180) / Math.PI; // y-flip: screen→math
let delta = Math.abs(((ang - gapCenterDeg + 540) % 360) - 180);
return delta < gapWidthDeg / 2 ? 0 : 1;
}
const circle = (cx, cy, r, color) => ({
color,
cover: (x, y) => (dist(x, y, cx, cy) <= r ? 1 : 0),
});
const ring = (cx, cy, r, stroke, gapC, gapW, color) => ({
color,
cover: (x, y) => ringCover(x, y, cx, cy, r, stroke, gapC, gapW),
});
const roundedRect = (x0, y0, x1, y1, rad, color) => ({
color,
cover: (x, y) => {
const qx = Math.max(x0 + rad - x, 0, x - (x1 - rad));
const qy = Math.max(y0 + rad - y, 0, y - (y1 - rad));
if (x < x0 || x > x1 || y < y0 || y > y1) return 0;
return Math.hypot(qx, qy) <= rad ? 1 : 0;
},
});
// ── tray template (black on transparent; macOS tints it) ──────────────────
// Slightly chunkier than the 64px favicon so it reads at 16px.
const BLACK = [0, 0, 0, 1];
const trayShapes = [
ring(0.5, 0.5, 0.36, 0.115, 45, 80, BLACK), // gap top-right, like the mark
circle(0.5, 0.5, 0.185, BLACK),
];
fs.writeFileSync(path.join(assets, 'trayTemplate.png'), render(16, trayShapes));
fs.writeFileSync(path.join(assets, 'trayTemplate@2x.png'), render(32, trayShapes));
// ── app icon (1024, HIG grid: art occupies ~824/1024 rounded rect) ────────
const BG = [0x09 / 255, 0x09 / 255, 0x0b / 255, 1]; // #09090b
const FG = [0xfa / 255, 0xfa / 255, 0xfa / 255, 1]; // #fafafa
const FG40 = [0xfa / 255, 0xfa / 255, 0xfa / 255, 0.4];
const inset = 100 / 1024, rectR = 180 / 1024;
const appShapes = [
roundedRect(inset, inset, 1 - inset, 1 - inset, rectR, BG),
ring(0.5, 0.5, 232 / 1024, 45 / 1024, 45, 80, FG40),
circle(0.5, 0.5, 122 / 1024, FG),
];
fs.writeFileSync(path.join(assets, 'appicon_1024.png'), render(1024, appShapes));
console.log('icons written to', assets);