Files
abdulive/docker/entrypoint.ts
T
ZeasyAlexandClaude Opus 5 974dfd075b S4: Untertitel aus der Mikrofonspur, plus vorgegebenes OBS-Layout
whisper.cpp laeuft ueber die Tonspur und liefert Tokens; daraus werden
Woerter mit Zeitstempeln (Wortstuecke wie "Sav"+"ina" wieder zusammengesetzt)
und daraus kurze Zeilen im ASS-Format mit wortweiser Hervorhebung. SRT und
Transkript fallen nebenbei ab.

An einem echten deutschen Clip geprueft. Drei Fehler kamen erst dabei heraus:
Zeilen mit einem einzigen Wort bei zoegerndem Sprechen, ein Nachlauf, der in
die naechste Zeile ragte (ASS stapelt gleichzeitige Zeilen uebereinander),
und ein Uebertragsfehler im Zeitformat bei 59,999 s.

Layout wird jetzt VORGEGEBEN statt nachgemessen: ein Vergleich zweier
Aufnahmen zeigte, dass die Facecam von Stream zu Stream anders liegt. Das
Profil "default" beschreibt die Vorgabe (Facecam 480x270 oben links, Chat ab
x=1405, Gameplay-Band 890 breit mittig auf x=960), "bestand-2026-09" haelt
den alten Zustand fuer altes Material fest. Dazu eine Ausrichtungsvorlage als
Bild, die in OBS ueber die Szene gelegt wird, und docs/obs-layout.md.

Befund am echten Clip: im Streambild sind bereits zwei Textebenen eingebrannt
(Spieluntertitel und ein Live-Caption-Overlay). Beides ist im Layout-Dokument
behandelt.

53 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
2026-09-09 10:55:54 +02:00

439 lines
18 KiB
JavaScript

#!/usr/bin/env node
// Einstiegspunkt des Container-Images.
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 { 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, toLines } from '#media/subtitles.ts';
import { loadConfig } from '#core/config.ts';
import { openDb } from '#db/index.ts';
import { secretStatus } from '#twitch/secrets.ts';
const WHISPER = process.env.WHISPER_BIN || 'whisper-cli';
const OBS_HOST = process.env.OBS_WS_HOST || 'host.docker.internal';
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-toolkit - 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');
try {
const v = run('ffmpeg', ['-version']).split('\n')[0] ?? '';
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
const cfg = run('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)');
} catch { report('bad', 'ffmpeg', 'nicht im PATH'); }
try { run('ffprobe', ['-version']); report('ok', 'ffprobe'); }
catch { report('bad', 'ffprobe', 'nicht im PATH'); }
try {
run(WHISPER, ['--help']);
report('ok', 'whisper-cli', WHISPER);
} catch (err) {
const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`;
report(text.includes('whisper') || text.includes('usage') ? 'ok' : 'bad', 'whisper-cli',
text.includes('whisper') ? WHISPER : 'nicht lauffaehig');
}
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 (Volume)', paths.db],
['Arbeitsverzeichnis (Volume)', paths.work],
['Sprachmodelle (Volume)', paths.models],
['Clips (Bind-Mount)', 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 - Mount gesetzt?`); }
}
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: process.env['WHISPER_BIN'] ?? 'whisper-cli',
model,
language: cfg.clipper.whisper.language,
threads: cfg.clipper.whisper.threads,
});
const lines = toLines(words);
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', `ass=${ass}`, '-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`);
}
// ---------------------------------------------------------------- help
function help(): void {
console.log(`
abdulive-toolkit - Container-Einstiegspunkt
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
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
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('../apps/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 'render': await renderOne(args[0], args[1]); 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;
}