Files
abdulive/apps/cli/main.ts
T
ZeasyAlexandClaude Opus 5 5f6301268a Native Windows-Auslieferung statt Container
Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt
TypeScript direkt aus - der Container schrieb nur noch drei Programme fest,
und dafuer braucht es keine Virtualisierung, sondern einen Ordner.

Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber
nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste
whisper.cpp aus dem Quelltext uebersetzen.

Plattformschicht (packages/core/src/platform.ts):
- Programme werden neben der Anwendung gesucht, nicht im PATH
- Daten unter %LOCALAPPDATA%, damit Installation und Update ohne
  Administratorrechte auskommen
- Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine
  Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite

Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert:
- Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche,
  escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht
- `nice` gibt es nicht, ersetzt durch os.setPriority

Auslieferung:
- scripts/build-package.ts baut das Paket, laeuft auch unter Linux
- install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner
- update legt neue Fassungen bereit; eingespielt wird beim naechsten Start,
  nie mitten im Stream
- ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts)

Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer
Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell
aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene
Vorgabeadresse mehr.

Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben.

ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 17:39:26 +02:00

616 lines
26 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}`;
// ---------------------------------------------------------------- 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)
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 '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;
}