#!/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; }