Files
abdulive/apps/cli/main.ts
T
ZeasyAlexandClaude Opus 5.5 63ea40d5a8 Vorlesen: Piper im Hub statt Sprachausgabe im Browser
In OBS' Browser-Quelle kam `speechSynthesis` amerikanisch heraus: der
eingebaute Browser sah keine deutsche Stimme, und die Seite nahm die erste
beliebige. Welche Stimmen es gibt, haengt am Rechner, der streamt.

Jetzt spricht der Hub mit Piper (lokal im LXC, 200 Zeichen in 0,7 s). Die
WAV liegt kurz im Speicher, `/tts` holt sie unter /tts/audio/<id> mit dem
Overlay-Schluessel und spielt sie ueber <audio> ab - das laeuft sicher ueber
OBS' Tonspur. Der Browser springt nur noch mit einer deutschen Stimme ein,
sonst wird nur angezeigt. Stimme und Sprecher stehen in rewards.tts.

Dazu:

- `abdulive rewards:anlegen`: Belohnung ueber die API anlegen. Nur die App,
  die sie angelegt hat, darf Einloesungen abschliessen oder erstatten.
- Einloesungen werden nach der Aktion als FULFILLED markiert, statt in
  Twitchs Warteschlange liegen zu bleiben.
- Wortfilter normalisiert vorher: Leetspeak, Punkte dazwischen, gezogene
  Buchstaben, Akzente. Kurze Eintraege nur am Wortanfang.
- Der Testknopf "Vorlesen" geht durch Piper und prueft so die echte Stimme.
- scripts/install-piper.sh richtet Piper und Stimme im LXC ein.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:58:36 +02:00

972 lines
40 KiB
JavaScript

