Files
ZeasyAlexandClaude Opus 5.5 8be195d938
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole)
- Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen
  ueber die neue Browser-Source /sounds
- Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem
  Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS
- Browser-Sources melden, welche Seite sie sind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:51:48 +01:00

134 lines
5.3 KiB
TypeScript

// Zeichnet die App-Symbole fuer das Pult (PWA) und schreibt sie nach
// apps/overlay-web/public/icons/. Ohne Grafikprogramm und ohne Abhaengigkeit:
// ein paar abgerundete Rechtecke, als PNG von Hand kodiert.
//
// Aufruf: node scripts/make-icons.ts
//
// Die PNGs liegen im Repo. Neu zeichnen nur, wenn sich das Motiv aendert.
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
const ZIEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
'..', 'apps', 'overlay-web', 'public', 'icons');
type Farbe = [number, number, number];
const hex = (h: string): Farbe =>
[parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const TASTEN: Farbe[] = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d'].map(hex);
const OBEN = hex('#221a3a');
const UNTEN = hex('#0d0b14');
/** Abstand zum Rand eines abgerundeten Rechtecks, negativ heisst innen. */
function rundRect(x: number, y: number, x0: number, y0: number, x1: number, y1: number, r: number): number {
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
const hx = (x1 - x0) / 2 - r, hy = (y1 - y0) / 2 - r;
const qx = Math.abs(x - cx) - hx, qy = Math.abs(y - cy) - hy;
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}
type Motiv = {
/** Hintergrund bis zum Rand (maskable, Apple) oder selbst abgerundet. */
vollflaechig: boolean;
/** Wo die vier Tasten sitzen, als Anteil der Kantenlaenge. */
von: number; bis: number;
};
function zeichne(groesse: number, m: Motiv): Buffer {
const px = Buffer.alloc(groesse * groesse * 4);
const ss = 4; // 4x4 Unterabtastung je Pixel
const spalt = (m.bis - m.von) * 0.09;
const taste = (m.bis - m.von - spalt) / 2;
for (let py = 0; py < groesse; py++) {
for (let pxi = 0; pxi < groesse; pxi++) {
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 = (pxi + (sx + 0.5) / ss) / groesse;
const y = (py + (sy + 0.5) / ss) / groesse;
const imGrund = m.vollflaechig || rundRect(x, y, 0, 0, 1, 1, 0.22) <= 0;
if (!imGrund) continue;
let c: Farbe = [0, 1, 2].map((i) => OBEN[i]! + (UNTEN[i]! - OBEN[i]!) * y) as Farbe;
for (let i = 0; i < 4; i++) {
const tx = m.von + (i % 2) * (taste + spalt);
const ty = m.von + Math.floor(i / 2) * (taste + spalt);
if (rundRect(x, y, tx, ty, tx + taste, ty + taste, taste * 0.26) <= 0) {
// Oben etwas heller: sieht nach Taste aus statt nach Flaeche.
const hell = 1.12 - 0.22 * ((y - ty) / taste);
c = TASTEN[i]!.map((v) => Math.min(255, v * hell)) as Farbe;
}
}
r += c[0]; g += c[1]; b += c[2]; a += 255;
}
}
const n = ss * ss, o = (py * groesse + pxi) * 4;
// Vormultipliziert gemittelt, damit die Kanten nicht dunkel ausfransen.
const deck = a / n;
px[o] = deck ? Math.round(r / (a / 255)) : 0;
px[o + 1] = deck ? Math.round(g / (a / 255)) : 0;
px[o + 2] = deck ? Math.round(b / (a / 255)) : 0;
px[o + 3] = Math.round(deck);
}
}
return png(groesse, groesse, px);
}
// ---------------------------------------------------------------- PNG
const CRC = new Uint32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(b: Buffer): number {
let c = 0xffffffff;
for (const v of b) c = CRC[(c ^ v) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function block(typ: string, daten: Buffer): Buffer {
const len = Buffer.alloc(4); len.writeUInt32BE(daten.length);
const td = Buffer.concat([Buffer.from(typ, 'latin1'), daten]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
function png(w: number, h: number, rgba: Buffer): Buffer {
const kopf = Buffer.alloc(13);
kopf.writeUInt32BE(w, 0); kopf.writeUInt32BE(h, 4);
kopf[8] = 8; kopf[9] = 6; kopf[10] = 0; kopf[11] = 0; kopf[12] = 0; // 8 bit RGBA
const zeilen = Buffer.alloc((w * 4 + 1) * h);
for (let y = 0; y < h; y++) rgba.copy(zeilen, 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]),
block('IHDR', kopf),
block('IDAT', zlib.deflateSync(zeilen, { level: 9 })),
block('IEND', Buffer.alloc(0)),
]);
}
// ---------------------------------------------------------------- los
const NORMAL: Motiv = { vollflaechig: false, von: 0.2, bis: 0.8 };
// Maskable: alles Wichtige im Kreis mit 40 % Radius, der Rest wird abgeschnitten.
const MASKE: Motiv = { vollflaechig: true, von: 0.29, bis: 0.71 };
// Apple rundet selbst und mag keine Durchsicht.
const APPLE: Motiv = { vollflaechig: true, von: 0.22, bis: 0.78 };
fs.mkdirSync(ZIEL, { recursive: true });
const dateien: Array<[string, number, Motiv]> = [
['pult-192.png', 192, NORMAL],
['pult-512.png', 512, NORMAL],
['pult-maskable-512.png', 512, MASKE],
['apple-touch-icon.png', 180, APPLE],
];
for (const [name, groesse, motiv] of dateien) {
const daten = zeichne(groesse, motiv);
fs.writeFileSync(path.join(ZIEL, name), daten);
console.log(`${name} ${groesse}x${groesse} ${(daten.length / 1024).toFixed(1)} KB`);
}