Struktur, Doku und S0-Toolchain fuer das abdulive-Toolkit

Monorepo-Geruest fuer neun Streamer-Tools plus den Ingest-Daemon, der
unter sechs davon liegt. Enthaelt:

- docs/: Ueberblick, Architektur, Twitch-Integration, Datenmodell,
  Roadmap, Setup, Hebel-Analyse und den MVP-Plan "Clip-Knopf"
- docs/projects/: je eine Spezifikation pro Tool
- docs/decisions/: vier ADRs (TS-Monorepo, SQLite, EventSub/IRC, Docker)
- docker/: Image mit ffmpeg und whisper.cpp, abhaengigkeitsfreier
  Entrypoint mit check-env, fetch-model und calibrate
- config/layout.yaml: aus einem VOD-Screenshot eingemessener
  Facecam-Ausschnitt (440x330 oben links, Chat-Overlay ab x=1270)

Noch kein Anwendungscode - Scheibe S0 aus docs/07-mvp-plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 08:56:09 +02:00
commit d09e2b945a
98 files changed
+3273

No files matched your search

+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env node
// Einstiegspunkt des Container-Images. Scheibe S0 aus docs/07-mvp-plan.md.
//
// Bewusst ohne npm-Abhaengigkeiten: dieses Skript muss laufen, bevor
// irgendetwas anderes installiert oder gebaut ist.
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 { parseYaml } from './lib/yaml.js';
const ENV = {
whisper: process.env.WHISPER_BIN || 'whisper-cli',
modelDir: process.env.MODEL_DIR || '/models',
dbDir: process.env.DB_DIR || '/db',
workDir: process.env.WORK_DIR || '/work',
clipsDir: process.env.CLIPS_DIR || '/app/data/clips',
replayDir: process.env.REPLAY_DIR || '/replay',
configDir: process.env.CONFIG_DIR || '/app/config',
obsHost: process.env.OBS_WS_HOST || 'host.docker.internal',
obsPort: Number(process.env.OBS_WS_PORT || 4455),
};
const MODELS = {
'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',
};
// ---------------------------------------------------------------- Ausgabe
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, label, detail = '') {
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) {
console.log(`\n${title}`);
console.log('-'.repeat(Math.max(title.length, 40)));
}
// ---------------------------------------------------------------- Helfer
function run(cmd, args, opts = {}) {
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
}
function runAsync(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024, ...opts }, (err, stdout, stderr) =>
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr })
);
});
}
async function canWrite(dir) {
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, port, timeout = 2500) {
return new Promise((resolve) => {
const sock = new net.Socket();
const done = (result) => { sock.destroy(); resolve(result); };
sock.setTimeout(timeout);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false));
sock.once('error', () => done(false));
sock.connect(port, host);
});
}
async function readLayout() {
const file = path.join(ENV.configDir, 'layout.yaml');
const text = await fs.readFile(file, 'utf8');
return { file, layout: parseYaml(text) };
}
// ---------------------------------------------------------------- check-env
async function checkEnv() {
console.log('\nabdulive-toolkit - Selbsttest der Umgebung');
section('Laufzeit');
const major = Number(process.versions.node.split('.')[0]);
report(major >= 22 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
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 {
const out = run(ENV.whisper, ['--help'], { stdio: ['ignore', 'pipe', 'pipe'] });
report('ok', 'whisper-cli', out.split('\n')[0].slice(0, 40) || ENV.whisper);
} catch (err) {
// whisper-cli beendet sich bei --help teils mit Code != 0, Ausgabe zaehlt
const text = `${err.stdout || ''}${err.stderr || ''}`;
report(text.includes('usage') || text.includes('whisper') ? 'ok' : 'bad', 'whisper-cli',
text.includes('whisper') ? '' : 'nicht lauffaehig');
}
section('Sprachmodell');
let anyModel = false;
for (const name of Object.keys(MODELS)) {
try {
const st = await fs.stat(path.join(ENV.modelDir, name));
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
anyModel = true;
} catch { /* nicht vorhanden, ist in Ordnung */ }
}
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
section('Verzeichnisse');
for (const [label, dir] of [
['Datenbank (Volume)', ENV.dbDir],
['Arbeitsverzeichnis (Volume)', ENV.workDir],
['Sprachmodelle (Volume)', ENV.modelDir],
['Clips (Bind-Mount)', ENV.clipsDir],
]) {
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
}
for (const [label, dir, hard] of [
['OBS-Wiedergabepuffer', ENV.replayDir, false],
['Konfiguration', ENV.configDir, true],
]) {
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('Layout (Facecam-Ausschnitt)');
try {
const { layout } = await readLayout();
const prof = layout.profiles?.default;
if (!prof) throw new Error('Profil "default" fehlt');
const { cam, game } = prof;
const sum = cam.band.h + game.band.h;
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
`${cam.band.h} + ${game.band.h} = ${sum} (soll ${layout.target.height})`);
for (const [name, c] of [['Facecam', cam.crop], ['Gameplay', game.crop]]) {
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', cam.crop, cam.band], ['Gameplay', game.crop, game.band]]) {
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.message);
}
section('OBS');
const obsUp = await tcpProbe(ENV.obsHost, ENV.obsPort);
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
`${ENV.obsHost}:${ENV.obsPort}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
section('Zugangsdaten (.env)');
for (const key of ['TWITCH_CLIENT_ID', 'TWITCH_CLIENT_SECRET', 'OBS_WS_PASSWORD']) {
report(process.env[key] ? 'ok' : 'warn', key, process.env[key] ? 'gesetzt' : 'fehlt');
}
console.log('');
if (hardFailures > 0) {
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Das Image ist noch nicht einsatzbereit.`);
process.exitCode = 1;
} else if (warnings > 0) {
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen - die sind fuer S0 in Ordnung.`);
} else {
console.log(`${C.ok}Alles in Ordnung.${C.off}`);
}
}
// ---------------------------------------------------------------- fetch-model
async function fetchModel(name = 'ggml-small.bin') {
const url = MODELS[name];
if (!url) {
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
process.exitCode = 1;
return;
}
await fs.mkdir(ENV.modelDir, { recursive: true });
const target = path.join(ENV.modelDir, name);
try {
const st = await fs.stat(target);
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
return;
} catch { /* nicht da, also laden */ }
console.log(`Lade ${name} ...`);
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) 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`;
const out = createWriteStream(tmp);
const body = res.body.getReader ? res.body : res.body;
await pipeline(
(async function* () {
for await (const chunk of body) {
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;
}
})(),
out
);
await fs.rename(tmp, target);
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
}
// ---------------------------------------------------------------- calibrate
function buildFilter(prof) {
const { cam, game } = prof;
return [
`[0:v]crop=${cam.crop.w}:${cam.crop.h}:${cam.crop.x}:${cam.crop.y},scale=${cam.band.w}:${cam.band.h},setsar=1[cam]`,
`[0:v]crop=${game.crop.w}:${game.crop.h}:${game.crop.x}:${game.crop.y},scale=${game.band.w}:${game.band.h},setsar=1[game]`,
`[cam][game]vstack=inputs=2[v]`,
].join(';');
}
async function calibrate(input, profileName = 'default') {
const { layout } = await readLayout();
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(ENV.replayDir).catch(() => []))
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpg)$/i.test(f))
.sort();
input = files.length ? path.join(ENV.replayDir, files[files.length - 1]) : null;
if (!input) throw new Error(`Keine Datei angegeben und nichts in ${ENV.replayDir} gefunden.`);
console.log(`Neueste Datei aus ${ENV.replayDir}: ${path.basename(input)}`);
}
const outDir = path.join(ENV.clipsDir, '_calibration');
await fs.mkdir(outDir, { recursive: true });
const isImage = /\.(png|jpe?g)$/i.test(input);
const seek = isImage ? [] : ['-ss', '5'];
// 1. Referenzbild aus der Quelle, unveraendert
const frame = path.join(outDir, 'frame.png');
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
...seek, '-i', input, '-frames:v', '1', frame]);
// 2. Vorschau der 9:16-Komposition mit den Werten aus layout.yaml
const preview = path.join(outDir, `preview-${profileName}.png`);
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
'-i', frame, '-filter_complex', buildFilter(prof), '-map', '[v]',
'-frames:v', '1', preview]);
// 3. Kontrollbild: Ausschnitte im Originalbild markiert
const marked = path.join(outDir, 'ausschnitte.png');
const box = (c, color) =>
`drawbox=x=${c.x}:y=${c.y}:w=${c.w}:h=${c.h}:color=${color}:t=4`;
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
'-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.');
}
// ---------------------------------------------------------------- help
function help() {
console.log(`
abdulive-toolkit - Container-Einstiegspunkt
check-env Umgebung pruefen (Werkzeuge, Volumes, Layout, OBS)
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
help Diese Uebersicht
Beispiele:
docker compose run --rm toolkit check-env
docker compose run --rm toolkit fetch-model ggml-small.bin
docker compose run --rm toolkit calibrate
Stand: Scheibe S0 aus docs/07-mvp-plan.md. Ingest, Clipping und Untertitel
folgen in S1 bis S4.
`);
}
// ---------------------------------------------------------------- main
const [cmd, ...args] = process.argv.slice(2);
try {
switch (cmd) {
case 'check-env': await checkEnv(); break;
case 'fetch-model': await fetchModel(args[0]); break;
case 'calibrate': await calibrate(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.message}`);
if (err.stderr) console.error(String(err.stderr).trim().split('\n').slice(-5).join('\n'));
process.exitCode = 1;
}