Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein Ding, das mitten im Stream kaputtgehen kann. Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md: Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource` verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge: der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar), und ein Zustands-Schnappschuss beim Verbinden. Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur ueber EventSub. GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten. gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif PRIVMSG #abdulive_ :[Idk GIF by Respective] Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn. Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]" im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094 Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse eben nicht im Text steht. Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind: - Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der Ausblend-Animation aus dem DOM verschwinden, und lief in eine Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag eingefroren. - Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe gueltig aus und wuerde den falschen Textteil ersetzen. Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht, sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus. build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket 404 statt Overlay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
230 lines
8.8 KiB
TypeScript
230 lines
8.8 KiB
TypeScript
// Baut das Windows-Paket, das Abdu bekommt.
|
|
//
|
|
// node scripts/build-package.ts [--version 0.5.0] [--out dist]
|
|
//
|
|
// Ergebnis in dist/:
|
|
// abdulive-<version>-win-x64.zip das Paket
|
|
// abdulive.json Manifest fuer den Updater
|
|
//
|
|
// Laeuft auf jedem Rechner, auch unter Linux in der CI - es wird nichts
|
|
// ausgefuehrt, nur heruntergeladen, entpackt und wieder eingepackt.
|
|
// Siehe ADR-0007.
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { readZip, writeZip } from '#core/zip.ts';
|
|
|
|
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
// ---------------------------------------------------------------- Bausteine
|
|
|
|
/**
|
|
* Node liefert versionierte, dauerhafte Adressen. ffmpeg und whisper.cpp
|
|
* nicht: BtbN raeumt alte Autobuilds nach einigen Wochen ab. Deshalb wird
|
|
* die jeweils aktuelle Ausgabe ueber die GitHub-API bestimmt und ihr genauer
|
|
* Name samt Pruefsumme ins Paket geschrieben - reproduzierbar ist damit das
|
|
* Paket, nicht die Adresse.
|
|
*/
|
|
const NODE_VERSION = 'v24.9.0';
|
|
|
|
type Component = {
|
|
name: string;
|
|
url: () => Promise<string>;
|
|
/** Welche Dateien aus dem Archiv, und wohin im Paket. */
|
|
pick: (entryName: string) => string | null;
|
|
};
|
|
|
|
const COMPONENTS: Component[] = [
|
|
{
|
|
name: 'node',
|
|
url: async () =>
|
|
`https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`,
|
|
pick: (n) => (n.endsWith('/node.exe') ? 'node.exe' : null),
|
|
},
|
|
{
|
|
name: 'ffmpeg',
|
|
url: () => githubAsset('BtbN/FFmpeg-Builds',
|
|
/^ffmpeg-n\d[\w.-]*win64-gpl-shared-[\d.]+\.zip$/),
|
|
// Aus dem shared-Build nur bin/: die beiden Programme und ihre DLLs.
|
|
// ffplay wird nicht gebraucht.
|
|
pick: (n) => {
|
|
const m = /\/bin\/(.+)$/.exec(n);
|
|
if (!m?.[1] || m[1].startsWith('ffplay')) return null;
|
|
return /\.(exe|dll)$/i.test(m[1]) ? `bin/${m[1]}` : null;
|
|
},
|
|
},
|
|
{
|
|
name: 'whisper',
|
|
url: () => githubAsset('ggml-org/whisper.cpp', /^whisper-bin-x64\.zip$/),
|
|
// whisper-cli plus die Bibliotheken. Die ggml-cpu-*.dll sind Varianten
|
|
// fuer verschiedene Befehlssaetze; ausgewaehlt wird zur Laufzeit, es
|
|
// muessen also alle mit.
|
|
pick: (n) => {
|
|
const m = /^Release\/(.+)$/.exec(n);
|
|
const f = m?.[1];
|
|
if (!f) return null;
|
|
if (f === 'whisper-cli.exe') return `bin/${f}`;
|
|
if (/^(whisper|ggml)[\w.-]*\.dll$/.test(f)) return `bin/${f}`;
|
|
return null;
|
|
},
|
|
},
|
|
];
|
|
|
|
async function githubAsset(repoName: string, pattern: RegExp): Promise<string> {
|
|
const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, {
|
|
headers: { 'user-agent': 'abdulive-build' },
|
|
});
|
|
if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`);
|
|
const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> };
|
|
const asset = rel.assets.find((a) => pattern.test(a.name));
|
|
if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`);
|
|
return asset.browser_download_url;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Werkzeug
|
|
|
|
const sha256 = (b: Buffer) => crypto.createHash('sha256').update(b).digest('hex');
|
|
|
|
async function download(url: string): Promise<Buffer> {
|
|
const cacheDir = path.join(repo, '.cache');
|
|
await fs.mkdir(cacheDir, { recursive: true });
|
|
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
|
|
|
|
const cached = await fs.readFile(file).catch(() => null);
|
|
if (cached) {
|
|
console.log(` aus dem Zwischenspeicher: ${path.basename(file)} ` +
|
|
`(${(cached.length / 1e6).toFixed(0)} MB)`);
|
|
return cached;
|
|
}
|
|
|
|
process.stdout.write(` lade ${path.basename(file)} ... `);
|
|
const res = await fetch(url, { redirect: 'follow' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} fuer ${url}`);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
await fs.writeFile(file, buf);
|
|
console.log(`${(buf.length / 1e6).toFixed(0)} MB`);
|
|
return buf;
|
|
}
|
|
|
|
/** Alle Dateien unterhalb `dir`, relativ, ohne die ausgeschlossenen. */
|
|
async function walk(dir: string, base = dir): Promise<string[]> {
|
|
const out: string[] = [];
|
|
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...await walk(full, base));
|
|
else out.push(path.relative(base, full).replace(/\\/g, '/'));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Bau
|
|
|
|
async function main(): Promise<void> {
|
|
const args = process.argv.slice(2);
|
|
const arg = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
const version = arg('--version') ?? new Date().toISOString().slice(0, 10).replace(/-/g, '.');
|
|
const outDir = path.resolve(repo, arg('--out') ?? 'dist');
|
|
|
|
console.log(`abdulive ${version} fuer Windows x64\n`);
|
|
|
|
const files: Array<{ name: string; data: Buffer }> = [];
|
|
const used: Record<string, { archiv: string; sha256: string }> = {};
|
|
|
|
// --- mitgelieferte Programme
|
|
for (const c of COMPONENTS) {
|
|
console.log(`${c.name}:`);
|
|
const url = await c.url();
|
|
const zip = await download(url);
|
|
used[c.name] = { archiv: path.basename(new URL(url).pathname), sha256: sha256(zip) };
|
|
|
|
let taken = 0;
|
|
for (const entry of readZip(zip)) {
|
|
if (entry.isDirectory) continue;
|
|
const target = c.pick(entry.name);
|
|
if (!target) continue;
|
|
files.push({ name: target, data: entry.data() });
|
|
taken++;
|
|
}
|
|
if (!taken) throw new Error(`${c.name}: keine Datei uebernommen - hat sich der Aufbau geaendert?`);
|
|
console.log(` ${taken} Datei(en) uebernommen`);
|
|
}
|
|
|
|
// --- Anwendung: Quellen, ohne Tests
|
|
console.log('\nAnwendung:');
|
|
for (const dir of ['packages', 'apps']) {
|
|
for (const rel of await walk(path.join(repo, dir))) {
|
|
if (rel.endsWith('.test.ts')) continue;
|
|
// html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in
|
|
// apps/overlay-web/public und werden zur Laufzeit von der Platte
|
|
// gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay.
|
|
if (!/\.(ts|sql|json|html|css|js|svg|png|woff2)$/.test(rel)) continue;
|
|
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
|
}
|
|
}
|
|
// Die "imports"-Map darin loest #core/... auf - ohne sie startet nichts.
|
|
files.push({ name: 'app/package.json', data: await fs.readFile(path.join(repo, 'package.json')) });
|
|
for (const f of ['config.example.yaml', 'layout.yaml']) {
|
|
files.push({ name: `app/config/${f}`, data: await fs.readFile(path.join(repo, 'config', f)) });
|
|
}
|
|
for (const rel of await walk(path.join(repo, 'assets', 'fonts'))) {
|
|
files.push({ name: `fonts/${rel}`, data: await fs.readFile(path.join(repo, 'assets/fonts', rel)) });
|
|
}
|
|
for (const f of ['launcher.ps1', 'install.ps1', 'uninstall.ps1', 'backup.ps1']) {
|
|
files.push({ name: f, data: await fs.readFile(path.join(repo, 'scripts', f)) });
|
|
}
|
|
console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`);
|
|
|
|
// --- Kopfdaten
|
|
const manifestInPackage = {
|
|
version,
|
|
gebaut: new Date().toISOString(),
|
|
node: NODE_VERSION,
|
|
bausteine: used,
|
|
};
|
|
files.push({
|
|
name: 'version.json',
|
|
data: Buffer.from(JSON.stringify(manifestInPackage, null, 2) + '\n'),
|
|
});
|
|
// NODE_OPTIONS setzen: node:sqlite meldet sich sonst bei jedem Aufruf mit
|
|
// einer ExperimentalWarning auf stderr. Im Container stand das in der
|
|
// Dockerfile - beim Umzug ging es verloren, und unter PowerShell wird aus
|
|
// stderr schnell ein Abbruch (siehe launcher.ps1).
|
|
files.push({
|
|
name: 'abdulive.cmd',
|
|
data: Buffer.from([
|
|
'@echo off',
|
|
'set "NODE_OPTIONS=--disable-warning=ExperimentalWarning"',
|
|
'"%~dp0node.exe" "%~dp0app\\apps\\cli\\main.ts" %*',
|
|
'',
|
|
].join('\r\n')),
|
|
});
|
|
|
|
// --- packen
|
|
await fs.mkdir(outDir, { recursive: true });
|
|
const zipName = `abdulive-${version}-win-x64.zip`;
|
|
const zip = writeZip(files);
|
|
await fs.writeFile(path.join(outDir, zipName), zip);
|
|
|
|
// Das Manifest, das der Updater abfragt. Die Adresse traegt die
|
|
// Veroeffentlichung ein - hier steht nur, wie die Datei heisst.
|
|
await fs.writeFile(path.join(outDir, 'abdulive.json'), JSON.stringify({
|
|
version,
|
|
datei: zipName,
|
|
groesse: zip.length,
|
|
sha256: sha256(zip),
|
|
veroeffentlicht: new Date().toISOString(),
|
|
}, null, 2) + '\n');
|
|
|
|
const roh = files.reduce((n, f) => n + f.data.length, 0);
|
|
console.log(`\nFertig: ${path.join(outDir, zipName)}`);
|
|
console.log(` ${files.length} Dateien, ${(roh / 1e6).toFixed(0)} MB roh, ` +
|
|
`${(zip.length / 1e6).toFixed(0)} MB gepackt`);
|
|
console.log(` sha256 ${sha256(zip)}`);
|
|
console.log('\nDas Sprachmodell (ca. 465 MB) ist nicht dabei - es wird bei der');
|
|
console.log('Einrichtung geladen (`abdulive fetch-model`).');
|
|
}
|
|
|
|
await main();
|