diff --git a/README.md b/README.md index 6ebfce3..d56290b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # EuerChat - euer.tv IRC & Livestream Client (Windows 64-bit) -

euer.tv chat

+

euer radio

Ein moderner, performanter und schlanker Desktop-Client für Windows 64-bit, gebaut mit **Rust + Tauri v2** und einem ansprechenden **Dark-Mode Web-Frontend**. diff --git a/assets/logo-icon.svg b/assets/logo-icon.svg deleted file mode 100644 index c1f312f..0000000 --- a/assets/logo-icon.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/logo-original.png b/assets/logo-original.png new file mode 100644 index 0000000..44e16fc Binary files /dev/null and b/assets/logo-original.png differ diff --git a/assets/logo-wide.png b/assets/logo-wide.png deleted file mode 100644 index 3660e4c..0000000 Binary files a/assets/logo-wide.png and /dev/null differ diff --git a/assets/logo-wide.svg b/assets/logo-wide.svg deleted file mode 100644 index b976e07..0000000 --- a/assets/logo-wide.svg +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - -euer.tv -euer.tv -euer.tv - -chat -chat -chat - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/installer/header.bmp b/installer/header.bmp index 349e671..09ca78b 100644 Binary files a/installer/header.bmp and b/installer/header.bmp differ diff --git a/installer/sidebar.bmp b/installer/sidebar.bmp index ab794e4..fa3e972 100644 Binary files a/installer/sidebar.bmp and b/installer/sidebar.bmp differ diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index ce0929a..a41eb1a 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 7bab16d..0d1c8de 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 44304ec..31f9c36 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index a614aac..7092bc9 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/tools/make-branding.mjs b/tools/make-branding.mjs index 3953461..d409c68 100644 --- a/tools/make-branding.mjs +++ b/tools/make-branding.mjs @@ -1,5 +1,6 @@ -// Erzeugt das EuerChat-Logo (Icon, Wortmarke, Installer-Bilder) aus SVG und rendert es mit Edge (Headless). -// Aufruf: node tools/make-branding.mjs +// 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'; @@ -14,157 +15,139 @@ const EDGE = [ ].find(fs.existsSync); if (!EDGE) throw new Error('Microsoft Edge nicht gefunden'); -const FONT = "'Segoe UI Black','Arial Black',Verdana,sans-serif"; +const sourcePath = path.join(root, 'assets', 'logo-original.png'); +const sourcePng = fs.readFileSync(sourcePath); +const dataUrl = 'data:image/png;base64,' + sourcePng.toString('base64'); // --------------------------------------------------------------------------- -// SVG-Bausteine +// PNG lesen (nur 8 Bit RGB/RGBA ohne Interlacing, wie vom Logo und von Edge geliefert) // --------------------------------------------------------------------------- -/** Rotes Orb-Symbol mit Chrom-Mikrofon und Chat-Blase (512x512). `p` macht die IDs eindeutig. */ -function iconInner(p) { - const slots = []; - for (let y = 144; y <= 268; y += 17) { - slots.push(``); - slots.push(``); +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; } - return ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ${slots.join('')} - - - - - - - - - - - - -`; + 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 }; } -function iconSvg(size) { - return `${iconInner('i')}`; -} - -/** Chrom-Schriftzug: dunkle Kontur, silberner Rand, helle Fuellung. */ -function chromeText(p, x, y, size, text, width) { - const common = `x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" text-anchor="middle" textLength="${width}" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round"`; - return ` -${text} -${text} -${text}`; -} - -function textDefs(p) { - return ` - - - - - - - - -`; -} - -/** Breites Logo im Stil des euer.radio-Logos: [euer.tv] (Orb) [chat] in einer roten Pille. */ -function wideSvg(measure) { - const size = 170; - const wL = Math.round(measure.left); - const wR = Math.round(measure.right); - const orb = 540; - const gap = 60; - const padEnd = 150; - const width = padEnd + wL + gap + orb + gap + wR + padEnd; - const height = 560; - const cx = padEnd + wL + gap + orb / 2; - const baseline = 335; - return { - width, - height, - svg: ` - - ${textDefs('w')} - - - - - - - - - - - - -${chromeText('w', padEnd + wL / 2 + 20, baseline, size, 'euer.tv', wL)} -${chromeText('w', width - padEnd - wR / 2 - 20, baseline, size, 'chat', wR)} -${iconInner('w')} -`, +/** 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 }; } -/** Hochformat fuer die Willkommensseite des Installers (164x314). */ -function sidebarSvg() { - return ` - - ${textDefs('s')} - - - - - -${iconInner('s')} -${chromeText('s', 82, 214, 31, 'euer.tv', 126)} -${chromeText('s', 82, 262, 40, 'chat', 92)} -`; +// --------------------------------------------------------------------------- +// 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 `
+
`; } -function headerSvg(wide) { - const w = 150, h = 57; - const scale = Math.min(w / wide.width, h / wide.height); - return ` - -${wide.svg.replace(/^]*>/, '').replace(/<\/svg>\s*$/, '')} -`; -} +const wideHtml = (w, h, bg = 'transparent') => `
`; + +const sidebarHtml = () => `
+
+${orbHtml(116, 'position:absolute;left:24px;top:26px;box-shadow:0 6px 18px rgba(0,0,0,.6)')} + +
EuerChat
+
IRC & LIVESTREAM
+
`; // --------------------------------------------------------------------------- // Rendering mit Edge (Chrome DevTools Protocol) @@ -198,23 +181,21 @@ async function startEdge() { await send('Page.enable'); await send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }); return { - send, - async html(markup, width, height) { + async render(markup, width, height) { await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); const doc = `${markup}`; await send('Page.navigate', { url: 'data:text/html;base64,' + Buffer.from(doc).toString('base64') }); - await new Promise((r) => setTimeout(r, 500)); - await send('Runtime.evaluate', { expression: 'document.fonts.ready.then(()=>1)', awaitPromise: true }); - }, - async png() { + 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'); }, - async eval(expression) { - const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); - return r.result?.value; - }, 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; + }, }; } @@ -243,45 +224,6 @@ function makeIco(frames) { return Buffer.concat([header, ...entries, ...sorted.map((f) => f.png)]); } -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 (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 }; -} - /** 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); @@ -309,42 +251,30 @@ function pngToBmp(png, bg = [255, 255, 255]) { // --------------------------------------------------------------------------- +console.log(`Logo ${logo.width}x${logo.height}, Ball: Mitte (${orb.cx}, ${orb.cy}), Radius ${orb.r}`); const edge = await startEdge(); try { - // Textbreiten der Wortmarke messen, damit nichts verzerrt wird - await edge.html(`euer.tvchat`, 400, 200); - const measure = { - left: await edge.eval("document.getElementById('a').getComputedTextLength()"), - right: await edge.eval("document.getElementById('b').getComputedTextLength()"), - }; - console.log('Textbreiten:', measure); - - const render = async (svg, w, h) => { await edge.html(svg, w, h); return edge.png(); }; const out = { icons: path.join(root, 'src-tauri', 'icons'), - assets: path.join(root, 'assets'), 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 render(iconSvg(size), size, size) }); - fs.writeFileSync(path.join(out.icons, 'icon.ico'), makeIco(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 render(iconSvg(512), 512, 512)); + 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)); - const wide = wideSvg(measure); - fs.writeFileSync(path.join(out.assets, 'logo-wide.png'), await render(wide.svg, wide.width, wide.height)); - fs.writeFileSync(path.join(out.assets, 'logo-icon.svg'), iconSvg(512)); - fs.writeFileSync(path.join(out.assets, 'logo-wide.svg'), wide.svg); - - fs.writeFileSync(path.join(out.installer, 'sidebar.bmp'), pngToBmp(await render(sidebarSvg(), 164, 314), [5, 0, 0])); - fs.writeFileSync(path.join(out.installer, 'header.bmp'), pngToBmp(await render(headerSvg(wide), 150, 57))); - console.log('Fertig:', Object.values(out).join('\n ')); + 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(); } diff --git a/ui/logo.png b/ui/logo.png index ce0929a..a41eb1a 100644 Binary files a/ui/logo.png and b/ui/logo.png differ