#!/usr/bin/env node
// Einstiegspunkt der Anwendung. Bis 2026-09 war das der Container-
// Entrypoint; siehe ADR-0007.
import { execFile, execFileSync } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { loadLayout, paths, type Rect } from '#core/config.ts';
import { binaries, fontsDir } from '#core/platform.ts';
import { assFilter, probe, run as runMedia } from '#media/ffmpeg.ts';
import { micTrackArgs, thumbnailArgs, verticalArgs, verticalFilter } from '#media/vertical.ts';
import { transcribe } from '#media/whisper.ts';
import { buildAss, buildSrt, lineOptionsFor, toLines } from '#media/subtitles.ts';
import { loadConfig } from '#core/config.ts';
import { openDb } from '#db/index.ts';
import { secretStatus } from '#twitch/secrets.ts';
import { judgeSource } from '#obs/scene-report.ts';
// Nativ laeuft OBS auf demselben Rechner. `host.docker.internal` war die
// Bruecke aus dem Container heraus und gibt es hier nicht mehr.
const OBS_HOST = process.env.OBS_WS_HOST || '127.0.0.1';
const OBS_PORT = Number(process.env.OBS_WS_PORT || 4455);
const MODELS: Record<string, string> = {
'ggml-small.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
'ggml-medium.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
'ggml-base.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
};
const C = process.stdout.isTTY
? { ok: '\x1b[32m', warn: '\x1b[33m', bad: '\x1b[31m', dim: '\x1b[90m', off: '\x1b[0m' }
: { ok: '', warn: '', bad: '', dim: '', off: '' };
let hardFailures = 0;
let warnings = 0;
function report(state: 'ok' | 'warn' | 'bad', label: string, detail = ''): void {
const mark = { ok: `${C.ok} OK ${C.off}`, warn: `${C.warn} WARN ${C.off}`, bad: `${C.bad} FEHLT${C.off}` }[state];
if (state === 'bad') hardFailures++;
if (state === 'warn') warnings++;
console.log(`${mark} ${label.padEnd(34)} ${C.dim}${detail}${C.off}`);
}
function section(title: string): void {
console.log(`\n${title}`);
console.log('-'.repeat(Math.max(title.length, 40)));
}
function run(cmd: string, args: string[]): string {
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
}
function runAsync(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) =>
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr }));
});
}
async function canWrite(dir: string): Promise<boolean> {
const probe = path.join(dir, `.probe-${process.pid}`);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(probe, 'x');
await fs.unlink(probe);
return true;
} catch { return false; }
}
function tcpProbe(host: string, port: number, timeout = 2500): Promise<boolean> {
return new Promise((resolve) => {
const sock = new net.Socket();
const done = (r: boolean) => { sock.destroy(); resolve(r); };
sock.setTimeout(timeout);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false));
sock.once('error', () => done(false));
sock.connect(port, host);
});
}
// ---------------------------------------------------------------- check-env
async function checkEnv(): Promise<void> {
console.log('\nabdulive - Selbsttest der Umgebung');
section('Laufzeit');
const major = Number(process.versions.node.split('.')[0]);
report(major >= 24 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
try {
const { DatabaseSync } = await import('node:sqlite');
new DatabaseSync(':memory:').close();
report('ok', 'node:sqlite (eingebaut)');
} catch (err) {
report('bad', 'node:sqlite', (err as Error).message.slice(0, 50));
}
report(typeof WebSocket !== 'undefined' ? 'ok' : 'bad', 'WebSocket (eingebaut)');
section('Medienwerkzeuge');
// Die Programme liegen neben der Anwendung, nicht im PATH.
const nichtGefunden = (bin: string) =>
bin.includes('/') || bin.includes('\\')
? `nicht startbar: ${bin}`
: 'nicht gefunden - weder im mitgelieferten bin-Ordner noch im PATH';
try {
const v = run(binaries.ffmpeg, ['-version']).split('\n')[0] ?? '';
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
const cfg = run(binaries.ffmpeg, ['-hide_banner', '-buildconf']);
report(cfg.includes('--enable-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)');
report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)');
// Ohne die mitgelieferten Schriften setzt libass wortlos eine
// Ersatzschrift ein - der Untertitel sieht dann anders aus als gedacht.
const fonts = fontsDir();
report(fonts ? 'ok' : 'bad', ' Schriften', fonts ?? 'kein fonts-Ordner gefunden');
} catch { report('bad', 'ffmpeg', nichtGefunden(binaries.ffmpeg)); }
try { run(binaries.ffprobe, ['-version']); report('ok', 'ffprobe'); }
catch { report('bad', 'ffprobe', nichtGefunden(binaries.ffprobe)); }
try {
run(binaries.whisper, ['--help']);
report('ok', 'whisper-cli', binaries.whisper);
} catch (err) {
const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`;
const laeuft = text.includes('whisper') || text.includes('usage');
report(laeuft ? 'ok' : 'bad', 'whisper-cli',
laeuft ? binaries.whisper : nichtGefunden(binaries.whisper));
}
section('Sprachmodell');
let anyModel = false;
for (const name of Object.keys(MODELS)) {
try {
const st = await fs.stat(path.join(paths.models, name));
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
anyModel = true;
} catch { /* nicht vorhanden */ }
}
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
section('Verzeichnisse');
for (const [label, dir] of [
['Datenbank', paths.db],
['Arbeitsverzeichnis', paths.work],
['Sprachmodelle', paths.models],
['Clips', paths.clips],
] as const) {
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
}
for (const [label, dir, hard] of [
['OBS-Wiedergabepuffer', paths.replay, false],
['Konfiguration', paths.config, true],
] as const) {
try {
const n = (await fs.readdir(dir)).length;
report('ok', label, `${dir} lesbar, ${n} Eintraege`);
} catch { report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar`); }
}
section('Datenbank');
try {
const db = openDb();
const n = (db.prepare('SELECT COUNT(*) AS n FROM schema_migrations').get() as { n: number }).n;
report('ok', 'Schema', `${n} Migration(en) angewendet`);
} catch (err) {
report('bad', 'Schema', (err as Error).message.slice(0, 60));
}
section('Layout (Facecam-Ausschnitt)');
try {
const layout = loadLayout();
const prof = layout.profiles?.['default'];
if (!prof) throw new Error('Profil "default" fehlt');
const sum = prof.cam.band.h + prof.game.band.h;
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
`${prof.cam.band.h} + ${prof.game.band.h} = ${sum} (soll ${layout.target.height})`);
for (const [name, c] of [['Facecam', prof.cam.crop], ['Gameplay', prof.game.crop]] as const) {
const inside = c.x >= 0 && c.y >= 0 &&
c.x + c.w <= layout.source.width && c.y + c.h <= layout.source.height;
report(inside ? 'ok' : 'bad', `${name}-Ausschnitt im Bild`, `${c.w}x${c.h} bei ${c.x},${c.y}`);
}
for (const [name, c, b] of [
['Facecam', prof.cam.crop, prof.cam.band], ['Gameplay', prof.game.crop, prof.game.band],
] as const) {
const dev = Math.abs((c.w / c.h) / (b.w / b.h) - 1) * 100;
report(dev < 1 ? 'ok' : 'warn', `${name}-Seitenverhaeltnis`,
`Abweichung ${dev.toFixed(2)} % (>1 % verzerrt sichtbar)`);
}
} catch (err) {
report('bad', 'config/layout.yaml', (err as Error).message);
}
section('OBS');
const obsUp = await tcpProbe(OBS_HOST, OBS_PORT);
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
`${OBS_HOST}:${OBS_PORT}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
section('Zugangsdaten');
// Meldet nur, OB etwas hinterlegt ist - nie was. Siehe ADR-0005.
const st = secretStatus();
for (const role of ['broadcaster', 'bot'] as const) {
const a = st.apps[role];
report(a.clientId && a.hasSecret ? 'ok' : 'warn', `${role}: App-Daten`,
a.clientId ? (a.hasSecret ? 'Client-ID und Secret hinterlegt' : 'Secret fehlt') : 'nichts hinterlegt');
report(st.tokens[role].connected ? 'ok' : 'warn', `${role}: Twitch verbunden`,
st.tokens[role].connected ? `als ${st.tokens[role].login}` : 'Einrichtung unter /setup');
}
report(st.obs ? 'ok' : 'warn', 'OBS-Passwort', st.obs ? 'hinterlegt' : 'Einrichtung unter /setup');
console.log('');
if (hardFailures > 0) {
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Noch nicht einsatzbereit.`);
process.exitCode = 1;
} else {
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen.` +
(warnings ? ' Warnungen ohne Einrichtung sind normal.' : ''));
}
}
// ---------------------------------------------------------------- fetch-model
async function fetchModel(name = 'ggml-small.bin'): Promise<void> {
const url = MODELS[name];
if (!url) {
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
process.exitCode = 1;
return;
}
await fs.mkdir(paths.models, { recursive: true });
const target = path.join(paths.models, name);
try {
const st = await fs.stat(target);
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
return;
} catch { /* laden */ }
console.log(`Lade ${name} ...`);
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok || !res.body) throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
const total = Number(res.headers.get('content-length') || 0);
let seen = 0;
let lastPrint = 0;
const tmp = `${target}.part`;
await pipeline(
(async function* () {
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
seen += chunk.length;
const now = Date.now();
if (now - lastPrint > 1000) {
lastPrint = now;
const pct = total ? ` (${((seen / total) * 100).toFixed(0)} %)` : '';
process.stdout.write(`\r ${(seen / 1024 / 1024).toFixed(0)} MB${pct} `);
}
yield chunk;
}
})(),
createWriteStream(tmp)
);
await fs.rename(tmp, target);
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
}
// ---------------------------------------------------------------- calibrate
async function calibrate(input?: string, profileName = 'default'): Promise<void> {
const layout = loadLayout();
const prof = layout.profiles?.[profileName];
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
if (!input) {
const files = (await fs.readdir(paths.replay).catch(() => [] as string[]))
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpe?g)$/i.test(f)).sort();
const last = files[files.length - 1];
if (!last) throw new Error(`Keine Datei angegeben und nichts in ${paths.replay} gefunden.`);
input = path.join(paths.replay, last);
console.log(`Neueste Datei aus ${paths.replay}: ${last}`);
}
const outDir = path.join(paths.clips, '_calibration');
await fs.mkdir(outDir, { recursive: true });
const seek = /\.(png|jpe?g)$/i.test(input) ? [] : ['-ss', '5'];
const quiet = ['-y', '-hide_banner', '-loglevel', 'error'];
const frame = path.join(outDir, 'frame.png');
await runAsync('ffmpeg', [...quiet, ...seek, '-i', input, '-frames:v', '1', frame]);
const preview = path.join(outDir, `preview-${profileName}.png`);
await runAsync('ffmpeg', [...quiet, '-i', frame, '-filter_complex', verticalFilter(prof),
'-map', '[v]', '-frames:v', '1', preview]);
const marked = path.join(outDir, 'ausschnitte.png');
const box = (r: Rect, color: string) => `drawbox=x=${r.x}:y=${r.y}:w=${r.w}:h=${r.h}:color=${color}:t=4`;
await runAsync('ffmpeg', [...quiet, '-i', frame, '-vf',
`${box(prof.cam.crop, 'yellow')},${box(prof.game.crop, 'cyan')}`, '-frames:v', '1', marked]);
console.log('\nGeschrieben:');
console.log(` ${frame} Einzelbild aus der Quelle`);
console.log(` ${marked} Ausschnitte markiert (gelb = Cam, cyan = Gameplay)`);
console.log(` ${preview} fertige 9:16-Vorschau`);
console.log('\nSitzt der Ausschnitt nicht, die Werte in config/layout.yaml anpassen');
console.log('und den Befehl erneut ausfuehren.');
}
// ---------------------------------------------------------------- render
/**
* Rendert eine Rohdatei nach 9:16. Dieselben Bausteine wie die Warteschlange
* im Daemon, aber ohne Datenbank - gedacht zum Nachrendern, wenn sich das
* Layout geaendert hat.
*/
async function renderOne(input?: string, profileName = 'default'): Promise<void> {
if (!input) throw new Error('Datei angeben: render <datei> [profil]');
const layout = loadLayout();
const prof = layout.profiles?.[profileName];
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
const cfg = loadConfig();
const info = await probe(input);
console.log(`Quelle: ${info.width}x${info.height}, ${info.durationSeconds.toFixed(1)} s, ` +
`${info.audio.length} Tonspur(en)`);
for (const [i, a] of info.audio.entries()) {
console.log(` Spur ${i}: ${a.codec}, ${a.channels} Kanal/Kanaele` +
(a.title ? ` - "${a.title}"` : ''));
}
if (info.width !== layout.source.width || info.height !== layout.source.height) {
console.log(` Achtung: Layout ist fuer ${layout.source.width}x${layout.source.height} ` +
'eingemessen, der Ausschnitt sitzt vermutlich falsch.');
}
const outDir = path.join(paths.clips, '_manuell', new Date().toISOString().replace(/[:.]/g, '-'));
await fs.mkdir(outDir, { recursive: true });
const vertical = path.join(outDir, 'vertical.mp4');
const track = cfg.clipper.audio.outputTrack < info.audio.length
? cfg.clipper.audio.outputTrack : 0;
const tail = Math.min(cfg.clipper.tailSeconds, Math.max(1, Math.floor(info.durationSeconds)));
const started = Date.now();
let lastShown = 0;
await runMedia('ffmpeg', verticalArgs({
input, output: vertical, profile: prof,
tailSeconds: tail,
audioTrack: track,
encoder: cfg.clipper.ffmpeg.encoder,
preset: cfg.clipper.ffmpeg.preset,
crf: cfg.clipper.ffmpeg.crf,
}), {
niceness: cfg.clipper.ffmpeg.niceness,
onProgress: (sec) => process.stdout.write(`
${sec.toFixed(0)} s ... `),
});
await runMedia('ffmpeg', thumbnailArgs(vertical, path.join(outDir, 'thumb.jpg')));
// ---- Untertitel (S4)
const model = path.join(paths.models, cfg.clipper.whisper.model);
let finalFile = vertical;
if (await fs.stat(model).then(() => true, () => false)) {
const micTrack = cfg.clipper.audio.transcribeTrack < info.audio.length
? cfg.clipper.audio.transcribeTrack : 0;
if (micTrack !== cfg.clipper.audio.transcribeTrack) {
console.log(` Hinweis: Tonspur ${cfg.clipper.audio.transcribeTrack} (Mikrofon) gibt es ` +
`nicht - transkribiere Spur ${micTrack}. Bei Twitch-Clips ist das normal, ` +
'die enthalten nur die Mischung.');
}
const wav = path.join(outDir, 'mic.wav');
await runMedia('ffmpeg', micTrackArgs(input, wav, micTrack, tail));
console.log(' Transkribiere ...');
const words = await transcribe(wav, {
binary: binaries.whisper,
model,
language: cfg.clipper.whisper.language,
threads: cfg.clipper.whisper.threads,
});
// Zeilenlaenge aus der Zielbreite ableiten, nicht raten - sonst laeuft
// der Text seitlich aus dem Bild (siehe subtitles.ts, maxUnitsFor).
const lines = toLines(words, lineOptionsFor(1080));
const ass = path.join(outDir, 'subs.ass');
await fs.writeFile(ass, buildAss(lines, { width: 1080, height: 1920 }));
await fs.writeFile(path.join(outDir, 'subs.srt'), buildSrt(lines));
await fs.writeFile(path.join(outDir, 'transcript.txt'),
lines.map((l) => l.words.map((w) => w.text).join(' ')).join('\n'));
finalFile = path.join(outDir, 'final.mp4');
await runMedia('ffmpeg', [
'-y', '-hide_banner', '-loglevel', 'error', '-i', vertical,
'-vf', assFilter(ass, fontsDir()), '-c:a', 'copy',
'-c:v', cfg.clipper.ffmpeg.encoder, '-preset', cfg.clipper.ffmpeg.preset,
'-crf', String(cfg.clipper.ffmpeg.crf), '-pix_fmt', 'yuv420p',
'-movflags', '+faststart', finalFile,
], { niceness: cfg.clipper.ffmpeg.niceness });
console.log(` ${words.length} Woerter, ${lines.length} Zeilen`);
} else {
console.log(` Kein Sprachmodell unter ${model} - ohne Untertitel. ` +
'Holen mit: fetch-model');
}
const out = await probe(finalFile);
if (process.stdout.isTTY) process.stdout.write('\r');
console.log(`\nFertig: ${finalFile}`);
console.log(` ${out.width}x${out.height}, ${out.durationSeconds.toFixed(1)} s, ` +
`Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`);
}
// ---------------------------------------------------------------- sources
/**
* Listet auf, was OBS in der laufenden Szene zusammensetzt - und sagt zu jeder
* Quelle, ob sie im 9:16-Ausschnitt landet.
*
* Der Anlass: In einem fertigen Clip lagen fremde Untertitel im Bild, und
* niemand wusste, woher sie kommen. Das ist auch nicht zu erraten - sie sind
* ins Streambild eingebrannt, also muss man nachsehen, welche Quelle sie malt.
* Genau das macht dieser Befehl.
*/
type SceneItemTransform = {
positionX: number; positionY: number;
width: number; height: number;
cropLeft: number; cropRight: number; cropTop: number; cropBottom: number;
};
type SceneItem = {
sceneItemId: number;
sourceName: string;
inputKind: string | null;
isGroup: boolean | null;
sceneItemEnabled: boolean;
sceneItemTransform: SceneItemTransform;
};
async function listSources(profileName = 'default'): Promise<void> {
const cfg = loadConfig();
const layout = loadLayout();
const prof = layout.profiles?.[profileName];
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
const { createObs } = await import('#obs/client.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const abort = new AbortController();
let ready: (() => void) | null = null;
const identified = new Promise<void>((res) => { ready = res; });
const obs = createObs(cfg.obs.url, () => readSecrets().obs?.password, {
onIdentified: () => ready?.(),
});
const running = obs.run(abort.signal);
running.catch(() => { /* Fehler kommt ueber das Rennen unten */ });
// Der Zeitgeber muss wieder weg. Bleibt er stehen, haengt der Befehl nach
// dem Bericht noch bis zu 15 s im Container, obwohl er fertig ist.
let ticker: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, rej) => {
ticker = setTimeout(
() => rej(new Error(`OBS antwortet nicht unter ${cfg.obs.url}. Laeuft OBS, und ist ` +
'Werkzeuge -> WebSocket-Server-Einstellungen -> Server aktivieren gesetzt?')), 15_000);
});
try {
await Promise.race([identified, running, timeout]);
const { currentProgramSceneName: scene } =
await obs.request<{ currentProgramSceneName: string }>('GetCurrentProgramScene');
console.log(`\nSzene: ${scene}`);
console.log(`Layoutprofil: ${profileName} (Cam ${rectStr(prof.cam.crop)}, ` +
`Gameplay ${rectStr(prof.game.crop)})\n`);
const rows: string[] = [];
const verdaechtig: string[] = [];
// Von hinten nach vorne: OBS liefert die oberste Quelle zuerst, im Bild
// liegt sie aber oben drauf. Umgedreht liest es sich wie der Stapel.
const walk = async (name: string, tiefe: number): Promise<void> => {
const req = tiefe === 0 ? 'GetSceneItemList' : 'GetGroupSceneItemList';
const { sceneItems } = await obs.request<{ sceneItems: SceneItem[] }>(
req, { sceneName: name });
for (const it of [...sceneItems].reverse()) {
const t = it.sceneItemTransform;
const box = {
x: Math.round(t.positionX), y: Math.round(t.positionY),
w: Math.round(t.width), h: Math.round(t.height),
};
const kind = it.isGroup ? 'Gruppe' : (it.inputKind ?? 'unbekannt');
// Browser-Quellen verraten ueber die Adresse, welcher Dienst dahinter
// steckt - bei fremden Untertiteln ist das meistens die Antwort.
let url = '';
if (!it.isGroup && it.inputKind === 'browser_source') {
const got = await obs.request<{ inputSettings: { url?: string } }>(
'GetInputSettings', { inputName: it.sourceName }).catch(() => null);
url = got?.inputSettings?.url ?? '';
}
const urteil = judgeSource({
box, enabled: it.sceneItemEnabled, name: it.sourceName, kind, url,
cam: prof.cam.crop, game: prof.game.crop,
});
const einzug = ' '.repeat(tiefe);
const sicht = it.sceneItemEnabled ? '' : `${C.dim} (ausgeblendet)${C.off}`;
const lage = `${String(box.x).padStart(5)},${String(box.y).padStart(4)} ` +
`${String(box.w).padStart(4)}x${String(box.h).padEnd(4)}`;
const treffer = !it.sceneItemEnabled ? `${C.dim}—${C.off}`
: urteil.inShort
? `${C.warn}im Short${C.off} (Cam ${urteil.cam} %, Gameplay ${urteil.game} %)`
: `${C.ok}ausserhalb${C.off}`;
rows.push(`${einzug}${it.sourceName.padEnd(30 - tiefe * 2)} ${C.dim}${kind.padEnd(20)}` +
`${C.off}${lage} ${treffer}${sicht}`);
if (url) rows.push(`${einzug} ${C.dim}${url.slice(0, 110)}${C.off}`);
if (urteil.suspectCaption) {
verdaechtig.push(`${it.sourceName} (${kind})${url ? ` - ${url}` : ''}`);
}
if (it.isGroup) await walk(it.sourceName, tiefe + 1);
}
};
await walk(scene, 0);
console.log(`${'Quelle'.padEnd(30)} ${'Art'.padEnd(20)}${'x,y'.padStart(10)} ` +
`${'Groesse'.padEnd(10)} Im 9:16-Ausschnitt?`);
console.log('-'.repeat(100));
for (const r of rows) console.log(r);
console.log('\nAlles, was mit "im Short" markiert ist, landet im fertigen Clip.');
if (verdaechtig.length) {
console.log(`\n${C.warn}Sieht nach fremden Untertiteln aus:${C.off}`);
for (const v of verdaechtig) console.log(` - ${v}`);
console.log('\nDiese Quellen aus dem Gameplay-Band schieben (oder ausblenden) -\n' +
'das Toolkit macht eigene Untertitel. Plaetze: docs/obs-layout.md');
} else {
console.log('Nichts erkannt, was nach Untertiteln aussieht. Steht trotzdem Text im\n' +
'Clip, kommt er aus dem Spiel selbst (dort abschalten) oder aus einer Quelle,\n' +
'die anders heisst - dann die Liste oben von oben nach unten durchgehen.');
}
} finally {
clearTimeout(ticker);
abort.abort();
// Klemmt OBS, bleibt der WebSocket im Schliess-Handshake stehen und der
// Prozess laeuft weiter, obwohl der Bericht schon dasteht. Genau dann
// fuehrt man dieses Werkzeug aber aus. Der Wachhund ist ge-unref-t: im
// Normalfall haelt er nichts auf und schlaegt nie an.
setTimeout(() => {
console.error(`${C.dim}(OBS schliesst die Verbindung nicht - beende trotzdem)${C.off}`);
process.exit(process.exitCode === undefined ? 0 : Number(process.exitCode));
}, 3000).unref();
}
}
const rectStr = (r: Rect) => `x ${r.x}..${r.x + r.w}, y ${r.y}..${r.y + r.h}`;
// ---------------------------------------------------------------- discord
/** Liest eine Eingabe, ohne sie im Terminal anzuzeigen. */
function promptHidden(frage: string): Promise<string> {
return new Promise((resolve, reject) => {
const input = process.stdin;
if (!input.isTTY) {
reject(new Error(`${frage.trim()} - dafuer wird ein Terminal gebraucht.`));
return;
}
process.stdout.write(frage);
input.setRawMode(true);
input.resume();
input.setEncoding('utf8');
let buf = '';
const stop = (): void => { input.off('data', onData); input.setRawMode(false); input.pause(); };
const onData = (chunk: string): void => {
for (const c of chunk) {
if (c === '\r' || c === '\n') { stop(); process.stdout.write('\n'); resolve(buf.trim()); return; }
if (c === '\u0003') { stop(); process.stdout.write('\n'); reject(new Error('Abgebrochen.')); return; }
if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue; }
buf += c;
}
};
input.on('data', onData);
});
}
async function discordSetup(args: string[]): Promise<void> {
const dry = args.includes('--vorschau');
const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
const stored = readSecrets().discord ?? {};
const at = args.indexOf('--server');
const fromArgs = at >= 0 ? args[at + 1] : undefined;
const guildId = fromArgs ?? stored.guildId ?? process.env.DISCORD_GUILD_ID;
if (!guildId || guildId.startsWith('--')) {
throw new Error(
'Keine Server-ID. Aufruf: abdulive discord:setup --server <ID>\n' +
'Die ID steht in Discord im Rechtsklickmenue des Servers unter "Server-ID kopieren"\n' +
'(dafuer in den Einstellungen unter "Erweitert" den Entwicklermodus einschalten).'
);
}
const token = stored.botToken || process.env.DISCORD_BOT_TOKEN ||
await promptHidden('Discord-Bot-Token (Eingabe wird nicht angezeigt): ');
if (!token) throw new Error('Ohne Bot-Token geht es nicht.');
const { Discord } = await import('#discord/api.ts');
const { applyPlan } = await import('#discord/apply.ts');
const result = await applyPlan(new Discord(token), guildId, { dryRun: dry });
if (!dry) updateSecrets((s) => { s.discord = { ...s.discord, botToken: token, guildId }; });
const mark: Record<string, string> = {
erstellt: `${C.ok} NEU ${C.off}`,
angepasst: `${C.ok} GESETZT${C.off}`,
unveraendert: `${C.dim} steht ${C.off}`,
uebersprungen: `${C.warn} UEBERSPRUNGEN${C.off}`,
};
console.log(`\nServer: ${result.guild}${dry ? ` ${C.dim}(Vorschau - nichts geaendert)${C.off}` : ''}\n`);
let kind = '';
for (const s of result.steps) {
if (s.kind !== kind) { console.log(`${C.dim}${s.kind}${C.off}`); kind = s.kind; }
const note = s.note ? ` ${C.dim}(${s.note})${C.off}` : '';
console.log(` ${mark[s.change] ?? s.change} ${s.name}${note}`);
}
const skipped = result.steps.filter((s) => s.change === 'uebersprungen').length;
console.log('');
if (dry) {
console.log('Nichts geaendert. Ohne --vorschau wird es angewendet.');
return;
}
console.log(`${result.webhooks} Webhook-URLs im verschluesselten Speicher abgelegt.`);
if (skipped) console.log(`${C.warn}${skipped} Punkt(e) uebersprungen - siehe Hinweise oben.${C.off}`);
console.log(`
Was jetzt noch von Hand gehoert:
1. Dir selbst die Rolle "Abdu" geben und die Bot-Rolle in den
Servereinstellungen ueber "Abdu" ziehen.
2. Server-Einstellungen > Onboarding: "Live-Ping" als waehlbare Rolle
anbieten, damit sie sich jeder selbst holen kann.
3. Einladungslink erzeugen (ohne Ablaufdatum) und in Twitch-Panel,
Kanalinfo und Chat-Befehl eintragen.
4. Twitch-Konto in den Servereinstellungen unter "Integrationen"
verbinden, dann bekommen Subs automatisch eine Rolle.`);
}
// ---------------------------------------------------------------- overlay
/**
* Overlay-Server allein starten. Zum Ansehen und Einrichten, ohne dass der
* ganze Hub laeuft. Mit --demo laeuft erfundener Betrieb: anders koennte man
* eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet.
*/
async function overlayOnly(args: string[]): Promise<void> {
const cfg = loadConfig();
const { startOverlayServer } = await import('../overlay-server/src/server.ts');
const { createBus } = await import('../overlay-server/src/bus.ts');
const bus = createBus();
const abort = new AbortController();
process.on('SIGINT', () => { abort.abort(); process.exit(0); });
startOverlayServer({
port: cfg.overlay.port,
channel: cfg.channel.login,
publicUrl: cfg.publicUrl,
bus,
});
if (args.includes('--demo')) {
const { startDemo } = await import('../overlay-server/src/demo.ts');
startDemo(bus, abort.signal);
}
const wo = cfg.publicUrl || `http://127.0.0.1:${cfg.overlay.port}`;
console.log(`
Uebersicht mit allen Adressen fuer OBS: ${wo}/`);
console.log(`${C.dim}Beenden mit Strg+C.${C.off}`);
await new Promise(() => { /* laeuft bis Strg+C */ });
}
/** Passwort fuer die Weboberflaeche setzen. Nur hier, nie ueber das Netz. */
async function webPasswort(args: string[]): Promise<void> {
const { setzePasswort } = await import('../overlay-server/src/auth.ts');
const pw = args[0] || process.env.ABDULIVE_WEB_PASSWORT ||
await promptHidden('Neues Passwort (Eingabe wird nicht angezeigt): ');
setzePasswort(pw.trim());
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
}
// ---------------------------------------------------------------- golive
/**
* Probelauf der Go-Live-Ankuendigung.
*
* Ohne Argument geht der Post in den internen Kanal und pingt niemanden -
* damit laesst sich das Aussehen pruefen, ohne den Server aufzuwecken. Mit
* `--echt` geht er dorthin, wo er im Betrieb hinginge, mit Ping. Das ist der
* einzige Weg, vor dem ersten Stream zu sehen, ob der Ping wirklich ankommt;
* eine Erwaehnung ohne `allowed_mentions` sieht naemlich genauso aus wie eine
* mit, benachrichtigt aber niemanden.
*/
async function goLiveTest(args: string[]): Promise<void> {
const echt = args.includes('--echt');
const cfg = loadConfig();
const { createAnnouncer } = await import('../broadcaster/src/golive.ts');
const { repos } = await import('#db/repos.ts');
const { helix } = await import('#twitch/helix.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const discord = readSecrets().discord;
const ziel = echt ? discord?.goLiveWebhook : discord?.technikWebhook;
if (!ziel) {
throw new Error(echt
? 'Kein Go-Live-Webhook hinterlegt. Einmal `abdulive discord:setup` laufen lassen.'
: 'Kein Webhook fuer den internen Kanal. Einmal `abdulive discord:setup` laufen lassen ' +
'- der Technik-Webhook ist seit Fassung 0.6 im Bauplan.');
}
// Wenn Abdu gerade live ist, wird der echte Stream angekuendigt. Sonst ein
// erfundener - das prueft denselben Weg, nur mit anderem Text.
let stream = {
id: `probe-${Date.now()}`,
title: 'Probelauf der Go-Live-Ankuendigung',
category: 'Just Chatting',
login: cfg.channel.login,
startedAt: new Date().toISOString(),
} as import('../broadcaster/src/golive.ts').StreamInfo;
try {
const user = await helix.userByLogin(cfg.channel.login);
const live = user ? await helix.streamByUserId(user.id) : null;
if (live) {
stream = {
id: `probe-${live.id}-${Date.now()}`,
title: live.title,
category: live.game_name,
login: cfg.channel.login,
startedAt: live.started_at,
thumbnailUrl: live.thumbnail_url,
};
console.log(`${C.dim}Stream laeuft gerade - der Probelauf nimmt die echten Daten.${C.off}`);
} else {
console.log(`${C.dim}Kein laufender Stream - der Probelauf nimmt erfundene Daten.${C.off}`);
}
} catch (err) {
// Haeufigster Fall ist nicht "Netz weg", sondern "noch keine Twitch-App
// hinterlegt". Der Satz soll das sagen, sonst sucht man am falschen Ende.
console.log(`${C.warn}Twitch nicht abgefragt:${C.off} ${(err as Error).message}`);
console.log(`${C.dim}Der Probelauf nimmt erfundene Daten - der Weg zum Webhook ` +
`wird trotzdem vollstaendig geprueft.${C.off}`);
}
// OBS nur dazunehmen, wenn es laeuft: sonst wartet der Screenshot ins Leere.
let obs: Awaited<ReturnType<typeof withObs>> | null = null;
if (cfg.broadcaster.obsScreenshot && await tcpProbe(OBS_HOST, OBS_PORT, 1500)) {
obs = await withObs();
}
const announcer = createAnnouncer({
cfg: {
enabled: true,
mode: echt ? 'live' : 'draft',
delaySeconds: 0,
cooldownHours: cfg.broadcaster.cooldownHours,
pingRole: cfg.broadcaster.pingRole,
obsScreenshot: Boolean(obs),
},
repo: repos(openDb()),
...(obs ? { obs: obs.client } : {}),
// Ein Probelauf geht durch die Abklingzeit hindurch, sonst koennte man ihn
// nach einem echten Stream sechs Stunden lang nicht wiederholen.
refresh: async (s) => s,
});
// Als eigene Art protokollieren: sonst sperrt der Probelauf ueber die
// Abklingzeit die naechste echte Ankuendigung.
const result = await announcer.announceNow(stream, { force: true, art: 'golive-test' });
obs?.stop();
if (result.status === 'gesendet') {
const wohin = result.mode === 'draft' ? 'interner Kanal' : 'Ankuendigungen';
const ping = result.pinged ? `mit Ping an "${cfg.broadcaster.pingRole}"` : 'ohne Ping';
console.log(`
${C.ok}Post ist raus${C.off} (${wohin}, ${ping}).`);
if (result.url) console.log(` ${result.url}`);
if (echt && !result.pinged) {
console.log(`
${C.warn}Kein Ping gesetzt.${C.off} Pruefen: gibt es die Rolle ` +
`"${cfg.broadcaster.pingRole}" auf dem Server, und ist ein Bot-Token hinterlegt?`);
}
if (!echt) console.log(`
${C.dim}Mit --echt geht derselbe Post in die Ankuendigungen, ` +
`mit Ping.${C.off}`);
} else {
console.log(`
${C.bad}${result.status}${C.off}: ${result.reason}`);
process.exitCode = 1;
}
}
/** OBS kurz verbinden, damit ein Screenshot moeglich ist. */
async function withObs() {
const { createObs } = await import('#obs/client.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const abort = new AbortController();
const client = createObs(`ws://${OBS_HOST}:${OBS_PORT}`,
() => readSecrets().obs?.password, {});
void client.run(abort.signal);
// Kurz auf die Identifikation warten; ohne sie gibt es keinen Screenshot,
// und das ist kein Grund zum Abbruch.
for (let i = 0; i < 30 && !client.isConnected(); i++) {
await new Promise((r) => setTimeout(r, 100));
}
return { client, stop: () => abort.abort() };
}
async function discordBot(): Promise<void> {
const { createBot } = await import('#discord/bot.ts');
const bot = createBot();
if (!bot) {
throw new Error('Kein Discord-Bot hinterlegt. Erst einrichten: abdulive discord:setup');
}
const abort = new AbortController();
const stop = (sig: string): void => {
console.log(`\n${sig} empfangen, trenne die Verbindung.`);
abort.abort();
setTimeout(() => process.exit(0), 300);
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
console.log('Discord-Bot laeuft. Beenden mit Strg+C.');
console.log(`${C.dim}Im Normalbetrieb laeuft er im Ingest-Daemon mit; dieser Befehl ` +
`ist zum Ausprobieren.${C.off}`);
await bot.run(abort.signal);
}
// ---------------------------------------------------------------- rewards:anlegen
/**
* Eine Kanalpunkt-Belohnung ueber die API anlegen statt im Dashboard.
*
* Der Grund ist nicht Bequemlichkeit: Twitch laesst nur die App, die eine
* Belohnung angelegt hat, deren Einloesungen aendern. Eine im Dashboard
* angelegte Belohnung kann das Toolkit weder erstatten noch als erledigt
* markieren. Wiederholbar: steht die Belohnung schon, wird nur ihre ID
* gezeigt.
*/
async function rewardsAnlegen(args: string[]): Promise<void> {
const wert = (name: string): string | undefined => {
const i = args.indexOf(name);
return i === -1 ? undefined : args[i + 1];
};
const titel = wert('--titel');
const kosten = Number(wert('--kosten'));
if (!titel || !Number.isInteger(kosten) || kosten < 1) {
throw new Error('Aufruf: rewards:anlegen --titel "Nachricht vorlesen" --kosten 1000 ' +
'[--text] [--hinweis "..."] [--abklingzeit SEKUNDEN]');
}
const cfg = loadConfig();
const { helix } = await import('#twitch/helix.ts');
const user = await helix.userByLogin(cfg.channel.login);
if (!user) throw new Error(`Kanal ${cfg.channel.login} nicht gefunden`);
const alle = await helix.customRewards(user.id);
const eigene = new Set((await helix.customRewards(user.id, true)).map((r) => r.id));
const da = alle.find((r) => r.title.toLowerCase() === titel.toLowerCase());
if (da) {
console.log(`Steht schon: "${da.title}" (${da.cost} Punkte), ID ${da.id}`);
if (!eigene.has(da.id)) {
console.log('Achtung: im Dashboard angelegt, nicht vom Toolkit. Erstatten und ' +
'Abschliessen gehen damit nicht. Im Dashboard loeschen und hier neu anlegen.');
}
return;
}
const abklingzeit = Number(wert('--abklingzeit') ?? 0);
const neu = await helix.createCustomReward(user.id, {
title: titel,
cost: kosten,
...(args.includes('--text') ? { is_user_input_required: true } : {}),
...(wert('--hinweis') ? { prompt: wert('--hinweis')! } : {}),
...(abklingzeit > 0
? { is_global_cooldown_enabled: true, global_cooldown_seconds: abklingzeit } : {}),
should_redemptions_skip_request_queue: false,
});
console.log(`Angelegt: "${neu.title}" (${neu.cost} Punkte), ID ${neu.id}`);
console.log('In config.yaml unter rewards.belohnungen mit `id:` eintragen - die ID gewinnt gegen den Titel.');
}
// ---------------------------------------------------------------- help
function help(): void {
console.log(`
abdulive - Werkzeugkasten fuer Abdus Stream
hub Zentraler Dienst: Chat, Discord, Go-Live, Overlay
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
web:passwort [wort] Passwort fuer die Weboberflaeche setzen
check-env Umgebung pruefen
db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt?
update [--force] Nach einer neuen Fassung sehen und sie bereitlegen
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
discord:setup [--server ID] [--vorschau]
Discord-Server nach docs/projects/discord-community.md einrichten
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
Kanal ohne Ping, mit --echt in die Ankuendigungen
rewards:anlegen --titel T --kosten N [--text] [--hinweis H] [--abklingzeit S]
Kanalpunkt-Belohnung ueber die API anlegen (noetig
zum Erstatten, siehe apps/hub/src/rewards.ts)
help Diese Uebersicht
Einrichtung nach dem Start: http://localhost:4792/setup
Stand: Scheiben S0 bis S3 aus docs/07-mvp-plan.md. Untertitel (S4) und die
Sichtungsseite mit Onboarding (S5) folgen.
`);
}
// ---------------------------------------------------------------- main
const [cmd, ...args] = process.argv.slice(2);
try {
switch (cmd) {
case 'ingest': {
const { runIngest } = await import('../ingest/src/index.ts');
await runIngest();
break;
}
case 'check-env': await checkEnv(); break;
case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break;
case 'fetch-model': await fetchModel(args[0]); break;
case 'calibrate': await calibrate(args[0], args[1]); break;
case 'sources': await listSources(args[0]); break;
case 'update': {
const { checkForUpdate } = await import('./update.ts');
await checkForUpdate({ force: args.includes('--force') });
break;
}
case 'render': await renderOne(args[0], args[1]); break;
case 'discord:setup': await discordSetup(args); break;
case 'golive:test': await goLiveTest(args); break;
case 'rewards:anlegen': await rewardsAnlegen(args); break;
case 'hub': {
const { runHub } = await import('../hub/src/index.ts');
await runHub();
break;
}
case 'overlay': await overlayOnly(args); break;
case 'web:passwort': await webPasswort(args); break;
case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break;
default:
console.error(`Unbekannter Befehl "${cmd}".`);
help();
process.exitCode = 1;
}
} catch (err) {
console.error(`\n${C.bad}Fehler:${C.off} ${(err as Error).message}`);
const stderr = (err as { stderr?: string }).stderr;
if (stderr) console.error(String(stderr).trim().split('\n').slice(-5).join('\n'));
process.exitCode = 1;
}