Compare commits

..
2 Commits
Author SHA1 Message Date
ZeasyAlexandClaude Opus 5 08f7c21450 Veroeffentlichung ueber Gitea, BOM-Toleranz, Tests fuer den YAML-Leser
Das Repo ist jetzt oeffentlich, die Pakete haengen an der Veroeffentlichung
`latest`. Damit funktioniert `abdulive update` ohne Token.

- config.example.yaml traegt die gepruefte Update-Adresse
- .gitea/workflows/release.yml baut beim Tag und ersetzt `latest`
- Byte Order Mark: Notepad und `Set-Content -Encoding utf8` schreiben eines,
  und ohne Behandlung hiess der erste Schluessel in der config.yaml
  "\ufeffchannel" - die Einstellung waere stillschweigend ignoriert worden
- Der YAML-Leser hatte gar keine Tests; er liest die Konfiguration, an der
  alles haengt. Jetzt acht.

Der Updater ist einmal komplett durchgelaufen: 0.4.0 erkannt, 119 MB geladen,
Pruefsumme geprueft, bereitgelegt, von launcher.ps1 getauscht.

93 Tests, Typpruefung sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 17:43:43 +02:00
ZeasyAlexandClaude Opus 5 5f6301268a Native Windows-Auslieferung statt Container
Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt
TypeScript direkt aus - der Container schrieb nur noch drei Programme fest,
und dafuer braucht es keine Virtualisierung, sondern einen Ordner.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 17:39:26 +02:00
44 changed files with 2386 additions and 312 deletions

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
node_modules
**/node_modules
**/dist
.git
data
logs
.env
docs
*.md
+76
View File
@@ -0,0 +1,76 @@
# Baut das Windows-Paket und haengt es an die Veroeffentlichung `latest`.
#
# Ausgeloest von einem Tag `v0.5.1` oder von Hand ueber die Actions-Seite.
# Der Updater auf Abdus Rechner fragt immer dieselbe Adresse ab:
# https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest/abdulive.json
# Deshalb wird `latest` bei jedem Lauf geloescht und neu angelegt.
name: Paket bauen und veroeffentlichen
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Versionsnummer, z. B. 0.5.1'
required: true
jobs:
paket:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Tests und Typpruefung
run: |
node --test "packages/**/*.test.ts" "apps/**/*.test.ts"
npm i --no-save typescript@5.9 @types/node
npx tsc --noEmit
- name: Version bestimmen
id: v
run: |
if [ -n "${{ inputs.version }}" ]; then
echo "wert=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "wert=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
# Laedt Node, ffmpeg und whisper.cpp bei ihren Herausgebern und packt
# alles zusammen. Siehe scripts/build-package.ts und ADR-0007.
- name: Paket bauen
run: node scripts/build-package.ts --version "${{ steps.v.outputs.wert }}"
- name: Veroeffentlichung ersetzen
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
VERSION: ${{ steps.v.outputs.wert }}
run: |
set -eu
auth="Authorization: token $TOKEN"
# Vorhandene `latest` weg - sie soll immer den aktuellen Stand tragen
alt=$(curl -sf -H "$auth" "$API/releases/tags/latest" \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).id))}catch{}})' || true)
if [ -n "$alt" ]; then
curl -sf -X DELETE -H "$auth" "$API/releases/$alt" || true
fi
id=$(curl -sf -H "$auth" -H 'Content-Type: application/json' -X POST "$API/releases" \
-d "{\"tag_name\":\"latest\",\"target_commitish\":\"$GITHUB_SHA\",\"name\":\"abdulive $VERSION\",\"body\":\"Paket entpacken und darin install.ps1 ausfuehren.\"}" \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(String(JSON.parse(d).id)))')
for f in dist/abdulive-"$VERSION"-win-x64.zip dist/abdulive.json; do
curl -sf -H "$auth" -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
-F "attachment=@$f" -o /dev/null
echo " hochgeladen: $(basename "$f")"
done
- name: Was drin ist
run: cat dist/abdulive.json
+4
View File
@@ -21,3 +21,7 @@ logs/
Thumbs.db Thumbs.db
.vscode/ .vscode/
.idea/ .idea/
# Zwischenspeicher des Paketbaus und sein Ergebnis
.cache/
dist/
+45 -14
View File
@@ -45,29 +45,60 @@ Dazu der Dienst, der unter allen anderen liegt:
- [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool - [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool
- [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs) - [`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 Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
gebraucht, ffmpeg und whisper.cpp stecken im Image. sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts
Siehe [`docs/05-setup.md`](docs/05-setup.md). 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 ```powershell
Copy-Item config\config.example.yaml config\config.yaml Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
docker compose build .\abdulive\install.ps1
docker compose run --rm toolkit check-env ```
docker compose run --rm toolkit fetch-model
docker compose up -d > 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 Dann **http://localhost:4792/setup** öffnen und die Twitch-Zugangsdaten
eintragen. Sie werden verschlüsselt im Docker-Volume abgelegt, nicht in einer eintragen. Sie werden verschlüsselt abgelegt, nicht in einer Datei im Projekt
Datei im Projekt ([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)). ([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 ## 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 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 — Offen: die Abnahme der OBS-Anbindung an einem laufenden OBS, dann S5 —
Onboarding-Assistent und Sichtungsseite. Onboarding-Assistent und Sichtungsseite.
@@ -77,5 +108,5 @@ Details: [`docs/handoff.md`](docs/handoff.md)
## Tests ## Tests
```powershell ```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"
``` ```
+202 -25
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node #!/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 { execFile, execFileSync } from 'node:child_process';
import { createWriteStream } from 'node:fs'; import { createWriteStream } from 'node:fs';
@@ -8,16 +9,19 @@ import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { loadLayout, paths, type Rect } from '#core/config.ts'; 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 { micTrackArgs, thumbnailArgs, verticalArgs, verticalFilter } from '#media/vertical.ts';
import { transcribe } from '#media/whisper.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 { loadConfig } from '#core/config.ts';
import { openDb } from '#db/index.ts'; import { openDb } from '#db/index.ts';
import { secretStatus } from '#twitch/secrets.ts'; import { secretStatus } from '#twitch/secrets.ts';
import { judgeSource } from '#obs/scene-report.ts';
const WHISPER = process.env.WHISPER_BIN || 'whisper-cli'; // Nativ laeuft OBS auf demselben Rechner. `host.docker.internal` war die
const OBS_HOST = process.env.OBS_WS_HOST || 'host.docker.internal'; // 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 OBS_PORT = Number(process.env.OBS_WS_PORT || 4455);
const MODELS: Record<string, string> = { const MODELS: Record<string, string> = {
@@ -81,7 +85,7 @@ function tcpProbe(host: string, port: number, timeout = 2500): Promise<boolean>
// ---------------------------------------------------------------- check-env // ---------------------------------------------------------------- check-env
async function checkEnv(): Promise<void> { async function checkEnv(): Promise<void> {
console.log('\nabdulive-toolkit - Selbsttest der Umgebung'); console.log('\nabdulive - Selbsttest der Umgebung');
section('Laufzeit'); section('Laufzeit');
const major = Number(process.versions.node.split('.')[0]); 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)'); report(typeof WebSocket !== 'undefined' ? 'ok' : 'bad', 'WebSocket (eingebaut)');
section('Medienwerkzeuge'); 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 { 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)); 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-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)');
report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)'); report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)');
} catch { report('bad', 'ffmpeg', 'nicht im PATH'); } // Ohne die mitgelieferten Schriften setzt libass wortlos eine
try { run('ffprobe', ['-version']); report('ok', 'ffprobe'); } // Ersatzschrift ein - der Untertitel sieht dann anders aus als gedacht.
catch { report('bad', 'ffprobe', 'nicht im PATH'); } 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 { try {
run(WHISPER, ['--help']); run(binaries.whisper, ['--help']);
report('ok', 'whisper-cli', WHISPER); report('ok', 'whisper-cli', binaries.whisper);
} catch (err) { } catch (err) {
const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`; const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`;
report(text.includes('whisper') || text.includes('usage') ? 'ok' : 'bad', 'whisper-cli', const laeuft = text.includes('whisper') || text.includes('usage');
text.includes('whisper') ? WHISPER : 'nicht lauffaehig'); report(laeuft ? 'ok' : 'bad', 'whisper-cli',
laeuft ? binaries.whisper : nichtGefunden(binaries.whisper));
} }
section('Sprachmodell'); section('Sprachmodell');
@@ -127,10 +141,10 @@ async function checkEnv(): Promise<void> {
section('Verzeichnisse'); section('Verzeichnisse');
for (const [label, dir] of [ for (const [label, dir] of [
['Datenbank (Volume)', paths.db], ['Datenbank', paths.db],
['Arbeitsverzeichnis (Volume)', paths.work], ['Arbeitsverzeichnis', paths.work],
['Sprachmodelle (Volume)', paths.models], ['Sprachmodelle', paths.models],
['Clips (Bind-Mount)', paths.clips], ['Clips', paths.clips],
] as const) { ] as const) {
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`); report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
} }
@@ -141,7 +155,7 @@ async function checkEnv(): Promise<void> {
try { try {
const n = (await fs.readdir(dir)).length; const n = (await fs.readdir(dir)).length;
report('ok', label, `${dir} lesbar, ${n} Eintraege`); 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'); section('Datenbank');
@@ -355,12 +369,14 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
console.log(' Transkribiere ...'); console.log(' Transkribiere ...');
const words = await transcribe(wav, { const words = await transcribe(wav, {
binary: process.env['WHISPER_BIN'] ?? 'whisper-cli', binary: binaries.whisper,
model, model,
language: cfg.clipper.whisper.language, language: cfg.clipper.whisper.language,
threads: cfg.clipper.whisper.threads, 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'); const ass = path.join(outDir, 'subs.ass');
await fs.writeFile(ass, buildAss(lines, { width: 1080, height: 1920 })); await fs.writeFile(ass, buildAss(lines, { width: 1080, height: 1920 }));
await fs.writeFile(path.join(outDir, 'subs.srt'), buildSrt(lines)); await fs.writeFile(path.join(outDir, 'subs.srt'), buildSrt(lines));
@@ -370,7 +386,7 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
finalFile = path.join(outDir, 'final.mp4'); finalFile = path.join(outDir, 'final.mp4');
await runMedia('ffmpeg', [ await runMedia('ffmpeg', [
'-y', '-hide_banner', '-loglevel', 'error', '-i', vertical, '-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, '-c:v', cfg.clipper.ffmpeg.encoder, '-preset', cfg.clipper.ffmpeg.preset,
'-crf', String(cfg.clipper.ffmpeg.crf), '-pix_fmt', 'yuv420p', '-crf', String(cfg.clipper.ffmpeg.crf), '-pix_fmt', 'yuv420p',
'-movflags', '+faststart', finalFile, '-movflags', '+faststart', finalFile,
@@ -388,17 +404,172 @@ async function renderOne(input?: string, profileName = 'default'): Promise<void>
`Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`); `Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`);
} }
// ---------------------------------------------------------------- sources
/**
* Listet auf, was OBS in der laufenden Szene zusammensetzt - und sagt zu jeder
* Quelle, ob sie im 9:16-Ausschnitt landet.
*
* Der Anlass: In einem fertigen Clip lagen fremde Untertitel im Bild, und
* niemand wusste, woher sie kommen. Das ist auch nicht zu erraten - sie sind
* ins Streambild eingebrannt, also muss man nachsehen, welche Quelle sie malt.
* Genau das macht dieser Befehl.
*/
type SceneItemTransform = {
positionX: number; positionY: number;
width: number; height: number;
cropLeft: number; cropRight: number; cropTop: number; cropBottom: number;
};
type SceneItem = {
sceneItemId: number;
sourceName: string;
inputKind: string | null;
isGroup: boolean | null;
sceneItemEnabled: boolean;
sceneItemTransform: SceneItemTransform;
};
async function listSources(profileName = 'default'): Promise<void> {
const cfg = loadConfig();
const layout = loadLayout();
const prof = layout.profiles?.[profileName];
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
const { createObs } = await import('#obs/client.ts');
const { readSecrets } = await import('#twitch/secrets.ts');
const abort = new AbortController();
let ready: (() => void) | null = null;
const identified = new Promise<void>((res) => { ready = res; });
const obs = createObs(cfg.obs.url, () => readSecrets().obs?.password, {
onIdentified: () => ready?.(),
});
const running = obs.run(abort.signal);
running.catch(() => { /* Fehler kommt ueber das Rennen unten */ });
// Der Zeitgeber muss wieder weg. Bleibt er stehen, haengt der Befehl nach
// dem Bericht noch bis zu 15 s im Container, obwohl er fertig ist.
let ticker: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, rej) => {
ticker = setTimeout(
() => rej(new Error(`OBS antwortet nicht unter ${cfg.obs.url}. Laeuft OBS, und ist ` +
'Werkzeuge -> WebSocket-Server-Einstellungen -> Server aktivieren gesetzt?')), 15_000);
});
try {
await Promise.race([identified, running, timeout]);
const { currentProgramSceneName: scene } =
await obs.request<{ currentProgramSceneName: string }>('GetCurrentProgramScene');
console.log(`\nSzene: ${scene}`);
console.log(`Layoutprofil: ${profileName} (Cam ${rectStr(prof.cam.crop)}, ` +
`Gameplay ${rectStr(prof.game.crop)})\n`);
const rows: string[] = [];
const verdaechtig: string[] = [];
// Von hinten nach vorne: OBS liefert die oberste Quelle zuerst, im Bild
// liegt sie aber oben drauf. Umgedreht liest es sich wie der Stapel.
const walk = async (name: string, tiefe: number): Promise<void> => {
const req = tiefe === 0 ? 'GetSceneItemList' : 'GetGroupSceneItemList';
const { sceneItems } = await obs.request<{ sceneItems: SceneItem[] }>(
req, { sceneName: name });
for (const it of [...sceneItems].reverse()) {
const t = it.sceneItemTransform;
const box = {
x: Math.round(t.positionX), y: Math.round(t.positionY),
w: Math.round(t.width), h: Math.round(t.height),
};
const kind = it.isGroup ? 'Gruppe' : (it.inputKind ?? 'unbekannt');
// Browser-Quellen verraten ueber die Adresse, welcher Dienst dahinter
// steckt - bei fremden Untertiteln ist das meistens die Antwort.
let url = '';
if (!it.isGroup && it.inputKind === 'browser_source') {
const got = await obs.request<{ inputSettings: { url?: string } }>(
'GetInputSettings', { inputName: it.sourceName }).catch(() => null);
url = got?.inputSettings?.url ?? '';
}
const urteil = judgeSource({
box, enabled: it.sceneItemEnabled, name: it.sourceName, kind, url,
cam: prof.cam.crop, game: prof.game.crop,
});
const einzug = ' '.repeat(tiefe);
const sicht = it.sceneItemEnabled ? '' : `${C.dim} (ausgeblendet)${C.off}`;
const lage = `${String(box.x).padStart(5)},${String(box.y).padStart(4)} ` +
`${String(box.w).padStart(4)}x${String(box.h).padEnd(4)}`;
const treffer = !it.sceneItemEnabled ? `${C.dim}—${C.off}`
: urteil.inShort
? `${C.warn}im Short${C.off} (Cam ${urteil.cam} %, Gameplay ${urteil.game} %)`
: `${C.ok}ausserhalb${C.off}`;
rows.push(`${einzug}${it.sourceName.padEnd(30 - tiefe * 2)} ${C.dim}${kind.padEnd(20)}` +
`${C.off}${lage} ${treffer}${sicht}`);
if (url) rows.push(`${einzug} ${C.dim}${url.slice(0, 110)}${C.off}`);
if (urteil.suspectCaption) {
verdaechtig.push(`${it.sourceName} (${kind})${url ? ` - ${url}` : ''}`);
}
if (it.isGroup) await walk(it.sourceName, tiefe + 1);
}
};
await walk(scene, 0);
console.log(`${'Quelle'.padEnd(30)} ${'Art'.padEnd(20)}${'x,y'.padStart(10)} ` +
`${'Groesse'.padEnd(10)} Im 9:16-Ausschnitt?`);
console.log('-'.repeat(100));
for (const r of rows) console.log(r);
console.log('\nAlles, was mit "im Short" markiert ist, landet im fertigen Clip.');
if (verdaechtig.length) {
console.log(`\n${C.warn}Sieht nach fremden Untertiteln aus:${C.off}`);
for (const v of verdaechtig) console.log(` - ${v}`);
console.log('\nDiese Quellen aus dem Gameplay-Band schieben (oder ausblenden) -\n' +
'das Toolkit macht eigene Untertitel. Plaetze: docs/obs-layout.md');
} else {
console.log('Nichts erkannt, was nach Untertiteln aussieht. Steht trotzdem Text im\n' +
'Clip, kommt er aus dem Spiel selbst (dort abschalten) oder aus einer Quelle,\n' +
'die anders heisst - dann die Liste oben von oben nach unten durchgehen.');
}
} finally {
clearTimeout(ticker);
abort.abort();
// Klemmt OBS, bleibt der WebSocket im Schliess-Handshake stehen und der
// Prozess laeuft weiter, obwohl der Bericht schon dasteht. Genau dann
// fuehrt man dieses Werkzeug aber aus. Der Wachhund ist ge-unref-t: im
// Normalfall haelt er nichts auf und schlaegt nie an.
setTimeout(() => {
console.error(`${C.dim}(OBS schliesst die Verbindung nicht - beende trotzdem)${C.off}`);
process.exit(process.exitCode === undefined ? 0 : Number(process.exitCode));
}, 3000).unref();
}
}
const rectStr = (r: Rect) => `x ${r.x}..${r.x + r.w}, y ${r.y}..${r.y + r.h}`;
// ---------------------------------------------------------------- help // ---------------------------------------------------------------- help
function help(): void { function help(): void {
console.log(` console.log(`
abdulive-toolkit - Container-Einstiegspunkt abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite) ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite)
check-env Umgebung pruefen check-env Umgebung pruefen
db:migrate Migrationen anwenden db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin) fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern 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) render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
help Diese Uebersicht help Diese Uebersicht
@@ -415,7 +586,7 @@ const [cmd, ...args] = process.argv.slice(2);
try { try {
switch (cmd) { switch (cmd) {
case 'ingest': { case 'ingest': {
const { runIngest } = await import('../apps/ingest/src/index.ts'); const { runIngest } = await import('../ingest/src/index.ts');
await runIngest(); await runIngest();
break; break;
} }
@@ -423,6 +594,12 @@ try {
case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break; case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break;
case 'fetch-model': await fetchModel(args[0]); break; case 'fetch-model': await fetchModel(args[0]); break;
case 'calibrate': await calibrate(args[0], args[1]); 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 'render': await renderOne(args[0], args[1]); break;
case 'help': case undefined: help(); break; case 'help': case undefined: help(); break;
default: default:
+53
View File
@@ -0,0 +1,53 @@
// 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, parseJsonLoose } 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);
});
test('ein Byte Order Mark macht das Manifest nicht unlesbar', () => {
// Windows PowerShell 5.1 schreibt bei `Set-Content -Encoding utf8` eines,
// und Notepad ebenso. JSON.parse verschluckt sich daran.
const mitBom = '{"version":"0.5.0"}';
assert.throws(() => JSON.parse(mitBom));
assert.deepEqual(parseJsonLoose(mitBom), { version: '0.5.0' });
assert.deepEqual(parseJsonLoose('{"a":1}'), { a: 1 });
});
+160
View File
@@ -0,0 +1,160 @@
// 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');
}
/** JSON.parse verschluckt sich an einem Byte Order Mark - Windows schreibt eines. */
export function parseJsonLoose(text: string): unknown {
return JSON.parse(text.replace(/^/, ''));
}
async function localVersion(): Promise<string> {
const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null);
if (!raw) return '0';
return (parseJsonLoose(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 = parseJsonLoose(
(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');
}
+4 -1
View File
@@ -11,6 +11,7 @@ import { sleep } from '#core/retry.ts';
import { openDb, closeDb } from '#db/index.ts'; import { openDb, closeDb } from '#db/index.ts';
import { repos } from '#db/repos.ts'; import { repos } from '#db/repos.ts';
import { createObs } from '#obs/client.ts'; import { createObs } from '#obs/client.ts';
import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
import { createIrc } from '#twitch/irc.ts'; import { createIrc } from '#twitch/irc.ts';
import { readSecrets } from '#twitch/secrets.ts'; import { readSecrets } from '#twitch/secrets.ts';
@@ -95,7 +96,9 @@ export async function runIngest(): Promise<void> {
cooldownUserSeconds: cfg.clipper.cooldownUserSeconds, cooldownUserSeconds: cfg.clipper.cooldownUserSeconds,
followersOnly: cfg.clipper.followersOnly, followersOnly: cfg.clipper.followersOnly,
...(process.env.OBS_REPLAY_DIR ? { replayDirHost: process.env.OBS_REPLAY_DIR } : {}), ...(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, clipsDir: paths.clips,
workDir: paths.work, workDir: paths.work,
}, },
Binary file not shown.
Binary file not shown.
+78
View File
@@ -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'.
+9
View File
@@ -22,6 +22,15 @@ ingest:
armed: false # erst nach Kalibrierung auf true armed: false # erst nach Kalibrierung auf true
laughEmotes: [KEKW, LUL, OMEGALUL, PogChamp, Pog] 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: "https://git.zeasy.dev/ZeasyAlex/abdulive/releases/download/latest"
obs: obs:
url: ws://127.0.0.1:4455 url: ws://127.0.0.1:4455
replayBufferSeconds: 90 replayBufferSeconds: 90
-59
View File
@@ -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:
-59
View File
@@ -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"]
+2 -1
View File
@@ -37,7 +37,8 @@ Einrichtungs-Assistenten unter `http://localhost:4792/setup`, **nicht** in einer
| `bits:read` | Bits-Events | Broadcaster | | `bits:read` | Bits-Events | Broadcaster |
Der Autorisierungs-Flow läuft über den Einrichtungs-Assistenten: 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 für Broadcaster- und Bot-Konto auf „Mit Twitch verbinden" klicken. Die
Redirect-URI, die in beiden Twitch-Apps hinterlegt sein muss, lautet Redirect-URI, die in beiden Twitch-Apps hinterlegt sein muss, lautet
`http://localhost:4792/callback`. `http://localhost:4792/callback`.
+82 -52
View File
@@ -1,34 +1,51 @@
# Setup # Setup
Alles läuft in Containern. Auf dem Rechner selbst wird nur Docker Desktop und Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften
OBS gebraucht — ffmpeg, whisper.cpp, Node und pnpm stecken im Image. sind mitgeliefert — vorausgesetzt wird nur OBS.
Begründung: [ADR-0004](decisions/ADR-0004-docker.md). Begruendung: [ADR-0007](decisions/ADR-0007-native-windows.md).
## Voraussetzungen ## Voraussetzungen
| Was | Version | Wo | | Was | Version | Anmerkung |
|---|---|---| |---|---|---|
| Docker Desktop | aktuell, WSL2-Backend | Host | | Windows | 10 oder 11, 64 Bit | |
| OBS Studio | 30+ mit obs-websocket v5 | Host | | OBS Studio | 30+ mit obs-websocket v5 | derselbe Rechner |
| Git | beliebig | Host |
Alles Weitere (Node 22, pnpm, ffmpeg mit libx264/libass, whisper.cpp, Mehr nicht. Kein Docker, kein WSL2, kein Compiler, kein Git.
Sprachmodell) liefert das Image.
## WSL2 begrenzen ## Installieren
Docker Desktop startet eine VM, die sich ohne Grenze so viel RAM nimmt, wie sie Das Paket herunterladen, entpacken, darin `install.ps1` ausfuehren:
kriegen kann. Auf einem Stream-PC ist das nicht egal. `%USERPROFILE%\.wslconfig`
anlegen:
```ini ```powershell
[wsl2] Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
memory=4GB .\abdulive\install.ps1
processors=4
swap=2GB
``` ```
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 ## Twitch-Apps anlegen
@@ -64,60 +81,73 @@ im Einrichtungs-Assistenten einfügen (siehe unten). Begründung:
## Erste Einrichtung ## Erste Einrichtung
```powershell ```powershell
Copy-Item config\config.example.yaml config\config.yaml abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout
abdulive fetch-model # Sprachmodell, ca. 465 MB
docker compose build Start-ScheduledTask -TaskName abdulive
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
``` ```
Dann **http://localhost:4792/setup** öffnen. Dort werden nacheinander Dann **http://localhost:4792/setup** oeffnen: Client-ID und Secret der beiden
eingetragen: Twitch-Apps eintragen und je einmal auf „Mit Twitch verbinden" klicken.
1. Client-ID und Client-Secret der beiden Twitch-Apps ## Layout einmessen
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:
```powershell ```powershell
docker compose run --rm toolkit calibrate abdulive calibrate
``` ```
Legt einen Frame als PNG in `data/clips/_calibration/` ab, fragt die vier Legt einen Frame als PNG unter `%LOCALAPPDATA%\abdulive\clips\_calibration`
Crop-Werte ab und schreibt sie nach `config/layout.yaml`. 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 ## Betrieb
```powershell ```powershell
docker compose up -d # startet Start-ScheduledTask -TaskName abdulive # startet
docker compose logs -f # Logs Stop-ScheduledTask -TaskName abdulive # stoppt
docker compose down # stoppt Get-Content "$env:LOCALAPPDATA\abdulive\logs\abdulive.log" -Wait -Tail 40
``` ```
Sichtungsseite: http://localhost:4792 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 ## Backup
```powershell ```powershell
.\scripts\backup.ps1 .\scripts\backup.ps1
``` ```
Kopiert die Datenbank per `VACUUM INTO` aus dem Volume `abdulive-db` nach Kopiert die Datenbank per `VACUUM INTO` aus `%LOCALAPPDATA%\abdulive\db`
`data/db/backup/` und hält 30 Generationen. Die DB liegt bewusst in einem nach `backup\` und haelt 30 Generationen. `VACUUM INTO` und nicht einfach
Docker-Volume und nicht in einem Windows-Ordner — Dateisperren über die kopieren: die Datei wird im laufenden Betrieb gesichert, und ein Kopieren
WSL2-Brücke sind für SQLite nicht verlässlich. 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 ## Was nicht ins Repo gehört
+5 -5
View File
@@ -113,10 +113,10 @@ Scheibe gibt es etwas, das man anschauen kann.
### S0 — Container und Toolchain · ~1 Tag ### S0 — Container und Toolchain · ~1 Tag
`docker/Dockerfile` (Node 22, ffmpeg mit libx264 + libass, whisper.cpp), `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. 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. whisper.cpp, Modell und die Schreibrechte auf allen Volumes als OK.
### S1 — Einrichtung, Auth und Datenaufnahme · ~3 Tage ### 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 Punkt 2 verbietet. Jetzt liegt die Rohdatei im Arbeits-Volume, und auf die
Windows-Seite kommt nur das fertige, kleine Ergebnis. 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. von Hand — gedacht zum Nachrendern, wenn sich das Layout ändert.
### S4 — Untertitel · ~1,5 Tage ### 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). | | 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. | | 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 | | 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 ## 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` 2. Zwei Twitch-Apps anlegen, Redirect-URI `http://localhost:4792/callback`
(siehe [`05-setup.md`](05-setup.md)). Client-ID und Secret **nicht** (siehe [`05-setup.md`](05-setup.md)). Client-ID und Secret **nicht**
irgendwo eintragen — die kommen in den Einrichtungs-Assistenten. irgendwo eintragen — die kommen in den Einrichtungs-Assistenten.
+7 -1
View File
@@ -1,6 +1,12 @@
# ADR-0004: Betrieb in Docker # 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 ## Kontext
+134
View File
@@ -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.
+2 -1
View File
@@ -5,9 +5,10 @@
| [0001](ADR-0001-monorepo-typescript.md) | TypeScript-Monorepo statt gemischter Sprachen | akzeptiert | | [0001](ADR-0001-monorepo-typescript.md) | TypeScript-Monorepo statt gemischter Sprachen | akzeptiert |
| [0002](ADR-0002-sqlite.md) | SQLite statt Postgres | akzeptiert | | [0002](ADR-0002-sqlite.md) | SQLite statt Postgres | akzeptiert |
| [0003](ADR-0003-eventsub-websocket.md) | EventSub ueber WebSocket, Chat ueber IRC | 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 | | [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 | | [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 / Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
Entscheidung / Begruendung / Konsequenzen. Entscheidung / Begruendung / Konsequenzen.
+42 -29
View File
@@ -11,7 +11,7 @@ Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist
| Scheibe | Stand | Nachweis | | 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 | | S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt |
| S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS | | S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS |
| S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme | | S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme |
@@ -19,68 +19,77 @@ Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist
| S5 Onboarding + Sichtung | offen | — | | S5 Onboarding + Sichtung | offen | — |
| S6 Erstchatter-Begrüßung | 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). [`offene-punkte.md`](offene-punkte.md).
## Was jemand wissen muss, bevor er etwas anfasst ## Was jemand wissen muss, bevor er etwas anfasst
**Es gibt keine Abhängigkeiten und keinen Build.** Node 24 bringt SQLite, **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`, läuft direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax — keine `enum`,
keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly` keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly`
in der `tsconfig.json` erzwingt das und hat schon einen echten Fehler gefangen. 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 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. darf nie sterben — was er nicht mitschreibt, ist für immer weg.
**Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie liegen **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 verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr — die Datenbank
gesichert und dabei auf die Windows-Seite kopiert wird. wird gesichert und weitergereicht, die Geheimnisse nicht.
**Rohdateien gehören ins Arbeits-Volume**, nicht in den Windows-Bind-Mount. Über **Rohdateien gehören in den Arbeitsordner**, nicht neben den OBS-Puffer. Das
die WSL2-Brücke zu encodieren kostet ein Vielfaches. Dagegen habe ich einmal war unter Docker ein Zeitproblem (Encodieren über die WSL2-Brücke kostete ein
selbst verstoßen — der Fehler fiel erst in S3 auf. 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 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 ## Loslegen
```powershell **Auf Abdus Rechner** läuft eine native Windows-Anwendung — kein Docker, kein
git clone https://git.zeasy.dev/ZeasyAlex/abdulive.git WSL2, nichts zu übersetzen ([ADR-0007](decisions/ADR-0007-native-windows.md)):
cd abdulive
Copy-Item config\config.example.yaml config\config.yaml
docker compose build ```powershell
docker compose run --rm toolkit check-env Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive
docker compose run --rm toolkit fetch-model # ca. 465 MB .\abdulive\install.ps1
docker compose up -d abdulive fetch-model
abdulive check-env
``` ```
Dann `http://localhost:4792/setup` öffnen. Ausführlich in Das Repo ist seit 2026-09-09 oeffentlich, die Pakete haengen an der
[`05-setup.md`](05-setup.md). Veroeffentlichung `latest`. Einen `irm … | iex`-Einzeiler gibt es trotzdem
nicht — Rohdateien liefert Gitea als `text/plain`, und ein Fehlschlag saehe
aus wie ein PowerShell-Syntaxfehler statt wie ein Fehlschlag.
Tests: Ausführlich in [`05-setup.md`](05-setup.md).
**Zum Entwickeln** reicht ein Checkout und Node 24 oder neuer:
```powershell ```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 --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): Typprüfung (das Einzige, wofür npm gebraucht wird):
```powershell ```powershell
docker run --rm -v "${PWD}:/app" -w /tmp node:24-bookworm-slim sh -c ` npm i typescript@5.9 @types/node
"npm i -s typescript@5.9 @types/node && cd /app && /tmp/node_modules/.bin/tsc --noEmit --typeRoots /tmp/node_modules/@types" npx tsc --noEmit
``` ```
## Die drei Dinge, die Abdu tun muss ## Die drei Dinge, die Abdu tun muss
1. **Zwei Twitch-Apps anlegen** (dev.twitch.tv/console/apps), Redirect-URI 1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps
`http://localhost:4792/callback`. Client-ID und Secret danach auf der (dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`)
Einrichtungsseite eintragen — nicht in eine Datei. 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, 2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s,
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren. Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md). Die 3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md). Die
@@ -109,5 +118,9 @@ 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 Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs konkrete
Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md). 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. unbegrenzt an. Das ist die Lücke, die als Erstes weh tun wird.
Der Updater ist einmal komplett durchgelaufen: 0.4.0 erkannt, 119 MB von
`git.zeasy.dev` geladen, Prüfsumme geprüft, bereitgelegt, von `launcher.ps1`
getauscht, `pending` aufgeräumt. Was noch nie lief, ist die CI-Pipeline.
+20 -4
View File
@@ -52,16 +52,32 @@ schiebt dann nicht sofort einen Streifen Gesicht in das Gameplay-Bild.
## Prüfen ## Prüfen
```powershell ```powershell
docker compose run --rm toolkit calibrate abdulive calibrate
``` ```
Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder in Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder
`data/clips/_calibration/` ab: das Einzelbild, dasselbe mit eingezeichneten unter `%LOCALAPPDATA%\abdulive\clips\_calibration` ab: das Einzelbild, dasselbe mit eingezeichneten
Ausschnitten, und die fertige 9:16-Vorschau. Sitzt in der Vorschau der Kopf 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. mittig und ist im Gameplay-Teil kein Chat zu sehen, stimmt das Layout.
## Text im Bild — hier wird es eng ## 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**: Bei der Prüfung eines echten Clips lagen **drei Textebenen übereinander**:
1. die Untertitel des Spiels („Genau, ich mach da … sag zu dem Typen") 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: korrekt rendern:
```powershell ```powershell
docker compose run --rm toolkit render <datei> bestand-2026-09 abdulive render <datei> bestand-2026-09
``` ```
+25 -9
View File
@@ -16,7 +16,7 @@ Erledigtes wandert raus, nicht ins Archiv — die Historie steht in Git.
## Bekannte Lücke im Betrieb ## 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 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 je Datei läuft das Volume in wenigen Wochen voll. Gebraucht wird eine
Aufbewahrungsregel — Vorschlag: Rohdateien der letzten 30 Clips behalten, 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 - [ ] **Position prüfen.** 340 px Abstand nach unten ist geschätzt, nicht
gemessen. An einem echten Handy gegen die TikTok-, Reels- und gemessen. An einem echten Handy gegen die TikTok-, Reels- und
Shorts-Oberfläche halten. 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 - [ ] **Nur transkribieren, wenn Abdu spricht.** Bei getrennten Tonspuren
reicht ein Pegelfilter auf der Mikrofonspur, damit aus Stille kein reicht ein Pegelfilter auf der Mikrofonspur, damit aus Stille kein
erfundener Text wird. erfundener Text wird.
@@ -55,11 +59,29 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
- [ ] **Live-Captions verschieben.** Sie liegen im Gameplay-Band und sind fest - [ ] **Live-Captions verschieben.** Sie liegen im Gameplay-Band und sind fest
eingebrannt. Zwei Vorschläge stehen in [`obs-layout.md`](obs-layout.md). 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.** - [ ] **Spieluntertitel abschalten.**
- [ ] **Bessere Bildquelle für die Facecam** — Plugin `Source Record` oder - [ ] **Bessere Bildquelle für die Facecam** — Plugin `Source Record` oder
`Aitum Vertical`. Damit entfällt die 2,25-fache Vergrößerung. Lohnt sich `Aitum Vertical`. Damit entfällt die 2,25-fache Vergrößerung. Lohnt sich
erst, wenn regelmäßig Shorts entstehen. erst, wenn regelmäßig Shorts entstehen.
## Auslieferung
- [ ] **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 einmal wirklich laufen lassen.** `.gitea/workflows/release.yml`
ist geschrieben, der Runner (`ubuntu-latest`) steht — ausgefuehrt wurde
sie aber noch nie. Beim ersten Tag zusehen, ob sie durchlaeuft.
- [ ] **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 ## Nach dem MVP
- [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden - [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden
@@ -72,11 +94,5 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel:
## Kleinigkeiten ## Kleinigkeiten
- [ ] `docker compose run` reicht ein TTY durch, dadurch schreibt die - [ ] Die Fortschrittsanzeige schreibt `\r`-Zeilen; im Protokoll der Aufgabe
Fortschrittsanzeige `\r`-Zeilen ins Protokoll. Mit `-T` sauber, aber sieht das unschoen aus. Beim Umleiten in eine Datei besser unterdruecken.
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.
+1 -1
View File
@@ -95,7 +95,7 @@ ffmpeg -i in.mp4 -filter_complex "
Nachmessen und Vorschau rendern: Nachmessen und Vorschau rendern:
```powershell ```powershell
docker compose run --rm toolkit calibrate abdulive calibrate
``` ```
Das legt drei Bilder in `data/clips/_calibration/` ab: das Einzelbild, dasselbe Das legt drei Bilder in `data/clips/_calibration/` ab: das Einzelbild, dasselbe
+3 -6
View File
@@ -16,12 +16,9 @@
"#media/*": "./packages/media/src/*" "#media/*": "./packages/media/src/*"
}, },
"scripts": { "scripts": {
"start": "docker compose up -d", "start": "node apps/cli/main.ts ingest",
"stop": "docker compose down", "check-env": "node apps/cli/main.ts check-env",
"status": "docker compose ps", "package": "node scripts/build-package.ts",
"logs": "docker compose logs -f",
"check-env": "node docker/entrypoint.ts check-env",
"ingest": "node docker/entrypoint.ts ingest",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test \"packages/**/*.test.ts\" \"apps/**/*.test.ts\"" "test": "node --test \"packages/**/*.test.ts\" \"apps/**/*.test.ts\""
}, },
+14 -7
View File
@@ -4,14 +4,19 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { parseYaml, type YamlMap } from './yaml.ts'; 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 = { export const paths = {
config: process.env.CONFIG_DIR || '/app/config', config: process.env.CONFIG_DIR || dirs.config,
db: process.env.DB_DIR || '/db', db: process.env.DB_DIR || dirs.db,
work: process.env.WORK_DIR || '/work', work: process.env.WORK_DIR || dirs.work,
clips: process.env.CLIPS_DIR || '/app/data/clips', clips: process.env.CLIPS_DIR || dirs.clips,
replay: process.env.REPLAY_DIR || '/replay', replay: process.env.REPLAY_DIR || dirs.replay,
models: process.env.MODEL_DIR || '/models', models: process.env.MODEL_DIR || dirs.models,
}; };
export type Config = { export type Config = {
@@ -27,6 +32,7 @@ export type Config = {
}; };
}; };
obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] }; obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] };
update: { url: string };
clipper: { clipper: {
enabled: boolean; enabled: boolean;
alsoCreateTwitchClip: boolean; alsoCreateTwitchClip: boolean;
@@ -57,7 +63,8 @@ const DEFAULTS = {
laughEmotes: ['KEKW', 'LUL', 'OMEGALUL', 'PogChamp', 'Pog'], 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: { clipper: {
enabled: true, enabled: true,
alsoCreateTwitchClip: true, alsoCreateTwitchClip: true,
+105
View File
@@ -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'),
};
}
+55
View File
@@ -0,0 +1,55 @@
// Der YAML-Leser ist selbst geschrieben (ADR-0001: keine Abhaengigkeiten) und
// liest die Konfiguration, an der alles haengt - hatte aber bis hierher keinen
// einzigen Test. Geprueft wird genau der Sprachumfang, den config/*.yaml
// benutzt.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseYaml } from './yaml.ts';
test('verschachtelte Maps ueber Einrueckung', () => {
assert.deepEqual(parseYaml('channel:\n login: abdulive_\n language: de\n'),
{ channel: { login: 'abdulive_', language: 'de' } });
});
test('erkennt Zahlen, Wahrheitswerte und Leeres', () => {
const y = parseYaml([
'a: 42', 'b: -7', 'c: 3.5', 'd: true', 'e: false', 'f:', 'g: ~',
].join('\n'));
assert.deepEqual(y, { a: 42, b: -7, c: 3.5, d: true, e: false, f: null, g: null });
});
test('Anfuehrungszeichen bleiben Text', () => {
// Wichtig fuer update.url - sonst wuerde aus "0.5" eine Zahl
const y = parseYaml('update:\n url: "https://git.zeasy.dev/x"\n v: "0.5"\n');
assert.deepEqual(y, { update: { url: 'https://git.zeasy.dev/x', v: '0.5' } });
});
test('Inline-Maps, wie sie im Layout stehen', () => {
const y = parseYaml('crop: { x: 0, y: 0, w: 480, h: 270 }\n');
assert.deepEqual(y, { crop: { x: 0, y: 0, w: 480, h: 270 } });
});
test('Listen aus Skalaren', () => {
const y = parseYaml('chapterScenes: [Gameplay, "Just Chatting"]\n');
assert.deepEqual(y, { chapterScenes: ['Gameplay', 'Just Chatting'] });
});
test('Kommentare und Leerzeilen stoeren nicht', () => {
const y = parseYaml('# Kopfzeile\n\nobs:\n # dazwischen\n url: ws://127.0.0.1:4455\n');
assert.deepEqual(y, { obs: { url: 'ws://127.0.0.1:4455' } });
});
test('Zeilenenden nach Windows-Art', () => {
// config.yaml wird auf dem Stream-PC bearbeitet
assert.deepEqual(parseYaml('a:\r\n b: 1\r\n'), { a: { b: 1 } });
});
test('ein Byte Order Mark verstellt nicht den ersten Schluessel', () => {
// Notepad und `Set-Content -Encoding utf8` unter Windows PowerShell 5.1
// schreiben eines. Ohne Behandlung hiesse der erste Schluessel "channel",
// und die Einstellung wuerde stillschweigend ignoriert - ohne Fehlermeldung.
const y = parseYaml('\uFEFFchannel:\n login: abdulive_\n');
assert.deepEqual(y, { channel: { login: 'abdulive_' } });
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
});
+5 -1
View File
@@ -51,7 +51,11 @@ function stripComment(line: string): string {
type Frame = { indent: number; target: YamlMap; listKey?: string }; type Frame = { indent: number; target: YamlMap; listKey?: string };
export function parseYaml(text: string): YamlMap { export function parseYaml(text: string): YamlMap {
const lines = text.split(/\r?\n/); // Byte Order Mark abschneiden. Notepad und `Set-Content -Encoding utf8`
// unter Windows PowerShell 5.1 schreiben eines an den Dateianfang; ohne
// diese Zeile heisst der erste Schluessel dann "channel" und die
// Einstellung wird stillschweigend ignoriert.
const lines = text.replace(/^/, '').split(/\r?\n/);
const root: YamlMap = {}; const root: YamlMap = {};
const stack: Frame[] = [{ indent: -1, target: root }]; const stack: Frame[] = [{ indent: -1, target: root }];
let i = 0; let i = 0;
+121
View File
@@ -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);
});
+164
View File
@@ -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;
}
+75 -7
View File
@@ -1,13 +1,15 @@
// Aufrufe von ffmpeg und ffprobe als Kindprozesse. // Aufrufe von ffmpeg und ffprobe als Kindprozesse.
// //
// Zwei Dinge sind hier wichtiger als alles andere (ADR-0006): // Zwei Dinge sind hier wichtiger als alles andere (ADR-0006):
// 1. Der Prozess laeuft nachrangig (`nice`), damit das Encodieren dem // 1. Der Prozess laeuft nachrangig (os.setPriority), damit das Encodieren
// laufenden Stream keine Frames kostet. // dem laufenden Stream keine Frames kostet.
// 2. Er darf scheitern, ohne den Daemon mitzureissen - deshalb Zeitlimit, // 2. Er darf scheitern, ohne den Daemon mitzureissen - deshalb Zeitlimit,
// sauberes Toeten und die letzten Zeilen von stderr in der Fehlermeldung. // sauberes Toeten und die letzten Zeilen von stderr in der Fehlermeldung.
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import os from 'node:os';
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { binaries } from '#core/platform.ts';
const log = logger('ffmpeg'); const log = logger('ffmpeg');
@@ -16,8 +18,65 @@ export type RunOptions = {
timeoutMs?: number; timeoutMs?: number;
onProgress?: (seconds: number) => void; onProgress?: (seconds: number) => void;
signal?: AbortSignal; 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 { export class FfmpegError extends Error {
code: number | null; code: number | null;
tail: string; tail: string;
@@ -36,13 +95,22 @@ export function parseProgress(chunk: string): number | null {
} }
export function run(bin: string, args: string[], opts: RunOptions = {}): Promise<string> { export function run(bin: string, args: string[], opts: RunOptions = {}): Promise<string> {
const niceness = opts.niceness ?? 0; const cmd = resolveBinary(bin);
const cmd = niceness > 0 ? 'nice' : bin;
const argv = niceness > 0 ? ['-n', String(niceness), bin, ...args] : args;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
log.debug(`${cmd} ${argv.slice(0, 6).join(' ')} ...`); log.debug(`${cmd} ${args.slice(0, 6).join(' ')} ...`);
const child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] }); 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 = ''; let stdout = '';
const errLines: string[] = []; const errLines: string[] = [];
+47
View File
@@ -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');
});
+59 -1
View File
@@ -2,7 +2,10 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { Word } from './whisper.ts'; import type { Word } from './whisper.ts';
import { wordsFromJson } 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 { function w(text: string, start: number, end: number): Word {
return { text, start, end }; return { text, start, end };
@@ -89,6 +92,61 @@ test('filtert Geraeuschbeschreibungen', () => {
assert.deepEqual(lines.flatMap((l) => l.words.map((x) => x.text)), ['hallo']); 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 // ------------------------------------------------------------ ASS-Ausgabe
test('Zeilen ueberlappen sich nie', () => { test('Zeilen ueberlappen sich nie', () => {
+98 -8
View File
@@ -40,14 +40,68 @@ export const DEFAULT_STYLE: SubtitleStyle = {
export type LineOptions = { export type LineOptions = {
maxWords: number; maxWords: number;
maxSeconds: 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 // maxSeconds war anfangs 2,2 s. An echtem Material zeigte sich, dass bei
// langsamem oder zoegerndem Sprechen dann Zeilen mit einem einzigen Wort // langsamem oder zoegerndem Sprechen dann Zeilen mit einem einzigen Wort
// entstehen ("Dank" / "es," / "Savina,"). 3,2 s plus das Nachverdichten // entstehen ("Dank" / "es," / "Savina,"). 3,2 s plus das Nachverdichten
// weiter unten ergibt lesbare Zeilen. // 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 }; 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))) { 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; const wouldBeSeconds = current.length ? w.end - current[0]!.start : 0;
if (current.length && ( if (current.length && (
current.length >= opts.maxWords || current.length >= opts.maxWords ||
wouldBeChars > opts.maxChars || wouldBeUnits > opts.maxUnits ||
wouldBeSeconds > opts.maxSeconds || wouldBeSeconds > opts.maxSeconds ||
// Eine Pause von mehr als einer halben Sekunde ist ein Satzende, // Eine Pause von mehr als einer halben Sekunde ist ein Satzende,
// egal was die Zeichensetzung sagt // egal was die Zeichensetzung sagt
@@ -127,11 +182,14 @@ function mergeStragglers(lines: Line[], opts: LineOptions): Line[] {
const out: Line[] = []; const out: Line[] = [];
for (const line of lines) { for (const line of lines) {
const prev = out[out.length - 1]; 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 && const soloAndClose = prev && line.words.length === 1 &&
line.start - prev.end < 0.6 && line.start - prev.end < 0.6 &&
prev.words.length + 1 <= opts.maxWords + 1 && prev.words.length + 1 <= opts.maxWords + 1 &&
chars(prev) + chars(line) <= opts.maxChars + 8; units(prev) + units(line) <= opts.maxUnits + 4;
if (soloAndClose) { if (soloAndClose) {
prev.words.push(...line.words); prev.words.push(...line.words);
@@ -154,7 +212,10 @@ export function buildAss(
'ScriptType: v4.00+', 'ScriptType: v4.00+',
`PlayResX: ${opts.width}`, `PlayResX: ${opts.width}`,
`PlayResY: ${opts.height}`, `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', 'ScaledBorderAndShadow: yes',
'', '',
'[V4+ Styles]', '[V4+ Styles]',
@@ -179,6 +240,8 @@ export function buildAss(
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
]; ];
const usable = opts.width - 2 * s.marginSide;
const events = lines.map((line, li) => { const events = lines.map((line, li) => {
// Nachlauf, damit die Zeile nicht im Moment des letzten Lautes verschwindet - // Nachlauf, damit die Zeile nicht im Moment des letzten Lautes verschwindet -
// aber nie in die naechste Zeile hinein. Zwei gleichzeitig sichtbare // aber nie in die naechste Zeile hinein. Zwei gleichzeitig sichtbare
@@ -186,6 +249,9 @@ export function buildAss(
const next = lines[li + 1]; const next = lines[li + 1];
const end = next ? Math.min(line.end + 0.25, next.start) : line.end + 0.4; 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 text = line.words.map((w, i) => {
const nextWord = line.words[i + 1]; const nextWord = line.words[i + 1];
// Die Marke laeuft bis zum Beginn des naechsten Wortes, damit Pausen // Die Marke laeuft bis zum Beginn des naechsten Wortes, damit Pausen
@@ -196,12 +262,36 @@ export function buildAss(
return `{\\k${cs}}${escapeAss(w.text)}`; return `{\\k${cs}}${escapeAss(w.text)}`;
}).join(' '); }).join(' ');
return `Dialogue: 0,${assTime(line.start)},${assTime(Math.max(end, line.start + 0.3))},` + 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'); 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 { function escapeAss(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}'); return text.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
} }
+15 -5
View File
@@ -1,15 +1,25 @@
// OBS laeuft auf dem Host und meldet Windows-Pfade; der Container sieht den // OBS meldet den Pfad der gespeicherten Pufferdatei. Nativ ist das derselbe
// Wiedergabepuffer-Ordner unter /replay. Diese Uebersetzung ist die Stelle, // Pfad, den auch die Anwendung sieht - es gibt nichts zu uebersetzen.
// an der die Clip-Pipeline am ehesten scheitert - deshalb eigene Datei mit //
// eigenen Tests. Siehe docs/07-mvp-plan.md, Scheibe S2. // 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 fs from 'node:fs/promises';
import path from 'node:path'; 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( export function mapReplayPath(
hostPath: string, hostDir: string | undefined, containerDir: string hostPath: string, hostDir: string | undefined, containerDir: string
): string { ): string {
// Kein Zielverzeichnis heisst: dieselbe Dateisicht wie OBS.
if (!containerDir) return hostPath;
const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, ''); const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '');
const file = norm(hostPath); const file = norm(hostPath);
+69
View File
@@ -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}`);
}
});
+63
View File
@@ -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 ?? ''}`),
};
}
+2
View File
@@ -35,6 +35,8 @@ export type SecretStore = {
obs?: { password: string }; obs?: { password: string };
discord?: { goLiveWebhook?: string; recapWebhook?: string }; discord?: { goLiveWebhook?: string; recapWebhook?: string };
bluesky?: { handle: string; appPassword: string }; bluesky?: { handle: string; appPassword: string };
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
update?: { token: string };
}; };
const EMPTY: SecretStore = { apps: {}, tokens: {} }; const EMPTY: SecretStore = { apps: {}, tokens: {} };
+13 -7
View File
@@ -1,12 +1,18 @@
# scripts # scripts
Skripte laufen entweder im Container (`docker compose run --rm toolkit <name>`) Befehle der Anwendung ruft man ueber `abdulive.cmd <name>` auf (im Repo:
oder auf dem Host, wo sie mit Docker sprechen. `node apps/cli/main.ts <name>`). Daneben stehen hier die Skripte fuer
Paketbau, Installation und Start.
| Skript | Wo | Zweck | | Skript | Wo | Zweck |
|---|---|---| |---|---|---|
| `check-env` | Container | Prueft ffmpeg, whisper-cli, Sprachmodell, Volumes, OBS-Erreichbarkeit | | `build-package.ts` | Entwicklungsrechner / CI | Baut `dist/abdulive-<version>-win-x64.zip` samt Updater-Manifest |
| `auth` | Container | OAuth-Flow fuer Broadcaster- und Bot-Token (braucht `--service-ports`) | | `install.ps1` | Stream-PC | Installiert Programm, Daten und Autostart. Ohne Administratorrechte |
| `db:migrate` | Container | Migrationen anwenden | | `launcher.ps1` | Stream-PC | Vom Aufgabenplaner gestartet: Update anwenden, dann Daemon starten |
| `calibrate` | Container | Facecam-Crop einmessen, schreibt `config/layout.yaml` | | `check-env` | Stream-PC | Prueft ffmpeg, whisper-cli, Schriften, Sprachmodell, Verzeichnisse, OBS |
| `backup.ps1` | Host | `VACUUM INTO` aus dem Volume `abdulive-db` nach `data/db/backup/`, 30 Generationen | | `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 |
+56
View File
@@ -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)"
}
+217
View File
@@ -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();
+187
View File
@@ -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 ''
+67
View File
@@ -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