281 lines
13 KiB
JavaScript
281 lines
13 KiB
JavaScript
// Erzeugt alle Icons und Installer-Bilder aus dem Original-Logo assets/logo-original.png (unveraendert verwendet).
|
|
// Quadratische Icons zeigen den runden Mikrofon-Ball aus dem Logo, breite Flaechen das komplette Logo.
|
|
// Aufruf: node tools/make-branding.mjs (rendert mit Microsoft Edge im Headless-Modus)
|
|
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import zlib from 'node:zlib';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const EDGE = [
|
|
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
].find(fs.existsSync);
|
|
if (!EDGE) throw new Error('Microsoft Edge nicht gefunden');
|
|
|
|
const sourcePath = path.join(root, 'assets', 'logo-original.png');
|
|
const sourcePng = fs.readFileSync(sourcePath);
|
|
const dataUrl = 'data:image/png;base64,' + sourcePng.toString('base64');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PNG lesen (nur 8 Bit RGB/RGBA ohne Interlacing, wie vom Logo und von Edge geliefert)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function decodePng(buf) {
|
|
let pos = 8;
|
|
let width = 0, height = 0, colorType = 0;
|
|
const idat = [];
|
|
while (pos < buf.length) {
|
|
const len = buf.readUInt32BE(pos);
|
|
const type = buf.toString('ascii', pos + 4, pos + 8);
|
|
const data = buf.subarray(pos + 8, pos + 8 + len);
|
|
if (type === 'IHDR') {
|
|
width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9];
|
|
if (data[8] !== 8 || data[12] !== 0 || (colorType !== 2 && colorType !== 6)) throw new Error('PNG-Format nicht unterstuetzt');
|
|
}
|
|
if (type === 'IDAT') idat.push(data);
|
|
pos += 12 + len;
|
|
}
|
|
const bpp = colorType === 6 ? 4 : 3;
|
|
const raw = zlib.inflateSync(Buffer.concat(idat));
|
|
const stride = width * bpp;
|
|
const px = Buffer.alloc(height * stride);
|
|
for (let y = 0; y < height; y++) {
|
|
const filter = raw[y * (stride + 1)];
|
|
for (let x = 0; x < stride; x++) {
|
|
const v = raw[y * (stride + 1) + 1 + x];
|
|
const a = x >= bpp ? px[y * stride + x - bpp] : 0;
|
|
const b = y > 0 ? px[(y - 1) * stride + x] : 0;
|
|
const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0;
|
|
let out;
|
|
if (filter === 0) out = v;
|
|
else if (filter === 1) out = v + a;
|
|
else if (filter === 2) out = v + b;
|
|
else if (filter === 3) out = v + ((a + b) >> 1);
|
|
else {
|
|
const p = a + b - c;
|
|
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
out = v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
|
|
}
|
|
px[y * stride + x] = out & 255;
|
|
}
|
|
}
|
|
return { width, height, bpp, px };
|
|
}
|
|
|
|
/** Findet den dunklen Ring um den roten Ball: senkrecht durch die Mitte (Durchmesser) und nahe dem oberen Rand (Mittelpunkt). */
|
|
function findOrb(logo) {
|
|
const { width, height, bpp, px } = logo;
|
|
const isDark = (x, y) => {
|
|
const i = (y * width + x) * bpp;
|
|
return (bpp === 4 ? px[i + 3] : 255) > 200 && Math.max(px[i], px[i + 1], px[i + 2]) < 70;
|
|
};
|
|
const cxEst = Math.round(width / 2);
|
|
|
|
let top = -1, bottom = -1;
|
|
for (let y = 0; y < height; y++) {
|
|
if (isDark(cxEst, y)) { if (top < 0) top = y; bottom = y; }
|
|
}
|
|
const r = (bottom - top) / 2;
|
|
const cy = (top + bottom) / 2;
|
|
|
|
// Die Messzeile nahe dem oberen Rand schneidet nur den Ring (links und rechts), nicht den Text.
|
|
let left = -1, right = -1;
|
|
for (let x = cxEst - 330; x <= cxEst + 330; x++) {
|
|
if (isDark(x, top + 40)) { if (left < 0) left = x; right = x; }
|
|
}
|
|
const cx = (left + right) / 2;
|
|
if (top < 0 || left < 0 || r < 200 || r > 300 || Math.abs(cx - cxEst) > 30) {
|
|
throw new Error(`Ball im Logo nicht erkannt (top=${top}, bottom=${bottom}, left=${left}, right=${right})`);
|
|
}
|
|
return { cx, cy, r };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTML-Bausteine (werden von Edge gerendert)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const logo = decodePng(sourcePng);
|
|
const orb = findOrb(logo);
|
|
|
|
// Im Original liegen Teile der Buchstaben ("r" von euer und "ra" von radio) ueber dem Ball.
|
|
// Fuer die runden Icons werden sie im Textband senkrecht zwischen den sauberen Pixeln darueber und darunter aufgefuellt;
|
|
// das Mikrofon bleibt unangetastet. Das breite Logo wird nie retuschiert.
|
|
const RETOUCH_JS = `(async () => {
|
|
const img = new Image(); img.src = ${JSON.stringify(dataUrl)}; await img.decode();
|
|
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
|
|
const ctx = c.getContext('2d', { willReadFrequently: true }); ctx.drawImage(img, 0, 0);
|
|
const d = ctx.getImageData(0, 0, c.width, c.height); const p = d.data; const W = c.width;
|
|
const O = ${JSON.stringify(orb)}; const s = O.r / 271;
|
|
const YT = Math.round(O.cy - 68 * s), Y0 = YT + 6, YB = Math.round(O.cy + 98 * s), Y1 = YB - 6;
|
|
const micL = O.cx - 126 * s, micR = O.cx + 82 * s, clearX = O.cx + 114 * s;
|
|
const at = (x, y) => { const i = (y * W + x) * 4; return [p[i], p[i + 1], p[i + 2], p[i + 3]]; };
|
|
for (let x = Math.floor(O.cx - O.r) - 5; x <= Math.ceil(O.cx + O.r) + 5; x++) {
|
|
if (x >= micL && x < micR) continue;
|
|
const a = at(x >= micR && x < clearX ? Math.round(clearX) : x, YT), b = at(x, YB);
|
|
// Neben dem Mikrofonkopf beginnt der Buchstabe erst weiter unten; den Kopfrand nicht anschneiden.
|
|
const yFrom = x >= micR && x < clearX ? Math.round(O.cy - 38 * s) : Y0;
|
|
for (let y = yFrom; y <= Y1; y++) {
|
|
const dx = x - O.cx, dy = y - O.cy;
|
|
if (dx * dx + dy * dy > (O.r + 5) * (O.r + 5)) continue;
|
|
const t = (y - YT) / (YB - YT), i = (y * W + x) * 4;
|
|
for (let k = 0; k < 4; k++) p[i + k] = Math.round(a[k] + (b[k] - a[k]) * t);
|
|
}
|
|
}
|
|
ctx.putImageData(d, 0, 0);
|
|
return c.toDataURL('image/png');
|
|
})()`;
|
|
|
|
// Quelle fuer die runden Icons; wird nach der Retusche ersetzt.
|
|
let orbUrl = dataUrl;
|
|
|
|
/** Runder Ausschnitt (Ball mit Mikrofon) in `size` Pixeln. */
|
|
function orbHtml(size, extra = '') {
|
|
const r = orb.r + 3;
|
|
const k = size / (2 * r);
|
|
return `<div style="position:relative;width:${size}px;height:${size}px;border-radius:50%;overflow:hidden;${extra}">
|
|
<img src="${orbUrl}" style="position:absolute;max-width:none;width:${logo.width * k}px;height:${logo.height * k}px;left:${-(orb.cx - r) * k}px;top:${-(orb.cy - r) * k}px"></div>`;
|
|
}
|
|
|
|
const wideHtml = (w, h, bg = 'transparent') => `<div style="width:${w}px;height:${h}px;background:${bg};display:flex;align-items:center;justify-content:center"><img src="${dataUrl}" style="width:100%;height:100%;object-fit:contain"></div>`;
|
|
|
|
const sidebarHtml = () => `<div style="position:relative;width:164px;height:314px;overflow:hidden;background:linear-gradient(#2b0606,#050000)">
|
|
<div style="position:absolute;inset:0;background:radial-gradient(circle at 50% 30%,rgba(255,40,16,.5),rgba(255,40,16,0) 62%)"></div>
|
|
${orbHtml(116, 'position:absolute;left:24px;top:26px;box-shadow:0 6px 18px rgba(0,0,0,.6)')}
|
|
<img src="${dataUrl}" style="position:absolute;left:6px;top:166px;width:152px;height:auto">
|
|
<div style="position:absolute;left:0;right:0;top:232px;text-align:center;font:900 25px 'Segoe UI Black','Arial Black',sans-serif;color:#fff;letter-spacing:.5px;text-shadow:0 0 10px rgba(255,50,30,.9),0 2px 3px #000">EuerChat</div>
|
|
<div style="position:absolute;left:0;right:0;top:266px;text-align:center;font:600 11px 'Segoe UI',sans-serif;color:#d8b8b8;letter-spacing:1.5px">IRC & LIVESTREAM</div>
|
|
</div>`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering mit Edge (Chrome DevTools Protocol)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function startEdge() {
|
|
const port = 9341;
|
|
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'euerchat-brand-'));
|
|
const proc = spawn(EDGE, [
|
|
'--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
|
|
'--hide-scrollbars', '--force-device-scale-factor=1', '--disable-gpu', 'about:blank',
|
|
], { stdio: 'ignore' });
|
|
let page;
|
|
for (let i = 0; i < 50 && !page; i++) {
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
try {
|
|
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
|
page = targets.find((t) => t.type === 'page');
|
|
} catch { /* Edge startet noch */ }
|
|
}
|
|
if (!page) throw new Error('Edge-Debugport nicht erreichbar');
|
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
await new Promise((r) => (ws.onopen = r));
|
|
let id = 0;
|
|
const pending = new Map();
|
|
ws.onmessage = (e) => {
|
|
const m = JSON.parse(e.data);
|
|
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id); }
|
|
};
|
|
const send = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
|
|
await send('Page.enable');
|
|
await send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
|
|
return {
|
|
async render(markup, width, height) {
|
|
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
|
const doc = `<!doctype html><html><body style="margin:0;background:transparent;overflow:hidden">${markup}</body></html>`;
|
|
await send('Page.navigate', { url: 'data:text/html;base64,' + Buffer.from(doc).toString('base64') });
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
await send('Runtime.evaluate', { expression: 'Promise.all([document.fonts.ready,...[...document.images].map(i=>i.decode())]).then(()=>1)', awaitPromise: true });
|
|
const r = await send('Page.captureScreenshot', { format: 'png', fromSurface: true });
|
|
return Buffer.from(r.data, 'base64');
|
|
},
|
|
close() { ws.close(); proc.kill(); },
|
|
async evaluate(expression) {
|
|
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
|
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));
|
|
return r.result.value;
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ICO / BMP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** ICO mit PNG-Frames, groesste zuerst (Tauri nimmt das erste Bild als Fenstersymbol). */
|
|
function makeIco(frames) {
|
|
const sorted = [...frames].sort((a, b) => b.size - a.size);
|
|
const header = Buffer.alloc(6);
|
|
header.writeUInt16LE(1, 2);
|
|
header.writeUInt16LE(sorted.length, 4);
|
|
let offset = 6 + sorted.length * 16;
|
|
const entries = sorted.map((f) => {
|
|
const e = Buffer.alloc(16);
|
|
e[0] = f.size >= 256 ? 0 : f.size;
|
|
e[1] = f.size >= 256 ? 0 : f.size;
|
|
e.writeUInt16LE(1, 4);
|
|
e.writeUInt16LE(32, 6);
|
|
e.writeUInt32LE(f.png.length, 8);
|
|
e.writeUInt32LE(offset, 12);
|
|
offset += f.png.length;
|
|
return e;
|
|
});
|
|
return Buffer.concat([header, ...entries, ...sorted.map((f) => f.png)]);
|
|
}
|
|
|
|
/** 24-Bit-BMP (NSIS kann nur BMP), Transparenz wird auf `bg` gemischt. */
|
|
function pngToBmp(png, bg = [255, 255, 255]) {
|
|
const { width, height, bpp, px } = decodePng(png);
|
|
const rowSize = Math.ceil((width * 3) / 4) * 4;
|
|
const out = Buffer.alloc(54 + rowSize * height);
|
|
out.write('BM', 0);
|
|
out.writeUInt32LE(out.length, 2);
|
|
out.writeUInt32LE(54, 10);
|
|
out.writeUInt32LE(40, 14);
|
|
out.writeInt32LE(width, 18);
|
|
out.writeInt32LE(height, 22);
|
|
out.writeUInt16LE(1, 26);
|
|
out.writeUInt16LE(24, 28);
|
|
out.writeUInt32LE(rowSize * height, 34);
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const i = (y * width + x) * bpp;
|
|
const alpha = bpp === 4 ? px[i + 3] / 255 : 1;
|
|
const o = 54 + (height - 1 - y) * rowSize + x * 3;
|
|
for (let c = 0; c < 3; c++) out[o + c] = Math.round(px[i + 2 - c] * alpha + bg[2 - c] * (1 - alpha));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log(`Logo ${logo.width}x${logo.height}, Ball: Mitte (${orb.cx}, ${orb.cy}), Radius ${orb.r}`);
|
|
const edge = await startEdge();
|
|
try {
|
|
const out = {
|
|
icons: path.join(root, 'src-tauri', 'icons'),
|
|
installer: path.join(root, 'installer'),
|
|
ui: path.join(root, 'ui'),
|
|
};
|
|
for (const d of Object.values(out)) fs.mkdirSync(d, { recursive: true });
|
|
|
|
orbUrl = await edge.evaluate(RETOUCH_JS);
|
|
|
|
const frames = [];
|
|
for (const size of [256, 128, 64, 48, 32, 24, 16]) frames.push({ size, png: await edge.render(orbHtml(size), size, size) });
|
|
const bySize = (s) => frames.find((f) => f.size === s).png;
|
|
fs.writeFileSync(path.join(out.icons, 'icon.ico'), makeIco(frames));
|
|
fs.writeFileSync(path.join(out.icons, '32x32.png'), bySize(32));
|
|
fs.writeFileSync(path.join(out.icons, '128x128.png'), bySize(128));
|
|
fs.writeFileSync(path.join(out.icons, 'icon.png'), await edge.render(orbHtml(512), 512, 512));
|
|
fs.writeFileSync(path.join(out.ui, 'logo.png'), bySize(128));
|
|
|
|
fs.writeFileSync(path.join(out.installer, 'sidebar.bmp'), pngToBmp(await edge.render(sidebarHtml(), 164, 314), [5, 0, 0]));
|
|
fs.writeFileSync(path.join(out.installer, 'header.bmp'), pngToBmp(await edge.render(wideHtml(150, 57), 150, 57)));
|
|
console.log('Fertig.');
|
|
} finally {
|
|
edge.close();
|
|
}
|