Compare commits
1
Commits
71dd660d01
..
latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f6301268a |
No files matched your search
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
data
|
||||
logs
|
||||
.env
|
||||
docs
|
||||
*.md
|
||||
@@ -21,3 +21,7 @@ logs/
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Zwischenspeicher des Paketbaus und sein Ergebnis
|
||||
.cache/
|
||||
dist/
|
||||
@@ -45,29 +45,60 @@ Dazu der Dienst, der unter allen anderen liegt:
|
||||
- [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool
|
||||
- [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs)
|
||||
|
||||
## Schnellstart
|
||||
## Auf dem Stream-PC installieren
|
||||
|
||||
Alles läuft in Docker — auf dem Rechner werden nur Docker Desktop und OBS
|
||||
gebraucht, ffmpeg und whisper.cpp stecken im Image.
|
||||
Siehe [`docs/05-setup.md`](docs/05-setup.md).
|
||||
Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
|
||||
sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts
|
||||
zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)).
|
||||
|
||||
Das Paket herunterladen, entpacken, darin `install.ps1` ausführen. Ohne
|
||||
Administratorrechte; Programm und Daten landen im Benutzerprofil.
|
||||
|
||||
```powershell
|
||||
Copy-Item config\config.example.yaml config\config.yaml
|
||||
docker compose build
|
||||
docker compose run --rm toolkit check-env
|
||||
docker compose run --rm toolkit fetch-model
|
||||
docker compose up -d
|
||||
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
|
||||
.\abdulive\install.ps1
|
||||
```
|
||||
|
||||
> Einen `irm … | iex`-Einzeiler gibt es nicht: `git.zeasy.dev` verlangt
|
||||
> instanzweit eine Anmeldung und antwortet auf **jede** Adresse mit einer
|
||||
> Umleitung auf `/user/login`. `iex` führt die Anmeldeseite dann als
|
||||
> PowerShell aus. Wo die Pakete künftig liegen sollen, ist noch offen —
|
||||
> siehe [`offene-punkte.md`](docs/offene-punkte.md).
|
||||
|
||||
Danach einmalig:
|
||||
|
||||
```powershell
|
||||
abdulive fetch-model # Sprachmodell, ca. 465 MB
|
||||
abdulive check-env # Selbsttest
|
||||
Start-ScheduledTask -TaskName abdulive
|
||||
```
|
||||
|
||||
Dann **http://localhost:4792/setup** öffnen und die Twitch-Zugangsdaten
|
||||
eintragen. Sie werden verschlüsselt im Docker-Volume abgelegt, nicht in einer
|
||||
Datei im Projekt ([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)).
|
||||
eintragen. Sie werden verschlüsselt abgelegt, nicht in einer Datei im Projekt
|
||||
([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)).
|
||||
|
||||
Ausführlich: [`docs/05-setup.md`](docs/05-setup.md).
|
||||
|
||||
## Paket bauen und ausliefern
|
||||
|
||||
```powershell
|
||||
node scripts/build-package.ts --version 0.5.0
|
||||
```
|
||||
|
||||
Lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern, packt alles nach
|
||||
`dist/` und schreibt `abdulive.json` — das Manifest, das der Updater auf
|
||||
Abdus Rechner abfragt. Läuft auch in der CI unter Linux.
|
||||
|
||||
Auf dem Stream-PC sieht `abdulive update` täglich nach und legt eine neue
|
||||
Fassung bereit; eingespielt wird sie beim nächsten Start, nie mitten im
|
||||
Stream.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut: Container-Toolchain,
|
||||
Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut: Toolchain,
|
||||
Einrichtung im Browser, Twitch-Auth, Datenbank, Chat-Aufnahme, OBS-Anbindung mit
|
||||
`!clip`, 9:16-Render und Untertitel. 53 Tests grün.
|
||||
`!clip`, 9:16-Render und Untertitel. Dazu die native Auslieferung mit Installer
|
||||
und Updater. 79 Tests grün.
|
||||
|
||||
Offen: die Abnahme der OBS-Anbindung an einem laufenden OBS, dann S5 —
|
||||
Onboarding-Assistent und Sichtungsseite.
|
||||
@@ -77,5 +108,5 @@ Details: [`docs/handoff.md`](docs/handoff.md)
|
||||
## Tests
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/app" -w /app node:24-bookworm-slim node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
```
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Einstiegspunkt des Container-Images.
|
||||
// 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';
|
||||
@@ -8,16 +9,19 @@ 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 { 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, toLines } from '#media/subtitles.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';
|
||||
|
||||
const WHISPER = process.env.WHISPER_BIN || 'whisper-cli';
|
||||
const OBS_HOST = process.env.OBS_WS_HOST || 'host.docker.internal';
|
||||
// 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> = {
|
||||
@@ -81,7 +85,7 @@ function tcpProbe(host: string, port: number, timeout = 2500): Promise<boolean>
|
||||
// ---------------------------------------------------------------- check-env
|
||||
|
||||
async function checkEnv(): Promise<void> {
|
||||
console.log('\nabdulive-toolkit - Selbsttest der Umgebung');
|
||||
console.log('\nabdulive - Selbsttest der Umgebung');
|
||||
|
||||
section('Laufzeit');
|
||||
const major = Number(process.versions.node.split('.')[0]);
|
||||
@@ -96,22 +100,32 @@ async function checkEnv(): Promise<void> {
|
||||
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('ffmpeg', ['-version']).split('\n')[0] ?? '';
|
||||
const v = run(binaries.ffmpeg, ['-version']).split('\n')[0] ?? '';
|
||||
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
|
||||
const cfg = run('ffmpeg', ['-hide_banner', '-buildconf']);
|
||||
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)');
|
||||
} catch { report('bad', 'ffmpeg', 'nicht im PATH'); }
|
||||
try { run('ffprobe', ['-version']); report('ok', 'ffprobe'); }
|
||||
catch { report('bad', 'ffprobe', 'nicht im PATH'); }
|
||||
// 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(WHISPER, ['--help']);
|
||||
report('ok', 'whisper-cli', WHISPER);
|
||||
run(binaries.whisper, ['--help']);
|
||||
report('ok', 'whisper-cli', binaries.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');
|
||||
const laeuft = text.includes('whisper') || text.includes('usage');
|
||||
report(laeuft ? 'ok' : 'bad', 'whisper-cli',
|
||||
laeuft ? binaries.whisper : nichtGefunden(binaries.whisper));
|
||||
}
|
||||
|
||||
section('Sprachmodell');
|
||||
@@ -127,10 +141,10 @@ async function checkEnv(): Promise<void> {
|
||||
|
||||
section('Verzeichnisse');
|
||||
for (const [label, dir] of [
|
||||
['Datenbank (Volume)', paths.db],
|
||||
['Arbeitsverzeichnis (Volume)', paths.work],
|
||||
['Sprachmodelle (Volume)', paths.models],
|
||||
['Clips (Bind-Mount)', paths.clips],
|
||||
['Datenbank', paths.db],
|
||||
['Arbeitsverzeichnis', paths.work],
|
||||
['Sprachmodelle', paths.models],
|
||||
['Clips', paths.clips],
|
||||
] as const) {
|
||||
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
|
||||
}
|
||||
@@ -141,7 +155,7 @@ async function checkEnv(): Promise<void> {
|
||||
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?`); }
|
||||
} catch { report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar`); }
|
||||
}
|
||||
|
||||
section('Datenbank');
|
||||
@@ -355,12 +369,14 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
|
||||
|
||||
console.log(' Transkribiere ...');
|
||||
const words = await transcribe(wav, {
|
||||
binary: process.env['WHISPER_BIN'] ?? 'whisper-cli',
|
||||
binary: binaries.whisper,
|
||||
model,
|
||||
language: cfg.clipper.whisper.language,
|
||||
threads: cfg.clipper.whisper.threads,
|
||||
});
|
||||
const lines = toLines(words);
|
||||
// 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));
|
||||
@@ -370,7 +386,7 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
|
||||
finalFile = path.join(outDir, 'final.mp4');
|
||||
await runMedia('ffmpeg', [
|
||||
'-y', '-hide_banner', '-loglevel', 'error', '-i', vertical,
|
||||
'-vf', `ass=${ass}`, '-c:a', 'copy',
|
||||
'-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,
|
||||
@@ -388,17 +404,172 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
|
||||
`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-toolkit - Container-Einstiegspunkt
|
||||
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
|
||||
|
||||
@@ -415,7 +586,7 @@ const [cmd, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'ingest': {
|
||||
const { runIngest } = await import('../apps/ingest/src/index.ts');
|
||||
const { runIngest } = await import('../ingest/src/index.ts');
|
||||
await runIngest();
|
||||
break;
|
||||
}
|
||||
@@ -423,6 +594,12 @@ try {
|
||||
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:
|
||||
@@ -0,0 +1,44 @@
|
||||
// Tests fuer den Updater. Der Anlass fuer `looksLikeLogin` war ein echter
|
||||
// Fehlschlag: git.zeasy.dev verlangt instanzweit eine Anmeldung und antwortet
|
||||
// auf jede Adresse mit 303 auf /user/login. `irm ... | iex` folgte der
|
||||
// Umleitung und versuchte, die Anmeldeseite als PowerShell auszufuehren.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isNewer, looksLikeLogin } from './update.ts';
|
||||
|
||||
test('vergleicht Versionen als Zahlen, nicht als Text', () => {
|
||||
// Der Fall, der bei Zeichenkettenvergleich falsch herauskaeme
|
||||
assert.equal(isNewer('2026.10.1', '2026.9.9'), true);
|
||||
assert.equal(isNewer('2026.9.9', '2026.10.1'), false);
|
||||
assert.equal(isNewer('0.5.1', '0.5.0'), true);
|
||||
assert.equal(isNewer('0.5.0', '0.5.0'), false);
|
||||
assert.equal(isNewer('0.10.0', '0.9.0'), true);
|
||||
});
|
||||
|
||||
test('fehlende Stellen zaehlen als Null', () => {
|
||||
assert.equal(isNewer('1.1', '1'), true);
|
||||
assert.equal(isNewer('1', '1.0.0'), false);
|
||||
});
|
||||
|
||||
test('erkennt eine Anmeldeseite am Inhaltstyp', () => {
|
||||
assert.equal(looksLikeLogin('text/html; charset=utf-8', Buffer.from('x')), true);
|
||||
assert.equal(looksLikeLogin('application/json', Buffer.from('{"version":"1"}')), false);
|
||||
assert.equal(looksLikeLogin('application/zip', Buffer.from([0x50, 0x4b, 3, 4])), false);
|
||||
});
|
||||
|
||||
test('erkennt sie auch, wenn der Inhaltstyp schweigt', () => {
|
||||
// Genau die Antwort, die git.zeasy.dev geliefert hat
|
||||
const gitea = Buffer.from(
|
||||
'<a href="/user/login?redirect_to=%2FZeasyAlex%2Fabdulive">See Other</a>.');
|
||||
assert.equal(looksLikeLogin(null, gitea), true);
|
||||
assert.equal(looksLikeLogin(null, Buffer.from('<!DOCTYPE html><html>')), true);
|
||||
});
|
||||
|
||||
test('haelt ein echtes Manifest nicht faelschlich fuer HTML', () => {
|
||||
const manifest = Buffer.from(JSON.stringify({
|
||||
version: '0.5.0', datei: 'abdulive-0.5.0-win-x64.zip', groesse: 119_000_000,
|
||||
sha256: 'a'.repeat(64),
|
||||
}));
|
||||
assert.equal(looksLikeLogin(null, manifest), false);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Nach einer neuen Fassung sehen und sie bereitlegen.
|
||||
//
|
||||
// Bereitlegen, nicht einspielen: Windows sperrt die Dateien laufender
|
||||
// Programme, und mitten im Stream soll ohnehin nichts ausgetauscht werden.
|
||||
// Das neue Paket landet unter `pending\`; `launcher.ps1` tauscht es beim
|
||||
// naechsten Start aus, wenn nichts laeuft. Siehe ADR-0007.
|
||||
//
|
||||
// Die Adresse steht in der Konfiguration und hat bewusst KEINE Vorgabe. Ein
|
||||
// erfundener Standardwert sieht so lange richtig aus, bis jemand ihn aufruft
|
||||
// und eine Anmeldeseite als Skript ausgefuehrt bekommt.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { installRoot } from '#core/platform.ts';
|
||||
import { readZip } from '#core/zip.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
|
||||
type Manifest = {
|
||||
version: string;
|
||||
datei: string;
|
||||
groesse: number;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Versionen vergleichen. `2026.9.9` ist aelter als `2026.10.1` - ein
|
||||
* Zeichenkettenvergleich saehe das andersherum.
|
||||
*/
|
||||
export function isNewer(candidate: string, current: string): boolean {
|
||||
const parts = (v: string) => v.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
|
||||
const a = parts(candidate);
|
||||
const b = parts(current);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x === y) continue;
|
||||
if (typeof x === 'number' && typeof y === 'number') return x > y;
|
||||
return String(x) > String(y);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Antwort, die wie eine Anmeldeseite aussieht, ist keine Datei.
|
||||
*
|
||||
* Genau das ist einmal passiert: `git.zeasy.dev` ist komplett angemeldet-only
|
||||
* und antwortet auf jede Adresse mit 303 auf `/user/login`. Wer dem folgt,
|
||||
* bekommt HTML - und `iex` versucht, es als PowerShell auszufuehren.
|
||||
*/
|
||||
export function looksLikeLogin(contentType: string | null, body: Buffer): boolean {
|
||||
if (contentType?.includes('text/html')) return true;
|
||||
const head = body.subarray(0, 200).toString('utf8').toLowerCase();
|
||||
return head.includes('<!doctype html') || head.includes('<html') ||
|
||||
head.includes('/user/login');
|
||||
}
|
||||
|
||||
async function localVersion(): Promise<string> {
|
||||
const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null);
|
||||
if (!raw) return '0';
|
||||
return (JSON.parse(raw) as { version?: string }).version ?? '0';
|
||||
}
|
||||
|
||||
/** Adresse und - falls hinterlegt - der Zugriffstoken. */
|
||||
function quelle(): { url: string; headers: Record<string, string> } {
|
||||
const url = (process.env['ABDULIVE_RELEASE_URL'] ?? loadConfig().update?.url ?? '').trim();
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'Keine Update-Adresse hinterlegt.\n\n' +
|
||||
' In config.yaml eintragen:\n' +
|
||||
' update:\n' +
|
||||
' url: "https://.../abdulive"\n\n' +
|
||||
' Dort muessen `abdulive.json` und die darin genannte ZIP-Datei liegen.\n' +
|
||||
' Gebaut werden beide mit: node scripts/build-package.ts');
|
||||
}
|
||||
const headers: Record<string, string> = { 'user-agent': 'abdulive-update' };
|
||||
const token = process.env['ABDULIVE_UPDATE_TOKEN'] ?? readSecrets().update?.token;
|
||||
// Gitea und Forgejo erwarten genau diese Form; ein Bearer-Token lehnen sie ab.
|
||||
if (token) headers['authorization'] = `token ${token}`;
|
||||
return { url: url.replace(/\/+$/, ''), headers };
|
||||
}
|
||||
|
||||
async function hole(url: string, headers: Record<string, string>): Promise<Buffer> {
|
||||
// Umleitungen NICHT automatisch folgen: eine Anmeldeseite ist genau das,
|
||||
// und ihr zu folgen liefert HTML statt einer Fehlermeldung.
|
||||
const res = await fetch(url, { headers, redirect: 'manual' });
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const ziel = res.headers.get('location') ?? '(ohne Angabe)';
|
||||
throw new Error(
|
||||
`${url}\n antwortet mit ${res.status} und leitet nach ${ziel} um.\n\n` +
|
||||
(ziel.includes('login')
|
||||
? ' Das ist eine Anmeldeseite - die Ablage ist nicht oeffentlich.\n' +
|
||||
' Entweder die Pakete an einen Ort ohne Anmeldung legen, oder einen\n' +
|
||||
' Lese-Token auf der Einrichtungsseite hinterlegen.'
|
||||
: ' Umleitungen werden hier absichtlich nicht verfolgt.'));
|
||||
}
|
||||
if (!res.ok) throw new Error(`${url}\n antwortet mit HTTP ${res.status}.`);
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (looksLikeLogin(res.headers.get('content-type'), body)) {
|
||||
throw new Error(
|
||||
`${url}\n liefert eine HTML-Seite statt einer Datei.\n\n` +
|
||||
' Vermutlich eine Anmelde- oder Fehlerseite. Die Adresse im Browser\n' +
|
||||
' aufrufen zeigt, was dort wirklich steht.');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function checkForUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
const { url, headers } = quelle();
|
||||
const current = await localVersion();
|
||||
console.log(`Quelle: ${url}`);
|
||||
console.log(`Installiert: ${current}`);
|
||||
|
||||
const manifest = JSON.parse((await hole(`${url}/abdulive.json`, headers)).toString('utf8')) as Manifest;
|
||||
console.log(`Angeboten: ${manifest.version}`);
|
||||
|
||||
if (!opts.force && !isNewer(manifest.version, current)) {
|
||||
console.log('\nNichts zu tun.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = path.join(installRoot, 'pending');
|
||||
// Ein Rest von einem abgebrochenen Versuch darf nicht mit eingespielt
|
||||
// werden - also erst weg damit.
|
||||
await fs.rm(pending, { recursive: true, force: true });
|
||||
|
||||
process.stdout.write(`\nLade ${manifest.datei} ... `);
|
||||
const zip = await hole(`${url}/${manifest.datei}`, headers);
|
||||
console.log(`${(zip.length / 1e6).toFixed(0)} MB`);
|
||||
|
||||
const sum = crypto.createHash('sha256').update(zip).digest('hex');
|
||||
if (sum !== manifest.sha256.toLowerCase()) {
|
||||
throw new Error(
|
||||
`Pruefsumme stimmt nicht.\n erwartet ${manifest.sha256}\n bekommen ${sum}\n` +
|
||||
'Das Paket wird nicht eingespielt.');
|
||||
}
|
||||
console.log('Pruefsumme stimmt.');
|
||||
|
||||
let count = 0;
|
||||
for (const entry of readZip(zip)) {
|
||||
if (entry.isDirectory) continue;
|
||||
const target = path.join(pending, entry.name);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, entry.data());
|
||||
count++;
|
||||
}
|
||||
console.log(`${count} Dateien nach ${pending} gelegt.`);
|
||||
console.log('\nEingespielt wird beim naechsten Start - waehrend eines Streams');
|
||||
console.log('wird nichts ausgetauscht. Sofort uebernehmen:');
|
||||
console.log(' Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive');
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { sleep } from '#core/retry.ts';
|
||||
import { openDb, closeDb } from '#db/index.ts';
|
||||
import { repos } from '#db/repos.ts';
|
||||
import { createObs } from '#obs/client.ts';
|
||||
import { isWindows } from '#core/platform.ts';
|
||||
import { helix } from '#twitch/helix.ts';
|
||||
import { createIrc } from '#twitch/irc.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
@@ -95,7 +96,9 @@ export async function runIngest(): Promise<void> {
|
||||
cooldownUserSeconds: cfg.clipper.cooldownUserSeconds,
|
||||
followersOnly: cfg.clipper.followersOnly,
|
||||
...(process.env.OBS_REPLAY_DIR ? { replayDirHost: process.env.OBS_REPLAY_DIR } : {}),
|
||||
replayDirContainer: paths.replay,
|
||||
// Nativ sieht die Anwendung denselben Pfad wie OBS. Leer heisst:
|
||||
// nicht uebersetzen (siehe #obs/replay-path.ts).
|
||||
replayDirContainer: isWindows ? '' : paths.replay,
|
||||
clipsDir: paths.clips,
|
||||
workDir: paths.work,
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: DejaVu fonts
|
||||
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
||||
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
||||
Source: https://dejavu-fonts.github.io/
|
||||
|
||||
Files: *
|
||||
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
DejaVu changes are in public domain.
|
||||
License: bitstream-vera
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
.
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
.
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
.
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
.
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
.
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
.
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Files: debian/*
|
||||
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
||||
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
||||
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
||||
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
||||
License: GPL-2+
|
||||
This program is free software; you can redistribute it
|
||||
and/or modify it under the terms of the GNU General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2 of the License, or (at your option) any later
|
||||
version.
|
||||
.
|
||||
This program is distributed in the hope that it will be
|
||||
useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
PURPOSE. See the GNU General Public License for more
|
||||
details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public
|
||||
License along with this package; if not, write to the Free
|
||||
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
||||
Boston, MA 02110-1301 USA
|
||||
.
|
||||
On Debian systems, the full text of the GNU General Public
|
||||
License version 2 can be found in the file
|
||||
/usr/share/common-licenses/GPL-2'.
|
||||
@@ -22,6 +22,15 @@ ingest:
|
||||
armed: false # erst nach Kalibrierung auf true
|
||||
laughEmotes: [KEKW, LUL, OMEGALUL, PogChamp, Pog]
|
||||
|
||||
# Woher `abdulive update` neue Fassungen holt. Erwartet wird dort
|
||||
# `abdulive.json` und die darin genannte ZIP-Datei.
|
||||
#
|
||||
# Leer = keine automatischen Updates. Liegt die Adresse hinter einer
|
||||
# Anmeldung (privates Gitea), gehoert der Zugriffstoken NICHT hierher,
|
||||
# sondern auf die Einrichtungsseite - siehe ADR-0005.
|
||||
update:
|
||||
url: ""
|
||||
|
||||
obs:
|
||||
url: ws://127.0.0.1:4455
|
||||
replayBufferSeconds: 90
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Stand: Scheiben S0 und S1 aus docs/07-mvp-plan.md.
|
||||
#
|
||||
# Nach `docker compose up -d` laeuft der Ingest-Daemon und die Einrichtung
|
||||
# ist unter http://localhost:4792/setup erreichbar. Zugangsdaten werden dort
|
||||
# eingegeben, nicht hier - siehe docs/decisions/ADR-0005-zugangsdaten.md.
|
||||
#
|
||||
# OBS_REPLAY_DIR in .env auf den Wiedergabepuffer-Ordner setzen, z. B.
|
||||
# OBS_REPLAY_DIR=C:/Users/abdu/Videos/replay
|
||||
|
||||
services:
|
||||
toolkit:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
image: abdulive/toolkit:dev
|
||||
container_name: abdulive-toolkit
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
command: ["ingest"]
|
||||
|
||||
# OBS laeuft auf dem Host, nicht im Container
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
ports:
|
||||
# Ein Server fuer Einrichtung, OAuth-Rueckruf, Sichtungsseite und
|
||||
# Dashboard - siehe docs/decisions/ADR-0005-zugangsdaten.md
|
||||
- "127.0.0.1:4792:4792"
|
||||
- "127.0.0.1:4791:4791" # Overlay-Server (ab M3)
|
||||
|
||||
volumes:
|
||||
# Konfiguration: nur lesend
|
||||
- ./config:/app/config:ro
|
||||
# OBS-Wiedergabepuffer: nur lesend, wird zum Verarbeiten einmal kopiert
|
||||
- ${OBS_REPLAY_DIR:-./data/replay}:/replay:ro
|
||||
# Fertige Clips landen wieder auf der Windows-Seite
|
||||
- ./data/clips:/app/data/clips
|
||||
# DB, Modelle und Arbeitsdateien in benannten Volumes - NICHT als
|
||||
# Bind-Mount, siehe docs/decisions/ADR-0004-docker.md
|
||||
- abdulive-db:/db
|
||||
- abdulive-models:/models
|
||||
- abdulive-work:/work
|
||||
|
||||
# Damit das Encoding dem Stream nicht die CPU wegnimmt
|
||||
cpus: "3.0"
|
||||
mem_limit: 3g
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:4792/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
abdulive-db:
|
||||
abdulive-models:
|
||||
abdulive-work:
|
||||
@@ -1,59 +0,0 @@
|
||||
# Scheiben S0 und S1 aus docs/07-mvp-plan.md.
|
||||
#
|
||||
# Das Projekt hat KEINE npm-Abhaengigkeiten: Node 24 bringt SQLite
|
||||
# (node:sqlite), WebSocket und fetch mit und fuehrt TypeScript direkt aus
|
||||
# (Type Stripping). Deshalb gibt es weder eine deps-Stufe noch einen
|
||||
# Build-Schritt. Siehe ADR-0001 und ADR-0004.
|
||||
|
||||
# ---------- whisper.cpp bauen ----------
|
||||
FROM debian:bookworm-slim AS whisper
|
||||
ARG WHISPER_VERSION=v1.7.4
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 --branch ${WHISPER_VERSION} \
|
||||
https://github.com/ggerganov/whisper.cpp /src
|
||||
WORKDIR /src
|
||||
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release -DWHISPER_BUILD_TESTS=OFF \
|
||||
-DWHISPER_BUILD_EXAMPLES=ON \
|
||||
&& cmake --build build --config Release -j"$(nproc)" \
|
||||
&& mkdir -p /out \
|
||||
&& cp build/bin/whisper-cli /out/ \
|
||||
&& find build -name 'libwhisper.so*' -o -name 'libggml*.so' | xargs -r cp -P -t /out/
|
||||
|
||||
# ---------- Laufzeit ----------
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg fonts-dejavu-core ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=whisper /out/ /usr/local/lib/whisper/
|
||||
RUN ln -s /usr/local/lib/whisper/whisper-cli /usr/local/bin/whisper-cli \
|
||||
&& echo /usr/local/lib/whisper > /etc/ld.so.conf.d/whisper.conf \
|
||||
&& ldconfig
|
||||
|
||||
WORKDIR /app
|
||||
# package.json zuerst: sie enthaelt die "imports"-Map, ueber die #core/... aufgeloest wird
|
||||
COPY package.json ./
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/ ./apps/
|
||||
COPY docker/ ./docker/
|
||||
|
||||
# Datenbank und Sprachmodelle liegen in benannten Volumes, nicht im
|
||||
# Bind-Mount - siehe docs/decisions/ADR-0004-docker.md
|
||||
ENV WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||
MODEL_DIR=/models \
|
||||
DB_DIR=/db \
|
||||
WORK_DIR=/work \
|
||||
CLIPS_DIR=/app/data/clips \
|
||||
REPLAY_DIR=/replay \
|
||||
CONFIG_DIR=/app/config \
|
||||
NODE_ENV=production \
|
||||
NODE_OPTIONS=--disable-warning=ExperimentalWarning \
|
||||
BIND_HOST=0.0.0.0
|
||||
|
||||
EXPOSE 4790 4791 4792
|
||||
|
||||
ENTRYPOINT ["node", "/app/docker/entrypoint.ts"]
|
||||
CMD ["help"]
|
||||
@@ -37,7 +37,8 @@ Einrichtungs-Assistenten unter `http://localhost:4792/setup`, **nicht** in einer
|
||||
| `bits:read` | Bits-Events | Broadcaster |
|
||||
|
||||
Der Autorisierungs-Flow läuft über den Einrichtungs-Assistenten:
|
||||
`docker compose up -d`, dann `http://localhost:4792/setup` öffnen und je einmal
|
||||
`Start-ScheduledTask -TaskName abdulive`, dann `http://localhost:4792/setup`
|
||||
öffnen und je einmal
|
||||
für Broadcaster- und Bot-Konto auf „Mit Twitch verbinden" klicken. Die
|
||||
Redirect-URI, die in beiden Twitch-Apps hinterlegt sein muss, lautet
|
||||
`http://localhost:4792/callback`.
|
||||
|
||||
+82
-52
@@ -1,34 +1,51 @@
|
||||
# Setup
|
||||
|
||||
Alles läuft in Containern. Auf dem Rechner selbst wird nur Docker Desktop und
|
||||
OBS gebraucht — ffmpeg, whisper.cpp, Node und pnpm stecken im Image.
|
||||
Begründung: [ADR-0004](decisions/ADR-0004-docker.md).
|
||||
Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
|
||||
sind mitgeliefert — vorausgesetzt wird nur OBS.
|
||||
Begruendung: [ADR-0007](decisions/ADR-0007-native-windows.md).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Was | Version | Wo |
|
||||
| Was | Version | Anmerkung |
|
||||
|---|---|---|
|
||||
| Docker Desktop | aktuell, WSL2-Backend | Host |
|
||||
| OBS Studio | 30+ mit obs-websocket v5 | Host |
|
||||
| Git | beliebig | Host |
|
||||
| Windows | 10 oder 11, 64 Bit | |
|
||||
| OBS Studio | 30+ mit obs-websocket v5 | derselbe Rechner |
|
||||
|
||||
Alles Weitere (Node 22, pnpm, ffmpeg mit libx264/libass, whisper.cpp,
|
||||
Sprachmodell) liefert das Image.
|
||||
Mehr nicht. Kein Docker, kein WSL2, kein Compiler, kein Git.
|
||||
|
||||
## WSL2 begrenzen
|
||||
## Installieren
|
||||
|
||||
Docker Desktop startet eine VM, die sich ohne Grenze so viel RAM nimmt, wie sie
|
||||
kriegen kann. Auf einem Stream-PC ist das nicht egal. `%USERPROFILE%\.wslconfig`
|
||||
anlegen:
|
||||
Das Paket herunterladen, entpacken, darin `install.ps1` ausfuehren:
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
memory=4GB
|
||||
processors=4
|
||||
swap=2GB
|
||||
```powershell
|
||||
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
|
||||
.\abdulive\install.ps1
|
||||
```
|
||||
|
||||
Danach `wsl --shutdown` und Docker Desktop neu starten.
|
||||
Liegt das Paket an einer Adresse **ohne** Anmeldung, holt der Installer es
|
||||
selbst:
|
||||
|
||||
```powershell
|
||||
.\install.ps1 -Quelle https://.../abdulive
|
||||
```
|
||||
|
||||
> Ein `irm … | iex`-Einzeiler funktioniert mit `git.zeasy.dev` nicht. Die
|
||||
> Instanz verlangt fuer alles eine Anmeldung und antwortet mit einer Umleitung
|
||||
> auf `/user/login`; `iex` fuehrt dann die Anmeldeseite als PowerShell aus.
|
||||
> Wo die Pakete liegen sollen, ist noch zu entscheiden.
|
||||
|
||||
Der Installer laeuft **ohne Administratorrechte**:
|
||||
|
||||
| Was | Wohin |
|
||||
|---|---|
|
||||
| Programm | `%LOCALAPPDATA%\Programs\abdulive` |
|
||||
| Daten, Datenbank, Clips | `%LOCALAPPDATA%\abdulive` |
|
||||
| Autostart | Aufgabe `abdulive`, bei der Anmeldung |
|
||||
| Update-Pruefung | Aufgabe `abdulive-update`, taeglich 05:00 |
|
||||
|
||||
Beim ersten Start warnt Windows SmartScreen vor einem unbekannten Herausgeber —
|
||||
das Paket ist nicht signiert. Einmal auf *Weitere Informationen* → *Trotzdem
|
||||
ausfuehren*.
|
||||
|
||||
## Twitch-Apps anlegen
|
||||
|
||||
@@ -64,60 +81,73 @@ im Einrichtungs-Assistenten einfügen (siehe unten). Begründung:
|
||||
## Erste Einrichtung
|
||||
|
||||
```powershell
|
||||
Copy-Item config\config.example.yaml config\config.yaml
|
||||
|
||||
docker compose build
|
||||
docker compose run --rm toolkit check-env # ffmpeg, whisper, Modell, Volumes, Layout
|
||||
docker compose run --rm toolkit fetch-model # Sprachmodell, ca. 465 MB
|
||||
docker compose up -d
|
||||
abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout
|
||||
abdulive fetch-model # Sprachmodell, ca. 465 MB
|
||||
Start-ScheduledTask -TaskName abdulive
|
||||
```
|
||||
|
||||
Dann **http://localhost:4792/setup** öffnen. Dort werden nacheinander
|
||||
eingetragen:
|
||||
Dann **http://localhost:4792/setup** oeffnen: Client-ID und Secret der beiden
|
||||
Twitch-Apps eintragen und je einmal auf „Mit Twitch verbinden" klicken.
|
||||
|
||||
1. Client-ID und Client-Secret der beiden Twitch-Apps
|
||||
2. „Mit Twitch verbinden" für Broadcaster- und Bot-Konto (OAuth)
|
||||
3. obs-websocket-Passwort
|
||||
4. später: Discord-Webhook, Bluesky-Zugang
|
||||
|
||||
Alles landet verschlüsselt im Docker-Volume, nicht in einer Datei im Projekt.
|
||||
|
||||
> **Diese Seite zeigt Zugangsdaten. Nicht öffnen, während gesendet wird.**
|
||||
|
||||
Eine `.env` ist optional und enthält nur noch den Pfad zum Wiedergabepuffer
|
||||
und das Log-Level (Vorlage: `.env.example`).
|
||||
|
||||
## Facecam einmessen
|
||||
|
||||
Einmalig, sobald eine Replay-Datei vorliegt:
|
||||
## Layout einmessen
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit calibrate
|
||||
abdulive calibrate
|
||||
```
|
||||
|
||||
Legt einen Frame als PNG in `data/clips/_calibration/` ab, fragt die vier
|
||||
Crop-Werte ab und schreibt sie nach `config/layout.yaml`.
|
||||
Legt einen Frame als PNG unter `%LOCALAPPDATA%\abdulive\clips\_calibration`
|
||||
ab und rendert die 9:16-Vorschau dazu. Anleitung: [`obs-layout.md`](obs-layout.md).
|
||||
|
||||
Welche OBS-Quelle im Ausschnitt landet — etwa fremde Untertitel:
|
||||
|
||||
```powershell
|
||||
abdulive sources
|
||||
```
|
||||
|
||||
## Betrieb
|
||||
|
||||
```powershell
|
||||
docker compose up -d # startet
|
||||
docker compose logs -f # Logs
|
||||
docker compose down # stoppt
|
||||
Start-ScheduledTask -TaskName abdulive # startet
|
||||
Stop-ScheduledTask -TaskName abdulive # stoppt
|
||||
Get-Content "$env:LOCALAPPDATA\abdulive\logs\abdulive.log" -Wait -Tail 40
|
||||
```
|
||||
|
||||
Sichtungsseite: http://localhost:4792
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
```powershell
|
||||
abdulive update
|
||||
```
|
||||
|
||||
Prueft das Manifest, laedt das Paket, vergleicht die SHA256 und legt es
|
||||
bereit. Die Adresse steht in `config.yaml` unter `update.url` und hat
|
||||
**keine Vorgabe** — ohne Eintrag sagt der Befehl das auch. Liegt die Ablage
|
||||
hinter einer Anmeldung, gehoert ein Lese-Token in den verschluesselten
|
||||
Speicher (Einrichtungsseite), nicht in die `config.yaml`. **Eingespielt wird es beim naechsten Start** — waehrend eines Streams
|
||||
wird nichts ausgetauscht. Sofort uebernehmen:
|
||||
|
||||
```powershell
|
||||
Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive
|
||||
```
|
||||
|
||||
Die Aufgabe `abdulive-update` macht die Pruefung taeglich um 05:00 von selbst.
|
||||
|
||||
## Backup
|
||||
|
||||
```powershell
|
||||
.\scripts\backup.ps1
|
||||
```
|
||||
|
||||
Kopiert die Datenbank per `VACUUM INTO` aus dem Volume `abdulive-db` nach
|
||||
`data/db/backup/` und hält 30 Generationen. Die DB liegt bewusst in einem
|
||||
Docker-Volume und nicht in einem Windows-Ordner — Dateisperren über die
|
||||
WSL2-Brücke sind für SQLite nicht verlässlich.
|
||||
Kopiert die Datenbank per `VACUUM INTO` aus `%LOCALAPPDATA%\abdulive\db`
|
||||
nach `backup\` und haelt 30 Generationen. `VACUUM INTO` und nicht einfach
|
||||
kopieren: die Datei wird im laufenden Betrieb gesichert, und ein Kopieren
|
||||
waehrend eines Schreibvorgangs ergaebe eine unbrauchbare Sicherung.
|
||||
|
||||
Die Datenbank liegt seit [ADR-0007](decisions/ADR-0007-native-windows.md)
|
||||
in einem gewoehnlichen Windows-Ordner. Der Grund fuer das fruehere
|
||||
Docker-Volume - unzuverlaessige Dateisperren ueber die WSL2-Bruecke - ist
|
||||
mit dem Container weggefallen.
|
||||
|
||||
## Was nicht ins Repo gehört
|
||||
|
||||
|
||||
+5
-5
@@ -113,10 +113,10 @@ Scheibe gibt es etwas, das man anschauen kann.
|
||||
### S0 — Container und Toolchain · ~1 Tag
|
||||
|
||||
`docker/Dockerfile` (Node 22, ffmpeg mit libx264 + libass, whisper.cpp),
|
||||
`docker-compose.yml` mit Volumes und Ports, Modell-Download beim ersten Start
|
||||
Paketbau, Installer und Updater, Modell-Download bei der Einrichtung
|
||||
in ein eigenes Volume, `scripts/check-env` als Selbsttest im Container.
|
||||
|
||||
**Fertig, wenn:** `docker compose run --rm toolkit check-env` meldet ffmpeg,
|
||||
**Fertig, wenn:** `abdulive check-env` meldet ffmpeg,
|
||||
whisper.cpp, Modell und die Schreibrechte auf allen Volumes als OK.
|
||||
|
||||
### S1 — Einrichtung, Auth und Datenaufnahme · ~3 Tage
|
||||
@@ -184,7 +184,7 @@ kopiert und dort verarbeitet — genau das, was [ADR-0004](decisions/ADR-0004-do
|
||||
Punkt 2 verbietet. Jetzt liegt die Rohdatei im Arbeits-Volume, und auf die
|
||||
Windows-Seite kommt nur das fertige, kleine Ergebnis.
|
||||
|
||||
Neu dazu: `docker compose run --rm toolkit render <datei>` rendert eine Rohdatei
|
||||
Neu dazu: `abdulive render <datei>` rendert eine Rohdatei
|
||||
von Hand — gedacht zum Nachrendern, wenn sich das Layout ändert.
|
||||
|
||||
### S4 — Untertitel · ~1,5 Tage
|
||||
@@ -301,11 +301,11 @@ Ein echter Stream, ohne Eingriff von außen:
|
||||
| whisper.cpp versteht Spielgeräusche statt Sprache | ~~mittel~~ **gelöst** | OBS nimmt getrennte Spuren auf; transkribiert wird nur das Mikrofon (Spur 2). |
|
||||
| Twitch ändert die Ableitung der Clip-MP4-URL | niedrig | Betrifft nur den Zweitweg; die Rohdatei kommt aus OBS. Gekapselt in einer Datei. |
|
||||
| Replay-Buffer-Pfad-Abbildung Windows ↔ Container | mittel | In S2 als Erstes lösen, mit einem Testlauf vor allem anderen |
|
||||
| Docker Desktop belastet den Stream-PC | mittel | `.wslconfig` mit Speicher- und CPU-Grenze; wenn es stört, ist ein Betrieb ohne Container jederzeit möglich, weil nur ffmpeg und whisper.cpp fehlen |
|
||||
| ~~Docker Desktop belastet den Stream-PC~~ | — | **eingetreten und geloest**: seit [ADR-0007](decisions/ADR-0007-native-windows.md) laeuft alles nativ, die WSL2-VM und ihre 1-2 GB entfallen |
|
||||
|
||||
## Was Abdu vorher tun muss
|
||||
|
||||
1. Docker Desktop installieren, WSL2-Backend aktivieren
|
||||
1. `install.ps1` ausfuehren (kein Docker, keine Administratorrechte)
|
||||
2. Zwei Twitch-Apps anlegen, Redirect-URI `http://localhost:4792/callback`
|
||||
(siehe [`05-setup.md`](05-setup.md)). Client-ID und Secret **nicht**
|
||||
irgendwo eintragen — die kommen in den Einrichtungs-Assistenten.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# ADR-0004: Betrieb in Docker
|
||||
|
||||
**Status:** akzeptiert · **Datum:** 2026-09-09
|
||||
**Status:** ~~akzeptiert~~ **ersetzt durch [ADR-0007](ADR-0007-native-windows.md)** · **Datum:** 2026-09-09
|
||||
|
||||
> Diese Entscheidung gilt nicht mehr. Das Toolkit laeuft nativ unter Windows.
|
||||
> Die Analyse unten bleibt lesenswert - die vier Fallen, die sie beschreibt,
|
||||
> waren real, und zwei davon (Pfaduebersetzung, Kopieren ueber die
|
||||
> WSL2-Bruecke) sind mit dem Container weggefallen statt geloest worden zu
|
||||
> sein. Warum gewechselt wurde, steht in ADR-0007.
|
||||
|
||||
## Kontext
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# ADR-0007: Native Windows-Anwendung statt Container
|
||||
|
||||
**Status:** akzeptiert · **Datum:** 2026-09-09 · **ersetzt:** [ADR-0004](ADR-0004-docker.md)
|
||||
|
||||
## Kontext
|
||||
|
||||
ADR-0004 hat den Betrieb in Docker festgelegt und dabei zwei Dinge versprochen:
|
||||
„Die einzige Voraussetzung auf dem Stream-PC ist Docker Desktop und OBS" und
|
||||
„Updates sind ein `docker compose pull && up -d`".
|
||||
|
||||
Das zweite Versprechen wurde nie eingelöst. Es gab keine CI, kein
|
||||
veröffentlichtes Image, und `image: abdulive/toolkit:dev` zeigte auf nichts.
|
||||
Wer das Toolkit installieren wollte, musste `docker compose build` ausführen —
|
||||
und damit **whisper.cpp aus dem Quelltext übersetzen**, gut zehn Minuten, mit
|
||||
allen Fehlermöglichkeiten eines Compilers. Für Abdu ist das nicht zumutbar.
|
||||
|
||||
Beim Nachdenken über die Reparatur fiel auf, dass die Voraussetzung selbst
|
||||
teurer ist als der Nutzen. Die Kosten stehen ehrlich in ADR-0004: die WSL2-VM
|
||||
belegt im Leerlauf ein bis zwei GB RAM, und Intel QSV ist aus WSL2 heraus nicht
|
||||
verlässlich erreichbar, also bleibt CPU-Encoding — beides auf einem Rechner,
|
||||
der gleichzeitig streamt.
|
||||
|
||||
Dem steht ein Umstand gegenüber, der die ursprüngliche Begründung untergräbt:
|
||||
**Das Projekt hat keine npm-Abhängigkeiten**, und Node führt TypeScript direkt
|
||||
aus (ADR-0001). Es gibt nichts zu bauen. Der Container schrieb also nur noch
|
||||
drei Programme fest — Node, ffmpeg, whisper.cpp — und dafür braucht es keine
|
||||
Virtualisierung, sondern einen Ordner.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Das Toolkit wird als **native Windows-Anwendung** ausgeliefert. Ein Ordner mit
|
||||
allem darin, ein PowerShell-Installer, ein Updater. Kein Docker, kein WSL2.
|
||||
|
||||
```
|
||||
abdulive/
|
||||
node.exe portable Node-Laufzeit
|
||||
bin/ ffmpeg, ffprobe, whisper-cli und ihre DLLs
|
||||
fonts/ DejaVu Sans (im Repo mitgeliefert, siehe unten)
|
||||
app/ die TypeScript-Quellen, unverändert
|
||||
abdulive.cmd Kommandozeile
|
||||
launcher.ps1 vom Aufgabenplaner gestartet
|
||||
version.json
|
||||
```
|
||||
|
||||
Gebaut wird mit `node scripts/build-package.ts` — lädt die drei Bausteine bei
|
||||
ihren Herausgebern, packt alles zusammen, schreibt Prüfsummen. Das läuft auch
|
||||
in der CI unter Linux.
|
||||
|
||||
## Begründung
|
||||
|
||||
- **Abdu baut nichts.** Herunterladen, `install.ps1`, fertig. Kein Docker
|
||||
Desktop, kein `.wslconfig`, kein Compiler.
|
||||
- **Ein bis zwei GB RAM** stehen wieder dem Stream zur Verfügung.
|
||||
- **Hardware-Encoding wird erreichbar.** Der mitgelieferte ffmpeg ist mit
|
||||
`--enable-libvpl`, `--enable-amf` und `--enable-ffnvcodec` gebaut; QSV und
|
||||
AMF sind nativ ansprechbar. Genutzt wird das noch nicht — aber es ist jetzt
|
||||
möglich, und aus WSL2 heraus war es das nicht.
|
||||
- **Die Pfadübersetzung entfällt.** `mapReplayPath` musste OBS' Windows-Pfade
|
||||
auf `/replay` abbilden; laut Übergabe die Stelle, an der die Clip-Pipeline
|
||||
am ehesten scheitert. Nativ sieht die Anwendung denselben Pfad wie OBS.
|
||||
- **Kein Kopieren über die WSL2-Brücke mehr.** ADR-0004 Punkt 2 verbot das
|
||||
Verarbeiten auf dem Bind-Mount, weil es „ein Vielfaches der Zeit" kostet.
|
||||
Diese Klasse von Fehlern gibt es nicht mehr.
|
||||
|
||||
## Konsequenzen — was dabei zu beachten ist
|
||||
|
||||
**1. Programme werden neben der Anwendung gesucht, nicht im PATH.**
|
||||
`packages/core/src/platform.ts` löst auf: erst die Umgebungsvariable
|
||||
(`FFMPEG_BIN` …), dann `bin/`, dann der PATH. Die Umgebungsvariable steht
|
||||
bewusst vorn — eine kaputte Mitlieferung lässt sich so ohne Neuinstallation
|
||||
überschreiben.
|
||||
|
||||
**2. Schriften müssen mitgeliefert werden.**
|
||||
Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie über
|
||||
fontconfig. Windows hat DejaVu nicht. Fehlt der Ordner, setzt libass
|
||||
**wortlos** eine Ersatzschrift ein — gemessen: dieselbe Zeile wird 720 statt
|
||||
826 Pixel breit, ohne jede Fehlermeldung. Deshalb liegen die Schriftdateien
|
||||
unter `assets/fonts/` im Repo, und `assFilter` setzt `fontsdir`.
|
||||
|
||||
**3. Windows-Pfade im ffmpeg-Filtergraphen brauchen drei Schritte.**
|
||||
`C:\Users\…` enthält einen Doppelpunkt (trennt Filteroptionen) und
|
||||
Rückwärtsschrägstriche (ffmpeg-Escapes). An echtem ffmpeg 8.1 durchprobiert:
|
||||
|
||||
| Form | Ergebnis |
|
||||
|---|---|
|
||||
| `ass=filename=C:/x/s.ass` | `No option name near '/x/s.ass'` |
|
||||
| `ass=filename=C\:/x/s.ass` | ebenso |
|
||||
| `ass=filename='C:/x/s.ass'` | ebenso |
|
||||
| `ass=filename='C\:/x/s.ass'` | läuft |
|
||||
|
||||
Anführungszeichen allein schützen den Doppelpunkt nicht, das Escape allein
|
||||
auch nicht. `filterPath()` macht alle drei Schritte, mit Tests.
|
||||
|
||||
**4. `nice` gibt es unter Windows nicht.**
|
||||
Ersetzt durch `os.setPriority` auf die Kind-PID. Das kann beides, braucht
|
||||
keinen Hilfsprozess davor, und das Zurückstufen darf jeder Benutzer.
|
||||
|
||||
**5. Daten liegen unter `%LOCALAPPDATA%\abdulive`, nicht neben dem Programm.**
|
||||
Ein Update tauscht den Programmordner komplett aus; die Datenbank darf dabei
|
||||
nicht mitgehen. `LOCALAPPDATA` und nicht `ProgramData`, weil die Anwendung als
|
||||
angemeldeter Benutzer läuft — dieselbe Sitzung wie OBS. Damit braucht weder
|
||||
Installation noch Update Administratorrechte. Genau daran scheitern
|
||||
Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird.
|
||||
|
||||
**6. Autostart über den Aufgabenplaner, nicht als Windows-Dienst.**
|
||||
Ein Dienst liefe unter SYSTEM in einer anderen Sitzung als OBS, bräuchte
|
||||
Administratorrechte und ein zusätzliches Wrapper-Programm (nssm, WinSW). Die
|
||||
Aufgabe `abdulive` startet bei der Anmeldung, versteckt, mit Neustart nach
|
||||
zwei Minuten bei Absturz.
|
||||
|
||||
**7. Updates werden bereitgelegt, nicht sofort eingespielt.**
|
||||
Windows sperrt die Dateien laufender Programme — `node.exe` lässt sich nicht
|
||||
ersetzen, solange es läuft. `abdulive update` prüft das Manifest, lädt das
|
||||
Paket, **prüft die SHA256** und legt es unter `pending/` ab. `launcher.ps1`
|
||||
tauscht beim nächsten Start aus, bevor irgendetwas startet. Damit gibt es nie
|
||||
einen halb ausgetauschten Ordner, und mitten im Stream wird nichts angefasst.
|
||||
Schlägt der Tausch fehl, läuft die alte Fassung weiter.
|
||||
|
||||
**Kosten, ehrlich benannt:**
|
||||
|
||||
- **Die Umgebung ist nicht mehr festgeschrieben.** Der Container garantierte
|
||||
„kein läuft bei mir". Jetzt garantiert das nur noch der Paketinhalt — Node,
|
||||
ffmpeg und whisper sind mitgeliefert, aber Windows selbst ist es nicht.
|
||||
- **Kein Code-Signing.** SmartScreen warnt beim ersten Start vor einem
|
||||
unbekannten Herausgeber. Ein Zertifikat kostet Geld; bis dahin muss man
|
||||
einmal auf „Trotzdem ausführen" klicken. Steht in `offene-punkte.md`.
|
||||
- **Windows-only.** Ein Linux-Paket gibt es nicht mehr. Für ein Werkzeug, das
|
||||
neben OBS auf einem Windows-Stream-PC läuft, ist das kein Verlust — die
|
||||
Pfade in `platform.ts` kennen den Linux-Fall aber weiterhin, und die Tests
|
||||
laufen dort.
|
||||
- **Drei Bausteine wollen gepflegt werden.** Node, ffmpeg und whisper.cpp
|
||||
aktualisieren sich nicht von selbst. `build-package.ts` holt jeweils die
|
||||
aktuelle Ausgabe und schreibt Name und Prüfsumme nach `version.json` — was
|
||||
drin ist, lässt sich also nachlesen.
|
||||
@@ -5,9 +5,10 @@
|
||||
| [0001](ADR-0001-monorepo-typescript.md) | TypeScript-Monorepo statt gemischter Sprachen | akzeptiert |
|
||||
| [0002](ADR-0002-sqlite.md) | SQLite statt Postgres | akzeptiert |
|
||||
| [0003](ADR-0003-eventsub-websocket.md) | EventSub ueber WebSocket, Chat ueber IRC | akzeptiert |
|
||||
| [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | akzeptiert |
|
||||
| [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | ersetzt durch 0007 |
|
||||
| [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert |
|
||||
| [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert |
|
||||
| [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert |
|
||||
|
||||
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
|
||||
Entscheidung / Begruendung / Konsequenzen.
|
||||
+42
-29
@@ -11,7 +11,7 @@ Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist
|
||||
|
||||
| Scheibe | Stand | Nachweis |
|
||||
|---|---|---|
|
||||
| S0 Container + Toolchain | fertig | `check-env` ohne harte Fehler |
|
||||
| S0 Toolchain + Auslieferung | fertig | `check-env` nativ ohne harte Fehler, Paket gebaut und installiert |
|
||||
| S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt |
|
||||
| S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS |
|
||||
| S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme |
|
||||
@@ -19,68 +19,76 @@ Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist
|
||||
| S5 Onboarding + Sichtung | offen | — |
|
||||
| S6 Erstchatter-Begrüßung | offen | — |
|
||||
|
||||
53 Tests, alle grün. Vollständige Liste dessen, was noch fehlt:
|
||||
79 Tests, alle grün. Vollständige Liste dessen, was noch fehlt:
|
||||
[`offene-punkte.md`](offene-punkte.md).
|
||||
|
||||
## Was jemand wissen muss, bevor er etwas anfasst
|
||||
|
||||
**Es gibt keine Abhängigkeiten und keinen Build.** Node 24 bringt SQLite,
|
||||
WebSocket, `fetch` und die Ausführung von TypeScript mit. `node docker/entrypoint.ts`
|
||||
WebSocket, `fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts`
|
||||
läuft direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax — keine `enum`,
|
||||
keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly`
|
||||
in der `tsconfig.json` erzwingt das und hat schon einen echten Fehler gefangen.
|
||||
|
||||
**Alles läuft unter einem Dach**, einem Prozess in einem Container. Schwere
|
||||
**Alles läuft unter einem Dach**, in einem Prozess. Schwere
|
||||
Arbeit (ffmpeg, whisper) läuft als Kindprozess und darf abstürzen. Der Ingest
|
||||
darf nie sterben — was er nicht mitschreibt, ist für immer weg.
|
||||
|
||||
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie liegen
|
||||
verschlüsselt in einem Docker-Volume, bewusst außerhalb der Datenbank, weil die
|
||||
gesichert und dabei auf die Windows-Seite kopiert wird.
|
||||
verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr — die Datenbank
|
||||
wird gesichert und weitergereicht, die Geheimnisse nicht.
|
||||
|
||||
**Rohdateien gehören ins Arbeits-Volume**, nicht in den Windows-Bind-Mount. Über
|
||||
die WSL2-Brücke zu encodieren kostet ein Vielfaches. Dagegen habe ich einmal
|
||||
selbst verstoßen — der Fehler fiel erst in S3 auf.
|
||||
**Rohdateien gehören in den Arbeitsordner**, nicht neben den OBS-Puffer. Das
|
||||
war unter Docker ein Zeitproblem (Encodieren über die WSL2-Brücke kostete ein
|
||||
Vielfaches) und ist nativ nur noch Ordnung — der Wiedergabepuffer-Ordner
|
||||
gehört OBS.
|
||||
|
||||
Die Begründungen stehen in [`decisions/`](decisions/), sechs ADRs. Wer eine
|
||||
Die Begründungen stehen in [`decisions/`](decisions/), sieben ADRs. Wer eine
|
||||
davon umwerfen will, sollte sie vorher lesen; jede hat einen Grund, der beim
|
||||
Bauen aufgetaucht ist.
|
||||
Bauen aufgetaucht ist. ADR-0004 (Docker) ist durch ADR-0007 ersetzt — die
|
||||
Analyse darin bleibt trotzdem lesenswert.
|
||||
|
||||
## Loslegen
|
||||
|
||||
```powershell
|
||||
git clone https://git.zeasy.dev/ZeasyAlex/abdulive.git
|
||||
cd abdulive
|
||||
Copy-Item config\config.example.yaml config\config.yaml
|
||||
**Auf Abdus Rechner** läuft eine native Windows-Anwendung — kein Docker, kein
|
||||
WSL2, nichts zu übersetzen ([ADR-0007](decisions/ADR-0007-native-windows.md)):
|
||||
|
||||
docker compose build
|
||||
docker compose run --rm toolkit check-env
|
||||
docker compose run --rm toolkit fetch-model # ca. 465 MB
|
||||
docker compose up -d
|
||||
```powershell
|
||||
Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
|
||||
.\abdulive\install.ps1
|
||||
abdulive fetch-model
|
||||
abdulive check-env
|
||||
```
|
||||
|
||||
Dann `http://localhost:4792/setup` öffnen. Ausführlich in
|
||||
[`05-setup.md`](05-setup.md).
|
||||
Einen Einzeiler zum Nachladen gibt es nicht — `git.zeasy.dev` ist komplett
|
||||
angemeldet-only. **Wo die Pakete liegen sollen, ist die naechste offene
|
||||
Entscheidung**, und ohne sie funktioniert auch `abdulive update` nicht.
|
||||
|
||||
Tests:
|
||||
Ausführlich in [`05-setup.md`](05-setup.md).
|
||||
|
||||
**Zum Entwickeln** reicht ein Checkout und Node 24 oder neuer:
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/app" -w /app node:24-bookworm-slim `
|
||||
node apps/cli/main.ts check-env
|
||||
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
|
||||
node scripts/build-package.ts --version 0.5.0 # baut dist/*.zip
|
||||
```
|
||||
|
||||
Typprüfung (das Einzige, wofür npm gebraucht wird):
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/app" -w /tmp node:24-bookworm-slim sh -c `
|
||||
"npm i -s typescript@5.9 @types/node && cd /app && /tmp/node_modules/.bin/tsc --noEmit --typeRoots /tmp/node_modules/@types"
|
||||
npm i typescript@5.9 @types/node
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Die drei Dinge, die Abdu tun muss
|
||||
|
||||
1. **Zwei Twitch-Apps anlegen** (dev.twitch.tv/console/apps), Redirect-URI
|
||||
`http://localhost:4792/callback`. Client-ID und Secret danach auf der
|
||||
Einrichtungsseite eintragen — nicht in eine Datei.
|
||||
1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps
|
||||
(dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`)
|
||||
kann auch jemand anders anlegen — sie hängen nicht am Kanal. Was **nur Abdu**
|
||||
kann: auf der Einrichtungsseite eingeloggt als er selbst auf „Mit Twitch
|
||||
verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner schon
|
||||
laufen; die Seite hört nur auf `127.0.0.1`.
|
||||
2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
|
||||
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
|
||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md). Die
|
||||
@@ -109,5 +117,10 @@ Die Untertitel sind brauchbar, nicht gut. `ggml-small` verhört sich hörbar
|
||||
Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs konkrete
|
||||
Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
|
||||
|
||||
Und: Das Aufräumen im Arbeits-Volume fehlt komplett. Rohdateien sammeln sich
|
||||
Und: Das Aufräumen im Arbeitsordner fehlt komplett. Rohdateien sammeln sich
|
||||
unbegrenzt an. Das ist die Lücke, die als Erstes weh tun wird.
|
||||
|
||||
Der Updater ist gebaut und in Teilen geprüft — Manifest, Download, Prüfsumme
|
||||
und Bereitlegen laufen. Der eigentliche Tausch durch `launcher.ps1` ist noch
|
||||
nie im Feld passiert. Vor dem ersten echten Update einmal künstlich
|
||||
durchspielen.
|
||||
+20
-4
@@ -52,16 +52,32 @@ schiebt dann nicht sofort einen Streifen Gesicht in das Gameplay-Bild.
|
||||
## Prüfen
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit calibrate
|
||||
abdulive calibrate
|
||||
```
|
||||
|
||||
Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder in
|
||||
`data/clips/_calibration/` ab: das Einzelbild, dasselbe mit eingezeichneten
|
||||
Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder
|
||||
unter `%LOCALAPPDATA%\abdulive\clips\_calibration` ab: das Einzelbild, dasselbe mit eingezeichneten
|
||||
Ausschnitten, und die fertige 9:16-Vorschau. Sitzt in der Vorschau der Kopf
|
||||
mittig und ist im Gameplay-Teil kein Chat zu sehen, stimmt das Layout.
|
||||
|
||||
## Text im Bild — hier wird es eng
|
||||
|
||||
**Wer malt den Text?** Das ist nicht zu erraten — die Overlays sind ins
|
||||
Streambild eingebrannt, man muss nachsehen, welche OBS-Quelle sie zeichnet:
|
||||
|
||||
```powershell
|
||||
abdulive sources
|
||||
```
|
||||
|
||||
Der Befehl fragt das laufende OBS und listet jede Quelle der aktuellen Szene
|
||||
mit Position, Größe und dem Urteil, ob sie im 9:16-Ausschnitt landet. Bei
|
||||
Browser-Quellen steht die Adresse dabei — daran erkennt man den Dienst
|
||||
dahinter. Was nach Untertiteln aussieht, wird am Ende noch einmal
|
||||
zusammengefasst.
|
||||
|
||||
Dafür muss in OBS **Werkzeuge → WebSocket-Server-Einstellungen → Server
|
||||
aktivieren** gesetzt sein (Port 4455) und das Passwort unter `/setup` liegen.
|
||||
|
||||
Bei der Prüfung eines echten Clips lagen **drei Textebenen übereinander**:
|
||||
|
||||
1. die Untertitel des Spiels („Genau, ich mach da … sag zu dem Typen")
|
||||
@@ -115,5 +131,5 @@ fest, wie es vor der Vorgabe aussah — damit lassen sich alte Aufnahmen noch
|
||||
korrekt rendern:
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit render <datei> bestand-2026-09
|
||||
abdulive render <datei> bestand-2026-09
|
||||
```
|
||||
+43
-9
@@ -16,7 +16,7 @@ Erledigtes wandert raus, nicht ins Archiv — die Historie steht in Git.
|
||||
|
||||
## Bekannte Lücke im Betrieb
|
||||
|
||||
- [ ] **Aufräumen im Arbeits-Volume.** Jede Rohdatei landet unter `/work/<clipId>/`
|
||||
- [ ] **Aufräumen im Arbeitsordner.** Jede Rohdatei landet unter `work/<clipId>/`
|
||||
und bleibt dort liegen. Bei fünf Clips pro Stream und mehreren hundert MB
|
||||
je Datei läuft das Volume in wenigen Wochen voll. Gebraucht wird eine
|
||||
Aufbewahrungsregel — Vorschlag: Rohdateien der letzten 30 Clips behalten,
|
||||
@@ -47,6 +47,10 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
|
||||
- [ ] **Position prüfen.** 340 px Abstand nach unten ist geschätzt, nicht
|
||||
gemessen. An einem echten Handy gegen die TikTok-, Reels- und
|
||||
Shorts-Oberfläche halten.
|
||||
- [ ] **Seitliche Ränder gegengeprüft**, aber nur rechnerisch gegen eine
|
||||
gemessene mittlere Zeichenbreite. Bei einer anderen Schrift (siehe
|
||||
Punkt oben) muss `CHAR_WIDTH_RATIO` in `subtitles.ts` neu eingemessen
|
||||
werden — sonst bricht die Zeile wieder an der falschen Stelle.
|
||||
- [ ] **Nur transkribieren, wenn Abdu spricht.** Bei getrennten Tonspuren
|
||||
reicht ein Pegelfilter auf der Mikrofonspur, damit aus Stille kein
|
||||
erfundener Text wird.
|
||||
@@ -55,11 +59,47 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
|
||||
|
||||
- [ ] **Live-Captions verschieben.** Sie liegen im Gameplay-Band und sind fest
|
||||
eingebrannt. Zwei Vorschläge stehen in [`obs-layout.md`](obs-layout.md).
|
||||
Welche OBS-Quelle sie überhaupt zeichnet, sagt
|
||||
`abdulive sources` — das war vorher unbekannt.
|
||||
- [ ] **Spieluntertitel abschalten.**
|
||||
- [ ] **Bessere Bildquelle für die Facecam** — Plugin `Source Record` oder
|
||||
`Aitum Vertical`. Damit entfällt die 2,25-fache Vergrößerung. Lohnt sich
|
||||
erst, wenn regelmäßig Shorts entstehen.
|
||||
|
||||
## Auslieferung
|
||||
|
||||
- [ ] **Wo liegen die Pakete?** Das ist die Entscheidung, die als Naechstes
|
||||
ansteht — ohne sie funktioniert `abdulive update` gar nicht.
|
||||
`git.zeasy.dev` verlangt **instanzweit** eine Anmeldung: `/explore`,
|
||||
die API und jede Rohdatei antworten mit 303 auf `/user/login`. Ein
|
||||
`irm … | iex`-Einzeiler bekommt dadurch die Anmeldeseite und versucht,
|
||||
sie als PowerShell auszufuehren. Drei Wege:
|
||||
*(a)* eine Ablage ohne Anmeldung, etwa `dl.zeasy.software` — am
|
||||
einfachsten, und das Paket enthaelt ohnehin den vollstaendigen Quelltext
|
||||
als lesbare `.ts`-Dateien;
|
||||
*(b)* Gitea-Release plus Lese-Token auf Abdus Rechner — der Token darf
|
||||
dann aber das ganze private Repo lesen;
|
||||
*(c)* kein Updater, Alexander spielt neue Fassungen per Fernwartung ein.
|
||||
Code und Installer sind auf *(a)* und *(b)* vorbereitet: `update.url` in
|
||||
der `config.yaml`, Token im verschluesselten Speicher.
|
||||
|
||||
- [ ] **Code-Signing.** Das Paket ist nicht signiert, SmartScreen warnt bei
|
||||
jeder Installation vor einem unbekannten Herausgeber. Ein
|
||||
OV-Zertifikat kostet rund 200 EUR im Jahr, ein EV-Zertifikat mehr und
|
||||
braucht Hardware. Solange nur Abdu installiert, reicht ein Klick auf
|
||||
"Trotzdem ausfuehren" - sobald jemand Drittes das Paket bekommt, nicht
|
||||
mehr.
|
||||
- [ ] **CI auf git.zeasy.dev.** `scripts/build-package.ts` laeuft bisher von
|
||||
Hand. Als Forgejo-Action beim Tag gebaut und als Veroeffentlichung
|
||||
hochgeladen, findet `abdulive update` sie von allein.
|
||||
- [ ] **Update noch nie im Feld gelaufen.** Bereitlegen und Pruefsumme sind
|
||||
getestet, der Tausch durch `launcher.ps1` nur gelesen. Vor dem ersten
|
||||
echten Update einmal kuenstlich durchspielen.
|
||||
- [ ] **Hardware-Encoding.** Der mitgelieferte ffmpeg kann QSV, AMF und NVENC
|
||||
(`--enable-libvpl`, `--enable-amf`, `--enable-ffnvcodec`) - aus WSL2
|
||||
heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf
|
||||
`libx264`. Auf Abdus Rechner messen, ob `h264_qsv` etwas bringt.
|
||||
|
||||
## Nach dem MVP
|
||||
|
||||
- [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden
|
||||
@@ -72,11 +112,5 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
|
||||
|
||||
## Kleinigkeiten
|
||||
|
||||
- [ ] `docker compose run` reicht ein TTY durch, dadurch schreibt die
|
||||
Fortschrittsanzeige `\r`-Zeilen ins Protokoll. Mit `-T` sauber, aber
|
||||
unschön als Standard.
|
||||
- [ ] `ldconfig` meldet beim Bauen `libwhisper.so.1 is not a symbolic link`.
|
||||
Funktioniert, ist aber unsauber.
|
||||
- [ ] `docs/05-setup.md` nennt ffmpeg 6+; im Image steckt 5.1.9 aus Debian
|
||||
bookworm. Für unseren Filtergraph irrelevant, die Angabe stimmt trotzdem
|
||||
nicht.
|
||||
- [ ] Die Fortschrittsanzeige schreibt `\r`-Zeilen; im Protokoll der Aufgabe
|
||||
sieht das unschoen aus. Beim Umleiten in eine Datei besser unterdruecken.
|
||||
@@ -95,7 +95,7 @@ ffmpeg -i in.mp4 -filter_complex "
|
||||
Nachmessen und Vorschau rendern:
|
||||
|
||||
```powershell
|
||||
docker compose run --rm toolkit calibrate
|
||||
abdulive calibrate
|
||||
```
|
||||
|
||||
Das legt drei Bilder in `data/clips/_calibration/` ab: das Einzelbild, dasselbe
|
||||
|
||||
+3
-6
@@ -16,12 +16,9 @@
|
||||
"#media/*": "./packages/media/src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "docker compose up -d",
|
||||
"stop": "docker compose down",
|
||||
"status": "docker compose ps",
|
||||
"logs": "docker compose logs -f",
|
||||
"check-env": "node docker/entrypoint.ts check-env",
|
||||
"ingest": "node docker/entrypoint.ts ingest",
|
||||
"start": "node apps/cli/main.ts ingest",
|
||||
"check-env": "node apps/cli/main.ts check-env",
|
||||
"package": "node scripts/build-package.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test \"packages/**/*.test.ts\" \"apps/**/*.test.ts\""
|
||||
},
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseYaml, type YamlMap } from './yaml.ts';
|
||||
import { defaultDirs } from './platform.ts';
|
||||
|
||||
// Die Vorgaben haengen am Betriebssystem (siehe platform.ts); die
|
||||
// Umgebungsvariablen ueberschreiben sie wie bisher.
|
||||
const dirs = defaultDirs();
|
||||
|
||||
export const paths = {
|
||||
config: process.env.CONFIG_DIR || '/app/config',
|
||||
db: process.env.DB_DIR || '/db',
|
||||
work: process.env.WORK_DIR || '/work',
|
||||
clips: process.env.CLIPS_DIR || '/app/data/clips',
|
||||
replay: process.env.REPLAY_DIR || '/replay',
|
||||
models: process.env.MODEL_DIR || '/models',
|
||||
config: process.env.CONFIG_DIR || dirs.config,
|
||||
db: process.env.DB_DIR || dirs.db,
|
||||
work: process.env.WORK_DIR || dirs.work,
|
||||
clips: process.env.CLIPS_DIR || dirs.clips,
|
||||
replay: process.env.REPLAY_DIR || dirs.replay,
|
||||
models: process.env.MODEL_DIR || dirs.models,
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
@@ -27,6 +32,7 @@ export type Config = {
|
||||
};
|
||||
};
|
||||
obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] };
|
||||
update: { url: string };
|
||||
clipper: {
|
||||
enabled: boolean;
|
||||
alsoCreateTwitchClip: boolean;
|
||||
@@ -57,7 +63,8 @@ const DEFAULTS = {
|
||||
laughEmotes: ['KEKW', 'LUL', 'OMEGALUL', 'PogChamp', 'Pog'],
|
||||
},
|
||||
},
|
||||
obs: { url: 'ws://host.docker.internal:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
obs: { url: 'ws://127.0.0.1:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
update: { url: '' },
|
||||
clipper: {
|
||||
enabled: true,
|
||||
alsoCreateTwitchClip: true,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Wo die Anwendung ihre Programme, Schriften und Daten findet.
|
||||
//
|
||||
// Bis 2026-09 lief alles in einem Container: ffmpeg lag im PATH, die Daten
|
||||
// unter festen Linux-Absolutpfaden (/db, /work, /models). Nativ unter Windows
|
||||
// gibt es beides nicht - die Programme liegen neben der Anwendung, die Daten
|
||||
// unter %ProgramData%. Siehe ADR-0007.
|
||||
//
|
||||
// Die Linux-Pfade bleiben als Vorgabe stehen, damit die Tests im Container
|
||||
// unveraendert laufen und ein Betrieb unter Linux moeglich bleibt.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const isWindows = process.platform === 'win32';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Wurzel des Quellbaums - im Paket der Ordner `app`. */
|
||||
export const appRoot = path.resolve(HERE, '..', '..', '..');
|
||||
|
||||
/**
|
||||
* Wurzel der Installation. Im Paket liegt darin `app/`, `bin/`, `fonts/` und
|
||||
* `node.exe`; im Quell-Checkout ist es schlicht der Ordner ueber dem Repo und
|
||||
* enthaelt nichts davon - dann greift die Suche im PATH.
|
||||
*/
|
||||
export const installRoot = path.resolve(appRoot, '..');
|
||||
|
||||
/** Verzeichnisse, in denen mitgelieferte Dateien gesucht werden. */
|
||||
const bundleDirs = (sub: string): string[] =>
|
||||
[path.join(installRoot, sub), path.join(appRoot, sub)];
|
||||
|
||||
const found = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Ein Programm auflösen: erst die Umgebungsvariable, dann das mitgelieferte
|
||||
* `bin/`, sonst der blanke Name - dann sucht das Betriebssystem im PATH.
|
||||
*
|
||||
* Die Reihenfolge ist Absicht. Die Umgebungsvariable gewinnt, damit sich eine
|
||||
* kaputte Mitlieferung ohne Neuinstallation ueberschreiben laesst.
|
||||
*/
|
||||
export function findBinary(name: string, envVar: string): string {
|
||||
const fromEnv = process.env[envVar];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const cached = found.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const exe = isWindows ? `${name}.exe` : name;
|
||||
for (const dir of bundleDirs('bin')) {
|
||||
const p = path.join(dir, exe);
|
||||
if (fs.existsSync(p)) { found.set(name, p); return p; }
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export const binaries = {
|
||||
get ffmpeg(): string { return findBinary('ffmpeg', 'FFMPEG_BIN'); },
|
||||
get ffprobe(): string { return findBinary('ffprobe', 'FFPROBE_BIN'); },
|
||||
get whisper(): string { return findBinary('whisper-cli', 'WHISPER_BIN'); },
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordner mit den mitgelieferten Schriften, oder `undefined`.
|
||||
*
|
||||
* Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie
|
||||
* ueber fontconfig. Windows hat DejaVu nicht. Ohne diesen Ordner setzt libass
|
||||
* still eine Ersatzschrift ein - der Untertitel sieht dann anders aus, ohne
|
||||
* dass irgendetwas eine Warnung ausgibt.
|
||||
*/
|
||||
export function fontsDir(): string | undefined {
|
||||
if (process.env['FONTS_DIR']) return process.env['FONTS_DIR'];
|
||||
for (const dir of bundleDirs('fonts')) if (fs.existsSync(dir)) return dir;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Vorgabe fuer die Datenverzeichnisse, je nach Betriebssystem. */
|
||||
export function defaultDirs(): Record<
|
||||
'config' | 'db' | 'work' | 'clips' | 'replay' | 'models', string
|
||||
> {
|
||||
if (!isWindows) {
|
||||
return {
|
||||
config: '/app/config', db: '/db', work: '/work',
|
||||
clips: '/app/data/clips', replay: '/replay', models: '/models',
|
||||
};
|
||||
}
|
||||
// Nicht neben die Programme: ein Update tauscht den Programmordner
|
||||
// komplett aus, und die Datenbank darf dabei nicht mitgehen.
|
||||
//
|
||||
// LOCALAPPDATA und nicht ProgramData, weil die Anwendung als angemeldeter
|
||||
// Benutzer laeuft - dieselbe Sitzung wie OBS. Damit braucht weder die
|
||||
// Installation noch ein Update Administratorrechte. Genau daran scheitern
|
||||
// Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird.
|
||||
const local = process.env['LOCALAPPDATA'] ??
|
||||
path.join(process.env['USERPROFILE'] ?? 'C:\\', 'AppData', 'Local');
|
||||
const base = path.join(local, 'abdulive');
|
||||
return {
|
||||
config: path.join(base, 'config'),
|
||||
db: path.join(base, 'db'),
|
||||
work: path.join(base, 'work'),
|
||||
clips: path.join(base, 'clips'),
|
||||
replay: path.join(base, 'replay'),
|
||||
models: path.join(base, 'models'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import zlib from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
import { readZip, writeZip } from './zip.ts';
|
||||
|
||||
/**
|
||||
* Baut ein ZIP von Hand. Ein Schreiber gehoert nicht ins Projekt, aber ohne
|
||||
* eigenes Archiv braeuchte der Test eine Beispieldatei im Repo - und die
|
||||
* saehe niemand mehr an.
|
||||
*/
|
||||
function makeZip(files: Array<{ name: string; body: string; deflate?: boolean }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name, 'utf8');
|
||||
const raw = Buffer.from(f.body, 'utf8');
|
||||
const data = f.deflate ? zlib.deflateRawSync(raw) : raw;
|
||||
const method = f.deflate ? 8 : 0;
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, data);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + data.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, dir, eocd]);
|
||||
}
|
||||
|
||||
test('liest gespeicherte und komprimierte Eintraege', () => {
|
||||
const zip = makeZip([
|
||||
{ name: 'bin/ffmpeg.exe', body: 'MZ-so-tun-als-ob' },
|
||||
{ name: 'liesmich.txt', body: 'Hallo '.repeat(200), deflate: true },
|
||||
]);
|
||||
const entries = readZip(zip);
|
||||
|
||||
assert.equal(entries.length, 2);
|
||||
assert.equal(entries[0]?.data().toString(), 'MZ-so-tun-als-ob');
|
||||
assert.equal(entries[1]?.data().toString(), 'Hallo '.repeat(200));
|
||||
assert.equal(entries[1]?.size, 1200);
|
||||
});
|
||||
|
||||
test('erkennt Verzeichniseintraege am Schraegstrich', () => {
|
||||
const entries = readZip(makeZip([{ name: 'bin/', body: '' }, { name: 'bin/a', body: 'x' }]));
|
||||
assert.equal(entries[0]?.isDirectory, true);
|
||||
assert.equal(entries[1]?.isDirectory, false);
|
||||
});
|
||||
|
||||
test('findet das Inhaltsverzeichnis auch hinter einem Kommentar', () => {
|
||||
// Der Kommentar steht hinter dem EOCD - von hinten zu suchen reicht nicht,
|
||||
// man muss das Feld tatsaechlich pruefen.
|
||||
const zip = makeZip([{ name: 'a.txt', body: 'inhalt' }]);
|
||||
const withComment = Buffer.concat([zip, Buffer.from('# angehaengter Kommentar')]);
|
||||
withComment.writeUInt16LE(24, withComment.length - 24 - 22 + 20);
|
||||
assert.equal(readZip(withComment)[0]?.data().toString(), 'inhalt');
|
||||
});
|
||||
|
||||
test('sagt deutlich, wenn kein ZIP vorliegt', () => {
|
||||
assert.throws(() => readZip(Buffer.alloc(200)), /Inhaltsverzeichnis/);
|
||||
});
|
||||
|
||||
test('meldet ZIP64 statt still Unsinn zu liefern', () => {
|
||||
const zip = makeZip([{ name: 'a', body: 'x' }]);
|
||||
zip.writeUInt32LE(0xffffffff, zip.length - 22 + 16); // dirStart
|
||||
assert.throws(() => readZip(zip), /ZIP64/);
|
||||
});
|
||||
|
||||
test('Geschriebenes laesst sich wieder lesen', () => {
|
||||
const gross = Buffer.from('x'.repeat(50_000));
|
||||
const zip = writeZip([
|
||||
{ name: 'bin/ffmpeg.exe', data: Buffer.from([0x4d, 0x5a, 0x90, 0x00]) },
|
||||
{ name: 'app/gross.txt', data: gross },
|
||||
]);
|
||||
const back = readZip(zip);
|
||||
assert.deepEqual(back.map((e) => e.name), ['bin/ffmpeg.exe', 'app/gross.txt']);
|
||||
assert.deepEqual(back[1]?.data(), gross);
|
||||
// Gut komprimierbar - der Eintrag muss deutlich kleiner geworden sein
|
||||
assert.ok(zip.length < 5_000, `Archiv ist ${zip.length} Bytes gross`);
|
||||
});
|
||||
|
||||
test('schreibt eine gueltige CRC32 - sonst meckert der Explorer', () => {
|
||||
const data = Buffer.from('Wiedergabepuffer');
|
||||
const zip = writeZip([{ name: 'a.txt', data }]);
|
||||
// Im lokalen Kopf steht die Pruefsumme an Offset 14
|
||||
assert.equal(zip.readUInt32LE(14), zlib.crc32(data));
|
||||
});
|
||||
|
||||
test('legt bereits komprimierte Daten unveraendert ab', () => {
|
||||
// Echter Zufall laesst sich nicht packen, deflate waere groesser als das
|
||||
// Original. Eine gerechnete Folge taugt dafuer nicht - `i * k % 256`
|
||||
// wiederholt sich nach 256 Werten und ist bestens komprimierbar.
|
||||
const rauschen = crypto.randomBytes(4096);
|
||||
const zip = writeZip([{ name: 'r.bin', data: rauschen }]);
|
||||
assert.equal(zip.readUInt16LE(8), 0, 'Verfahren 0 = gespeichert');
|
||||
assert.deepEqual(readZip(zip)[0]?.data(), rauschen);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// ZIP lesen. Node bringt zlib mit, aber kein Archivformat, und das Projekt
|
||||
// kommt ohne Abhaengigkeiten aus (ADR-0001) - wie beim YAML-Parser und beim
|
||||
// obs-websocket-Client also selbst gebaut.
|
||||
//
|
||||
// Gebraucht wird das an zwei Stellen: beim Paketbau kommen ffmpeg,
|
||||
// whisper.cpp und Node als ZIP von ihren Herausgebern, und der Updater auf
|
||||
// Abdus Rechner packt damit das neue Paket aus - ohne PowerShell, ohne
|
||||
// externes Programm.
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const EOCD = 0x06054b50; // End of Central Directory
|
||||
const CENTRAL = 0x02014b50; // Eintrag im Inhaltsverzeichnis
|
||||
const LOCAL = 0x04034b50; // Kopf vor den eigentlichen Daten
|
||||
|
||||
export type ZipEntry = {
|
||||
name: string;
|
||||
/** Unkomprimierte Groesse in Bytes. */
|
||||
size: number;
|
||||
/** Verzeichniseintraege haben keine Daten. */
|
||||
isDirectory: boolean;
|
||||
data: () => Buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inhaltsverzeichnis eines ZIP lesen.
|
||||
*
|
||||
* Die Daten werden erst beim Aufruf von `data()` entpackt - beim Paketbau
|
||||
* werden von 800 Eintraegen keine 30 gebraucht.
|
||||
*/
|
||||
export function readZip(buf: Buffer): ZipEntry[] {
|
||||
const eocd = findEocd(buf);
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
const dirSize = buf.readUInt32LE(eocd + 12);
|
||||
const dirStart = buf.readUInt32LE(eocd + 16);
|
||||
|
||||
if (dirStart === 0xffffffff || dirSize === 0xffffffff || count === 0xffff) {
|
||||
throw new Error('ZIP64 wird nicht unterstuetzt - das Archiv ist zu gross.');
|
||||
}
|
||||
|
||||
const entries: ZipEntry[] = [];
|
||||
let p = dirStart;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buf.readUInt32LE(p) !== CENTRAL) {
|
||||
throw new Error(`Beschaedigtes Inhaltsverzeichnis bei Eintrag ${i}`);
|
||||
}
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const compressed = buf.readUInt32LE(p + 20);
|
||||
const size = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
size,
|
||||
isDirectory: name.endsWith('/'),
|
||||
data: () => extract(buf, localOffset, method, compressed, size, name),
|
||||
});
|
||||
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extract(
|
||||
buf: Buffer, localOffset: number, method: number,
|
||||
compressed: number, size: number, name: string
|
||||
): Buffer {
|
||||
if (buf.readUInt32LE(localOffset) !== LOCAL) {
|
||||
throw new Error(`Kein lokaler Dateikopf fuer ${name}`);
|
||||
}
|
||||
// Der lokale Kopf hat eigene Laengen fuer Name und Extra-Feld; sie koennen
|
||||
// von denen im Inhaltsverzeichnis abweichen und muessen von hier kommen.
|
||||
const nameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const extraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + nameLen + extraLen;
|
||||
const raw = buf.subarray(start, start + compressed);
|
||||
|
||||
if (method === 0) return Buffer.from(raw);
|
||||
if (method === 8) {
|
||||
const out = zlib.inflateRawSync(raw);
|
||||
if (out.length !== size) {
|
||||
throw new Error(`${name}: ${out.length} Bytes entpackt, ${size} erwartet`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
throw new Error(`${name}: unbekanntes Kompressionsverfahren ${method}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Inhaltsverzeichnis steht am Dateiende, hinter einem Kommentar
|
||||
* beliebiger Laenge. Also von hinten suchen - hoechstens 64 KB weit, mehr
|
||||
* darf der Kommentar nicht sein.
|
||||
*/
|
||||
function findEocd(buf: Buffer): number {
|
||||
const min = Math.max(0, buf.length - 0x10000 - 22);
|
||||
for (let i = buf.length - 22; i >= min; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD) return i;
|
||||
}
|
||||
throw new Error('Kein ZIP-Inhaltsverzeichnis gefunden.');
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP schreiben. Nur so viel Format, wie der Windows-Explorer und
|
||||
* `Expand-Archive` verlangen - insbesondere die CRC32-Pruefsumme, sonst
|
||||
* halten beide das Archiv fuer beschaedigt.
|
||||
*/
|
||||
export function writeZip(files: Array<{ name: string; data: Buffer }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name.replace(/\\/g, '/'), 'utf8');
|
||||
const packed = zlib.deflateRawSync(f.data, { level: 9 });
|
||||
// Groesser als das Original kommt bei schon komprimierten Dateien vor -
|
||||
// dann unveraendert ablegen.
|
||||
const useDeflate = packed.length < f.data.length;
|
||||
const body = useDeflate ? packed : f.data;
|
||||
const crc = zlib.crc32(f.data);
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(LOCAL, 0);
|
||||
local.writeUInt16LE(20, 4); // benoetigte Version: 2.0
|
||||
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(f.data.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, body);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(CENTRAL, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(body.length, 20);
|
||||
central.writeUInt32LE(f.data.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + body.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(EOCD, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
|
||||
const out = Buffer.concat([...locals, dir, eocd]);
|
||||
if (out.length > 0xffffffff) throw new Error('Ueber 4 GB braeuchte ZIP64.');
|
||||
return out;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
// Aufrufe von ffmpeg und ffprobe als Kindprozesse.
|
||||
//
|
||||
// Zwei Dinge sind hier wichtiger als alles andere (ADR-0006):
|
||||
// 1. Der Prozess laeuft nachrangig (`nice`), damit das Encodieren dem
|
||||
// laufenden Stream keine Frames kostet.
|
||||
// 1. Der Prozess laeuft nachrangig (os.setPriority), damit das Encodieren
|
||||
// dem laufenden Stream keine Frames kostet.
|
||||
// 2. Er darf scheitern, ohne den Daemon mitzureissen - deshalb Zeitlimit,
|
||||
// sauberes Toeten und die letzten Zeilen von stderr in der Fehlermeldung.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { binaries } from '#core/platform.ts';
|
||||
|
||||
const log = logger('ffmpeg');
|
||||
|
||||
@@ -16,8 +18,65 @@ export type RunOptions = {
|
||||
timeoutMs?: number;
|
||||
onProgress?: (seconds: number) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Arbeitsverzeichnis. Erlaubt relative Dateinamen im Filtergraphen. */
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Blanke Programmnamen auf die mitgelieferten Dateien abbilden.
|
||||
*
|
||||
* Im Container lagen ffmpeg und whisper-cli im PATH. Nativ liegen sie neben
|
||||
* der Anwendung. Die Abbildung steht an dieser einen Stelle, damit die
|
||||
* Aufrufer weiter schlicht `run('ffmpeg', ...)` schreiben koennen.
|
||||
*/
|
||||
function resolveBinary(bin: string): string {
|
||||
if (bin === 'ffmpeg') return binaries.ffmpeg;
|
||||
if (bin === 'ffprobe') return binaries.ffprobe;
|
||||
if (bin === 'whisper-cli') return binaries.whisper;
|
||||
return bin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Dateipfad so verpacken, dass ffmpeg ihn im Filtergraphen als *einen*
|
||||
* Wert liest.
|
||||
*
|
||||
* Das ist unter Windows die Stelle, an der es sonst reihenweise knallt:
|
||||
* C:\Users\... enthaelt einen Doppelpunkt, und der trennt in ffmpeg die
|
||||
* Filteroptionen voneinander. Rueckwaertsschraegstriche sind ausserdem
|
||||
* ffmpeg-Escapes und gehen unterwegs verloren.
|
||||
*
|
||||
* Es braucht **alle drei** Schritte. An echtem ffmpeg 8.1 durchprobiert:
|
||||
*
|
||||
* ass=filename=C:/x/s.ass -> "No option name near '/x/s.ass'"
|
||||
* ass=filename=C\:/x/s.ass -> ebenso
|
||||
* ass=filename='C:/x/s.ass' -> ebenso
|
||||
* ass=filename='C\:/x/s.ass' -> laeuft
|
||||
*
|
||||
* Anfuehrungszeichen allein schuetzen den Doppelpunkt also nicht, und das
|
||||
* Escape allein auch nicht.
|
||||
*/
|
||||
export function filterPath(file: string): string {
|
||||
const forward = file.replace(/\\/g, '/').replace(/:/g, '\\:');
|
||||
// Ein Anfuehrungszeichen laesst sich innerhalb der Gruppe nicht escapen:
|
||||
// Gruppe schliessen, Zeichen einzeln escapen, Gruppe wieder oeffnen. Bei
|
||||
// Benutzernamen wie O'Brien passiert das wirklich.
|
||||
return `'${forward.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filterausdruck zum Einbrennen einer ASS-Datei.
|
||||
*
|
||||
* `fontsdir` ist nicht optional im Sinne von "nice to have": im Container kam
|
||||
* DejaVu Sans aus dem Betriebssystem, Windows hat sie nicht. Fehlt der Ordner,
|
||||
* setzt libass wortlos eine Ersatzschrift ein und der Untertitel sieht anders
|
||||
* aus, als er soll - ohne jede Fehlermeldung.
|
||||
*/
|
||||
export function assFilter(assFile: string, fonts?: string): string {
|
||||
const parts = [`ass=filename=${filterPath(assFile)}`];
|
||||
if (fonts) parts.push(`fontsdir=${filterPath(fonts)}`);
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
export class FfmpegError extends Error {
|
||||
code: number | null;
|
||||
tail: string;
|
||||
@@ -36,13 +95,22 @@ export function parseProgress(chunk: string): number | null {
|
||||
}
|
||||
|
||||
export function run(bin: string, args: string[], opts: RunOptions = {}): Promise<string> {
|
||||
const niceness = opts.niceness ?? 0;
|
||||
const cmd = niceness > 0 ? 'nice' : bin;
|
||||
const argv = niceness > 0 ? ['-n', String(niceness), bin, ...args] : args;
|
||||
const cmd = resolveBinary(bin);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
log.debug(`${cmd} ${argv.slice(0, 6).join(' ')} ...`);
|
||||
const child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
log.debug(`${cmd} ${args.slice(0, 6).join(' ')} ...`);
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
||||
});
|
||||
|
||||
// Frueher lief das ueber `nice`, das es unter Windows nicht gibt.
|
||||
// os.setPriority kann beides und braucht keinen Hilfsprozess davor;
|
||||
// das Zurueckstufen darf jeder, nur das Hochstufen braucht Rechte.
|
||||
if (opts.niceness && child.pid !== undefined) {
|
||||
try { os.setPriority(child.pid, opts.niceness); }
|
||||
catch (err) { log.debug(`Prioritaet nicht setzbar: ${(err as Error).message}`); }
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
const errLines: string[] = [];
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Diese Tests halten fest, was an echtem ffmpeg 8.1 unter Windows
|
||||
// durchprobiert wurde. Ohne sie schleicht sich beim naechsten Aufraeumen
|
||||
// wieder eine der drei Varianten ein, die nicht funktionieren.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assFilter, filterPath } from './ffmpeg.ts';
|
||||
|
||||
test('macht aus einem Windows-Pfad einen Wert, den ffmpeg als einen liest', () => {
|
||||
const got = filterPath('C:\\Users\\abdu\\work\\c1\\subs.ass');
|
||||
// Alle drei Schritte sind noetig - jeder fuer sich reicht nicht:
|
||||
// Vorwaertsschraegstriche, escapter Doppelpunkt, in Anfuehrungszeichen.
|
||||
assert.equal(got, "'C\\:/Users/abdu/work/c1/subs.ass'");
|
||||
});
|
||||
|
||||
test('Leerzeichen brauchen nichts Besonderes', () => {
|
||||
// Es geht kein Shell dazwischen - gespawnt wird ohne Kommandozeile.
|
||||
assert.equal(filterPath('C:\\Program Files\\abdulive\\fonts'),
|
||||
"'C\\:/Program Files/abdulive/fonts'");
|
||||
});
|
||||
|
||||
test('Anfuehrungszeichen im Pfad brechen die Gruppe nicht auf', () => {
|
||||
// Benutzernamen wie O'Brien gibt es wirklich. Innerhalb der Gruppe laesst
|
||||
// sich das Zeichen nicht escapen: schliessen, escapen, wieder oeffnen.
|
||||
assert.equal(filterPath("C:\\Users\\O'Brien\\subs.ass"),
|
||||
"'C\\:/Users/O'\\''Brien/subs.ass'");
|
||||
});
|
||||
|
||||
test('Linux-Pfade bleiben unveraendert bis auf die Anfuehrungszeichen', () => {
|
||||
assert.equal(filterPath('/work/c1/subs.ass'), "'/work/c1/subs.ass'");
|
||||
});
|
||||
|
||||
test('assFilter haengt fontsdir nur an, wenn es einen Ordner gibt', () => {
|
||||
assert.equal(assFilter('/work/s.ass'), "ass=filename='/work/s.ass'");
|
||||
assert.equal(assFilter('/work/s.ass', '/opt/fonts'),
|
||||
"ass=filename='/work/s.ass':fontsdir='/opt/fonts'");
|
||||
});
|
||||
|
||||
test('der Doppelpunkt aus fontsdir trennt keine Filteroption ab', () => {
|
||||
// Genau hier stand der Fehler: ohne Escape las ffmpeg alles ab "/Users/"
|
||||
// als neuen Optionsnamen und brach mit "No option name near" ab.
|
||||
const vf = assFilter('C:\\w\\s.ass', 'C:\\app\\fonts');
|
||||
const optionSeparators = vf.split(':').length - 1;
|
||||
const escaped = vf.split('\\:').length - 1;
|
||||
assert.equal(escaped, 2, 'beide Laufwerksbuchstaben escapt');
|
||||
assert.equal(optionSeparators - escaped, 1, 'genau ein echter Trenner');
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Word } from './whisper.ts';
|
||||
import { wordsFromJson } from './whisper.ts';
|
||||
import { assColor, assTime, buildAss, buildSrt, isNoise, toLines } from './subtitles.ts';
|
||||
import {
|
||||
assColor, assTime, buildAss, buildSrt, DEFAULT_STYLE, isNoise, lineOptionsFor,
|
||||
fitFontSize, toLines, widthUnits,
|
||||
} from './subtitles.ts';
|
||||
|
||||
function w(text: string, start: number, end: number): Word {
|
||||
return { text, start, end };
|
||||
@@ -89,6 +92,61 @@ test('filtert Geraeuschbeschreibungen', () => {
|
||||
assert.deepEqual(lines.flatMap((l) => l.words.map((x) => x.text)), ['hallo']);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Zeilenbreite
|
||||
|
||||
test('keine Zeile laeuft seitlich aus dem Bild', () => {
|
||||
// Der Fehler, der das ausgeloest hat: die feste Obergrenze von 30 Zeichen
|
||||
// ergibt bei Schriftgroesse 76 rund 1085 px. Nutzbar sind 1080 minus zwei
|
||||
// Seitenraender = 940 px. Gemessen mit libass, nicht gerechnet.
|
||||
const opts = lineOptionsFor(1080);
|
||||
const satz = ('WARUM MACHT DER DAS DENN EINFACH NICHT RICHTIG das ist doch ' +
|
||||
'wirklich nicht so schwer zu verstehen oder etwa doch').split(' ');
|
||||
const lines = toLines(satz.map((t, i) => w(t, i * 0.35, i * 0.35 + 0.3)), opts);
|
||||
|
||||
assert.ok(lines.length > 1);
|
||||
for (const l of lines) {
|
||||
const text = l.words.map((x) => x.text).join(' ');
|
||||
const units = widthUnits(text);
|
||||
assert.ok(units <= opts.maxUnits + 4,
|
||||
`zu breit (${units.toFixed(1)} von ${opts.maxUnits} Normalzeichen): "${text}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Versalien zaehlen breiter als Kleinbuchstaben', () => {
|
||||
assert.ok(widthUnits('WARUM') > widthUnits('warum'));
|
||||
assert.equal(widthUnits('warum'), 5);
|
||||
});
|
||||
|
||||
test('Zeilenlaenge haengt an Breite und Schriftgroesse', () => {
|
||||
const schmal = lineOptionsFor(1080, { ...DEFAULT_STYLE, fontSize: 110 });
|
||||
const breit = lineOptionsFor(1080, { ...DEFAULT_STYLE, fontSize: 56 });
|
||||
assert.ok(schmal.maxUnits < breit.maxUnits,
|
||||
'groessere Schrift muss frueher umbrechen');
|
||||
assert.ok(lineOptionsFor(1080).maxUnits < 30, 'die alte 30 war zu viel');
|
||||
});
|
||||
|
||||
test('verkleinert die Schrift fuer Woerter, die nicht umbrechbar sind', () => {
|
||||
// Innerhalb eines Wortes bricht libass nicht um. Deutsche Komposita sind
|
||||
// regelmaessig breiter als das Bild - gemessen 1078 px bei 1080 Bildbreite.
|
||||
const lang = toLines([w('Geschwindigkeitsbegrenzung', 0, 2)]);
|
||||
const kurz = toLines([w('kurz', 0, 2)]);
|
||||
assert.ok(fitFontSize(lang[0]!, 940, DEFAULT_STYLE) < DEFAULT_STYLE.fontSize);
|
||||
assert.equal(fitFontSize(kurz[0]!, 940, DEFAULT_STYLE), DEFAULT_STYLE.fontSize);
|
||||
|
||||
const ass = buildAss(lang, { width: 1080, height: 1920 });
|
||||
assert.match(ass, /\{\\fs\d+\}\{\\k\d+\}Geschwindigkeits/,
|
||||
'die Zeile braucht eine eigene Schriftgroesse');
|
||||
// Kurze Zeilen bleiben unangetastet
|
||||
assert.doesNotMatch(buildAss(kurz, { width: 1080, height: 1920 }), /\\fs/);
|
||||
});
|
||||
|
||||
test('libass darf selbst umbrechen', () => {
|
||||
// WrapStyle 2 bricht nur an \N um - buildAss setzt aber nie eines.
|
||||
// Damit war jede zu lange Zeile ein Ueberlauf statt eines Umbruchs.
|
||||
const ass = buildAss(toLines([w('test', 0, 1)]), { width: 1080, height: 1920 });
|
||||
assert.match(ass, /^WrapStyle: 0$/m);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ ASS-Ausgabe
|
||||
|
||||
test('Zeilen ueberlappen sich nie', () => {
|
||||
|
||||
@@ -40,14 +40,68 @@ export const DEFAULT_STYLE: SubtitleStyle = {
|
||||
export type LineOptions = {
|
||||
maxWords: number;
|
||||
maxSeconds: number;
|
||||
maxChars: number;
|
||||
/** Obergrenze in Normalzeichen, nicht in Zeichen - siehe `widthUnits`. */
|
||||
maxUnits: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mittlere Zeichenbreite von DejaVu Sans **Bold** als Anteil der
|
||||
* Schriftgroesse. Nicht geschaetzt, sondern gemessen: deutsche Beispielzeilen
|
||||
* mit libass auf eine ueberbreite Leinwand gerendert und die Randspalten
|
||||
* ausgezaehlt. Die Werte lagen zwischen 0,45 und 0,53 - hier steht der obere
|
||||
* Rand, weil eine zu breite Zeile teurer ist als eine zu frueh gebrochene.
|
||||
*/
|
||||
export const CHAR_WIDTH_RATIO = 0.53;
|
||||
|
||||
/**
|
||||
* Versalien sind rund ein Drittel breiter als Kleinbuchstaben. Ohne diesen
|
||||
* Zuschlag passt gerade der Fall nicht, der in Clips staendig vorkommt:
|
||||
* gebruellte Zeilen. Gemessen bei Schriftgroesse 76 - "und dann bin ich
|
||||
* einfach" 899 px, "WARUM MACHT DER DAS DENN" bei gleicher Zeichenzahl
|
||||
* 1146 px.
|
||||
*/
|
||||
const UPPERCASE_FACTOR = 1.3;
|
||||
|
||||
/** Geschaetzte Breite eines Textes in Normalzeichen. */
|
||||
export function widthUnits(text: string): number {
|
||||
let units = 0;
|
||||
for (const ch of text) units += /[A-ZÄÖÜ]/.test(ch) ? UPPERCASE_FACTOR : 1;
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Normalzeichen zwischen die Seitenraender passen.
|
||||
*
|
||||
* Vorher stand hier fest `maxChars: 30`, und das lief ueber: 30 Zeichen sind
|
||||
* bei Groesse 76 rund 1085 px breit, nutzbar sind aber nur 940. Zusammen mit
|
||||
* `WrapStyle: 2` - libass bricht dann gar nicht um - schob sich der Text links
|
||||
* und rechts aus dem Bild.
|
||||
*/
|
||||
export function maxUnitsFor(width: number, style: SubtitleStyle): number {
|
||||
const usable = width - 2 * style.marginSide;
|
||||
return Math.max(8, Math.floor(usable / (style.fontSize * CHAR_WIDTH_RATIO)));
|
||||
}
|
||||
|
||||
/** Zeilenvorgaben passend zur Zielbreite und zum Stil. */
|
||||
export function lineOptionsFor(
|
||||
width: number,
|
||||
style: SubtitleStyle = DEFAULT_STYLE,
|
||||
opts: Partial<LineOptions> = {}
|
||||
): LineOptions {
|
||||
return { ...DEFAULT_LINES, maxUnits: maxUnitsFor(width, style), ...opts };
|
||||
}
|
||||
|
||||
// maxSeconds war anfangs 2,2 s. An echtem Material zeigte sich, dass bei
|
||||
// langsamem oder zoegerndem Sprechen dann Zeilen mit einem einzigen Wort
|
||||
// entstehen ("Dank" / "es," / "Savina,"). 3,2 s plus das Nachverdichten
|
||||
// weiter unten ergibt lesbare Zeilen.
|
||||
export const DEFAULT_LINES: LineOptions = { maxWords: 5, maxSeconds: 3.2, maxChars: 30 };
|
||||
//
|
||||
// maxUnits gilt fuer das Zielformat 1080 breit. Andere Breiten: lineOptionsFor.
|
||||
export const DEFAULT_LINES: LineOptions = {
|
||||
maxWords: 5,
|
||||
maxSeconds: 3.2,
|
||||
maxUnits: 23,
|
||||
};
|
||||
|
||||
export type Line = { words: Word[]; start: number; end: number };
|
||||
|
||||
@@ -99,12 +153,13 @@ export function toLines(words: Word[], opts: LineOptions = DEFAULT_LINES): Line[
|
||||
};
|
||||
|
||||
for (const w of words.filter((x) => !isNoise(x.text))) {
|
||||
const wouldBeChars = current.reduce((n, x) => n + x.text.length + 1, 0) + w.text.length;
|
||||
const wouldBeUnits = current.reduce((n, x) => n + widthUnits(x.text) + 1, 0) +
|
||||
widthUnits(w.text);
|
||||
const wouldBeSeconds = current.length ? w.end - current[0]!.start : 0;
|
||||
|
||||
if (current.length && (
|
||||
current.length >= opts.maxWords ||
|
||||
wouldBeChars > opts.maxChars ||
|
||||
wouldBeUnits > opts.maxUnits ||
|
||||
wouldBeSeconds > opts.maxSeconds ||
|
||||
// Eine Pause von mehr als einer halben Sekunde ist ein Satzende,
|
||||
// egal was die Zeichensetzung sagt
|
||||
@@ -127,11 +182,14 @@ function mergeStragglers(lines: Line[], opts: LineOptions): Line[] {
|
||||
const out: Line[] = [];
|
||||
for (const line of lines) {
|
||||
const prev = out[out.length - 1];
|
||||
const chars = (l: Line) => l.words.reduce((n, w) => n + w.text.length + 1, 0);
|
||||
const units = (l: Line) => l.words.reduce((n, w) => n + widthUnits(w.text) + 1, 0);
|
||||
// Der Zuschlag war 8 Zeichen - das sind knapp 320 px und damit weit ueber
|
||||
// dem Rand. 4 reichen fuer den Zweck, und was doch nicht passt, bricht
|
||||
// libass jetzt sauber um (WrapStyle 0), statt es aus dem Bild zu schieben.
|
||||
const soloAndClose = prev && line.words.length === 1 &&
|
||||
line.start - prev.end < 0.6 &&
|
||||
prev.words.length + 1 <= opts.maxWords + 1 &&
|
||||
chars(prev) + chars(line) <= opts.maxChars + 8;
|
||||
units(prev) + units(line) <= opts.maxUnits + 4;
|
||||
|
||||
if (soloAndClose) {
|
||||
prev.words.push(...line.words);
|
||||
@@ -154,7 +212,10 @@ export function buildAss(
|
||||
'ScriptType: v4.00+',
|
||||
`PlayResX: ${opts.width}`,
|
||||
`PlayResY: ${opts.height}`,
|
||||
'WrapStyle: 2',
|
||||
// 0 = automatischer Umbruch mit moeglichst gleich langen Zeilen. Vorher
|
||||
// stand hier 2 ("nur an \N umbrechen"), und weil buildAss nie ein \N
|
||||
// setzt, lief jede zu lange Zeile einfach aus dem Bild heraus.
|
||||
'WrapStyle: 0',
|
||||
'ScaledBorderAndShadow: yes',
|
||||
'',
|
||||
'[V4+ Styles]',
|
||||
@@ -179,6 +240,8 @@ export function buildAss(
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
];
|
||||
|
||||
const usable = opts.width - 2 * s.marginSide;
|
||||
|
||||
const events = lines.map((line, li) => {
|
||||
// Nachlauf, damit die Zeile nicht im Moment des letzten Lautes verschwindet -
|
||||
// aber nie in die naechste Zeile hinein. Zwei gleichzeitig sichtbare
|
||||
@@ -186,6 +249,9 @@ export function buildAss(
|
||||
const next = lines[li + 1];
|
||||
const end = next ? Math.min(line.end + 0.25, next.start) : line.end + 0.4;
|
||||
|
||||
const fs = fitFontSize(line, usable, s);
|
||||
const prefix = fs === s.fontSize ? '' : `{\\fs${fs}}`;
|
||||
|
||||
const text = line.words.map((w, i) => {
|
||||
const nextWord = line.words[i + 1];
|
||||
// Die Marke laeuft bis zum Beginn des naechsten Wortes, damit Pausen
|
||||
@@ -196,12 +262,36 @@ export function buildAss(
|
||||
return `{\\k${cs}}${escapeAss(w.text)}`;
|
||||
}).join(' ');
|
||||
return `Dialogue: 0,${assTime(line.start)},${assTime(Math.max(end, line.start + 0.3))},` +
|
||||
`Short,,0,0,0,,${text}`;
|
||||
`Short,,0,0,0,,${prefix}${text}`;
|
||||
});
|
||||
|
||||
return [...header, ...events, ''].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Innerhalb eines Wortes bricht libass nicht um. Im Deutschen ist das keine
|
||||
* Randerscheinung: "Geschwindigkeitsbegrenzung" ist bei Schriftgroesse 76 rund
|
||||
* 1090 px breit, das Bild ist 1080 breit - gemessen, das Wort wurde links und
|
||||
* rechts abgeschnitten. Solche Zeilen bekommen eine kleinere Schrift, statt
|
||||
* ueber den Rand zu laufen.
|
||||
*/
|
||||
export function fitFontSize(line: Line, usable: number, style: SubtitleStyle): number {
|
||||
// CHAR_WIDTH_RATIO ist ein Mittelwert. Beim Zeilenumbruch faengt WrapStyle 0
|
||||
// ab, wenn er danebenliegt - hier nicht, hier ist es sofort ein Ueberlauf.
|
||||
// Deshalb 5 % Reserve; ohne sie landete "Geschwindigkeitsbegrenzung" gemessen
|
||||
// bei 950 px statt der erlaubten 940.
|
||||
const budget = usable * 0.95;
|
||||
const longest = Math.max(...line.words.map((w) => widthUnits(w.text)));
|
||||
const needed = longest * style.fontSize * CHAR_WIDTH_RATIO;
|
||||
if (needed <= budget) return style.fontSize;
|
||||
// Nicht beliebig weit verkleinern: unter 55 % liest das auf einem Handy
|
||||
// niemand mehr, und dann ist ein Rest Ueberlauf das kleinere Uebel.
|
||||
return Math.max(
|
||||
Math.round(style.fontSize * 0.55),
|
||||
Math.floor((style.fontSize * budget) / needed)
|
||||
);
|
||||
}
|
||||
|
||||
function escapeAss(text: string): string {
|
||||
return text.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
// OBS laeuft auf dem Host und meldet Windows-Pfade; der Container sieht den
|
||||
// Wiedergabepuffer-Ordner unter /replay. Diese Uebersetzung ist die Stelle,
|
||||
// an der die Clip-Pipeline am ehesten scheitert - deshalb eigene Datei mit
|
||||
// eigenen Tests. Siehe docs/07-mvp-plan.md, Scheibe S2.
|
||||
// OBS meldet den Pfad der gespeicherten Pufferdatei. Nativ ist das derselbe
|
||||
// Pfad, den auch die Anwendung sieht - es gibt nichts zu uebersetzen.
|
||||
//
|
||||
// Solange das Toolkit im Container lief, war das anders: OBS meldete
|
||||
// `C:\Users\...`, drinnen hiess derselbe Ordner `/replay`. Diese Uebersetzung
|
||||
// war die Stelle, an der die Clip-Pipeline am ehesten scheiterte. Sie bleibt
|
||||
// samt Tests erhalten, wird aber nur noch gebraucht, wenn `containerDir`
|
||||
// gesetzt ist. Siehe ADR-0007.
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/** `C:\Users\abdu\Videos\replay\Replay 2026-09-09.mkv` -> `/replay/Replay 2026-09-09.mkv` */
|
||||
/**
|
||||
* Nativ: unveraendert zurueck.
|
||||
* Im Container: `C:\Users\abdu\Videos\replay\Replay.mkv` -> `/replay/Replay.mkv`
|
||||
*/
|
||||
export function mapReplayPath(
|
||||
hostPath: string, hostDir: string | undefined, containerDir: string
|
||||
): string {
|
||||
// Kein Zielverzeichnis heisst: dieselbe Dateisicht wie OBS.
|
||||
if (!containerDir) return hostPath;
|
||||
|
||||
const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const file = norm(hostPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CAPTION_HINTS, judgeSource, overlapPercent } from './scene-report.ts';
|
||||
|
||||
// Die Ausschnitte aus dem vorgegebenen Layout (config/layout.yaml, Profil default)
|
||||
const CAM = { x: 0, y: 0, w: 480, h: 270 };
|
||||
const GAME = { x: 515, y: 0, w: 890, h: 1080 };
|
||||
|
||||
const judge = (box: { x: number; y: number; w: number; h: number }, over: {
|
||||
enabled?: boolean; name?: string; kind?: string; url?: string;
|
||||
} = {}) => judgeSource({
|
||||
box, enabled: over.enabled ?? true, name: over.name ?? 'Quelle',
|
||||
kind: over.kind ?? 'text_gdiplus_v3', cam: CAM, game: GAME,
|
||||
...(over.url === undefined ? {} : { url: over.url }),
|
||||
});
|
||||
|
||||
test('rechnet die Ueberdeckung in Prozent des Ausschnitts', () => {
|
||||
assert.equal(overlapPercent({ x: 0, y: 0, w: 480, h: 270 }, CAM), 100);
|
||||
assert.equal(overlapPercent({ x: 0, y: 0, w: 240, h: 270 }, CAM), 50);
|
||||
assert.equal(overlapPercent({ x: 1405, y: 0, w: 515, h: 1080 }, GAME), 0);
|
||||
});
|
||||
|
||||
test('Quellen rechts der Chatlinie sind ausserhalb', () => {
|
||||
// Genau die Vorgabe aus docs/obs-layout.md: Chat und Alerts ab x=1405
|
||||
const chat = judge({ x: 1405, y: 0, w: 515, h: 1080 }, { name: 'Chat-Overlay' });
|
||||
assert.equal(chat.inShort, false);
|
||||
assert.equal(chat.game, 0);
|
||||
});
|
||||
|
||||
test('ein Untertitel-Overlay im Gameplay-Band faellt auf', () => {
|
||||
// Der Fall aus dem echten Clip: Live-Captions ueber der vollen Bildbreite
|
||||
const u = judge({ x: 0, y: 900, w: 1920, h: 120 }, { name: 'Live Captions' });
|
||||
assert.equal(u.inShort, true);
|
||||
assert.equal(u.suspectCaption, true);
|
||||
assert.ok(u.game > 0);
|
||||
});
|
||||
|
||||
test('dasselbe Overlay unten links ist in Ordnung', () => {
|
||||
// Der empfohlene Ausweichplatz: x 0..515, also links neben dem Band
|
||||
const u = judge({ x: 0, y: 900, w: 515, h: 120 }, { name: 'Live Captions' });
|
||||
assert.equal(u.inShort, false);
|
||||
assert.equal(u.suspectCaption, false, 'ausserhalb ist kein Problem, sondern die Loesung');
|
||||
});
|
||||
|
||||
test('ausgeblendete Quellen zaehlen nicht', () => {
|
||||
const u = judge({ x: 0, y: 900, w: 1920, h: 120 },
|
||||
{ name: 'Untertitel', enabled: false });
|
||||
assert.equal(u.inShort, false);
|
||||
assert.equal(u.suspectCaption, false);
|
||||
});
|
||||
|
||||
test('erkennt Untertitelquellen auch an Art und Adresse', () => {
|
||||
const box = { x: 600, y: 900, w: 700, h: 100 };
|
||||
assert.equal(judge(box, { name: 'Overlay 3', kind: 'localvocal_filter' }).suspectCaption, true);
|
||||
assert.equal(judge(box, {
|
||||
name: 'Overlay 4', kind: 'browser_source', url: 'https://webcaptioner.com/captioner/view/x',
|
||||
}).suspectCaption, true);
|
||||
assert.equal(judge(box, { name: 'Killfeed', kind: 'image_source' }).suspectCaption, false);
|
||||
});
|
||||
|
||||
test('die Stichwortliste trifft die ueblichen Verdaechtigen', () => {
|
||||
for (const s of ['Live Captions', 'Untertitel', 'Web Captioner', 'obs-localvocal',
|
||||
'Speech to Text', 'closed captions', 'CC Overlay']) {
|
||||
assert.ok(CAPTION_HINTS.test(s), `nicht erkannt: ${s}`);
|
||||
}
|
||||
for (const s of ['Facecam', 'Gameplay', 'Alerts', 'Soccer', 'Discord']) {
|
||||
assert.equal(CAPTION_HINTS.test(s), false, `Fehlalarm: ${s}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Auswertung einer OBS-Szene gegen den 9:16-Ausschnitt.
|
||||
//
|
||||
// Der Anlass: In einem fertigen Clip lagen fremde Untertitel im Bild, und
|
||||
// niemand wusste, woher sie kommen. Raten hilft da nicht - sie sind ins
|
||||
// Streambild eingebrannt, also muss man nachsehen, welche Quelle sie malt.
|
||||
// Die reine Rechnerei steht hier, damit sie pruefbar ist; das Anzeigen und
|
||||
// das Reden mit OBS bleibt im Entrypoint.
|
||||
|
||||
import type { Rect } from '#core/config.ts';
|
||||
|
||||
export type Box = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/**
|
||||
* Quellenarten und Namen, hinter denen erfahrungsgemaess Untertitel stecken.
|
||||
* Bewusst weit gefasst: ein Fehlalarm kostet einen Blick, eine uebersehene
|
||||
* Quelle kostet jeden Clip.
|
||||
*/
|
||||
export const CAPTION_HINTS =
|
||||
/caption|untertitel|subtitle|transcri|speech|vocal|stt|dictat|closed.?cc|(^|\W)cc(\W|$)/i;
|
||||
|
||||
/** Wie viel Prozent des Ausschnitts die Quelle ueberdeckt. */
|
||||
export function overlapPercent(box: Box, r: Rect): number {
|
||||
const w = Math.max(0, Math.min(box.x + box.w, r.x + r.w) - Math.max(box.x, r.x));
|
||||
const h = Math.max(0, Math.min(box.y + box.h, r.y + r.h) - Math.max(box.y, r.y));
|
||||
const area = r.w * r.h;
|
||||
return area > 0 ? Math.round(((w * h) / area) * 100) : 0;
|
||||
}
|
||||
|
||||
export type SourceVerdict = {
|
||||
cam: number;
|
||||
game: number;
|
||||
/** Steht die Quelle im fertigen Short? */
|
||||
inShort: boolean;
|
||||
/** Sieht sie nach fremden Untertiteln aus? */
|
||||
suspectCaption: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Urteil ueber eine einzelne Quelle.
|
||||
*
|
||||
* Ausgeblendete Quellen sind nie im Bild - egal wo sie liegen. Und eine
|
||||
* Untertitelquelle ausserhalb des Ausschnitts ist kein Problem, sondern genau
|
||||
* das, was docs/obs-layout.md vorschlaegt: im Stream sichtbar lassen, aus dem
|
||||
* Gameplay-Band herausschieben.
|
||||
*/
|
||||
export function judgeSource(args: {
|
||||
box: Box;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
kind: string;
|
||||
url?: string;
|
||||
cam: Rect;
|
||||
game: Rect;
|
||||
}): SourceVerdict {
|
||||
const cam = overlapPercent(args.box, args.cam);
|
||||
const game = overlapPercent(args.box, args.game);
|
||||
const inShort = args.enabled && (cam > 0 || game > 0);
|
||||
return {
|
||||
cam, game, inShort,
|
||||
suspectCaption: inShort &&
|
||||
CAPTION_HINTS.test(`${args.name} ${args.kind} ${args.url ?? ''}`),
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export type SecretStore = {
|
||||
obs?: { password: string };
|
||||
discord?: { goLiveWebhook?: string; recapWebhook?: string };
|
||||
bluesky?: { handle: string; appPassword: string };
|
||||
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
|
||||
update?: { token: string };
|
||||
};
|
||||
|
||||
const EMPTY: SecretStore = { apps: {}, tokens: {} };
|
||||
|
||||
+13
-7
@@ -1,12 +1,18 @@
|
||||
# scripts
|
||||
|
||||
Skripte laufen entweder im Container (`docker compose run --rm toolkit <name>`)
|
||||
oder auf dem Host, wo sie mit Docker sprechen.
|
||||
Befehle der Anwendung ruft man ueber `abdulive.cmd <name>` auf (im Repo:
|
||||
`node apps/cli/main.ts <name>`). Daneben stehen hier die Skripte fuer
|
||||
Paketbau, Installation und Start.
|
||||
|
||||
| Skript | Wo | Zweck |
|
||||
|---|---|---|
|
||||
| `check-env` | Container | Prueft ffmpeg, whisper-cli, Sprachmodell, Volumes, OBS-Erreichbarkeit |
|
||||
| `auth` | Container | OAuth-Flow fuer Broadcaster- und Bot-Token (braucht `--service-ports`) |
|
||||
| `db:migrate` | Container | Migrationen anwenden |
|
||||
| `calibrate` | Container | Facecam-Crop einmessen, schreibt `config/layout.yaml` |
|
||||
| `backup.ps1` | Host | `VACUUM INTO` aus dem Volume `abdulive-db` nach `data/db/backup/`, 30 Generationen |
|
||||
| `build-package.ts` | Entwicklungsrechner / CI | Baut `dist/abdulive-<version>-win-x64.zip` samt Updater-Manifest |
|
||||
| `install.ps1` | Stream-PC | Installiert Programm, Daten und Autostart. Ohne Administratorrechte |
|
||||
| `launcher.ps1` | Stream-PC | Vom Aufgabenplaner gestartet: Update anwenden, dann Daemon starten |
|
||||
| `check-env` | Stream-PC | Prueft ffmpeg, whisper-cli, Schriften, Sprachmodell, Verzeichnisse, OBS |
|
||||
| `auth` | Stream-PC | OAuth-Flow fuer Broadcaster- und Bot-Token |
|
||||
| `db:migrate` | Stream-PC | Migrationen anwenden |
|
||||
| `calibrate` | Stream-PC | Facecam-Crop einmessen, schreibt `config/layout.yaml` |
|
||||
| `sources` | Stream-PC | Listet die OBS-Quellen der laufenden Szene und sagt, welche im 9:16-Ausschnitt landen |
|
||||
| `backup.ps1` | Stream-PC | `VACUUM INTO` aus `%LOCALAPPDATA%\abdulive\db`, 30 Generationen |
|
||||
| `update` | Stream-PC | Neue Fassung pruefen und bereitlegen; eingespielt wird beim naechsten Start |
|
||||
@@ -0,0 +1,56 @@
|
||||
# Sichert die Datenbank.
|
||||
#
|
||||
# `VACUUM INTO` und nicht kopieren: die Datenbank laeuft im WAL-Modus und wird
|
||||
# waehrend des Streams beschrieben. Eine im Schreibvorgang kopierte Datei ist
|
||||
# unbrauchbar - VACUUM INTO schreibt dagegen einen in sich stimmigen Stand,
|
||||
# ohne den laufenden Betrieb anzuhalten.
|
||||
#
|
||||
# Die Sicherung enthaelt Chatverlaeufe und damit personenbezogene Daten. Sie
|
||||
# gehoert nicht ins Repo und nicht in eine Cloud ohne Absprache.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DatenOrdner = (Join-Path $env:LOCALAPPDATA 'abdulive'),
|
||||
[string]$Ziel = (Join-Path $env:LOCALAPPDATA 'abdulive\backup'),
|
||||
[int]$Generationen = 30
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$db = Join-Path $DatenOrdner 'db\abdulive.db'
|
||||
if (-not (Test-Path $db)) { throw "Keine Datenbank unter $db" }
|
||||
|
||||
$node = Join-Path $env:LOCALAPPDATA 'Programs\abdulive\node.exe'
|
||||
if (-not (Test-Path $node)) { $node = (Get-Command node -ErrorAction Stop).Source }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
|
||||
$stand = Get-Date -Format 'yyyy-MM-dd_HHmm'
|
||||
$datei = Join-Path $Ziel "abdulive-$stand.db"
|
||||
|
||||
# node:sqlite kann VACUUM INTO direkt - dafuer braucht es kein sqlite3.exe.
|
||||
$skript = @'
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const [quelle, ziel] = process.argv.slice(2);
|
||||
const db = new DatabaseSync(quelle, { readOnly: true });
|
||||
db.exec(`VACUUM INTO '${ziel.replace(/'/g, "''")}'`);
|
||||
db.close();
|
||||
'@
|
||||
$tmp = Join-Path $env:TEMP "abdulive-backup-$PID.cjs"
|
||||
Set-Content -Path $tmp -Value $skript -Encoding utf8
|
||||
try {
|
||||
& $node $tmp $db $datei
|
||||
if ($LASTEXITCODE -ne 0) { throw "VACUUM INTO fehlgeschlagen (Code $LASTEXITCODE)" }
|
||||
} finally {
|
||||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$mb = [math]::Round((Get-Item $datei).Length / 1MB, 1)
|
||||
Write-Host "Gesichert: $datei ($mb MB)"
|
||||
|
||||
# Aeltere Generationen abraeumen
|
||||
$alt = Get-ChildItem $Ziel -Filter 'abdulive-*.db' |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -Skip $Generationen
|
||||
foreach ($f in $alt) {
|
||||
Remove-Item $f.FullName
|
||||
Write-Host " entfernt: $($f.Name)"
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Baut das Windows-Paket, das Abdu bekommt.
|
||||
//
|
||||
// node scripts/build-package.ts [--version 0.5.0] [--out dist]
|
||||
//
|
||||
// Ergebnis in dist/:
|
||||
// abdulive-<version>-win-x64.zip das Paket
|
||||
// abdulive.json Manifest fuer den Updater
|
||||
//
|
||||
// Laeuft auf jedem Rechner, auch unter Linux in der CI - es wird nichts
|
||||
// ausgefuehrt, nur heruntergeladen, entpackt und wieder eingepackt.
|
||||
// Siehe ADR-0007.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readZip, writeZip } from '#core/zip.ts';
|
||||
|
||||
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
// ---------------------------------------------------------------- Bausteine
|
||||
|
||||
/**
|
||||
* Node liefert versionierte, dauerhafte Adressen. ffmpeg und whisper.cpp
|
||||
* nicht: BtbN raeumt alte Autobuilds nach einigen Wochen ab. Deshalb wird
|
||||
* die jeweils aktuelle Ausgabe ueber die GitHub-API bestimmt und ihr genauer
|
||||
* Name samt Pruefsumme ins Paket geschrieben - reproduzierbar ist damit das
|
||||
* Paket, nicht die Adresse.
|
||||
*/
|
||||
const NODE_VERSION = 'v24.9.0';
|
||||
|
||||
type Component = {
|
||||
name: string;
|
||||
url: () => Promise<string>;
|
||||
/** Welche Dateien aus dem Archiv, und wohin im Paket. */
|
||||
pick: (entryName: string) => string | null;
|
||||
};
|
||||
|
||||
const COMPONENTS: Component[] = [
|
||||
{
|
||||
name: 'node',
|
||||
url: async () =>
|
||||
`https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`,
|
||||
pick: (n) => (n.endsWith('/node.exe') ? 'node.exe' : null),
|
||||
},
|
||||
{
|
||||
name: 'ffmpeg',
|
||||
url: () => githubAsset('BtbN/FFmpeg-Builds',
|
||||
/^ffmpeg-n\d[\w.-]*win64-gpl-shared-[\d.]+\.zip$/),
|
||||
// Aus dem shared-Build nur bin/: die beiden Programme und ihre DLLs.
|
||||
// ffplay wird nicht gebraucht.
|
||||
pick: (n) => {
|
||||
const m = /\/bin\/(.+)$/.exec(n);
|
||||
if (!m?.[1] || m[1].startsWith('ffplay')) return null;
|
||||
return /\.(exe|dll)$/i.test(m[1]) ? `bin/${m[1]}` : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'whisper',
|
||||
url: () => githubAsset('ggml-org/whisper.cpp', /^whisper-bin-x64\.zip$/),
|
||||
// whisper-cli plus die Bibliotheken. Die ggml-cpu-*.dll sind Varianten
|
||||
// fuer verschiedene Befehlssaetze; ausgewaehlt wird zur Laufzeit, es
|
||||
// muessen also alle mit.
|
||||
pick: (n) => {
|
||||
const m = /^Release\/(.+)$/.exec(n);
|
||||
const f = m?.[1];
|
||||
if (!f) return null;
|
||||
if (f === 'whisper-cli.exe') return `bin/${f}`;
|
||||
if (/^(whisper|ggml)[\w.-]*\.dll$/.test(f)) return `bin/${f}`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function githubAsset(repoName: string, pattern: RegExp): Promise<string> {
|
||||
const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, {
|
||||
headers: { 'user-agent': 'abdulive-build' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`);
|
||||
const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> };
|
||||
const asset = rel.assets.find((a) => pattern.test(a.name));
|
||||
if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`);
|
||||
return asset.browser_download_url;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werkzeug
|
||||
|
||||
const sha256 = (b: Buffer) => crypto.createHash('sha256').update(b).digest('hex');
|
||||
|
||||
async function download(url: string): Promise<Buffer> {
|
||||
const cacheDir = path.join(repo, '.cache');
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
|
||||
|
||||
const cached = await fs.readFile(file).catch(() => null);
|
||||
if (cached) {
|
||||
console.log(` aus dem Zwischenspeicher: ${path.basename(file)} ` +
|
||||
`(${(cached.length / 1e6).toFixed(0)} MB)`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
process.stdout.write(` lade ${path.basename(file)} ... `);
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} fuer ${url}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.writeFile(file, buf);
|
||||
console.log(`${(buf.length / 1e6).toFixed(0)} MB`);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Alle Dateien unterhalb `dir`, relativ, ohne die ausgeschlossenen. */
|
||||
async function walk(dir: string, base = dir): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...await walk(full, base));
|
||||
else out.push(path.relative(base, full).replace(/\\/g, '/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bau
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const arg = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
||||
const version = arg('--version') ?? new Date().toISOString().slice(0, 10).replace(/-/g, '.');
|
||||
const outDir = path.resolve(repo, arg('--out') ?? 'dist');
|
||||
|
||||
console.log(`abdulive ${version} fuer Windows x64\n`);
|
||||
|
||||
const files: Array<{ name: string; data: Buffer }> = [];
|
||||
const used: Record<string, { archiv: string; sha256: string }> = {};
|
||||
|
||||
// --- mitgelieferte Programme
|
||||
for (const c of COMPONENTS) {
|
||||
console.log(`${c.name}:`);
|
||||
const url = await c.url();
|
||||
const zip = await download(url);
|
||||
used[c.name] = { archiv: path.basename(new URL(url).pathname), sha256: sha256(zip) };
|
||||
|
||||
let taken = 0;
|
||||
for (const entry of readZip(zip)) {
|
||||
if (entry.isDirectory) continue;
|
||||
const target = c.pick(entry.name);
|
||||
if (!target) continue;
|
||||
files.push({ name: target, data: entry.data() });
|
||||
taken++;
|
||||
}
|
||||
if (!taken) throw new Error(`${c.name}: keine Datei uebernommen - hat sich der Aufbau geaendert?`);
|
||||
console.log(` ${taken} Datei(en) uebernommen`);
|
||||
}
|
||||
|
||||
// --- Anwendung: Quellen, ohne Tests
|
||||
console.log('\nAnwendung:');
|
||||
for (const dir of ['packages', 'apps']) {
|
||||
for (const rel of await walk(path.join(repo, dir))) {
|
||||
if (rel.endsWith('.test.ts')) continue;
|
||||
if (!/\.(ts|sql|json)$/.test(rel)) continue;
|
||||
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
||||
}
|
||||
}
|
||||
// Die "imports"-Map darin loest #core/... auf - ohne sie startet nichts.
|
||||
files.push({ name: 'app/package.json', data: await fs.readFile(path.join(repo, 'package.json')) });
|
||||
for (const f of ['config.example.yaml', 'layout.yaml']) {
|
||||
files.push({ name: `app/config/${f}`, data: await fs.readFile(path.join(repo, 'config', f)) });
|
||||
}
|
||||
for (const rel of await walk(path.join(repo, 'assets', 'fonts'))) {
|
||||
files.push({ name: `fonts/${rel}`, data: await fs.readFile(path.join(repo, 'assets/fonts', rel)) });
|
||||
}
|
||||
for (const f of ['launcher.ps1', 'install.ps1', 'backup.ps1']) {
|
||||
files.push({ name: f, data: await fs.readFile(path.join(repo, 'scripts', f)) });
|
||||
}
|
||||
console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`);
|
||||
|
||||
// --- Kopfdaten
|
||||
const manifestInPackage = {
|
||||
version,
|
||||
gebaut: new Date().toISOString(),
|
||||
node: NODE_VERSION,
|
||||
bausteine: used,
|
||||
};
|
||||
files.push({
|
||||
name: 'version.json',
|
||||
data: Buffer.from(JSON.stringify(manifestInPackage, null, 2) + '\n'),
|
||||
});
|
||||
files.push({
|
||||
name: 'abdulive.cmd',
|
||||
data: Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0app\\apps\\cli\\main.ts" %*\r\n'),
|
||||
});
|
||||
|
||||
// --- packen
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const zipName = `abdulive-${version}-win-x64.zip`;
|
||||
const zip = writeZip(files);
|
||||
await fs.writeFile(path.join(outDir, zipName), zip);
|
||||
|
||||
// Das Manifest, das der Updater abfragt. Die Adresse traegt die
|
||||
// Veroeffentlichung ein - hier steht nur, wie die Datei heisst.
|
||||
await fs.writeFile(path.join(outDir, 'abdulive.json'), JSON.stringify({
|
||||
version,
|
||||
datei: zipName,
|
||||
groesse: zip.length,
|
||||
sha256: sha256(zip),
|
||||
veroeffentlicht: new Date().toISOString(),
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const roh = files.reduce((n, f) => n + f.data.length, 0);
|
||||
console.log(`\nFertig: ${path.join(outDir, zipName)}`);
|
||||
console.log(` ${files.length} Dateien, ${(roh / 1e6).toFixed(0)} MB roh, ` +
|
||||
`${(zip.length / 1e6).toFixed(0)} MB gepackt`);
|
||||
console.log(` sha256 ${sha256(zip)}`);
|
||||
console.log('\nDas Sprachmodell (ca. 465 MB) ist nicht dabei - es wird bei der');
|
||||
console.log('Einrichtung geladen (`abdulive fetch-model`).');
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,187 @@
|
||||
# Installiert abdulive auf dem Stream-PC.
|
||||
#
|
||||
# .\install.ps1
|
||||
#
|
||||
# Aus einem entpackten Paket heraus ausfuehren - install.ps1 liegt darin neben
|
||||
# node.exe.
|
||||
#
|
||||
# Einen `irm ... | iex`-Einzeiler gibt es bewusst nicht: git.zeasy.dev verlangt
|
||||
# instanzweit eine Anmeldung und antwortet auf jede Adresse mit einer Umleitung
|
||||
# auf /user/login. `iex` fuehrt die Anmeldeseite dann als PowerShell aus, mit
|
||||
# einer Fehlermeldung, aus der niemand die Ursache ablesen kann. Wer eine
|
||||
# Ablage ohne Anmeldung hat, gibt sie mit -Quelle an; dann laedt das Skript
|
||||
# das Paket selbst.
|
||||
#
|
||||
# Ohne Administratorrechte, mit Absicht: Programm und Daten liegen im Profil
|
||||
# des Benutzers, damit auch Updates ohne Rueckfrage durchlaufen. Siehe
|
||||
# ADR-0007.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Nur noetig, wenn nicht aus einem entpackten Paket heraus installiert
|
||||
# wird. Dort muessen abdulive.json und die ZIP-Datei ohne Anmeldung
|
||||
# erreichbar sein.
|
||||
[string]$Quelle,
|
||||
[string]$Ziel = (Join-Path $env:LOCALAPPDATA 'Programs\abdulive'),
|
||||
[switch]$KeinAutostart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$daten = Join-Path $env:LOCALAPPDATA 'abdulive'
|
||||
|
||||
function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan }
|
||||
function Hinweis($text) { Write-Host " $text" -ForegroundColor DarkGray }
|
||||
|
||||
# ---------------------------------------------------------------- Pruefen
|
||||
|
||||
Schritt 'Umgebung pruefen'
|
||||
if ([Environment]::Is64BitOperatingSystem -ne $true) {
|
||||
throw 'Das Paket gibt es nur fuer 64-Bit-Windows.'
|
||||
}
|
||||
$obs = Get-Process obs64, obs -ErrorAction SilentlyContinue
|
||||
Hinweis ("OBS laeuft: " + $(if ($obs) { 'ja' } else { 'nein - macht nichts, wird spaeter gebraucht' }))
|
||||
|
||||
# ---------------------------------------------------------------- Quelle
|
||||
|
||||
$hier = if ($PSScriptRoot) { $PSScriptRoot } else { $null }
|
||||
$ausPaket = $hier -and (Test-Path (Join-Path $hier 'node.exe'))
|
||||
|
||||
if ($ausPaket) {
|
||||
Schritt "Installiere aus dem entpackten Paket ($hier)"
|
||||
$quellordner = $hier
|
||||
} elseif (-not $Quelle) {
|
||||
Write-Host ''
|
||||
Write-Host 'Dieses Skript liegt nicht neben einem Paket, und -Quelle ist nicht gesetzt.' -ForegroundColor Red
|
||||
Write-Host ''
|
||||
Write-Host 'Entweder:'
|
||||
Write-Host ' - das Paket herunterladen, entpacken und darin install.ps1 ausfuehren'
|
||||
Write-Host ' - oder eine Ablage angeben, unter der abdulive.json ohne Anmeldung liegt:'
|
||||
Write-Host ' .\install.ps1 -Quelle https://.../abdulive'
|
||||
Write-Host ''
|
||||
exit 1
|
||||
} else {
|
||||
Schritt 'Lade das aktuelle Paket'
|
||||
$tmp = Join-Path $env:TEMP ("abdulive-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||
|
||||
# Nicht auf die Form der Ausnahme verlassen: `-MaximumRedirection 0` wirft
|
||||
# in Windows PowerShell 5.1 eine WebException ganz ohne `Response`, und ein
|
||||
# Zugriff auf .Response.StatusCode laeuft dann selbst auf die Nase.
|
||||
# Stattdessen holen, was kommt, und den Inhalt ansehen.
|
||||
try {
|
||||
$antwort = Invoke-WebRequest "$Quelle/abdulive.json" -UseBasicParsing -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host ''
|
||||
Write-Host "$Quelle/abdulive.json ist nicht erreichbar." -ForegroundColor Red
|
||||
Write-Host " $($_.Exception.Message)"
|
||||
Write-Host ''
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Eine Anmeldeseite ist HTML und kommt mit Status 200 an, wenn der Umleitung
|
||||
# gefolgt wurde. Genau das ist bei git.zeasy.dev passiert.
|
||||
$typ = [string]$antwort.Headers['Content-Type']
|
||||
$anfang = [string]$antwort.Content
|
||||
if ($typ -like 'text/html*' -or $anfang -match '(?i)^\s*(<!doctype|<html|<a href="/user/login)') {
|
||||
Write-Host ''
|
||||
Write-Host "$Quelle/abdulive.json liefert HTML statt JSON." -ForegroundColor Red
|
||||
Write-Host ' Vermutlich eine Anmeldeseite - die Ablage ist nicht oeffentlich.'
|
||||
Write-Host ' Die Adresse einmal im Browser aufrufen zeigt, was dort steht.'
|
||||
Write-Host ''
|
||||
exit 1
|
||||
}
|
||||
$manifest = $antwort.Content | ConvertFrom-Json
|
||||
Hinweis "Version $($manifest.version), $([math]::Round($manifest.groesse / 1MB)) MB"
|
||||
|
||||
$zip = Join-Path $tmp $manifest.datei
|
||||
Invoke-WebRequest "$Quelle/$($manifest.datei)" -OutFile $zip
|
||||
|
||||
$ist = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
if ($ist -ne $manifest.sha256.ToLower()) {
|
||||
throw "Pruefsumme stimmt nicht. Erwartet $($manifest.sha256), bekommen $ist."
|
||||
}
|
||||
Hinweis 'Pruefsumme stimmt.'
|
||||
|
||||
Expand-Archive -Path $zip -DestinationPath (Join-Path $tmp 'paket') -Force
|
||||
$quellordner = Join-Path $tmp 'paket'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Kopieren
|
||||
|
||||
Schritt "Programm nach $Ziel"
|
||||
# Laeuft gerade eine aeltere Fassung, muss sie erst weichen - Windows sperrt
|
||||
# die Dateien laufender Programme.
|
||||
Get-Process node -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -and $_.Path.StartsWith($Ziel, 'OrdinalIgnoreCase') } |
|
||||
ForEach-Object { Hinweis "beende laufende Fassung (PID $($_.Id))"; Stop-Process -Id $_.Id -Force }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
|
||||
foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json',
|
||||
'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'backup.ps1')) {
|
||||
$von = Join-Path $quellordner $item
|
||||
if (-not (Test-Path $von)) { continue }
|
||||
$nach = Join-Path $Ziel $item
|
||||
if (Test-Path $nach) { Remove-Item -Recurse -Force $nach }
|
||||
Copy-Item -Recurse -Force -Path $von -Destination $nach
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Daten
|
||||
|
||||
Schritt "Daten nach $daten"
|
||||
foreach ($unter in @('config', 'db', 'work', 'clips', 'models', 'logs')) {
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $daten $unter) | Out-Null
|
||||
}
|
||||
# Eine vorhandene config.yaml wird nie ueberschrieben - da stehen Abdus
|
||||
# Einstellungen drin.
|
||||
$cfg = Join-Path $daten 'config\config.yaml'
|
||||
if (-not (Test-Path $cfg)) {
|
||||
Copy-Item (Join-Path $Ziel 'app\config\config.example.yaml') $cfg
|
||||
Hinweis 'config.yaml aus der Vorlage angelegt.'
|
||||
} else {
|
||||
Hinweis 'config.yaml war schon da, bleibt unveraendert.'
|
||||
}
|
||||
Copy-Item (Join-Path $Ziel 'app\config\layout.yaml') (Join-Path $daten 'config\layout.yaml') -Force
|
||||
|
||||
# ---------------------------------------------------------------- Autostart
|
||||
|
||||
if (-not $KeinAutostart) {
|
||||
Schritt 'Autostart einrichten'
|
||||
# Aufgabenplaner statt Windows-Dienst: die Anwendung muss in derselben
|
||||
# Sitzung laufen wie OBS, und ein Dienst braeuchte Administratorrechte
|
||||
# samt eigenem Wrapper-Programm.
|
||||
$ps = (Get-Command powershell.exe).Source
|
||||
$start = New-ScheduledTaskAction -Execute $ps `
|
||||
-Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$Ziel\launcher.ps1`""
|
||||
$beiAnmeldung = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
||||
$einst = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries -StartWhenAvailable -RestartInterval (New-TimeSpan -Minutes 2) `
|
||||
-RestartCount 3 -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName 'abdulive' -Action $start -Trigger $beiAnmeldung `
|
||||
-Settings $einst -Force | Out-Null
|
||||
Hinweis 'Aufgabe "abdulive" laeuft ab der naechsten Anmeldung mit.'
|
||||
|
||||
# Taeglich nach einem Update sehen. Angewendet wird es erst beim naechsten
|
||||
# Start - waehrend eines Streams wird nichts ausgetauscht.
|
||||
$pruefen = New-ScheduledTaskAction -Execute (Join-Path $Ziel 'node.exe') `
|
||||
-Argument "`"$Ziel\app\apps\cli\main.ts`" update"
|
||||
$taeglich = New-ScheduledTaskTrigger -Daily -At '05:00'
|
||||
Register-ScheduledTask -TaskName 'abdulive-update' -Action $pruefen -Trigger $taeglich `
|
||||
-Settings $einst -Force | Out-Null
|
||||
Hinweis 'Aufgabe "abdulive-update" sieht taeglich um 05:00 nach.'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Fertig
|
||||
|
||||
$version = (Get-Content (Join-Path $Ziel 'version.json') -Raw | ConvertFrom-Json).version
|
||||
Write-Host ''
|
||||
Write-Host "abdulive $version ist installiert." -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Write-Host 'Naechste Schritte:'
|
||||
Write-Host " 1. Sprachmodell laden (einmalig, ca. 465 MB):"
|
||||
Write-Host " & '$Ziel\abdulive.cmd' fetch-model"
|
||||
Write-Host " 2. Umgebung pruefen:"
|
||||
Write-Host " & '$Ziel\abdulive.cmd' check-env"
|
||||
Write-Host ' 3. Starten und einrichten:'
|
||||
Write-Host ' Start-ScheduledTask -TaskName abdulive'
|
||||
Write-Host ' dann http://localhost:4792/setup im Browser oeffnen'
|
||||
Write-Host ''
|
||||
@@ -0,0 +1,67 @@
|
||||
# Wird vom Aufgabenplaner bei der Anmeldung gestartet.
|
||||
#
|
||||
# Zwei Aufgaben, in dieser Reihenfolge:
|
||||
# 1. Ein bereitgelegtes Update anwenden.
|
||||
# 2. Die Anwendung starten.
|
||||
#
|
||||
# Warum getrennt: Windows sperrt laufende Programmdateien. Solange node.exe
|
||||
# laeuft, laesst es sich nicht ersetzen. Der Updater legt das neue Paket
|
||||
# deshalb nur unter `pending\` ab; ausgetauscht wird hier, bevor irgendetwas
|
||||
# startet. So gibt es nie einen halb ausgetauschten Ordner. Siehe ADR-0007.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$pending = Join-Path $root 'pending'
|
||||
$log = Join-Path $env:LOCALAPPDATA 'abdulive\logs'
|
||||
New-Item -ItemType Directory -Force -Path $log | Out-Null
|
||||
|
||||
function Write-Log($text) {
|
||||
$line = "{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $text
|
||||
Add-Content -Path (Join-Path $log 'starter.log') -Value $line -Encoding utf8
|
||||
Write-Output $line
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Update
|
||||
|
||||
if (Test-Path $pending) {
|
||||
Write-Log 'Update liegt bereit - wende es an.'
|
||||
try {
|
||||
# Sicherheitshalber: laeuft noch etwas aus diesem Ordner?
|
||||
Get-Process node -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -and $_.Path.StartsWith($root, 'OrdinalIgnoreCase') } |
|
||||
ForEach-Object {
|
||||
Write-Log " beende noch laufende node.exe (PID $($_.Id))"
|
||||
Stop-Process -Id $_.Id -Force
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json',
|
||||
'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'backup.ps1')) {
|
||||
$new = Join-Path $pending $item
|
||||
if (-not (Test-Path $new)) { continue }
|
||||
$old = Join-Path $root $item
|
||||
if (Test-Path $old) { Remove-Item -Recurse -Force $old }
|
||||
Move-Item -Path $new -Destination $old
|
||||
}
|
||||
Remove-Item -Recurse -Force $pending
|
||||
$v = (Get-Content (Join-Path $root 'version.json') -Raw | ConvertFrom-Json).version
|
||||
Write-Log "Update angewendet, jetzt Version $v."
|
||||
} catch {
|
||||
# Ein misslungenes Update darf den Stream nicht kosten: der alte
|
||||
# Stand ist noch da, also einfach damit weiterlaufen.
|
||||
Write-Log "Update fehlgeschlagen, starte die vorhandene Version: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Start
|
||||
|
||||
$node = Join-Path $root 'node.exe'
|
||||
$main = Join-Path $root 'app\apps\cli\main.ts'
|
||||
if (-not (Test-Path $node)) { Write-Log "node.exe fehlt in $root"; exit 1 }
|
||||
|
||||
Write-Log 'Starte den Daemon.'
|
||||
& $node $main ingest 2>&1 | ForEach-Object {
|
||||
Add-Content -Path (Join-Path $log 'abdulive.log') -Value $_ -Encoding utf8
|
||||
}
|
||||
Write-Log "Daemon beendet (Code $LASTEXITCODE)."
|
||||
exit $LASTEXITCODE
|
||||
Reference in new issue
Block a user