Files
abdulive/apps/cli/main.ts
T
ZeasyAlexandClaude Opus 5 994ce4faf0 Discord: Knopf fuer die Live-Ping-Rolle statt Onboarding
Live-Ping nuetzt nur, wenn man sie sich selbst geben kann. Discords
Onboarding waere der vorgesehene Weg, laesst sich hier aber nicht
einschalten - gemessen, nicht vermutet:

  HTTP 400  Cannot enable onboarding, requirements are not met  (350000)
  sichtbar fuer @everyone: 5   davon beschreibbar: 3
  Discord verlangt:        7   davon beschreibbar: 5

Zwei Kanaele anzulegen, damit eine Schwelle passt, macht den Server leerer
statt besser. Stattdessen ein Knopf unter dem Willkommenstext: ein Druck
holt die Rolle, der naechste gibt sie ab, die Antwort sieht nur, wer
geklickt hat.

Der Knopf braucht eine dauerhafte Verbindung, weil Discord Klicks ans
Gateway oder an eine oeffentliche HTTPS-Adresse liefert und der Stream-PC
keine hat:

- gateway.ts   WebSocket mit Herzschlag, Fortsetzen und Wiederverbinden.
               Bei 4004 und Verwandten wird aufgegeben statt im Kreis zu
               laufen - dagegen hilft nur eine Aenderung an der App.
               intents: 0, der Bot sieht weder Nachrichten noch Mitglieder.
- selfroles.ts Knopfleiste und Rollenwechsel.
- bot.ts       beides zusammen, laeuft im Ingest-Daemon mit. Ohne
               hinterlegtes Token entfaellt er wortlos.

Neuer Befehl `abdulive discord:bot` zum Ausprobieren; im Normalbetrieb
haengt der Bot im Daemon und meldet sich als discordConnected auf der
Statusseite.

Die Knopfleiste gehoert zum Soll-Zustand des Willkommenstextes: fehlt sie,
wird sie nachgereicht, ohne die Nachricht neu zu posten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 21:59:08 +02:00

731 lines
30 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.`);
}
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);
}
// ---------------------------------------------------------------- help
function help(): void {
console.log(`
abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite)
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)
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 '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;
}