#!/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 = { '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 { 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 { 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 { 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 { 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) { 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 { 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 { if (!input) throw new Error('Datei angeben: render [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 { 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((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((_, 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 => { 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 { 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 { 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 \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 = { 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 { 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 [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; }