Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /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>
134 lines
5.3 KiB
TypeScript
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`);
|
|
}
|