Struktur, Doku und S0-Toolchain fuer das abdulive-Toolkit
Monorepo-Geruest fuer neun Streamer-Tools plus den Ingest-Daemon, der unter sechs davon liegt. Enthaelt: - docs/: Ueberblick, Architektur, Twitch-Integration, Datenmodell, Roadmap, Setup, Hebel-Analyse und den MVP-Plan "Clip-Knopf" - docs/projects/: je eine Spezifikation pro Tool - docs/decisions/: vier ADRs (TS-Monorepo, SQLite, EventSub/IRC, Docker) - docker/: Image mit ffmpeg und whisper.cpp, abhaengigkeitsfreier Entrypoint mit check-env, fetch-model und calibrate - config/layout.yaml: aus einem VOD-Screenshot eingemessener Facecam-Ausschnitt (440x330 oben links, Chat-Overlay ab x=1270) Noch kein Anwendungscode - Scheibe S0 aus docs/07-mvp-plan.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
commit
d09e2b945a
98 files changed
+3273
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
data
|
||||||
|
logs
|
||||||
|
.env
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Kopie als .env anlegen. Diese Datei enthaelt Secrets und gehoert nie ins Repo.
|
||||||
|
|
||||||
|
# Twitch-App fuer den Broadcaster-Account (abdulive_)
|
||||||
|
TWITCH_CLIENT_ID=
|
||||||
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Twitch-App fuer das Bot-Konto
|
||||||
|
TWITCH_BOT_CLIENT_ID=
|
||||||
|
TWITCH_BOT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# OAuth-Redirect (muss in beiden Apps eingetragen sein)
|
||||||
|
TWITCH_REDIRECT_URI=http://localhost:4780/callback
|
||||||
|
|
||||||
|
# obs-websocket v5
|
||||||
|
OBS_WS_URL=ws://127.0.0.1:4455
|
||||||
|
OBS_WS_PASSWORD=
|
||||||
|
|
||||||
|
# Discord-Webhooks
|
||||||
|
DISCORD_WEBHOOK_GOLIVE=
|
||||||
|
DISCORD_WEBHOOK_RECAP=
|
||||||
|
DISCORD_WEBHOOK_INTERNAL=
|
||||||
|
|
||||||
|
# Bluesky
|
||||||
|
BLUESKY_HANDLE=
|
||||||
|
BLUESKY_APP_PASSWORD=
|
||||||
|
|
||||||
|
# Mastodon (optional)
|
||||||
|
MASTODON_INSTANCE=
|
||||||
|
MASTODON_TOKEN=
|
||||||
|
|
||||||
|
# Push-Benachrichtigung aufs Handy (optional, ntfy.sh)
|
||||||
|
NTFY_TOPIC=
|
||||||
|
|
||||||
|
# Schluessel fuer die Token-Verschluesselung in data/db/tokens.json
|
||||||
|
TOKEN_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Windows-Pfad zum OBS-Wiedergabepuffer-Ordner (fuer den Bind-Mount)
|
||||||
|
# Forward-Slashes verwenden, z. B. C:/Users/abdu/Videos/replay
|
||||||
|
OBS_REPLAY_DIR=
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Secrets und lokale Konfiguration
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
config/config.yaml
|
||||||
|
config/raid-blocklist.yaml
|
||||||
|
|
||||||
|
# Personenbezogene Daten und Medien: gehoeren nie ins Repo
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# abdulive-toolkit
|
||||||
|
|
||||||
|
Werkzeugkasten für den Twitch-Kanal **abdulive_**. Ein Monorepo mit mehreren
|
||||||
|
kleinen Diensten, die sich einen gemeinsamen Kern teilen: Twitch-Auth,
|
||||||
|
Helix-Client, EventSub/IRC-Ingest und eine SQLite-Datenbank.
|
||||||
|
|
||||||
|
Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese
|
||||||
|
Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**.
|
||||||
|
|
||||||
|
## Die neun Tools
|
||||||
|
|
||||||
|
| # | Tool | Ordner | Zweck | Status |
|
||||||
|
|---|------|--------|-------|--------|
|
||||||
|
| 1 | Clip-to-Shorts | `apps/clipper` | Clips → 9:16 mit Untertiteln, Upload-fertig | geplant |
|
||||||
|
| 2 | Go-Live-Broadcaster | `apps/broadcaster` | Stream-Start → Discord/Bluesky/Instagram | geplant |
|
||||||
|
| 3 | Raid- & Netzwerk-Tool | `apps/raid-scout` | Raid-Ziele finden, Raid-Historie führen | geplant |
|
||||||
|
| 4 | Analytics-Dashboard | `apps/dashboard` | Viewer vs. Chat, Titel-Performance, Retention | geplant |
|
||||||
|
| 5 | VOD-Kapitel | `apps/vod-chapters` | Szenen-/Kategoriewechsel → Timestamps | geplant |
|
||||||
|
| 6 | Community-Gedächtnis | `apps/community` | Chatter-Profile, Begrüßungen, Overlay-Hinweise | geplant |
|
||||||
|
| 7 | Chat steuert Dinge | `apps/overlay-server` + `apps/overlay-web` | Overlay, Soundboard, Minigames, Umfragen | geplant |
|
||||||
|
| 8 | Punktesystem + Shop | `apps/loyalty` | Punkte mit echten Konsequenzen | geplant |
|
||||||
|
| 9 | Stream-Recap | `apps/recap` | Zusammenfassung nach Streamende in Discord | geplant |
|
||||||
|
|
||||||
|
Dazu der Dienst, der unter allen anderen liegt:
|
||||||
|
|
||||||
|
| — | **Ingest-Daemon** | `apps/ingest` | EventSub + IRC → Event-Bus + DB | **zuerst** |
|
||||||
|
|
||||||
|
## Doku
|
||||||
|
|
||||||
|
- [`docs/00-overview.md`](docs/00-overview.md) — Was hier gebaut wird und warum
|
||||||
|
- [`docs/01-architecture.md`](docs/01-architecture.md) — Aufbau, Datenfluss, Prozesse
|
||||||
|
- [`docs/02-twitch-integration.md`](docs/02-twitch-integration.md) — Auth, Scopes, EventSub, Rate Limits
|
||||||
|
- [`docs/03-data-model.md`](docs/03-data-model.md) — Datenbankschema
|
||||||
|
- [`docs/04-roadmap.md`](docs/04-roadmap.md) — Reihenfolge und Meilensteine
|
||||||
|
- [`docs/05-setup.md`](docs/05-setup.md) — Lokal einrichten
|
||||||
|
- [`docs/06-hebel-analyse.md`](docs/06-hebel-analyse.md) — **Womit anfangen und warum**
|
||||||
|
- [`docs/07-mvp-plan.md`](docs/07-mvp-plan.md) — **MVP „Clip-Knopf": Umfang, Scheiben, Zeitplan**
|
||||||
|
- [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool
|
||||||
|
- [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs)
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
Alles läuft in Docker — auf dem Rechner werden nur Docker Desktop und OBS
|
||||||
|
gebraucht, ffmpeg und whisper.cpp stecken im Image.
|
||||||
|
Siehe [`docs/05-setup.md`](docs/05-setup.md).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
Copy-Item config\config.example.yaml config\config.yaml
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm toolkit check-env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aktueller Stand
|
||||||
|
|
||||||
|
Struktur und Dokumentation stehen, Code noch nicht. Als Nächstes:
|
||||||
|
**MVP „Clip-Knopf"** nach [`docs/07-mvp-plan.md`](docs/07-mvp-plan.md).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/broadcaster
|
||||||
|
|
||||||
|
Go-Live-Ankuendigung auf Discord, Bluesky, Mastodon, plus Instagram-Story-Bild.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/go-live-broadcaster.md](../../docs/projects/go-live-broadcaster.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/broadcaster",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/broadcaster — siehe docs/projects/go-live-broadcaster.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/clipper
|
||||||
|
|
||||||
|
Clip-to-Shorts-Pipeline: Trigger -> Replay/Clip -> 9:16 -> Untertitel -> Upload-Kandidat.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/clip-shorts.md](../../docs/projects/clip-shorts.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/clipper",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/clipper — siehe docs/projects/clip-shorts.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/community
|
||||||
|
|
||||||
|
Community-Gedaechtnis: Begruessungen, Meilensteine, Overlay-Hinweis. Modul im overlay-server.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/community-memory.md](../../docs/projects/community-memory.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/community",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/community — siehe docs/projects/community-memory.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/dashboard
|
||||||
|
|
||||||
|
Analytics-Dashboard und Freigabe-Ansicht fuer Clip-Kandidaten.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/analytics.md](../../docs/projects/analytics.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/dashboard — siehe docs/projects/analytics.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/ingest
|
||||||
|
|
||||||
|
Daemon: IRC + EventSub + Helix-Polling + OBS -> DB und Event-Bus. Das Fundament.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/00-ingest.md](../../docs/projects/00-ingest.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/ingest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/ingest — siehe docs/projects/00-ingest.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/loyalty
|
||||||
|
|
||||||
|
Punktesystem mit Ledger und Shop mit echten Effekten. Modul im overlay-server.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/loyalty-shop.md](../../docs/projects/loyalty-shop.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/loyalty",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/loyalty — siehe docs/projects/loyalty-shop.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/overlay-server
|
||||||
|
|
||||||
|
WebSocket-Backend und Command-Registry fuer alle Overlays.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/overlay-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/overlay-server — siehe docs/projects/chat-control.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/overlay-web
|
||||||
|
|
||||||
|
Browser-Sources fuer OBS: Alerts, Effekte, Community-Hinweis, Umfrage, Raid-Panel.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/overlay-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/overlay-web — siehe docs/projects/chat-control.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/raid-scout
|
||||||
|
|
||||||
|
Raid-Ziele finden und bewerten, Raid-Historie und Erwiderungs-Erinnerung.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/raid-network.md](../../docs/projects/raid-network.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/raid-scout",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/raid-scout — siehe docs/projects/raid-network.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/recap
|
||||||
|
|
||||||
|
Stream-Zusammenfassung nach Streamende als Discord-Post.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/stream-recap.md](../../docs/projects/stream-recap.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/recap",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/recap — siehe docs/projects/stream-recap.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/vod-chapters
|
||||||
|
|
||||||
|
Kapitel-Timestamps aus Kategorie- und Szenenwechseln.
|
||||||
|
|
||||||
|
Spezifikation: [docs/projects/vod-chapters.md](../../docs/projects/vod-chapters.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/vod-chapters",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/vod-chapters — siehe docs/projects/vod-chapters.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Kopie als config/config.yaml anlegen. Secrets gehoeren in .env, nicht hierher.
|
||||||
|
|
||||||
|
channel:
|
||||||
|
login: abdulive_
|
||||||
|
userId: "" # wird beim ersten `pnpm auth` gefuellt
|
||||||
|
language: de
|
||||||
|
timezone: Europe/Berlin
|
||||||
|
|
||||||
|
bot:
|
||||||
|
login: abdulive_bot
|
||||||
|
|
||||||
|
ingest:
|
||||||
|
viewerPollSeconds: 60
|
||||||
|
chattersPollSeconds: 300
|
||||||
|
sessionGapMinutes: 5 # kuerzere Luecken gelten als derselbe Stream
|
||||||
|
spike:
|
||||||
|
windowSeconds: 15
|
||||||
|
baselineMinutes: 10
|
||||||
|
factor: 3
|
||||||
|
minDelta: 4
|
||||||
|
cooldownSeconds: 60
|
||||||
|
armed: false # erst nach Kalibrierung auf true
|
||||||
|
laughEmotes: [KEKW, LUL, OMEGALUL, PogChamp, Pog]
|
||||||
|
|
||||||
|
obs:
|
||||||
|
url: ws://127.0.0.1:4455
|
||||||
|
replayBufferSeconds: 90
|
||||||
|
chapterScenes: [Gameplay, "Just Chatting", Startbildschirm]
|
||||||
|
|
||||||
|
clipper:
|
||||||
|
enabled: true
|
||||||
|
source: obs-replay # obs-replay | twitch
|
||||||
|
alsoCreateTwitchClip: true
|
||||||
|
maxDurationSeconds: 60
|
||||||
|
renderWhileLive: false # true = sofort rendern, false = erst nach Streamende
|
||||||
|
whisper:
|
||||||
|
binary: whisper-cli # whisper.cpp, im Image enthalten
|
||||||
|
model: ggml-small.bin
|
||||||
|
language: de
|
||||||
|
threads: 4
|
||||||
|
ffmpeg:
|
||||||
|
preset: veryfast
|
||||||
|
crf: 20
|
||||||
|
encoder: libx264 # keine NVIDIA-GPU da, QSV aus WSL2 unzuverlaessig
|
||||||
|
niceness: 15 # Encoding laeuft nachrangig zum Stream
|
||||||
|
triggers:
|
||||||
|
chatCommand: "!clip"
|
||||||
|
hotkeyName: SaveReplay
|
||||||
|
onSpike: false
|
||||||
|
|
||||||
|
broadcaster:
|
||||||
|
enabled: true
|
||||||
|
mode: draft # draft | live
|
||||||
|
delaySecondsAfterOnline: 60
|
||||||
|
cooldownHours: 6
|
||||||
|
platforms:
|
||||||
|
discord: true
|
||||||
|
bluesky: true
|
||||||
|
mastodon: false
|
||||||
|
instagramStoryImage: true
|
||||||
|
|
||||||
|
community:
|
||||||
|
greetings:
|
||||||
|
enabled: true
|
||||||
|
minSecondsBetween: 20
|
||||||
|
maxPerStream: 10
|
||||||
|
returningAfterDays: 21
|
||||||
|
milestones: [5, 10, 25, 50, 100]
|
||||||
|
suppressWhileStreamerSpeaking: true
|
||||||
|
overlayHint: true
|
||||||
|
|
||||||
|
raidScout:
|
||||||
|
minViewers: 5
|
||||||
|
maxViewers: 30
|
||||||
|
minUptimeMinutes: 30
|
||||||
|
maxUptimeMinutes: 300
|
||||||
|
languages: [de]
|
||||||
|
topN: 5
|
||||||
|
|
||||||
|
recap:
|
||||||
|
enabled: true
|
||||||
|
delayMinutesAfterOffline: 10
|
||||||
|
minStreamMinutes: 20
|
||||||
|
hideViewerNumbersIfWeak: true
|
||||||
|
topChatters: 3
|
||||||
|
topClips: 2
|
||||||
|
|
||||||
|
loyalty:
|
||||||
|
enabled: false # spaetere Ausbaustufe
|
||||||
|
overlay:
|
||||||
|
port: 4791
|
||||||
|
dashboard:
|
||||||
|
port: 4792
|
||||||
|
ingestServer:
|
||||||
|
port: 4790
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Eingemessen am 2026-09-09 aus einem VOD-Screenshot (GTA 5, Szene "Gameplay").
|
||||||
|
# Alle Werte in Koordinaten des fertigen Streambildes 1920x1080.
|
||||||
|
#
|
||||||
|
# Nachmessen: docker compose run --rm toolkit calibrate
|
||||||
|
# Hintergrund: docs/projects/clip-shorts.md
|
||||||
|
|
||||||
|
source:
|
||||||
|
width: 1920
|
||||||
|
height: 1080
|
||||||
|
|
||||||
|
# Ziel: 9:16 fuer Shorts/Reels/TikTok
|
||||||
|
target:
|
||||||
|
width: 1080
|
||||||
|
height: 1920
|
||||||
|
|
||||||
|
profiles:
|
||||||
|
# Standardprofil: Facecam oben links, Chat-Overlay rechts eingebrannt
|
||||||
|
default:
|
||||||
|
description: >
|
||||||
|
Facecam bündig oben links (440x330, 4:3). Chat-Overlay ist ins Streambild
|
||||||
|
eingebrannt und beginnt bei x=1270 - der Gameplay-Ausschnitt endet
|
||||||
|
deshalb bei x=1268. Minimap (x<360) und Handy-Overlay (x>1540) liegen
|
||||||
|
ausserhalb und fallen automatisch weg.
|
||||||
|
|
||||||
|
cam:
|
||||||
|
# Facecam im Streambild: x 0..440, y 0..330
|
||||||
|
# Davon wird ein Streifen genommen, der zum Zielband passt - NICHT
|
||||||
|
# verzerrt. 4:3 auf 16:9 zu strecken wuerde das Gesicht breit ziehen.
|
||||||
|
crop: { x: 0, y: 100, w: 440, h: 208 }
|
||||||
|
band: { w: 1080, h: 512 }
|
||||||
|
|
||||||
|
game:
|
||||||
|
# Sauberes Band zwischen Facecam (endet 440) und Chat (beginnt 1270)
|
||||||
|
crop: { x: 440, y: 0, w: 828, h: 1080 }
|
||||||
|
band: { w: 1080, h: 1408 }
|
||||||
|
|
||||||
|
# 512 + 1408 = 1920
|
||||||
|
|
||||||
|
# Messwerte aus dem Referenzbild, zur Nachvollziehbarkeit:
|
||||||
|
# Videobereich im Screenshot : x 0..1916, y 60..1137 (1917x1078)
|
||||||
|
# Facecam rechte Kante : x 439 (konstant bei y=120/200/280/360)
|
||||||
|
# Facecam untere Kante : y 391 (konstant bei x=40/140/240/340/420)
|
||||||
|
# Chat-Overlay beginnt : x 1268
|
||||||
|
# Gesicht (Cam-lokal) : y 126..300, Mitte ~213
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Stand: Scheibe S0 aus docs/07-mvp-plan.md.
|
||||||
|
# Es gibt noch keinen Daemon - der Container fuehrt einzelne Befehle aus
|
||||||
|
# (check-env, fetch-model, calibrate). Ab S1 wird daraus ein Langlaeufer.
|
||||||
|
#
|
||||||
|
# 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: ["help"]
|
||||||
|
|
||||||
|
# OBS laeuft auf dem Host, nicht im Container
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4780:4780" # OAuth-Rueckruf, nur waehrend `auth` (ab S1)
|
||||||
|
- "127.0.0.1:4791:4791" # Overlay-Server (ab M3)
|
||||||
|
- "127.0.0.1:4792:4792" # Sichtungsseite (ab S5)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
abdulive-db:
|
||||||
|
abdulive-models:
|
||||||
|
abdulive-work:
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Scheibe S0 aus docs/07-mvp-plan.md: Toolchain-Image.
|
||||||
|
#
|
||||||
|
# Bewusst noch ohne `pnpm install` - es gibt noch keine Abhaengigkeiten und
|
||||||
|
# keine Lockfile. Der Entrypoint kommt mit reinem Node aus. Sobald in S1
|
||||||
|
# echter Code dazukommt, wird hier eine deps-Stufe ergaenzt.
|
||||||
|
|
||||||
|
# ---------- 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/ \
|
||||||
|
&& (cp build/src/libwhisper.so* /out/ 2>/dev/null || true) \
|
||||||
|
&& (cp build/ggml/src/libggml*.so /out/ 2>/dev/null || true)
|
||||||
|
|
||||||
|
# ---------- Laufzeit ----------
|
||||||
|
FROM node:22-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
|
||||||
|
COPY docker/ ./docker/
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Die Datenbank und die 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
|
||||||
|
|
||||||
|
EXPOSE 4780 4790 4791 4792
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/app/docker/entrypoint.js"]
|
||||||
|
CMD ["help"]
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Einstiegspunkt des Container-Images. Scheibe S0 aus docs/07-mvp-plan.md.
|
||||||
|
//
|
||||||
|
// Bewusst ohne npm-Abhaengigkeiten: dieses Skript muss laufen, bevor
|
||||||
|
// irgendetwas anderes installiert oder gebaut ist.
|
||||||
|
|
||||||
|
import { execFile, execFileSync } from 'node:child_process';
|
||||||
|
import { createWriteStream } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { parseYaml } from './lib/yaml.js';
|
||||||
|
|
||||||
|
const ENV = {
|
||||||
|
whisper: process.env.WHISPER_BIN || 'whisper-cli',
|
||||||
|
modelDir: process.env.MODEL_DIR || '/models',
|
||||||
|
dbDir: process.env.DB_DIR || '/db',
|
||||||
|
workDir: process.env.WORK_DIR || '/work',
|
||||||
|
clipsDir: process.env.CLIPS_DIR || '/app/data/clips',
|
||||||
|
replayDir: process.env.REPLAY_DIR || '/replay',
|
||||||
|
configDir: process.env.CONFIG_DIR || '/app/config',
|
||||||
|
obsHost: process.env.OBS_WS_HOST || 'host.docker.internal',
|
||||||
|
obsPort: Number(process.env.OBS_WS_PORT || 4455),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODELS = {
|
||||||
|
'ggml-small.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
||||||
|
'ggml-medium.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
|
||||||
|
'ggml-base.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Ausgabe
|
||||||
|
|
||||||
|
const C = process.stdout.isTTY
|
||||||
|
? { ok: '\x1b[32m', warn: '\x1b[33m', bad: '\x1b[31m', dim: '\x1b[90m', off: '\x1b[0m' }
|
||||||
|
: { ok: '', warn: '', bad: '', dim: '', off: '' };
|
||||||
|
|
||||||
|
let hardFailures = 0;
|
||||||
|
let warnings = 0;
|
||||||
|
|
||||||
|
function report(state, label, detail = '') {
|
||||||
|
const mark = { ok: `${C.ok} OK ${C.off}`, warn: `${C.warn} WARN ${C.off}`, bad: `${C.bad} FEHLT${C.off}` }[state];
|
||||||
|
if (state === 'bad') hardFailures++;
|
||||||
|
if (state === 'warn') warnings++;
|
||||||
|
console.log(`${mark} ${label.padEnd(34)} ${C.dim}${detail}${C.off}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title) {
|
||||||
|
console.log(`\n${title}`);
|
||||||
|
console.log('-'.repeat(Math.max(title.length, 40)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Helfer
|
||||||
|
|
||||||
|
function run(cmd, args, opts = {}) {
|
||||||
|
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAsync(cmd, args, opts = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024, ...opts }, (err, stdout, stderr) =>
|
||||||
|
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canWrite(dir) {
|
||||||
|
const probe = path.join(dir, `.probe-${process.pid}`);
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(probe, 'x');
|
||||||
|
await fs.unlink(probe);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tcpProbe(host, port, timeout = 2500) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
const done = (result) => { sock.destroy(); resolve(result); };
|
||||||
|
sock.setTimeout(timeout);
|
||||||
|
sock.once('connect', () => done(true));
|
||||||
|
sock.once('timeout', () => done(false));
|
||||||
|
sock.once('error', () => done(false));
|
||||||
|
sock.connect(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLayout() {
|
||||||
|
const file = path.join(ENV.configDir, 'layout.yaml');
|
||||||
|
const text = await fs.readFile(file, 'utf8');
|
||||||
|
return { file, layout: parseYaml(text) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- check-env
|
||||||
|
|
||||||
|
async function checkEnv() {
|
||||||
|
console.log('\nabdulive-toolkit - Selbsttest der Umgebung');
|
||||||
|
|
||||||
|
section('Laufzeit');
|
||||||
|
const major = Number(process.versions.node.split('.')[0]);
|
||||||
|
report(major >= 22 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
|
||||||
|
|
||||||
|
section('Medienwerkzeuge');
|
||||||
|
try {
|
||||||
|
const v = run('ffmpeg', ['-version']).split('\n')[0];
|
||||||
|
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
|
||||||
|
const cfg = run('ffmpeg', ['-hide_banner', '-buildconf']);
|
||||||
|
report(cfg.includes('--enable-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)');
|
||||||
|
report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)');
|
||||||
|
} catch {
|
||||||
|
report('bad', 'ffmpeg', 'nicht im PATH');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
run('ffprobe', ['-version']);
|
||||||
|
report('ok', 'ffprobe');
|
||||||
|
} catch {
|
||||||
|
report('bad', 'ffprobe', 'nicht im PATH');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = run(ENV.whisper, ['--help'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
report('ok', 'whisper-cli', out.split('\n')[0].slice(0, 40) || ENV.whisper);
|
||||||
|
} catch (err) {
|
||||||
|
// whisper-cli beendet sich bei --help teils mit Code != 0, Ausgabe zaehlt
|
||||||
|
const text = `${err.stdout || ''}${err.stderr || ''}`;
|
||||||
|
report(text.includes('usage') || text.includes('whisper') ? 'ok' : 'bad', 'whisper-cli',
|
||||||
|
text.includes('whisper') ? '' : 'nicht lauffaehig');
|
||||||
|
}
|
||||||
|
|
||||||
|
section('Sprachmodell');
|
||||||
|
let anyModel = false;
|
||||||
|
for (const name of Object.keys(MODELS)) {
|
||||||
|
try {
|
||||||
|
const st = await fs.stat(path.join(ENV.modelDir, name));
|
||||||
|
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
|
||||||
|
anyModel = true;
|
||||||
|
} catch { /* nicht vorhanden, ist in Ordnung */ }
|
||||||
|
}
|
||||||
|
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
|
||||||
|
|
||||||
|
section('Verzeichnisse');
|
||||||
|
for (const [label, dir] of [
|
||||||
|
['Datenbank (Volume)', ENV.dbDir],
|
||||||
|
['Arbeitsverzeichnis (Volume)', ENV.workDir],
|
||||||
|
['Sprachmodelle (Volume)', ENV.modelDir],
|
||||||
|
['Clips (Bind-Mount)', ENV.clipsDir],
|
||||||
|
]) {
|
||||||
|
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
|
||||||
|
}
|
||||||
|
for (const [label, dir, hard] of [
|
||||||
|
['OBS-Wiedergabepuffer', ENV.replayDir, false],
|
||||||
|
['Konfiguration', ENV.configDir, true],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
const n = (await fs.readdir(dir)).length;
|
||||||
|
report('ok', label, `${dir} lesbar, ${n} Eintraege`);
|
||||||
|
} catch {
|
||||||
|
report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar - Mount gesetzt?`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section('Layout (Facecam-Ausschnitt)');
|
||||||
|
try {
|
||||||
|
const { layout } = await readLayout();
|
||||||
|
const prof = layout.profiles?.default;
|
||||||
|
if (!prof) throw new Error('Profil "default" fehlt');
|
||||||
|
const { cam, game } = prof;
|
||||||
|
const sum = cam.band.h + game.band.h;
|
||||||
|
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
|
||||||
|
`${cam.band.h} + ${game.band.h} = ${sum} (soll ${layout.target.height})`);
|
||||||
|
for (const [name, c] of [['Facecam', cam.crop], ['Gameplay', game.crop]]) {
|
||||||
|
const inside = c.x >= 0 && c.y >= 0 &&
|
||||||
|
c.x + c.w <= layout.source.width && c.y + c.h <= layout.source.height;
|
||||||
|
report(inside ? 'ok' : 'bad', `${name}-Ausschnitt im Bild`,
|
||||||
|
`${c.w}x${c.h} bei ${c.x},${c.y}`);
|
||||||
|
}
|
||||||
|
for (const [name, c, b] of [['Facecam', cam.crop, cam.band], ['Gameplay', game.crop, game.band]]) {
|
||||||
|
const dev = Math.abs((c.w / c.h) / (b.w / b.h) - 1) * 100;
|
||||||
|
report(dev < 1 ? 'ok' : 'warn', `${name}-Seitenverhaeltnis`,
|
||||||
|
`Abweichung ${dev.toFixed(2)} % (>1 % verzerrt sichtbar)`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
report('bad', 'config/layout.yaml', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
section('OBS');
|
||||||
|
const obsUp = await tcpProbe(ENV.obsHost, ENV.obsPort);
|
||||||
|
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
|
||||||
|
`${ENV.obsHost}:${ENV.obsPort}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
|
||||||
|
|
||||||
|
section('Zugangsdaten (.env)');
|
||||||
|
for (const key of ['TWITCH_CLIENT_ID', 'TWITCH_CLIENT_SECRET', 'OBS_WS_PASSWORD']) {
|
||||||
|
report(process.env[key] ? 'ok' : 'warn', key, process.env[key] ? 'gesetzt' : 'fehlt');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
if (hardFailures > 0) {
|
||||||
|
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Das Image ist noch nicht einsatzbereit.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else if (warnings > 0) {
|
||||||
|
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen - die sind fuer S0 in Ordnung.`);
|
||||||
|
} else {
|
||||||
|
console.log(`${C.ok}Alles in Ordnung.${C.off}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- fetch-model
|
||||||
|
|
||||||
|
async function fetchModel(name = 'ggml-small.bin') {
|
||||||
|
const url = MODELS[name];
|
||||||
|
if (!url) {
|
||||||
|
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.mkdir(ENV.modelDir, { recursive: true });
|
||||||
|
const target = path.join(ENV.modelDir, name);
|
||||||
|
try {
|
||||||
|
const st = await fs.stat(target);
|
||||||
|
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
|
||||||
|
return;
|
||||||
|
} catch { /* nicht da, also laden */ }
|
||||||
|
|
||||||
|
console.log(`Lade ${name} ...`);
|
||||||
|
const res = await fetch(url, { redirect: 'follow' });
|
||||||
|
if (!res.ok) throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
|
||||||
|
const total = Number(res.headers.get('content-length') || 0);
|
||||||
|
let seen = 0;
|
||||||
|
let lastPrint = 0;
|
||||||
|
const tmp = `${target}.part`;
|
||||||
|
const out = createWriteStream(tmp);
|
||||||
|
const body = res.body.getReader ? res.body : res.body;
|
||||||
|
await pipeline(
|
||||||
|
(async function* () {
|
||||||
|
for await (const chunk of body) {
|
||||||
|
seen += chunk.length;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastPrint > 1000) {
|
||||||
|
lastPrint = now;
|
||||||
|
const pct = total ? ` (${((seen / total) * 100).toFixed(0)} %)` : '';
|
||||||
|
process.stdout.write(`\r ${(seen / 1024 / 1024).toFixed(0)} MB${pct} `);
|
||||||
|
}
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
out
|
||||||
|
);
|
||||||
|
await fs.rename(tmp, target);
|
||||||
|
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- calibrate
|
||||||
|
|
||||||
|
function buildFilter(prof) {
|
||||||
|
const { cam, game } = prof;
|
||||||
|
return [
|
||||||
|
`[0:v]crop=${cam.crop.w}:${cam.crop.h}:${cam.crop.x}:${cam.crop.y},scale=${cam.band.w}:${cam.band.h},setsar=1[cam]`,
|
||||||
|
`[0:v]crop=${game.crop.w}:${game.crop.h}:${game.crop.x}:${game.crop.y},scale=${game.band.w}:${game.band.h},setsar=1[game]`,
|
||||||
|
`[cam][game]vstack=inputs=2[v]`,
|
||||||
|
].join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calibrate(input, profileName = 'default') {
|
||||||
|
const { layout } = await readLayout();
|
||||||
|
const prof = layout.profiles?.[profileName];
|
||||||
|
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
const files = (await fs.readdir(ENV.replayDir).catch(() => []))
|
||||||
|
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpg)$/i.test(f))
|
||||||
|
.sort();
|
||||||
|
input = files.length ? path.join(ENV.replayDir, files[files.length - 1]) : null;
|
||||||
|
if (!input) throw new Error(`Keine Datei angegeben und nichts in ${ENV.replayDir} gefunden.`);
|
||||||
|
console.log(`Neueste Datei aus ${ENV.replayDir}: ${path.basename(input)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(ENV.clipsDir, '_calibration');
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const isImage = /\.(png|jpe?g)$/i.test(input);
|
||||||
|
const seek = isImage ? [] : ['-ss', '5'];
|
||||||
|
|
||||||
|
// 1. Referenzbild aus der Quelle, unveraendert
|
||||||
|
const frame = path.join(outDir, 'frame.png');
|
||||||
|
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
|
||||||
|
...seek, '-i', input, '-frames:v', '1', frame]);
|
||||||
|
|
||||||
|
// 2. Vorschau der 9:16-Komposition mit den Werten aus layout.yaml
|
||||||
|
const preview = path.join(outDir, `preview-${profileName}.png`);
|
||||||
|
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-i', frame, '-filter_complex', buildFilter(prof), '-map', '[v]',
|
||||||
|
'-frames:v', '1', preview]);
|
||||||
|
|
||||||
|
// 3. Kontrollbild: Ausschnitte im Originalbild markiert
|
||||||
|
const marked = path.join(outDir, 'ausschnitte.png');
|
||||||
|
const box = (c, color) =>
|
||||||
|
`drawbox=x=${c.x}:y=${c.y}:w=${c.w}:h=${c.h}:color=${color}:t=4`;
|
||||||
|
await runAsync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-i', frame, '-vf',
|
||||||
|
`${box(prof.cam.crop, 'yellow')},${box(prof.game.crop, 'cyan')}`,
|
||||||
|
'-frames:v', '1', marked]);
|
||||||
|
|
||||||
|
console.log('\nGeschrieben:');
|
||||||
|
console.log(` ${frame} Einzelbild aus der Quelle`);
|
||||||
|
console.log(` ${marked} Ausschnitte markiert (gelb = Cam, cyan = Gameplay)`);
|
||||||
|
console.log(` ${preview} fertige 9:16-Vorschau`);
|
||||||
|
console.log('\nSitzt der Ausschnitt nicht, die Werte in config/layout.yaml anpassen');
|
||||||
|
console.log('und den Befehl erneut ausfuehren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- help
|
||||||
|
|
||||||
|
function help() {
|
||||||
|
console.log(`
|
||||||
|
abdulive-toolkit - Container-Einstiegspunkt
|
||||||
|
|
||||||
|
check-env Umgebung pruefen (Werkzeuge, Volumes, Layout, OBS)
|
||||||
|
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
||||||
|
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
|
||||||
|
help Diese Uebersicht
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
docker compose run --rm toolkit check-env
|
||||||
|
docker compose run --rm toolkit fetch-model ggml-small.bin
|
||||||
|
docker compose run --rm toolkit calibrate
|
||||||
|
|
||||||
|
Stand: Scheibe S0 aus docs/07-mvp-plan.md. Ingest, Clipping und Untertitel
|
||||||
|
folgen in S1 bis S4.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- main
|
||||||
|
|
||||||
|
const [cmd, ...args] = process.argv.slice(2);
|
||||||
|
try {
|
||||||
|
switch (cmd) {
|
||||||
|
case 'check-env': await checkEnv(); break;
|
||||||
|
case 'fetch-model': await fetchModel(args[0]); break;
|
||||||
|
case 'calibrate': await calibrate(args[0], args[1]); break;
|
||||||
|
case 'help': case undefined: help(); break;
|
||||||
|
default:
|
||||||
|
console.error(`Unbekannter Befehl "${cmd}".`);
|
||||||
|
help();
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\n${C.bad}Fehler:${C.off} ${err.message}`);
|
||||||
|
if (err.stderr) console.error(String(err.stderr).trim().split('\n').slice(-5).join('\n'));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
|
||||||
|
//
|
||||||
|
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
||||||
|
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren und
|
||||||
|
// Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - sobald in S1
|
||||||
|
// echte Abhaengigkeiten dazukommen, wird das durch `yaml` aus npm ersetzt.
|
||||||
|
|
||||||
|
function scalar(raw) {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (s === '' || s === '~' || s === 'null') return null;
|
||||||
|
if (s === 'true') return true;
|
||||||
|
if (s === 'false') return false;
|
||||||
|
if (/^-?\d+$/.test(s)) return Number.parseInt(s, 10);
|
||||||
|
if (/^-?\d*\.\d+$/.test(s)) return Number.parseFloat(s);
|
||||||
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowMap(raw) {
|
||||||
|
const out = {};
|
||||||
|
for (const part of raw.slice(1, -1).split(',')) {
|
||||||
|
if (!part.trim()) continue;
|
||||||
|
const i = part.indexOf(':');
|
||||||
|
if (i === -1) continue;
|
||||||
|
out[part.slice(0, i).trim()] = scalar(part.slice(i + 1));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripComment(line) {
|
||||||
|
let quote = null;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote) quote = null;
|
||||||
|
} else if (c === '"' || c === "'") {
|
||||||
|
quote = c;
|
||||||
|
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||||
|
return line.slice(0, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseYaml(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const root = {};
|
||||||
|
// Stapel aus [Einrueckung, Zielobjekt]
|
||||||
|
const stack = [[-1, root]];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const rawLine = lines[i];
|
||||||
|
const line = stripComment(rawLine);
|
||||||
|
if (!line.trim()) { i++; continue; }
|
||||||
|
|
||||||
|
const indent = line.length - line.trimStart().length;
|
||||||
|
const body = line.trim();
|
||||||
|
|
||||||
|
while (stack.length > 1 && indent <= stack[stack.length - 1][0]) stack.pop();
|
||||||
|
const parent = stack[stack.length - 1][1];
|
||||||
|
|
||||||
|
// Listeneintrag
|
||||||
|
if (body.startsWith('- ')) {
|
||||||
|
const key = stack[stack.length - 1][2];
|
||||||
|
if (key && Array.isArray(parent[key])) parent[key].push(scalar(body.slice(2)));
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colon = body.indexOf(':');
|
||||||
|
if (colon === -1) { i++; continue; }
|
||||||
|
const key = body.slice(0, colon).trim();
|
||||||
|
const rest = body.slice(colon + 1).trim();
|
||||||
|
|
||||||
|
if (rest === '') {
|
||||||
|
// Verschachtelte Map, leere Liste oder leerer Wert - anhand der
|
||||||
|
// naechsten inhaltstragenden Zeile entscheiden
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < lines.length && !stripComment(lines[j]).trim()) j++;
|
||||||
|
const next = j < lines.length ? stripComment(lines[j]) : '';
|
||||||
|
const nextIndent = next ? next.length - next.trimStart().length : -1;
|
||||||
|
if (next && nextIndent > indent && next.trim().startsWith('- ')) {
|
||||||
|
parent[key] = [];
|
||||||
|
stack.push([indent, parent, key]);
|
||||||
|
} else if (next && nextIndent > indent) {
|
||||||
|
parent[key] = {};
|
||||||
|
stack.push([indent, parent[key]]);
|
||||||
|
} else {
|
||||||
|
parent[key] = null;
|
||||||
|
}
|
||||||
|
} else if (rest === '>' || rest === '|' || rest === '>-' || rest === '|-') {
|
||||||
|
// Blockskalar: alle staerker eingerueckten Zeilen einsammeln
|
||||||
|
const parts = [];
|
||||||
|
let j = i + 1;
|
||||||
|
for (; j < lines.length; j++) {
|
||||||
|
if (!lines[j].trim()) { parts.push(''); continue; }
|
||||||
|
const ind = lines[j].length - lines[j].trimStart().length;
|
||||||
|
if (ind <= indent) break;
|
||||||
|
parts.push(lines[j].trim());
|
||||||
|
}
|
||||||
|
parent[key] = rest.startsWith('>') ? parts.join(' ').trim() : parts.join('\n');
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
} else if (rest.startsWith('{')) {
|
||||||
|
parent[key] = flowMap(rest);
|
||||||
|
} else if (rest.startsWith('[')) {
|
||||||
|
parent[key] = rest.slice(1, -1).split(',').map(scalar).filter((v) => v !== null);
|
||||||
|
} else {
|
||||||
|
parent[key] = scalar(rest);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Überblick
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
|
||||||
|
abdulive_ ist ein kleiner Twitch-Kanal in der Phase 5–30 gleichzeitiger
|
||||||
|
Zuschauer. In dieser Phase gelten andere Regeln als bei größeren Kanälen:
|
||||||
|
|
||||||
|
- **Twitch selbst bringt kaum Entdeckung.** Die Browse-Seite sortiert nach
|
||||||
|
Viewerzahl. Unter 30 CCV ist man dort faktisch unsichtbar. Reichweite muss
|
||||||
|
von außen kommen.
|
||||||
|
- **Jeder einzelne Zuschauer zählt überproportional.** Bei 10 Leuten ist der
|
||||||
|
Unterschied zwischen "hat zugeschaut" und "kommt wieder" der ganze Kanal.
|
||||||
|
- **Zeit ist der Engpass, nicht Ideen.** Alles, was manuell passieren muss,
|
||||||
|
passiert unregelmäßig oder gar nicht.
|
||||||
|
|
||||||
|
Daraus folgen drei Wirkrichtungen, und jedes Tool hier gehört in genau eine:
|
||||||
|
|
||||||
|
| Richtung | Frage | Tools |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| **Reichweite** | Wie finden mich neue Leute? | Clip-to-Shorts, Go-Live-Broadcaster, Raid-Tool |
|
||||||
|
| **Bindung** | Warum kommen sie wieder? | Community-Gedächtnis, Chat-Steuerung, Punktesystem, Recap |
|
||||||
|
| **Steuerung** | Was funktioniert überhaupt? | Analytics-Dashboard, VOD-Kapitel |
|
||||||
|
|
||||||
|
## Leitprinzipien
|
||||||
|
|
||||||
|
1. **Ein Ingest, viele Konsumenten.** Chat und Events werden genau einmal
|
||||||
|
eingelesen. Jedes Tool liest aus derselben Datenbank bzw. demselben
|
||||||
|
Event-Bus. Kein Tool baut sich eine zweite IRC-Verbindung.
|
||||||
|
2. **Alles läuft lokal.** Ein Rechner, ein Prozessverbund, SQLite. Kein Cloud-
|
||||||
|
Deployment, keine Container-Orchestrierung. Das ist keine Sparmaßnahme,
|
||||||
|
sondern die richtige Größe.
|
||||||
|
3. **Jedes Tool ist einzeln benutzbar.** Kein Tool wartet auf ein anderes,
|
||||||
|
außer auf den Ingest-Daemon.
|
||||||
|
4. **Nichts postet ungefragt nach außen.** Alles, was den Kanal verlässt
|
||||||
|
(Social-Posts, Uploads, Raids), erzeugt standardmäßig einen Entwurf, den
|
||||||
|
Abdu freigibt. Auto-Post ist ein Schalter, kein Default.
|
||||||
|
5. **Rohdaten werden nie weggeworfen.** Jede Chat-Nachricht, jeder Viewer-Sample
|
||||||
|
landet in der DB. Auswertungen kann man nachträglich ändern, Daten nicht
|
||||||
|
nachträglich erheben.
|
||||||
|
|
||||||
|
## Was hier bewusst *nicht* gebaut wird
|
||||||
|
|
||||||
|
- Kein Multi-Tenant-System. Ein Kanal.
|
||||||
|
- Kein eigener Chatbot-Ersatz für Moderation (Nightbot/StreamElements bleiben,
|
||||||
|
wo sie sind, solange sie reichen).
|
||||||
|
- Keine Web-Auth/Userverwaltung im Dashboard — es läuft auf `localhost`.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Architektur
|
||||||
|
|
||||||
|
## Bild
|
||||||
|
|
||||||
|
```
|
||||||
|
Twitch OBS
|
||||||
|
┌──────────────────┐ ┌───────────┐
|
||||||
|
│ EventSub (WS) │ │ obs- │
|
||||||
|
│ IRC / Chat │ │ websocket │
|
||||||
|
│ Helix REST │ └─────┬─────┘
|
||||||
|
└────────┬─────────┘ │
|
||||||
|
│ │
|
||||||
|
┌────────▼────────────────────────▼────────┐
|
||||||
|
│ apps/ingest (Daemon) │
|
||||||
|
│ normalisiert alles zu Events │
|
||||||
|
└────────┬─────────────────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
schreibt │ │ publiziert
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌──────────────────────┐
|
||||||
|
│ SQLite │ │ Event-Bus │
|
||||||
|
│ data/db/ │ │ (in-proc + WS 4790) │
|
||||||
|
└────────┬────────┘ └──────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
┌───────────┬───────┼───────────┬───────────┼───────────┬──────────┐
|
||||||
|
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||||
|
dashboard recap raid-scout vod-chapters community overlay- clipper
|
||||||
|
server │
|
||||||
|
│ │
|
||||||
|
overlay-web ffmpeg
|
||||||
|
(Browser- whisper
|
||||||
|
Source) │
|
||||||
|
data/clips
|
||||||
|
│
|
||||||
|
broadcaster
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prozesse
|
||||||
|
|
||||||
|
Der Verbund besteht aus wenigen langlaufenden Prozessen und mehreren
|
||||||
|
Kurzläufern, gestartet über `docker compose up -d`.
|
||||||
|
|
||||||
|
> **Im MVP gilt das noch nicht.** Dort laufen Ingest und Clipper als *ein*
|
||||||
|
> Prozess in *einem* Container; die Aufteilung unten ist der Zielzustand ab
|
||||||
|
> Meilenstein M3, wenn es mehr als einen Konsumenten gibt. Siehe
|
||||||
|
> [`07-mvp-plan.md`](07-mvp-plan.md).
|
||||||
|
|
||||||
|
**Langläufer (laufen immer, auch offline):**
|
||||||
|
|
||||||
|
| Prozess | Port | Aufgabe |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `ingest` | 4790 (WS) | EventSub, IRC, OBS-WS, Viewer-Polling → DB + Bus |
|
||||||
|
| `overlay-server` | 4791 (HTTP+WS) | liefert Overlay-Seiten, nimmt Chat-Commands entgegen |
|
||||||
|
| `dashboard` | 4792 (HTTP) | Analytics-UI |
|
||||||
|
|
||||||
|
**Kurzläufer (event- oder zeitgetriggert):**
|
||||||
|
|
||||||
|
| Prozess | Trigger |
|
||||||
|
|---------|---------|
|
||||||
|
| `clipper` | Clip-Event vom Bus, oder manuell über CLI |
|
||||||
|
| `broadcaster` | `stream.online` |
|
||||||
|
| `recap` | `stream.offline` (+ 5 min Karenz) |
|
||||||
|
| `vod-chapters` | `stream.offline` |
|
||||||
|
| `raid-scout` | manuell / Chat-Command `!raidziel` |
|
||||||
|
|
||||||
|
`community` und `loyalty` sind keine eigenen Prozesse, sondern Module, die im
|
||||||
|
`overlay-server` mitlaufen — sie brauchen dieselbe Chat-Verbindung und
|
||||||
|
denselben WS-Kanal zum Overlay.
|
||||||
|
|
||||||
|
## Event-Bus
|
||||||
|
|
||||||
|
Der Ingest normalisiert alles auf ein einheitliches Event-Format:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type StreamEvent = {
|
||||||
|
id: string; // ULID
|
||||||
|
ts: string; // ISO-8601, UTC
|
||||||
|
type: EventType; // 'chat.message' | 'stream.online' | ...
|
||||||
|
streamId: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
payload: unknown; // typisiert pro EventType
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport: In-Prozess ein `EventEmitter`, prozessübergreifend ein
|
||||||
|
WebSocket-Broadcast auf `ws://127.0.0.1:4790`. Kein Kafka, kein Redis. Wenn ein
|
||||||
|
Konsument abstürzt, holt er sich beim Neustart die verpassten Events aus der
|
||||||
|
`events`-Tabelle über `ts > lastSeen`.
|
||||||
|
|
||||||
|
Vollständige Event-Liste: siehe [`03-data-model.md`](03-data-model.md).
|
||||||
|
|
||||||
|
## Pakete
|
||||||
|
|
||||||
|
| Paket | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| `packages/shared` | Typen, Event-Definitionen, Zod-Schemas |
|
||||||
|
| `packages/core` | Config-Laden, Logger, Event-Bus, Scheduler, Retry |
|
||||||
|
| `packages/twitch` | OAuth-Token-Store, Helix-Client, EventSub-WS, IRC-Client |
|
||||||
|
| `packages/db` | SQLite-Verbindung, Migrationen, Repositories |
|
||||||
|
|
||||||
|
Regel: Apps dürfen von Paketen abhängen, Pakete nie von Apps. Apps hängen nicht
|
||||||
|
voneinander ab — Kommunikation läuft über Bus und DB.
|
||||||
|
|
||||||
|
## Technologie
|
||||||
|
|
||||||
|
- **TypeScript / Node 22**, pnpm-Workspaces — siehe
|
||||||
|
[ADR-0001](decisions/ADR-0001-monorepo-typescript.md)
|
||||||
|
- **SQLite** über `better-sqlite3` — siehe [ADR-0002](decisions/ADR-0002-sqlite.md)
|
||||||
|
- **EventSub über WebSocket**, nicht Webhooks — siehe
|
||||||
|
[ADR-0003](decisions/ADR-0003-eventsub-websocket.md)
|
||||||
|
- **ffmpeg** (CLI) für Videoverarbeitung
|
||||||
|
- **whisper.cpp** (CLI) für Untertitel — kein Python, keine GPU
|
||||||
|
- **Vite + Preact** für Dashboard und Overlay-Seiten
|
||||||
|
- **Docker Compose** für den Betrieb — siehe [ADR-0004](decisions/ADR-0004-docker.md)
|
||||||
|
|
||||||
|
## Fehlerverhalten
|
||||||
|
|
||||||
|
- Jeder Netzwerkaufruf hat Retry mit exponentiellem Backoff (`packages/core/retry`).
|
||||||
|
- Der Ingest darf niemals wegen eines Konsumentenfehlers sterben. Konsumenten
|
||||||
|
laufen in eigenen Prozessen; ein Fehler dort schreibt in `logs/` und wird im
|
||||||
|
Dashboard als Warnung angezeigt.
|
||||||
|
- Wenn EventSub die Verbindung verliert: Reconnect mit Session-Resume, danach
|
||||||
|
Lücke über Helix-Polling schließen (`stream.online`-Status, Follower-Liste).
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Twitch-Integration
|
||||||
|
|
||||||
|
## Zwei Identitäten
|
||||||
|
|
||||||
|
Es werden zwei getrennte Twitch-Apps/Tokens gebraucht:
|
||||||
|
|
||||||
|
1. **Broadcaster-Token** (Konto `abdulive_`) — für alles, was den eigenen Kanal
|
||||||
|
betrifft: Clips erstellen, Titel ändern, Raids starten, EventSub abonnieren,
|
||||||
|
Analytics lesen.
|
||||||
|
2. **Bot-Token** (eigenes Bot-Konto, z. B. `abdulive_bot`) — für Chat-Nachrichten.
|
||||||
|
Trennung, damit Bot-Nachrichten als Bot erkennbar sind und der Broadcaster-
|
||||||
|
Token nicht im Chat-Client hängt.
|
||||||
|
|
||||||
|
Beide Tokens liegen verschlüsselt in `data/db/tokens.json`, werden über Refresh
|
||||||
|
Tokens automatisch erneuert (`packages/twitch/tokenStore.ts`). Client-Secret
|
||||||
|
kommt aus `.env`, niemals aus der DB, niemals ins Repo.
|
||||||
|
|
||||||
|
## Scopes
|
||||||
|
|
||||||
|
| Scope | Wofür | Token |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `chat:read` | IRC lesen | Bot |
|
||||||
|
| `chat:edit` | IRC schreiben | Bot |
|
||||||
|
| `user:write:chat` | Chat über Helix senden (Alternative zu IRC) | Bot |
|
||||||
|
| `moderator:read:chatters` | Zuschauerliste im Chat | Broadcaster |
|
||||||
|
| `clips:edit` | Clips erstellen | Broadcaster |
|
||||||
|
| `channel:manage:broadcast` | Titel/Kategorie ändern, Marker setzen | Broadcaster |
|
||||||
|
| `channel:read:subscriptions` | Sub-Events | Broadcaster |
|
||||||
|
| `moderator:read:followers` | Follow-Events (EventSub) | Broadcaster |
|
||||||
|
| `channel:manage:raids` | Raid starten/abbrechen | Broadcaster |
|
||||||
|
| `channel:read:redemptions` | Channel-Point-Einlösungen | Broadcaster |
|
||||||
|
| `channel:manage:redemptions` | eigene Punkte-Rewards verwalten | Broadcaster |
|
||||||
|
| `channel:manage:polls` | Umfragen | Broadcaster |
|
||||||
|
| `analytics:read:games` | Game-Analytics-Report | Broadcaster |
|
||||||
|
| `bits:read` | Bits-Events | Broadcaster |
|
||||||
|
|
||||||
|
Der Autorisierungs-Flow läuft einmalig über `docker compose run --rm --service-ports toolkit auth` → lokaler Redirect auf
|
||||||
|
`http://localhost:4780/callback`.
|
||||||
|
|
||||||
|
## EventSub (WebSocket)
|
||||||
|
|
||||||
|
Abonniert werden beim Start:
|
||||||
|
|
||||||
|
| Subscription | Version | Nutzen für |
|
||||||
|
|---|---|---|
|
||||||
|
| `stream.online` | 1 | Broadcaster, Analytics, Recap |
|
||||||
|
| `stream.offline` | 1 | Recap, VOD-Kapitel |
|
||||||
|
| `channel.update` | 2 | VOD-Kapitel (Kategorie/Titel), Analytics |
|
||||||
|
| `channel.follow` | 2 | Community, Recap |
|
||||||
|
| `channel.subscribe` / `.message` / `.gift` | 1 | Recap, Overlay |
|
||||||
|
| `channel.cheer` | 1 | Recap, Overlay |
|
||||||
|
| `channel.raid` (to + from) | 1 | Raid-Tool |
|
||||||
|
| `channel.channel_points_custom_reward_redemption.add` | 1 | Loyalty, Overlay |
|
||||||
|
| `channel.poll.begin/progress/end` | 1 | Overlay |
|
||||||
|
| `channel.chat.message` | 1 | **optional**, siehe unten |
|
||||||
|
| `channel.chat.notification` | 1 | Community (Erstzuschauer-Marker) |
|
||||||
|
|
||||||
|
**Chat: IRC oder EventSub?** Beides funktioniert. Wir lesen den Chat über
|
||||||
|
**IRC (`irc-ws.chat.twitch.tv:443`)**, weil die Tags dort (`first-msg`,
|
||||||
|
`returning-chatter`, `emotes`, `badges`, `msg-id`) reichhaltiger und stabiler
|
||||||
|
sind als im EventSub-Payload, und weil Rate Limits beim Senden dort klar
|
||||||
|
dokumentiert sind. EventSub übernimmt alles Nicht-Chat.
|
||||||
|
|
||||||
|
**Verbindungsregeln:** Eine WS-Session hält max. 300 Subscriptions. Twitch
|
||||||
|
sendet alle ~10 s ein Keepalive; bleibt es aus, neu verbinden.
|
||||||
|
`session_reconnect` liefert eine neue URL — dort verbinden, *dann* erst die
|
||||||
|
alte Verbindung schließen. Nach jedem Reconnect: Lücke per Helix-Polling füllen.
|
||||||
|
|
||||||
|
## Helix — die relevanten Endpunkte
|
||||||
|
|
||||||
|
| Endpunkt | Tool |
|
||||||
|
|---|---|
|
||||||
|
| `GET /helix/streams?user_id=` | Ingest (Viewer-Sampling, alle 60 s) |
|
||||||
|
| `GET /helix/streams?game_id=&first=100` | Raid-Scout |
|
||||||
|
| `GET /helix/channels` / `PATCH /helix/channels` | Loyalty (Titel-Shop), VOD-Kapitel |
|
||||||
|
| `POST /helix/clips` | Clipper |
|
||||||
|
| `GET /helix/clips?id=` | Clipper (Metadaten + Thumbnail-URL) |
|
||||||
|
| `GET /helix/videos?user_id=&type=archive` | VOD-Kapitel |
|
||||||
|
| `POST /helix/streams/markers` | VOD-Kapitel, Clipper |
|
||||||
|
| `GET /helix/chat/chatters` | Community (Anwesenheit, alle 5 min) |
|
||||||
|
| `GET /helix/channels/followers` | Recap, Community |
|
||||||
|
| `POST /helix/raids` | Raid-Tool |
|
||||||
|
| `GET /helix/users?login=` | überall (Login → ID) |
|
||||||
|
|
||||||
|
**Rate Limits:** 800 Punkte/Minute pro Client-ID (Standard: 1 Punkt pro Request).
|
||||||
|
Der Helix-Client in `packages/twitch` führt einen Token-Bucket und drosselt
|
||||||
|
selbst; `Ratelimit-Remaining` aus dem Response-Header wird ausgewertet.
|
||||||
|
Bei `429` → Backoff nach `Ratelimit-Reset`.
|
||||||
|
|
||||||
|
## Clips: die Fallstricke
|
||||||
|
|
||||||
|
Das ist der Teil, an dem die Clip-Pipeline scheitert, wenn man ihn nicht kennt:
|
||||||
|
|
||||||
|
1. `POST /helix/clips` schneidet **rückwirkend ~30 s** ab dem Aufrufzeitpunkt
|
||||||
|
(minus Stream-Delay). Man kann keinen Zeitbereich wählen.
|
||||||
|
2. Der Clip ist **nicht sofort verfügbar**. Nach dem POST bekommt man `id` und
|
||||||
|
`edit_url`; die Verarbeitung dauert typischerweise 5–20 s. Danach liefert
|
||||||
|
`GET /helix/clips?id=` erst Metadaten.
|
||||||
|
3. Die MP4-URL steht nirgends in der API. Sie wird aus `thumbnail_url`
|
||||||
|
abgeleitet: alles ab `-preview-` abschneiden, `.mp4` anhängen.
|
||||||
|
```
|
||||||
|
https://clips-media-assets2.twitch.tv/AT-cm%7C123-preview-480x272.jpg
|
||||||
|
→ https://clips-media-assets2.twitch.tv/AT-cm%7C123.mp4
|
||||||
|
```
|
||||||
|
Das ist undokumentiert und kann sich ändern — deshalb im Code an genau einer
|
||||||
|
Stelle (`packages/twitch/clips.ts`) gekapselt und mit Fallback versehen.
|
||||||
|
4. Der Clip enthält das **fertig komposite Bild** (1920×1080), nicht die
|
||||||
|
Einzelquellen. Facecam und Gameplay lassen sich nur per Crop trennen, und nur
|
||||||
|
wenn das OBS-Layout fest ist.
|
||||||
|
5. Bitrate/Qualität entspricht dem Transcode, nicht dem Original.
|
||||||
|
|
||||||
|
**Deshalb: OBS-Replay-Buffer ist der bessere Primärweg.** Details in
|
||||||
|
[`docs/projects/clip-shorts.md`](projects/clip-shorts.md).
|
||||||
|
|
||||||
|
## OBS-WebSocket
|
||||||
|
|
||||||
|
obs-websocket v5 auf `ws://127.0.0.1:4455`. Genutzt für:
|
||||||
|
|
||||||
|
- `CurrentProgramSceneChanged` → VOD-Kapitel
|
||||||
|
- `SaveReplayBuffer` → Clipper
|
||||||
|
- `TriggerHotkeyByName`, `SetInputSettings` → Chat-Steuerung, Loyalty-Shop
|
||||||
|
- `GetSourceScreenshot` → Go-Live-Broadcaster (Screenshot für den Post)
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Datenmodell
|
||||||
|
|
||||||
|
SQLite, Datei `data/db/abdulive.db`. WAL-Modus, damit mehrere Prozesse
|
||||||
|
gleichzeitig lesen können, während der Ingest schreibt. Migrationen als
|
||||||
|
nummerierte SQL-Dateien in `packages/db/migrations/`.
|
||||||
|
|
||||||
|
## Kerntabellen
|
||||||
|
|
||||||
|
### `events` — der Rohstrom
|
||||||
|
Append-only. Alles landet hier zuerst. Alle Auswertungen sind ableitbar.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE events (
|
||||||
|
id TEXT PRIMARY KEY, -- ULID
|
||||||
|
ts TEXT NOT NULL, -- ISO-8601 UTC
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
stream_id TEXT, -- FK streams.id, NULL wenn offline
|
||||||
|
user_id TEXT, -- Twitch user id
|
||||||
|
payload TEXT NOT NULL -- JSON
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_events_ts ON events(ts);
|
||||||
|
CREATE INDEX idx_events_type ON events(type, ts);
|
||||||
|
CREATE INDEX idx_events_strm ON events(stream_id, ts);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `streams` — eine Zeile pro Sendung
|
||||||
|
```sql
|
||||||
|
CREATE TABLE streams (
|
||||||
|
id TEXT PRIMARY KEY, -- Twitch stream id
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
vod_id TEXT,
|
||||||
|
peak_viewers INTEGER DEFAULT 0,
|
||||||
|
avg_viewers REAL,
|
||||||
|
unique_chatters INTEGER DEFAULT 0,
|
||||||
|
new_followers INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `stream_segments` — Titel/Kategorie über die Zeit
|
||||||
|
Basis für "welcher Titel hat was gebracht" und für VOD-Kapitel.
|
||||||
|
```sql
|
||||||
|
CREATE TABLE stream_segments (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
stream_id TEXT NOT NULL REFERENCES streams(id),
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
title TEXT,
|
||||||
|
category_id TEXT,
|
||||||
|
category TEXT,
|
||||||
|
obs_scene TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `viewer_samples` — Viewerzahl im Zeitverlauf
|
||||||
|
Alle 60 s ein Sample aus `GET /helix/streams`.
|
||||||
|
```sql
|
||||||
|
CREATE TABLE viewer_samples (
|
||||||
|
stream_id TEXT NOT NULL REFERENCES streams(id),
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
viewers INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (stream_id, ts)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `chat_messages` — jede Nachricht
|
||||||
|
```sql
|
||||||
|
CREATE TABLE chat_messages (
|
||||||
|
id TEXT PRIMARY KEY, -- msg-id aus IRC
|
||||||
|
stream_id TEXT REFERENCES streams(id),
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
emotes TEXT, -- JSON: [{id,name,count}]
|
||||||
|
is_first INTEGER DEFAULT 0, -- first-msg Tag
|
||||||
|
is_command INTEGER DEFAULT 0,
|
||||||
|
bits INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, ts);
|
||||||
|
CREATE INDEX idx_chat_user ON chat_messages(user_id, ts);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `users` — das Community-Gedächtnis
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY, -- Twitch user id (stabil, nicht der Login!)
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
streams_present INTEGER DEFAULT 0, -- an wie vielen Streams anwesend
|
||||||
|
streams_chatted INTEGER DEFAULT 0, -- an wie vielen geschrieben
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
is_follower INTEGER DEFAULT 0,
|
||||||
|
followed_at TEXT,
|
||||||
|
is_sub INTEGER DEFAULT 0,
|
||||||
|
sub_tier TEXT,
|
||||||
|
points INTEGER DEFAULT 0,
|
||||||
|
watch_minutes INTEGER DEFAULT 0,
|
||||||
|
greeting TEXT, -- individuelle Begrüßung
|
||||||
|
notes TEXT, -- freie Notizen des Streamers
|
||||||
|
pronouns TEXT,
|
||||||
|
muted_greeting INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
```
|
||||||
|
> `login` ändert sich bei Namensänderung, `id` nie. Überall wird über `id`
|
||||||
|
> verknüpft, `login`/`display_name` bei jedem Kontakt aktualisiert.
|
||||||
|
|
||||||
|
### `user_streams` — Anwesenheit pro Stream
|
||||||
|
Grundlage für "Nummer 15 für dich" und für neue-vs.-wiederkehrende Chatter.
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_streams (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
stream_id TEXT NOT NULL REFERENCES streams(id),
|
||||||
|
first_ts TEXT NOT NULL,
|
||||||
|
last_ts TEXT NOT NULL,
|
||||||
|
messages INTEGER DEFAULT 0,
|
||||||
|
minutes INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, stream_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool-Tabellen
|
||||||
|
|
||||||
|
### `clips`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE clips (
|
||||||
|
id TEXT PRIMARY KEY, -- interne ULID
|
||||||
|
twitch_clip_id TEXT,
|
||||||
|
stream_id TEXT REFERENCES streams(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
trigger TEXT NOT NULL, -- 'spike'|'command'|'manual'|'hotkey'
|
||||||
|
trigger_score REAL,
|
||||||
|
source TEXT NOT NULL, -- 'twitch'|'obs-replay'
|
||||||
|
raw_path TEXT,
|
||||||
|
vertical_path TEXT,
|
||||||
|
subtitle_path TEXT,
|
||||||
|
duration_s REAL,
|
||||||
|
title TEXT,
|
||||||
|
transcript TEXT,
|
||||||
|
status TEXT NOT NULL, -- 'pending'|'rendered'|'approved'|'uploaded'|'rejected'
|
||||||
|
score REAL, -- Ranking für Upload-Kandidaten
|
||||||
|
uploaded_to TEXT -- JSON: {"tiktok": "...", "yt": "..."}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `raids`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE raids (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
direction TEXT NOT NULL, -- 'in'|'out'
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
channel_login TEXT NOT NULL,
|
||||||
|
viewers INTEGER,
|
||||||
|
category TEXT,
|
||||||
|
reciprocated INTEGER DEFAULT 0, -- hat der zurückgeraidet?
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `raid_candidates` — Scout-Ergebnisse
|
||||||
|
```sql
|
||||||
|
CREATE TABLE raid_candidates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
scanned_at TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
channel_login TEXT NOT NULL,
|
||||||
|
viewers INTEGER NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
language TEXT,
|
||||||
|
title TEXT,
|
||||||
|
uptime_min INTEGER,
|
||||||
|
score REAL,
|
||||||
|
history_raids INTEGER DEFAULT 0 -- schon mal Kontakt gehabt?
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `shop_items` / `redemptions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE shop_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
cost INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- 'set_title'|'obs_hotkey'|'sound'|'emote_rain'|'challenge'
|
||||||
|
params TEXT, -- JSON
|
||||||
|
cooldown_s INTEGER DEFAULT 0,
|
||||||
|
per_stream_limit INTEGER,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
requires_approval INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE redemptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
stream_id TEXT REFERENCES streams(id),
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
item_id TEXT NOT NULL REFERENCES shop_items(id),
|
||||||
|
cost INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- 'pending'|'executed'|'refunded'
|
||||||
|
result TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `posts` — ausgehende Social-Posts
|
||||||
|
```sql
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- 'golive'|'recap'|'clip'
|
||||||
|
platform TEXT NOT NULL, -- 'discord'|'bluesky'|'instagram'
|
||||||
|
status TEXT NOT NULL, -- 'draft'|'sent'|'failed'
|
||||||
|
body TEXT,
|
||||||
|
media_path TEXT,
|
||||||
|
remote_url TEXT,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event-Typen
|
||||||
|
|
||||||
|
```
|
||||||
|
stream.online stream.offline stream.segment_changed
|
||||||
|
chat.message chat.first_message chat.cleared
|
||||||
|
chat.spike ← vom Ingest berechnet
|
||||||
|
user.arrived user.returning
|
||||||
|
follow subscribe subgift cheer
|
||||||
|
raid.in raid.out
|
||||||
|
points.redeemed shop.redeemed
|
||||||
|
poll.begin poll.end
|
||||||
|
obs.scene_changed obs.replay_saved
|
||||||
|
clip.requested clip.created clip.rendered
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aufbewahrung
|
||||||
|
|
||||||
|
Nichts wird gelöscht. Die DB wächst bei ~10 Zuschauern um wenige MB pro Monat.
|
||||||
|
Nächtliches Backup nach `data/db/backup/` per `scripts/backup.ps1`
|
||||||
|
(SQLite `VACUUM INTO`, 30 Generationen).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Begründung der Reihenfolge: [`06-hebel-analyse.md`](06-hebel-analyse.md).
|
||||||
|
|
||||||
|
## M1+M2 — MVP „Clip-Knopf" (Woche 1–3)
|
||||||
|
|
||||||
|
Zusammengefasst und detailliert ausgeplant in
|
||||||
|
**[`07-mvp-plan.md`](07-mvp-plan.md)** — sieben Scheiben von der Container-
|
||||||
|
Toolchain bis zur Sichtungsseite.
|
||||||
|
|
||||||
|
Kurz: Container mit ffmpeg und whisper.cpp, Twitch-Auth, minimale DB, IRC-Ingest,
|
||||||
|
OBS-Replay-Trigger über `!clip` und Hotkey, 9:16-Render, eingebrannte
|
||||||
|
Untertitel, statische Sichtungsseite. EventSub, Event-Bus und Dashboard-
|
||||||
|
Framework sind bewusst nicht dabei.
|
||||||
|
|
||||||
|
**Fertig, wenn:** nach einem Stream fünf fertige Shorts zur Auswahl bereitliegen
|
||||||
|
und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
|
||||||
|
|
||||||
|
## M3 — Community und Ankündigung (Woche 4)
|
||||||
|
|
||||||
|
- [ ] `apps/overlay-server` mit WS-Kanal und Reconnect-Client
|
||||||
|
- [ ] Community-Modul: Begrüßungslogik, Cooldowns, `!notiz`
|
||||||
|
- [ ] Overlay-Seite `/overlay/community` (Chatter-Hinweis für Abdu)
|
||||||
|
- [ ] `apps/broadcaster`: Discord + Bluesky, Story-Bild-Generator
|
||||||
|
- [ ] Draft-Modus, Kill-Switch, Doppelpost-Schutz
|
||||||
|
|
||||||
|
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
|
||||||
|
automatisch angekündigt.
|
||||||
|
|
||||||
|
## M4 — Auswertung (Woche 5–6)
|
||||||
|
|
||||||
|
- [ ] `apps/dashboard`: Stream-Zeitachse, Abgangs-Analyse
|
||||||
|
- [ ] Titel-/Kategorie-Performance mit Stichprobenhinweis
|
||||||
|
- [ ] Kohortenansicht neu vs. wiederkehrend
|
||||||
|
- [ ] Betriebsstatus-Seite
|
||||||
|
- [ ] Spike-Detektor an echten Daten kalibrieren und scharf schalten
|
||||||
|
|
||||||
|
## M5 — Netzwerk (Woche 7)
|
||||||
|
|
||||||
|
- [ ] `apps/raid-scout`: Kandidatensuche, Scoring, `!raidziel`
|
||||||
|
- [ ] Raid-Historie und Erwiderungs-Erinnerung
|
||||||
|
- [ ] Overlay-Panel für das Streamende
|
||||||
|
|
||||||
|
## M6 — Recap (Woche 8)
|
||||||
|
|
||||||
|
- [ ] `apps/recap`: Discord-Post nach Streamende
|
||||||
|
- [ ] Clip-Einbettung, Erstchatter-Begrüßung, Wochenvergleich
|
||||||
|
|
||||||
|
## Später
|
||||||
|
|
||||||
|
- [ ] Chat-Steuerung: Soundboard, Effekte, WLED, Umfragen mit Konsequenz
|
||||||
|
- [ ] Punktesystem und Shop (sinnvoll ab ~30 CCV)
|
||||||
|
- [ ] VOD-Kapitel (sinnvoll ab YouTube-Zweitverwertung)
|
||||||
|
- [ ] Automatischer Upload der Shorts (TikTok/YouTube-API)
|
||||||
|
- [ ] Minispiele
|
||||||
|
|
||||||
|
## Nicht geplant
|
||||||
|
|
||||||
|
- Cloud-Deployment
|
||||||
|
- Mehrkanal-Unterstützung
|
||||||
|
- Ersatz für Moderationsbots
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Setup
|
||||||
|
|
||||||
|
Alles läuft in Containern. Auf dem Rechner selbst wird nur Docker Desktop und
|
||||||
|
OBS gebraucht — ffmpeg, whisper.cpp, Node und pnpm stecken im Image.
|
||||||
|
Begründung: [ADR-0004](decisions/ADR-0004-docker.md).
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| Was | Version | Wo |
|
||||||
|
|---|---|---|
|
||||||
|
| Docker Desktop | aktuell, WSL2-Backend | Host |
|
||||||
|
| OBS Studio | 30+ mit obs-websocket v5 | Host |
|
||||||
|
| Git | beliebig | Host |
|
||||||
|
|
||||||
|
Alles Weitere (Node 22, pnpm, ffmpeg mit libx264/libass, whisper.cpp,
|
||||||
|
Sprachmodell) liefert das Image.
|
||||||
|
|
||||||
|
## WSL2 begrenzen
|
||||||
|
|
||||||
|
Docker Desktop startet eine VM, die sich ohne Grenze so viel RAM nimmt, wie sie
|
||||||
|
kriegen kann. Auf einem Stream-PC ist das nicht egal. `%USERPROFILE%\.wslconfig`
|
||||||
|
anlegen:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[wsl2]
|
||||||
|
memory=4GB
|
||||||
|
processors=4
|
||||||
|
swap=2GB
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `wsl --shutdown` und Docker Desktop neu starten.
|
||||||
|
|
||||||
|
## Twitch-Apps anlegen
|
||||||
|
|
||||||
|
Zwei Anwendungen unter https://dev.twitch.tv/console/apps:
|
||||||
|
|
||||||
|
1. `abdulive-toolkit` — OAuth Redirect `http://localhost:4780/callback`
|
||||||
|
2. `abdulive-bot` — gleicher Redirect
|
||||||
|
|
||||||
|
Client-ID und Client-Secret beider Apps in `.env` eintragen
|
||||||
|
(Vorlage: `.env.example`).
|
||||||
|
|
||||||
|
## OBS vorbereiten
|
||||||
|
|
||||||
|
1. **obs-websocket aktivieren:** Werkzeuge → WebSocket-Servereinstellungen →
|
||||||
|
Server aktivieren, Port 4455, Passwort setzen und in `.env` eintragen.
|
||||||
|
2. **Wiedergabepuffer aktivieren:** Einstellungen → Ausgabe → Wiedergabepuffer,
|
||||||
|
90 Sekunden. Den Zielordner merken und in `.env` als `OBS_REPLAY_DIR`
|
||||||
|
eintragen — mit Forward-Slashes, z. B. `C:/Users/abdu/Videos/replay`.
|
||||||
|
3. **Hotkey setzen:** Einstellungen → Hotkeys → „Wiedergabepuffer speichern".
|
||||||
|
Das ist der Clip-Knopf. Ein globaler Windows-Hotkey wird bewusst *nicht*
|
||||||
|
gebaut — aus einem Container ist er nicht abfangbar, und OBS meldet den
|
||||||
|
gespeicherten Puffer ohnehin über WebSocket.
|
||||||
|
4. **Browser-Sources** (erst ab Meilenstein M3 relevant): „Browser-Quelle beim
|
||||||
|
Szenenwechsel neu laden" **aus**, sonst brechen laufende Effekte ab.
|
||||||
|
|
||||||
|
## Erste Einrichtung
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item .env.example .env # Secrets und OBS_REPLAY_DIR eintragen
|
||||||
|
Copy-Item config\config.example.yaml config\config.yaml
|
||||||
|
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm toolkit check-env # prueft ffmpeg, whisper, Modell, Volumes
|
||||||
|
docker compose run --rm --service-ports toolkit auth # Browser oeffnet sich, zweimal bestaetigen
|
||||||
|
docker compose run --rm toolkit db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Der `auth`-Lauf braucht `--service-ports`, damit Port 4780 für den
|
||||||
|
OAuth-Rücksprung erreichbar ist.
|
||||||
|
|
||||||
|
## Facecam einmessen
|
||||||
|
|
||||||
|
Einmalig, sobald eine Replay-Datei vorliegt:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose run --rm toolkit calibrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Legt einen Frame als PNG in `data/clips/_calibration/` ab, fragt die vier
|
||||||
|
Crop-Werte ab und schreibt sie nach `config/layout.yaml`.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d # startet
|
||||||
|
docker compose logs -f # Logs
|
||||||
|
docker compose down # stoppt
|
||||||
|
```
|
||||||
|
|
||||||
|
Sichtungsseite: http://localhost:4792
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\backup.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopiert die Datenbank per `VACUUM INTO` aus dem Volume `abdulive-db` nach
|
||||||
|
`data/db/backup/` und hält 30 Generationen. Die DB liegt bewusst in einem
|
||||||
|
Docker-Volume und nicht in einem Windows-Ordner — Dateisperren über die
|
||||||
|
WSL2-Brücke sind für SQLite nicht verlässlich.
|
||||||
|
|
||||||
|
## Was nicht ins Repo gehört
|
||||||
|
|
||||||
|
`.env`, `config/config.yaml`, alles unter `data/`. Steht in `.gitignore`.
|
||||||
|
Die Datenbank enthält personenbezogene Daten (Chatverläufe, Notizen zu
|
||||||
|
Zuschauern) und darf nicht committet oder geteilt werden.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Hebel-Analyse: womit anfangen
|
||||||
|
|
||||||
|
## Bewertung aller neun Ideen
|
||||||
|
|
||||||
|
Bewertet nach vier Achsen. **Wirkung** = erwarteter Effekt auf Kanalwachstum in
|
||||||
|
den nächsten 3 Monaten. **Aufwand** = Entwicklungszeit bis zu einer Version, die
|
||||||
|
im echten Stream läuft. **Risiko** = Wahrscheinlichkeit, dass es technisch oder
|
||||||
|
sozial nicht funktioniert. **Abhängig von** = was vorher stehen muss.
|
||||||
|
|
||||||
|
| Tool | Wirkung | Aufwand | Risiko | Abhängig von | Hebel |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **Ingest-Daemon** | — (indirekt) | 2–3 Tage | niedrig | — | **Voraussetzung** |
|
||||||
|
| **Clip-to-Shorts** | sehr hoch | 8–12 Tage | mittel | Ingest, OBS | **★★★★★** |
|
||||||
|
| **Community-Gedächtnis** | hoch | 2–3 Tage | niedrig | Ingest | **★★★★★** |
|
||||||
|
| **Go-Live-Broadcaster** | mittel | 1–2 Tage | niedrig | Ingest | ★★★★☆ |
|
||||||
|
| **Analytics-Dashboard** | mittel (indirekt) | 4–6 Tage | niedrig | Ingest | ★★★☆☆ |
|
||||||
|
| **Raid-/Netzwerk-Tool** | mittel–hoch | 3–4 Tage | niedrig | Ingest | ★★★☆☆ |
|
||||||
|
| **Stream-Recap** | mittel | 2 Tage | niedrig | Ingest, Analytics | ★★★☆☆ |
|
||||||
|
| **Chat steuert Dinge** | mittel | 5–8 Tage | mittel | Overlay-Server | ★★☆☆☆ |
|
||||||
|
| **Punktesystem + Shop** | niedrig–mittel | 4–6 Tage | mittel | Overlay, Loyalty | ★★☆☆☆ |
|
||||||
|
| **VOD-Kapitel** | niedrig | 2 Tage | niedrig | Ingest | ★☆☆☆☆ |
|
||||||
|
|
||||||
|
## Die Antwort
|
||||||
|
|
||||||
|
**Der größte Hebel ist die Clip-to-Shorts-Pipeline.** Deine Einschätzung stimmt:
|
||||||
|
Bei 5–30 Zuschauern ist Twitchs eigene Entdeckung praktisch tot, weil die
|
||||||
|
Browse-Seite nach Viewerzahl sortiert. Off-Platform-Kurzvideo ist der einzige
|
||||||
|
Kanal, in dem ein kleiner Account nichtlineare Reichweite bekommen kann — ein
|
||||||
|
Short kann 50.000 Leute erreichen, während der Stream 11 hat. Kein anderes Tool
|
||||||
|
in dieser Liste hat diese Größenordnung.
|
||||||
|
|
||||||
|
**Aber du kannst nicht mit ihr anfangen.** Sie braucht einen Trigger, und der
|
||||||
|
Trigger braucht eine Chat-Verbindung, Stream-Session-Erkennung und einen
|
||||||
|
Event-Bus. Das ist der Ingest-Daemon — 2–3 Tage, die ohnehin zur Clip-Pipeline
|
||||||
|
gehören.
|
||||||
|
|
||||||
|
Und genau das macht die Reihenfolge einfach: **Wenn der Ingest steht, kostet das
|
||||||
|
Community-Gedächtnis noch zwei Tage extra**, weil es fast nur Auswertung
|
||||||
|
vorhandener Daten ist. Damit hast du in einem Monat beide Seiten abgedeckt:
|
||||||
|
|
||||||
|
- **Clips bringen Leute rein** (Reichweite)
|
||||||
|
- **Community-Gedächtnis sorgt dafür, dass sie bleiben** (Bindung)
|
||||||
|
|
||||||
|
Das eine ohne das andere verpufft. Ein viraler Clip, der 300 Leute in einen
|
||||||
|
Stream spült, in dem niemand ihre Namen kennt, bringt am nächsten Tag null
|
||||||
|
Rückkehrer. Umgekehrt bindet die beste Community-Pflege niemanden, der nie
|
||||||
|
ankommt.
|
||||||
|
|
||||||
|
## Zwei Dinge, die du an der Clip-Pipeline anders bauen solltest, als es
|
||||||
|
## zunächst naheliegt
|
||||||
|
|
||||||
|
**1. Nicht auf Chat-Spikes triggern — jedenfalls nicht zuerst.**
|
||||||
|
Bei 10 Zuschauern ist die Nachrichtenrate so niedrig, dass "Spike" statistisch
|
||||||
|
kaum von Rauschen zu trennen ist. Drei Leute, die gleichzeitig "lol" schreiben,
|
||||||
|
sind bereits eine Verdreifachung. Der Detektor läuft von Anfang an mit und
|
||||||
|
schreibt jeden Kandidaten mit Score in die DB — aber **scharf geschaltet wird er
|
||||||
|
erst, wenn du an echtem Material siehst, wo die Schwelle liegen muss.** Als
|
||||||
|
Trigger reichen anfangs `!clip` im Chat und ein Hotkey für dich. Der Hotkey ist
|
||||||
|
ohnehin das beste Signal, das es gibt: Du weißt am besten, wann gerade etwas
|
||||||
|
Gutes passiert ist.
|
||||||
|
|
||||||
|
**2. Nicht die Twitch-Clip-API als Videoquelle nehmen, sondern den
|
||||||
|
OBS-Replay-Buffer.** Der Twitch-Clip ist ein Transcode des fertig komponierten
|
||||||
|
1920×1080-Bildes — Facecam und Gameplay musst du per Crop wieder auseinander
|
||||||
|
schneiden, und die Qualität ist schon einmal durch einen Encoder gelaufen. Der
|
||||||
|
Replay-Buffer liefert dir dieselben 30–90 Sekunden in Aufnahmequalität, sofort,
|
||||||
|
ohne API-Wartezeit. Und wenn du in OBS eine zweite Aufnahmespur oder eine
|
||||||
|
Vertical-Szene einrichtest, sind Cam und Gameplay dort bereits getrennt — dann
|
||||||
|
entfällt das ganze Crop-Problem. Den Twitch-Clip erzeugst du trotzdem
|
||||||
|
gleichzeitig, weil er eine teilbare URL hat.
|
||||||
|
|
||||||
|
Beides steht ausführlich in [`projects/clip-shorts.md`](projects/clip-shorts.md).
|
||||||
|
|
||||||
|
## Was ich in dieser Reihenfolge nach hinten schieben würde
|
||||||
|
|
||||||
|
**Punktesystem und Chat-Minispiele.** Beides ist Spaß und beides macht viel
|
||||||
|
Arbeit, aber es wirkt nur auf Leute, die schon da sind — und bei 10 Zuschauern
|
||||||
|
sind das zu wenige, als dass ein Shop-Ökosystem trägt. Punkte werden erst
|
||||||
|
interessant, wenn genug Leute regelmäßig da sind, dass sie überhaupt genug
|
||||||
|
sammeln können. Das ist ein Werkzeug für 30–50 CCV, nicht für 10.
|
||||||
|
|
||||||
|
**VOD-Kapitel.** Nettes Detail, aber die VODs eines kleinen Kanals werden kaum
|
||||||
|
geschaut. Der Aufwand ist klein, deshalb kommt es trotzdem irgendwann — aber
|
||||||
|
nicht in den ersten zwei Monaten. Sinnvoll wird es in dem Moment, in dem du
|
||||||
|
anfängst, VODs auf YouTube zweitzuverwerten.
|
||||||
|
|
||||||
|
## Konkreter Vorschlag für die ersten vier Wochen
|
||||||
|
|
||||||
|
| Woche | Was | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| **1** | Ingest-Daemon + DB-Schema + Auth | Jede Nachricht, jeder Viewer-Sample landet in der DB. Ab hier sammelst du Daten, auch wenn noch nichts damit passiert. |
|
||||||
|
| **2** | Clip-Pipeline Teil 1: `!clip` + Hotkey → OBS-Replay → `raw.mp4` | Auf Knopfdruck liegt ein Clip auf der Platte. |
|
||||||
|
| **3** | Clip-Pipeline Teil 2: 9:16-Crop + Whisper-Untertitel + Freigabe-Ansicht | Fertige Upload-Kandidaten nach jedem Stream. |
|
||||||
|
| **4** | Community-Gedächtnis + Go-Live-Broadcaster | Erstchatter werden erkannt, du siehst im Overlay, wer reinkommt, und der Stream-Start wird automatisch angekündigt. |
|
||||||
|
|
||||||
|
Danach, nach Bedarf: Analytics-Dashboard (Woche 5–6, wertet dann bereits vier
|
||||||
|
Wochen echte Daten aus — deshalb lohnt es sich, den Ingest jetzt zu bauen und
|
||||||
|
das Dashboard später), dann Raid-Tool, dann Recap.
|
||||||
|
|
||||||
|
**Der wichtigste Satz in diesem Dokument:** Der Ingest-Daemon in Woche 1 sammelt
|
||||||
|
Daten, die du später nicht rückwirkend erheben kannst. Selbst wenn danach zwei
|
||||||
|
Monate nichts passiert, ist er die beste Investition, weil jedes spätere Tool
|
||||||
|
dann sofort auf einem Berg echter Daten aufsetzt statt bei null anzufangen.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# MVP-Paket: „Clip-Knopf"
|
||||||
|
|
||||||
|
**Ziel in einem Satz:** Nach jedem Stream liegen fertige 9:16-Videos mit
|
||||||
|
eingebrannten Untertiteln zur Auswahl bereit — Abdu klickt zwei davon an und
|
||||||
|
lädt sie hoch.
|
||||||
|
|
||||||
|
Begründung der Auswahl: [`06-hebel-analyse.md`](06-hebel-analyse.md).
|
||||||
|
Detailspezifikation: [`projects/clip-shorts.md`](projects/clip-shorts.md).
|
||||||
|
|
||||||
|
## Warum genau dieser Zuschnitt
|
||||||
|
|
||||||
|
Der spürbare Effekt entsteht nicht dadurch, dass ein Clip *entsteht* — das kann
|
||||||
|
Twitch selbst. Er entsteht dadurch, dass die Strecke von „das war lustig" bis
|
||||||
|
„fertiges Short liegt bereit" von 30 Minuten Handarbeit auf 0 Minuten fällt.
|
||||||
|
Genau diese Strecke ist der MVP. Alles, was nicht auf dieser Strecke liegt,
|
||||||
|
fliegt raus.
|
||||||
|
|
||||||
|
Das MVP ist bewusst **ein einziger Prozess in einem einzigen Container**. Die in
|
||||||
|
[`01-architecture.md`](01-architecture.md) beschriebene Aufteilung in Ingest,
|
||||||
|
Overlay-Server und Konsumenten kommt erst, wenn es einen zweiten Konsumenten
|
||||||
|
gibt. Vorher ist sie nur Ballast.
|
||||||
|
|
||||||
|
## Rahmenbedingungen (geklärt)
|
||||||
|
|
||||||
|
| Punkt | Stand |
|
||||||
|
|---|---|
|
||||||
|
| Zielmaschine | Abdus Stream-PC, dort wo OBS läuft — alles lokal |
|
||||||
|
| OBS-Layout | Facecam bündig oben links, 440×330 — **eingemessen**, siehe unten |
|
||||||
|
| GPU | keine NVIDIA-Karte → CPU-Encoding mit libx264 |
|
||||||
|
| Untertitel | whisper.cpp (Binary, kein Python) statt faster-whisper |
|
||||||
|
| Betrieb | Docker Compose, siehe [ADR-0004](decisions/ADR-0004-docker.md) |
|
||||||
|
|
||||||
|
## Fortschritt
|
||||||
|
|
||||||
|
| Scheibe | Stand |
|
||||||
|
|---|---|
|
||||||
|
| S0 Container + Toolchain | in Arbeit |
|
||||||
|
| Layout eingemessen | **fertig** — `config/layout.yaml`, vorgezogen aus S3 |
|
||||||
|
| S1–S6 | offen |
|
||||||
|
|
||||||
|
Das Einmessen der Facecam war eigentlich Teil von S3, ließ sich aber aus dem
|
||||||
|
gelieferten Screenshot sofort erledigen. Dabei kamen zwei Dinge heraus, die das
|
||||||
|
geplante Layout verändert haben — **der Chat ist ins Streambild eingebrannt**
|
||||||
|
und **die Facecam ist 4:3, nicht 16:9**. Beides ist in
|
||||||
|
[`projects/clip-shorts.md`](projects/clip-shorts.md) dokumentiert und in
|
||||||
|
`config/layout.yaml` aufgelöst.
|
||||||
|
|
||||||
|
## Umfang
|
||||||
|
|
||||||
|
### Drin
|
||||||
|
|
||||||
|
- Trigger: `!clip` im Chat **und** OBS-Hotkey (über `ReplayBufferSaved`)
|
||||||
|
- Quelle: OBS-Replay-Buffer, parallel dazu ein Twitch-Clip für die teilbare URL
|
||||||
|
- Render: 1080×1920, Facecam oben, Gameplay unten, fester Crop aus Config
|
||||||
|
- Untertitel: whisper.cpp deutsch → SRT → ASS mit wortweisem Highlight → eingebrannt
|
||||||
|
- Ablage: `data/clips/<datum>/<clip-id>/` mit `final.mp4`, Transkript, Metadaten
|
||||||
|
- Sichtung: eine statische HTML-Seite mit Vorschau, Score und zwei Knöpfen
|
||||||
|
- Nebenbei: Chat, Zuschauerzahlen und Nutzer werden ab Tag 1 in die DB
|
||||||
|
geschrieben, auch wenn im MVP noch nichts damit passiert
|
||||||
|
|
||||||
|
### Bewusst draußen
|
||||||
|
|
||||||
|
| Nicht im MVP | Warum |
|
||||||
|
|---|---|
|
||||||
|
| EventSub | Für den Clip-Weg reicht IRC plus Helix-Polling. Spart einen ganzen Verbindungstyp mit Reconnect-Logik. |
|
||||||
|
| Event-Bus über WebSocket | Es gibt nur einen Prozess. Funktionsaufrufe genügen. |
|
||||||
|
| Dashboard mit Framework | Eine statische HTML-Seite reicht für zehn Videos. |
|
||||||
|
| Spike-Trigger scharf | Läuft mit und protokolliert, löst aber nichts aus — Schwelle wird erst an echten Daten kalibriert. |
|
||||||
|
| Automatischer Upload | Die Auswahl ist ohnehin manuell. Kein Gewinn, viel API-Aufwand. |
|
||||||
|
| Overlay, Punkte, Recap, Raid | Eigene Meilensteine. |
|
||||||
|
|
||||||
|
## Die sieben Scheiben
|
||||||
|
|
||||||
|
Jede Scheibe ist einzeln lauffähig und einzeln überprüfbar. Am Ende jeder
|
||||||
|
Scheibe gibt es etwas, das man anschauen kann.
|
||||||
|
|
||||||
|
### S0 — Container und Toolchain · ~1 Tag
|
||||||
|
|
||||||
|
`docker/Dockerfile` (Node 22, ffmpeg mit libx264 + libass, whisper.cpp),
|
||||||
|
`docker-compose.yml` mit Volumes und Ports, Modell-Download beim ersten Start
|
||||||
|
in ein eigenes Volume, `scripts/check-env` als Selbsttest im Container.
|
||||||
|
|
||||||
|
**Fertig, wenn:** `docker compose run --rm toolkit check-env` meldet ffmpeg,
|
||||||
|
whisper.cpp, Modell und die Schreibrechte auf allen Volumes als OK.
|
||||||
|
|
||||||
|
### S1 — Auth und Datenaufnahme · ~2 Tage
|
||||||
|
|
||||||
|
OAuth-Flow (`auth`-Kommando, Callback auf Port 4780), verschlüsselter
|
||||||
|
Token-Store mit automatischem Refresh, Helix-Client mit Rate-Limit-Bucket,
|
||||||
|
DB-Migrationen für `streams`, `users`, `user_streams`, `chat_messages`,
|
||||||
|
`viewer_samples`, `clips`. IRC-Client, der jede Nachricht schreibt.
|
||||||
|
Stream-Sessions über Helix-Polling im Minutentakt.
|
||||||
|
|
||||||
|
**Fertig, wenn:** Nach einem echten Stream stehen alle Nachrichten in
|
||||||
|
`chat_messages` und eine korrekte Zeile in `streams`.
|
||||||
|
|
||||||
|
> Diese Scheibe ist der Teil ohne sichtbaren Effekt — und trotzdem der, den man
|
||||||
|
> nicht verschieben darf. Was hier nicht mitgeschrieben wird, ist für immer weg.
|
||||||
|
|
||||||
|
### S2 — OBS-Anbindung und Trigger · ~1 Tag
|
||||||
|
|
||||||
|
obs-websocket v5 über `host.docker.internal:4455`, Reconnect-Schleife.
|
||||||
|
`SaveReplayBuffer` auslösen, auf `ReplayBufferSaved` warten, Dateipfad vom
|
||||||
|
Windows-Pfad auf den Container-Mount abbilden. Trigger: `!clip` im Chat
|
||||||
|
(Cooldown 60 s global, 300 s pro Nutzer, nur Follower) und der OBS-Hotkey.
|
||||||
|
Parallel `POST /helix/clips`, Ergebnis-ID in `clips` nachtragen.
|
||||||
|
Bestätigung im Chat, genau eine pro Auslösung.
|
||||||
|
|
||||||
|
**Fertig, wenn:** `!clip` im laufenden Stream erzeugt binnen 5 s eine
|
||||||
|
`raw.mp4` im Ablageordner und eine Chat-Rückmeldung.
|
||||||
|
|
||||||
|
### S3 — 9:16-Render · ~1,5 Tage
|
||||||
|
|
||||||
|
`calibrate`-Kommando: zieht einen Frame aus einer Replay-Datei, schreibt ihn
|
||||||
|
als PNG raus, nimmt die vier Crop-Werte entgegen und legt sie in
|
||||||
|
`config/layout.yaml` ab. Render-Schritt mit dem Filtergraph aus
|
||||||
|
[`projects/clip-shorts.md`](projects/clip-shorts.md), Kopie der Rohdatei ins
|
||||||
|
Arbeitsvolume vor der Verarbeitung.
|
||||||
|
|
||||||
|
**Fertig, wenn:** Aus einer Replay-Datei entsteht eine `vertical.mp4` mit
|
||||||
|
korrekt sitzender Facecam oben und Gameplay unten.
|
||||||
|
|
||||||
|
### S4 — Untertitel · ~1,5 Tage
|
||||||
|
|
||||||
|
Audiospur extrahieren (16 kHz Mono WAV), whisper.cpp mit `ggml-small` und
|
||||||
|
`--language de`, Ausgabe als SRT mit Wort-Zeitstempeln, Umwandlung in ASS mit
|
||||||
|
wortweisem Highlight, Stil aus `config/subtitle-style.ass`, Einbrennen.
|
||||||
|
Sicherheitsabstand nach unten mindestens 320 px.
|
||||||
|
|
||||||
|
**Fertig, wenn:** `final.mp4` hat lesbare, zeitlich passende deutsche
|
||||||
|
Untertitel, und der Text wird auf dem Handy von der TikTok-Oberfläche nicht
|
||||||
|
verdeckt.
|
||||||
|
|
||||||
|
### S5 — Sichtung · ~1 Tag
|
||||||
|
|
||||||
|
Ein kleiner HTTP-Server auf Port 4792 liefert eine Seite mit allen Kandidaten
|
||||||
|
des letzten Streams: Videovorschau, Score, Auslöser, der Chat-Ausschnitt aus
|
||||||
|
dem Clip-Fenster, Transkript, und die Knöpfe *Übernehmen* / *Verwerfen*.
|
||||||
|
Übernommene Clips wandern nach `data/clips/_upload/`.
|
||||||
|
|
||||||
|
**Fertig, wenn:** Nach einem Stream lässt sich in unter zwei Minuten
|
||||||
|
entscheiden, welche Clips hochgeladen werden.
|
||||||
|
|
||||||
|
### S6 — Erstchatter-Begrüßung · ~0,5 Tage · optional
|
||||||
|
|
||||||
|
Der IRC-Client aus S1 liegt ohnehin da, das `first-msg`-Tag ebenso. Eine
|
||||||
|
Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit
|
||||||
|
Cooldown und Obergrenze pro Stream. Details in
|
||||||
|
[`projects/community-memory.md`](projects/community-memory.md).
|
||||||
|
|
||||||
|
Diese Scheibe steht am Ende, weil sie unabhängig ist und notfalls entfällt.
|
||||||
|
Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt
|
||||||
|
merken — deshalb ist sie trotzdem drin.
|
||||||
|
|
||||||
|
## Zeitplan
|
||||||
|
|
||||||
|
| Woche | Scheiben | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | S0, S1 | Läuft im Container, schreibt jeden Stream mit |
|
||||||
|
| 2 | S2, S3 | Knopfdruck erzeugt ein fertiges 9:16-Video |
|
||||||
|
| 3 | S4, S5, S6 | Untertitel, Sichtungsseite, Begrüßung |
|
||||||
|
|
||||||
|
Rund neun Arbeitstage. Realistisch drei Kalenderwochen, wenn nebenher noch
|
||||||
|
etwas anderes läuft.
|
||||||
|
|
||||||
|
## Definition of Done für das gesamte MVP
|
||||||
|
|
||||||
|
Ein echter Stream, ohne Eingriff von außen:
|
||||||
|
|
||||||
|
1. Container läuft von Streambeginn bis -ende durch, auch über einen
|
||||||
|
Twitch-Reconnect hinweg.
|
||||||
|
2. Fünfmal ausgelöst (dreimal `!clip`, zweimal Hotkey) ergibt fünf
|
||||||
|
`final.mp4` mit sitzendem Crop und brauchbaren Untertiteln.
|
||||||
|
3. Kein Auslöser bleibt unbestätigt, kein Clip geht verloren.
|
||||||
|
4. Die Sichtungsseite zeigt alle fünf, sortiert nach Score.
|
||||||
|
5. Das Encoding kostet den Stream keine sichtbaren Frames (Kontrolle über
|
||||||
|
die OBS-Statistik: keine übersprungenen Frames wegen Encoder-Überlast).
|
||||||
|
6. Chat und Zuschauerzahlen des Streams sind vollständig in der DB.
|
||||||
|
|
||||||
|
## Risiken
|
||||||
|
|
||||||
|
| Risiko | Wahrscheinlichkeit | Gegenmaßnahme |
|
||||||
|
|---|---|---|
|
||||||
|
| Encoding kostet Frames im Stream | mittel | `-preset veryfast`, CPU-Limit im Compose-File, Schalter `renderWhileLive: false` — dann läuft alles nach Streamende |
|
||||||
|
| whisper.cpp versteht Spielgeräusche statt Sprache | mittel | Falls OBS getrennte Audiospuren aufnimmt, nur die Mikrofonspur transkribieren. Sonst Rauschgatter vorschalten. |
|
||||||
|
| Twitch ändert die Ableitung der Clip-MP4-URL | niedrig | Betrifft nur den Zweitweg; die Rohdatei kommt aus OBS. Gekapselt in einer Datei. |
|
||||||
|
| Replay-Buffer-Pfad-Abbildung Windows ↔ Container | mittel | In S2 als Erstes lösen, mit einem Testlauf vor allem anderen |
|
||||||
|
| Docker Desktop belastet den Stream-PC | mittel | `.wslconfig` mit Speicher- und CPU-Grenze; wenn es stört, ist ein Betrieb ohne Container jederzeit möglich, weil nur ffmpeg und whisper.cpp fehlen |
|
||||||
|
|
||||||
|
## Was Abdu vorher tun muss
|
||||||
|
|
||||||
|
1. Docker Desktop installieren, WSL2-Backend aktivieren
|
||||||
|
2. Zwei Twitch-Apps anlegen (siehe [`05-setup.md`](05-setup.md))
|
||||||
|
3. In OBS: obs-websocket aktivieren (Port 4455, Passwort), Wiedergabepuffer auf
|
||||||
|
90 Sekunden, Hotkey auf „Wiedergabepuffer speichern" legen
|
||||||
|
4. **Einen Screenshot der laufenden Szene** schicken — daraus wird der
|
||||||
|
Facecam-Crop eingemessen
|
||||||
|
5. Klären, ob OBS Mikrofon und Spiel auf getrennte Audiospuren aufnimmt (macht
|
||||||
|
die Untertitel deutlich besser)
|
||||||
|
|
||||||
|
## Danach
|
||||||
|
|
||||||
|
Wenn das MVP steht, ist der teuerste Teil erledigt: Auth, DB, Chat-Anbindung
|
||||||
|
und Container laufen. Die nächsten Meilensteine aus
|
||||||
|
[`04-roadmap.md`](04-roadmap.md) setzen darauf auf und werden dadurch
|
||||||
|
erheblich billiger — das Analytics-Dashboard wertet dann bereits mehrere Wochen
|
||||||
|
echter Daten aus, statt bei null anzufangen.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# ADR-0001: TypeScript-Monorepo statt gemischter Sprachen
|
||||||
|
|
||||||
|
**Status:** akzeptiert · **Datum:** 2026-09-09
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Neun Tools, die sich Twitch-Anbindung, Datenbank und Event-Strom teilen. Teile
|
||||||
|
davon sind Medienverarbeitung (ffmpeg, Whisper), was klassisch nach Python
|
||||||
|
riecht; Teile sind Browser-Overlays, was TypeScript nahelegt.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Alles in TypeScript auf Node 22, als pnpm-Workspace-Monorepo. ffmpeg und
|
||||||
|
whisper.cpp werden als **CLI-Prozesse** aufgerufen, nicht über Sprachbindings.
|
||||||
|
|
||||||
|
> **Nachtrag 2026-09-09:** Ursprünglich war faster-whisper (Python) vorgesehen.
|
||||||
|
> Der Check der Zielmaschine ergab Python 3.14, für das `ctranslate2` noch keine
|
||||||
|
> verlässlichen Wheels liefert. Da ohnehin nur ein Kommandozeilenaufruf
|
||||||
|
> gebraucht wird, fällt die Wahl auf **whisper.cpp** — eine einzelne Binary
|
||||||
|
> ohne Laufzeitabhängigkeiten. Damit entfällt Python vollständig aus dem
|
||||||
|
> Projekt. Siehe [ADR-0004](ADR-0004-docker.md).
|
||||||
|
|
||||||
|
## Begründung
|
||||||
|
|
||||||
|
- Overlays sind zwingend Browser-Code. Ein Stack statt zwei bedeutet, dass
|
||||||
|
Typen (`packages/shared`) zwischen Server und Overlay geteilt werden können.
|
||||||
|
- Twitch-Bibliotheken sind im Node-Ökosystem am ausgereiftesten.
|
||||||
|
- Der Whisper-Anteil ist in Wahrheit klein: Er wird als Kommandozeilenwerkzeug
|
||||||
|
aufgerufen und liefert SRT. Dafür lohnt kein zweiter Stack mit eigener
|
||||||
|
Umgebung, eigenem Paketmanager und eigener Prozessverwaltung.
|
||||||
|
- Ein Entwickler. Kontextwechsel zwischen Sprachen ist der teuerste Teil.
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
- `whisper-cli` und `ffmpeg` müssen im `PATH` liegen. Beide sind fest im
|
||||||
|
Container-Image enthalten und werden beim Start geprüft (`check-env`).
|
||||||
|
- Sollte später ein echtes ML-Modell im Prozess laufen müssen (z. B.
|
||||||
|
Szenenerkennung), wird das ein separater Python-Dienst hinter HTTP — dann
|
||||||
|
bewusst und isoliert, nicht schleichend.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ADR-0002: SQLite statt Postgres
|
||||||
|
|
||||||
|
**Status:** akzeptiert · **Datum:** 2026-09-09
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Mehrere Prozesse schreiben und lesen dieselben Daten. Datenvolumen: bei ~10
|
||||||
|
Zuschauern und 4 Streams pro Woche etwa 20.000 Chat-Nachrichten und 15.000
|
||||||
|
Viewer-Samples pro Monat — wenige MB.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
SQLite (`better-sqlite3`) im WAL-Modus, eine Datei unter `data/db/`.
|
||||||
|
|
||||||
|
## Begründung
|
||||||
|
|
||||||
|
- Kein Server, kein Docker, kein Zustand außerhalb einer Datei. Backup ist ein
|
||||||
|
Dateikopie-Vorgang (`VACUUM INTO`).
|
||||||
|
- WAL erlaubt beliebig viele gleichzeitige Leser neben einem Schreiber. Genau
|
||||||
|
unsere Topologie: Der Ingest schreibt, alle anderen lesen.
|
||||||
|
- `better-sqlite3` ist synchron, was den Code im Ingest erheblich vereinfacht —
|
||||||
|
keine Race Conditions zwischen Event-Verarbeitung und Schreibvorgang.
|
||||||
|
- Die Datenmenge ist um Größenordnungen von SQLites Grenzen entfernt.
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
- **Nur ein schreibender Prozess.** Der Ingest ist der einzige Schreiber für
|
||||||
|
`events`, `chat_messages`, `viewer_samples`, `streams`, `user_streams`.
|
||||||
|
Andere Apps schreiben ausschließlich in ihre eigenen Tabellen (`clips`,
|
||||||
|
`posts`, `redemptions`, `raids`). Diese Aufteilung ist verbindlich.
|
||||||
|
- Schreibende Zugriffe immer in Transaktionen bündeln, `busy_timeout` auf 5000 ms.
|
||||||
|
- Migration auf Postgres wäre nötig, wenn das Dashboard je remote laufen soll.
|
||||||
|
Steht nicht an; die Repository-Schicht in `packages/db` kapselt SQL genug,
|
||||||
|
dass es machbar bliebe.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# ADR-0003: EventSub über WebSocket, Chat über IRC
|
||||||
|
|
||||||
|
**Status:** akzeptiert · **Datum:** 2026-09-09
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Twitch bietet drei Wege an Live-Daten: EventSub über Webhooks, EventSub über
|
||||||
|
WebSocket, und IRC für den Chat. Chat-Nachrichten gibt es inzwischen sowohl über
|
||||||
|
IRC als auch über EventSub (`channel.chat.message`).
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
- **Nicht-Chat-Events über EventSub-WebSocket.**
|
||||||
|
- **Chat über IRC** (`wss://irc-ws.chat.twitch.tv:443`).
|
||||||
|
|
||||||
|
## Begründung
|
||||||
|
|
||||||
|
**Gegen Webhooks:** Sie brauchen eine öffentlich erreichbare HTTPS-Adresse mit
|
||||||
|
gültigem Zertifikat. Für einen Dienst, der auf Abdus Rechner läuft, hieße das
|
||||||
|
Tunnel oder VPS — zusätzliche bewegliche Teile und eine dauerhafte Abhängigkeit
|
||||||
|
von einem Dritten. WebSocket verbindet sich ausgehend und braucht nichts davon.
|
||||||
|
|
||||||
|
**Für IRC beim Chat:** Die IRCv3-Tags enthalten mehr, als der EventSub-Payload
|
||||||
|
liefert — insbesondere `first-msg`, `returning-chatter`, `msg-id`, vollständige
|
||||||
|
`badges` und `emotes` mit Positionen. Das Community-Gedächtnis und der
|
||||||
|
Spike-Detektor hängen genau an diesen Feldern. Außerdem sind die Sende-Rate-
|
||||||
|
Limits über IRC klar dokumentiert (20 Nachrichten / 30 s, 100 für Mods), was
|
||||||
|
für den Begrüßungs-Bot relevant ist.
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
- Zwei Verbindungen statt einer, beide mit eigenem Reconnect-Verhalten.
|
||||||
|
- `session_reconnect` von EventSub muss korrekt behandelt werden: erst zur neuen
|
||||||
|
URL verbinden, dann die alte schließen — sonst gehen Events verloren.
|
||||||
|
- Nach jedem Reconnect wird die Lücke per Helix-Polling geschlossen
|
||||||
|
(Stream-Status, Follower-Liste). Kurze Lücken bei Chat-Nachrichten sind
|
||||||
|
hinnehmbar und nicht nachholbar.
|
||||||
|
- Wenn Twitch IRC eines Tages abkündigt, ist der Umstieg auf
|
||||||
|
`channel.chat.message` auf `packages/twitch/chat.ts` beschränkt — die
|
||||||
|
Normalisierung auf das interne Event-Format findet dort statt.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ADR-0004: Betrieb in Docker
|
||||||
|
|
||||||
|
**Status:** akzeptiert · **Datum:** 2026-09-09
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Das Toolkit braucht ffmpeg, ein Whisper-Modell, Node und SQLite. Der Check der
|
||||||
|
Zielmaschine ergab: kein ffmpeg, kein pnpm, Python 3.14 (für das
|
||||||
|
`ctranslate2` — die Basis von faster-whisper — noch keine verlässlichen Wheels
|
||||||
|
liefert), keine NVIDIA-GPU. Das Toolkit soll auf Abdus Stream-PC laufen, also
|
||||||
|
auf einem Rechner, dessen Umgebung sich unabhängig vom Projekt ändert.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Alles läuft in Containern, orchestriert über `docker-compose`. Die einzige
|
||||||
|
Voraussetzung auf dem Stream-PC ist Docker Desktop und OBS.
|
||||||
|
|
||||||
|
## Begründung
|
||||||
|
|
||||||
|
- Die gesamte Toolchain (Node 22, ffmpeg mit libx264/libass, whisper.cpp, das
|
||||||
|
Sprachmodell) ist im Image festgeschrieben. Kein "läuft bei mir".
|
||||||
|
- Der Python-Konflikt entfällt vollständig, weil kein Python mehr gebraucht
|
||||||
|
wird (siehe [ADR-0001](ADR-0001-monorepo-typescript.md), aktualisiert).
|
||||||
|
- Updates sind ein `docker compose pull && up -d`, kein Nachinstallieren auf
|
||||||
|
einem Rechner, der gerade streamt.
|
||||||
|
|
||||||
|
## Konsequenzen — die vier Dinge, die man dabei falsch machen kann
|
||||||
|
|
||||||
|
**1. Die SQLite-Datei gehört nicht in einen Windows-Bind-Mount.**
|
||||||
|
Dateisperren über die 9p/virtiofs-Brücke zwischen Windows und WSL2 sind
|
||||||
|
unzuverlässig; SQLite im WAL-Modus reagiert darauf mit
|
||||||
|
`SQLITE_BUSY`/`database is locked` und im schlimmsten Fall mit Korruption.
|
||||||
|
Die Datenbank liegt deshalb in einem **benannten Docker-Volume**
|
||||||
|
(`abdulive-db`). Backups werden per Skript aus dem Volume nach Windows
|
||||||
|
kopiert, nicht durch direktes Mounten.
|
||||||
|
|
||||||
|
**2. Große Mediendateien nicht über den Bind-Mount verarbeiten.**
|
||||||
|
Das OBS-Replay-Verzeichnis wird nur lesend gemountet. Die Rohdatei wird
|
||||||
|
**einmal** in ein containerlokales Volume kopiert, dort verarbeitet, und nur
|
||||||
|
das fertige Ergebnis wird zurück nach `data/clips/` geschrieben. Direkt auf
|
||||||
|
dem Bind-Mount zu encodieren kostet ein Vielfaches der Zeit.
|
||||||
|
|
||||||
|
**3. OBS läuft auf dem Host, nicht im Container.**
|
||||||
|
Erreichbar über `host.docker.internal:4455`. In `docker-compose.yml` steht
|
||||||
|
`extra_hosts: ["host.docker.internal:host-gateway"]`, damit es auch außerhalb
|
||||||
|
von Docker Desktop funktioniert.
|
||||||
|
|
||||||
|
**4. Ein globaler Windows-Hotkey lässt sich aus einem Container nicht abfangen.**
|
||||||
|
Deshalb wird gar nicht erst versucht, einen eigenen Hotkey-Dienst zu bauen:
|
||||||
|
Der Hotkey wird **in OBS** auf "Wiedergabepuffer speichern" gelegt. OBS meldet
|
||||||
|
danach über obs-websocket das Ereignis `ReplayBufferSaved` mit dem Dateipfad —
|
||||||
|
das ist der Trigger. Kein Agent auf dem Host, kein zusätzlicher Autostart.
|
||||||
|
|
||||||
|
**Kosten, ehrlich benannt:** Docker Desktop startet eine WSL2-VM, die im
|
||||||
|
Leerlauf ein bis zwei GB RAM belegt und beim Encodieren CPU zieht, die dem
|
||||||
|
Stream fehlt. Gegenmaßnahmen: `.wslconfig` mit `memory=4GB` und
|
||||||
|
`processors=4`, ffmpeg mit `-preset veryfast` und niedriger Priorität, und
|
||||||
|
Rendering standardmäßig **nach** Streamende (`clipper.renderWhileLive: false`),
|
||||||
|
sofern die Kandidatenliste erst danach gebraucht wird.
|
||||||
|
|
||||||
|
**GPU:** Kein NVENC (keine NVIDIA-Karte), und Intel QSV ist aus WSL2 heraus
|
||||||
|
nicht verlässlich erreichbar. Es bleibt bei CPU-Encoding mit libx264. Für
|
||||||
|
Clips von 30–60 Sekunden ist das unkritisch.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Architekturentscheidungen
|
||||||
|
|
||||||
|
| ADR | Thema | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| [0001](ADR-0001-monorepo-typescript.md) | TypeScript-Monorepo statt gemischter Sprachen | akzeptiert |
|
||||||
|
| [0002](ADR-0002-sqlite.md) | SQLite statt Postgres | akzeptiert |
|
||||||
|
| [0003](ADR-0003-eventsub-websocket.md) | EventSub ueber WebSocket, Chat ueber IRC | akzeptiert |
|
||||||
|
| [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | akzeptiert |
|
||||||
|
|
||||||
|
Neue Entscheidung: Datei `ADR-<nummer>-<kurzname>.md`, Aufbau Kontext /
|
||||||
|
Entscheidung / Begruendung / Konsequenzen.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Ingest-Daemon (`apps/ingest`)
|
||||||
|
|
||||||
|
> Kein eigenes "Tool" im Sinne der Wunschliste, sondern das Fundament, auf dem
|
||||||
|
> sechs der neun Tools stehen. Wird zuerst gebaut.
|
||||||
|
|
||||||
|
## Aufgabe
|
||||||
|
|
||||||
|
Ein Prozess, der dauerhaft läuft und alle Datenquellen in einen einheitlichen
|
||||||
|
Event-Strom übersetzt: schreibt in SQLite, publiziert auf dem Bus.
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
| Quelle | Verbindung | liefert |
|
||||||
|
|---|---|---|
|
||||||
|
| Twitch IRC | `wss://irc-ws.chat.twitch.tv:443` | Chat-Nachrichten mit Tags |
|
||||||
|
| Twitch EventSub | `wss://eventsub.wss.twitch.tv/ws` | Follows, Subs, Raids, Kategorie, Online/Offline |
|
||||||
|
| Twitch Helix | REST, Polling 60 s | Viewerzahl, Chatter-Liste (5 min) |
|
||||||
|
| obs-websocket | `ws://127.0.0.1:4455` | Szenenwechsel, Replay-Buffer-Events |
|
||||||
|
|
||||||
|
## Was der Ingest selbst berechnet
|
||||||
|
|
||||||
|
**Stream-Sessions.** `stream.online` legt eine `streams`-Zeile an,
|
||||||
|
`stream.offline` schließt sie. Wenn der Daemon mitten im Stream startet, wird
|
||||||
|
die laufende Session über `GET /helix/streams` rekonstruiert. Kurze
|
||||||
|
Unterbrechungen (< 5 min) werden als *dieselbe* Session behandelt — sonst
|
||||||
|
zerfallen Streams bei jedem Verbindungsabbruch in Fragmente.
|
||||||
|
|
||||||
|
**Chat-Spikes.** Gleitendes Fenster über die Nachrichtenrate:
|
||||||
|
|
||||||
|
```
|
||||||
|
rate(t) = Nachrichten in den letzten 15 s
|
||||||
|
baseline(t) = Median von rate über die letzten 10 min
|
||||||
|
spike = rate >= max(baseline * 3, baseline + 4)
|
||||||
|
&& Zeit seit letztem Spike > 60 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich gewichtet: Lach-Emotes (`KEKW`, `LUL`, `OMEGALUL`, Kanal-Emotes),
|
||||||
|
Ausrufe (`?!`, Großbuchstaben-Anteil), das Wort `clip`. Ergebnis ist ein
|
||||||
|
`chat.spike`-Event mit `score`. Wer darauf reagiert, entscheidet der Konsument.
|
||||||
|
|
||||||
|
> Bei 10 Zuschauern ist die Baseline klein und die Statistik dünn. Der Detektor
|
||||||
|
> ist deshalb bewusst konservativ und protokolliert *alle* Kandidaten mit Score,
|
||||||
|
> auch die unter der Schwelle — so lässt sich die Schwelle nachträglich am
|
||||||
|
> echten Material kalibrieren, statt sie zu raten.
|
||||||
|
|
||||||
|
**Anwesenheit.** Aus `GET /helix/chat/chatters` (alle 5 min) plus jeder
|
||||||
|
Nachricht wird `user_streams` fortgeschrieben: Ersterscheinung, letzte
|
||||||
|
Aktivität, Minuten. Daraus fällt `streams_present` für jeden User ab.
|
||||||
|
|
||||||
|
## Schnittstellen nach außen
|
||||||
|
|
||||||
|
- **WS `ws://127.0.0.1:4790`** — Broadcast aller Events als JSON-Zeilen.
|
||||||
|
Ein Client kann beim Connect `{"since":"<iso-ts>"}` senden und bekommt die
|
||||||
|
verpassten Events aus der DB nachgeliefert.
|
||||||
|
- **HTTP `GET /health`** — Status aller vier Verbindungen, für das Dashboard.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- Läuft 7 Tage ohne Neustart durch, inklusive Twitch-Reconnects.
|
||||||
|
- Nach einem erzwungenen Prozess-Kill mitten im Stream: Neustart rekonstruiert
|
||||||
|
die laufende Session, keine doppelten `streams`-Zeilen, keine doppelten Events.
|
||||||
|
- Jede Chat-Nachricht eines Test-Streams ist in `chat_messages` (Abgleich gegen
|
||||||
|
ein parallel mitgeschriebenes Log).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Spezifikationen
|
||||||
|
|
||||||
|
Ein Dokument pro Tool. Aufbau jeweils: Wirkrichtung, Ablauf, technische
|
||||||
|
Entscheidungen, Fallstricke, Abnahmekriterien.
|
||||||
|
|
||||||
|
| # | Dokument | App |
|
||||||
|
|---|---|---|
|
||||||
|
| — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` |
|
||||||
|
| 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/clipper` |
|
||||||
|
| 2 | [Go-Live-Broadcaster](go-live-broadcaster.md) | `apps/broadcaster` |
|
||||||
|
| 3 | [Raid- und Netzwerk-Tool](raid-network.md) | `apps/raid-scout` |
|
||||||
|
| 4 | [Analytics-Dashboard](analytics.md) | `apps/dashboard` |
|
||||||
|
| 5 | [VOD-Kapitel](vod-chapters.md) | `apps/vod-chapters` |
|
||||||
|
| 6 | [Community-Gedaechtnis](community-memory.md) | `apps/community` |
|
||||||
|
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
|
||||||
|
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
|
||||||
|
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 4 — Analytics-Dashboard (`apps/dashboard`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Steuerung. Beantwortet die Frage "was funktioniert
|
||||||
|
eigentlich", die Twitch selbst nicht beantwortet.
|
||||||
|
|
||||||
|
Läuft auf `http://localhost:4792`. Vite + Preact + uPlot, liest direkt aus
|
||||||
|
SQLite. Keine Authentifizierung, weil nur lokal erreichbar.
|
||||||
|
|
||||||
|
## Seiten
|
||||||
|
|
||||||
|
### Stream-Zeitachse (die wichtigste)
|
||||||
|
|
||||||
|
Eine gemeinsame X-Achse (Stream-Laufzeit), darauf übereinander:
|
||||||
|
|
||||||
|
- Viewerzahl (Linie, aus `viewer_samples`)
|
||||||
|
- Chat-Nachrichten pro Minute (Balken)
|
||||||
|
- unterschiedliche Chatter pro 5 min (Linie)
|
||||||
|
- Marker: Kategoriewechsel, Titelwechsel, Raids rein/raus, Clips, Follows, Subs
|
||||||
|
|
||||||
|
Damit sieht man direkt: An welcher Stelle kamen Leute rein, an welcher gingen
|
||||||
|
sie, und war der Chat da tot oder wach. **Zusammenhang von Viewerzahl und
|
||||||
|
Chat-Aktivität ist die Kernfrage** — 15 Zuschauer mit stillem Chat sind
|
||||||
|
weniger wert als 8 mit lebendigem.
|
||||||
|
|
||||||
|
### Abgangs-Analyse
|
||||||
|
|
||||||
|
Aus `viewer_samples`: Ableitung über die Zeit, die stärksten Abfälle markiert
|
||||||
|
und mit dem gleichzeitigen `stream_segments`-Eintrag und den Chat-Nachrichten
|
||||||
|
der Minute davor angereichert. Antwort auf "wann gehen die Leute".
|
||||||
|
|
||||||
|
Wichtige Einschränkung, die im UI stehen muss: `viewer_samples` ist eine
|
||||||
|
Momentaufnahme der Gesamtzahl, keine Ein-/Austrittsliste. Twitch liefert das
|
||||||
|
nicht. Ein Abfall von 12 auf 8 kann vier Abgänge oder sechs Abgänge plus zwei
|
||||||
|
Zugänge bedeuten. Für Chatter ist es exakt (über `user_streams`), für stille
|
||||||
|
Zuschauer geschätzt.
|
||||||
|
|
||||||
|
### Titel- und Kategorie-Performance
|
||||||
|
|
||||||
|
Aggregation über `stream_segments`: pro Titel/Kategorie die durchschnittliche
|
||||||
|
Viewerzahl, der Nettozufluss während des Segments, Chat-Rate, gewonnene
|
||||||
|
Follower pro Stunde. Sortierbar. Mit Hinweis auf die Stichprobengröße —
|
||||||
|
bei drei Streams pro Titel ist jede Zahl Rauschen, und das Dashboard sagt das,
|
||||||
|
statt eine Rangliste vorzutäuschen.
|
||||||
|
|
||||||
|
### Chatter: neu vs. wiederkehrend
|
||||||
|
|
||||||
|
Pro Stream ein Stapelbalken: Erstchatter / wiederkehrend (1–4 Streams) /
|
||||||
|
Stammgast (5+). Dazu eine Kohorten-Tabelle: Von den Erstchattern aus Stream N,
|
||||||
|
wie viele waren in N+1, N+2, N+3 wieder da. Das ist die Zahl, die tatsächlich
|
||||||
|
über Wachstum entscheidet.
|
||||||
|
|
||||||
|
### Betriebsstatus
|
||||||
|
|
||||||
|
`GET /health` vom Ingest, letzte Fehler aus `logs/`, Größe der DB,
|
||||||
|
Clip-Warteschlange.
|
||||||
|
|
||||||
|
## Clip-Freigabe
|
||||||
|
|
||||||
|
Zusätzlich hostet das Dashboard die Freigabe-Ansicht für die Clip-Pipeline
|
||||||
|
(siehe [`clip-shorts.md`](clip-shorts.md)): Kandidatenliste mit
|
||||||
|
Videovorschau, Score, Chat-Ausschnitt und den Knöpfen *Übernehmen* /
|
||||||
|
*Verwerfen*.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- Die Zeitachse eines abgeschlossenen Streams lädt in unter 2 s.
|
||||||
|
- Alle Marker liegen zeitlich korrekt (Abgleich gegen das VOD).
|
||||||
|
- Die Kohorten-Tabelle stimmt mit einer manuellen SQL-Auszählung überein.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 7 — Chat steuert Dinge (`apps/overlay-server` + `apps/overlay-web`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Bindung. Aus Zuschauen wird Mitmachen.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
Chat-Command ──► overlay-server (4791) ──► WS ──► Browser-Source in OBS
|
||||||
|
│
|
||||||
|
├──► obs-websocket (Szenen, Filter, Quellen, Hotkeys)
|
||||||
|
├──► Home Assistant / WLED (Licht)
|
||||||
|
└──► Helix (Umfragen, Titel)
|
||||||
|
```
|
||||||
|
|
||||||
|
`overlay-server` ist ein Fastify-Server, der drei Dinge tut: Overlay-Seiten
|
||||||
|
ausliefern, den WS-Kanal zu den Browser-Sources halten, und Commands vom
|
||||||
|
Event-Bus in Aktionen übersetzen.
|
||||||
|
|
||||||
|
## Overlay-Seiten (Browser-Sources in OBS)
|
||||||
|
|
||||||
|
| Pfad | Zweck | Größe |
|
||||||
|
|---|---|---|
|
||||||
|
| `/overlay/alerts` | Follow/Sub/Raid-Einblendungen | 1920x1080 |
|
||||||
|
| `/overlay/effects` | Emote-Regen, Soundboard-Feedback, Konfetti | 1920x1080 |
|
||||||
|
| `/overlay/community` | Chatter-Hinweis (nur für Abdu) | 480x220 |
|
||||||
|
| `/overlay/poll` | laufende Umfrage | 480x600 |
|
||||||
|
| `/overlay/game` | Minigame-Anzeige | 1920x1080 |
|
||||||
|
| `/overlay/raid-panel` | Raid-Ziele am Streamende | 900x600 |
|
||||||
|
|
||||||
|
Alle Seiten teilen sich einen WS-Client (`packages/shared/overlay-client.ts`)
|
||||||
|
mit automatischem Reconnect — eine Browser-Source, die nach einem Serverneustart
|
||||||
|
tot bleibt, ist der häufigste Fehler bei solchen Overlays.
|
||||||
|
|
||||||
|
## Command-Registry
|
||||||
|
|
||||||
|
Commands liegen deklarativ in `config/commands.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- trigger: "!sound"
|
||||||
|
args: [name]
|
||||||
|
cooldown: { global: 20, user: 120 }
|
||||||
|
permission: everyone
|
||||||
|
cost: 50 # Punkte, optional
|
||||||
|
action: { type: sound, file: "sounds/{name}.mp3" }
|
||||||
|
|
||||||
|
- trigger: "!licht"
|
||||||
|
args: [farbe]
|
||||||
|
cooldown: { global: 60 }
|
||||||
|
permission: subscriber
|
||||||
|
action: { type: wled, preset: "{farbe}" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktionstypen: `sound`, `overlay-effect`, `obs-hotkey`, `obs-source-toggle`,
|
||||||
|
`obs-filter`, `wled`, `helix-poll`, `set-title`, `script`.
|
||||||
|
|
||||||
|
**Cooldowns und Rechte sind nicht optional.** Ein Soundboard ohne Cooldown ist
|
||||||
|
nach zwei Streams abgeschaltet. Default: 20 s global, 120 s pro User,
|
||||||
|
Sounds nur für Follower.
|
||||||
|
|
||||||
|
## Minispiele
|
||||||
|
|
||||||
|
Ausbaustufe 2. Kandidaten, die bei kleinem Chat funktionieren (wichtig: sie
|
||||||
|
dürfen mit 3 Mitspielern genauso gut sein wie mit 30):
|
||||||
|
|
||||||
|
- **Tipprunde** — `!tipp <zahl>`, Abdu löst auf, Punkte für die Nächsten
|
||||||
|
- **Kollektive Entscheidung** — Helix-Umfrage mit echter Konsequenz im Spiel
|
||||||
|
- **Boss-Bingo** — Karte im Overlay, Chat ruft Felder aus
|
||||||
|
|
||||||
|
## Umfragen mit Konsequenz
|
||||||
|
|
||||||
|
`!umfrage` startet eine Helix-Poll (`POST /helix/polls`). Bei `poll.end` führt
|
||||||
|
der Server die hinterlegte Aktion zur Gewinneroption aus — Szenenwechsel,
|
||||||
|
Schwierigkeitsgrad, nächstes Spiel. Der Unterschied zu einer normalen Umfrage
|
||||||
|
ist, dass das Ergebnis automatisch etwas tut, statt nur angezeigt zu werden.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
Alles, was den Stream sichtbar verändert oder Geräte im Haus schaltet, ist
|
||||||
|
missbrauchbar. Deshalb:
|
||||||
|
|
||||||
|
- `overlay-server` bindet nur auf `127.0.0.1`.
|
||||||
|
- Der Panik-Command `!aus` (Mods) schaltet alle Effekte für 10 min ab.
|
||||||
|
- Aktionen mit `requires_approval: true` erscheinen erst in einer Warteschlange
|
||||||
|
im Overlay-Panel und laufen nach Abdus Klick.
|
||||||
|
- Kein Command darf beliebigen Text auf dem Bildschirm anzeigen. TTS und freie
|
||||||
|
Textanzeigen sind bewusst nicht Teil von Ausbaustufe 1.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- `!sound huch` spielt innerhalb von 300 ms den Sound ab.
|
||||||
|
- Cooldown greift und wird im Chat sichtbar quittiert (einmal, nicht bei jedem
|
||||||
|
Versuch).
|
||||||
|
- OBS-Neustart oder Server-Neustart: Browser-Source verbindet sich innerhalb von
|
||||||
|
5 s automatisch neu.
|
||||||
|
- `!aus` stoppt sofort alle laufenden Effekte.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# 1 — Clip-to-Shorts-Pipeline (`apps/clipper`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Reichweite. Der einzige Kanal mit nichtlinearer Reichweite für
|
||||||
|
einen Kanal dieser Größe.
|
||||||
|
|
||||||
|
## Der ehrliche Engpass
|
||||||
|
|
||||||
|
Nicht das Zuschneiden ist schwer, sondern **die Auswahl**. Ein automatisch
|
||||||
|
erzeugter, mittelmäßiger Clip pro Stream bringt nichts; drei gute pro Woche
|
||||||
|
bringen viel. Die Pipeline ist deshalb so gebaut, dass sie **Kandidaten
|
||||||
|
produziert und rankt**, nicht dass sie blind veröffentlicht. Abdu sieht eine
|
||||||
|
Liste fertig gerenderter 9:16-Videos und wählt aus. Das dauert zwei Minuten
|
||||||
|
statt zwei Stunden — genau das ist der Hebel.
|
||||||
|
|
||||||
|
Zweiter ehrlicher Punkt: Bei 10 Zuschauern sind Chat-Spikes ein **schwaches
|
||||||
|
Signal**. Deshalb ist die Reihenfolge der Trigger:
|
||||||
|
|
||||||
|
1. **`!clip` im Chat** — funktioniert ab Tag 1, jeder Zuschauer kann auslösen
|
||||||
|
2. **Hotkey / Streamdeck** durch Abdu selbst — bestes Signal überhaupt
|
||||||
|
3. **Chat-Spike** — wird von Anfang an mitgeschrieben und protokolliert, aber
|
||||||
|
erst scharf geschaltet, wenn genug Material für eine Kalibrierung da ist
|
||||||
|
|
||||||
|
## Zwei Quellwege
|
||||||
|
|
||||||
|
### Weg A: OBS-Replay-Buffer (Primärweg)
|
||||||
|
|
||||||
|
Replay-Buffer in OBS auf 90 s. Trigger → `SaveReplayBuffer` über obs-websocket
|
||||||
|
→ MP4 in voller Aufnahmequalität landet lokal.
|
||||||
|
|
||||||
|
**Vorteile:** Originalqualität statt Twitch-Transcode. Kein API-Delay. Kein
|
||||||
|
Rate Limit. Und — der eigentliche Gewinn — man kann in OBS eine **zweite
|
||||||
|
Aufnahmespur oder eine dedizierte Vertical-Szene** einrichten, in der Facecam
|
||||||
|
und Gameplay bereits sauber getrennt sind. Dann entfällt das fehleranfällige
|
||||||
|
Zurückschneiden aus dem komposite Bild komplett.
|
||||||
|
|
||||||
|
**Nachteil:** Braucht Festplattenplatz und eine korrekte OBS-Konfiguration.
|
||||||
|
|
||||||
|
### Weg B: Twitch-Clip über Helix (Zweitweg)
|
||||||
|
|
||||||
|
`POST /helix/clips` → warten → `GET /helix/clips?id=` → MP4 über die aus
|
||||||
|
`thumbnail_url` abgeleitete URL (siehe
|
||||||
|
[`../02-twitch-integration.md`](../02-twitch-integration.md)).
|
||||||
|
|
||||||
|
**Wird trotzdem gebraucht,** weil ein Twitch-Clip eine teilbare URL hat, im
|
||||||
|
Kanal auffindbar ist und von Zuschauern weiterverbreitet werden kann. Deshalb:
|
||||||
|
**beide Wege gleichzeitig auslösen.** Twitch-Clip für die Plattform, OBS-Datei
|
||||||
|
für das Short.
|
||||||
|
|
||||||
|
## Layout und ffmpeg
|
||||||
|
|
||||||
|
Zielformat: 1080×1920, H.264, höchstens 60 s.
|
||||||
|
|
||||||
|
### Eingemessen am 2026-09-09
|
||||||
|
|
||||||
|
Aus einem VOD-Screenshot (GTA 5, Szene „Gameplay") ergaben sich diese Werte,
|
||||||
|
festgehalten in [`config/layout.yaml`](../../config/layout.yaml):
|
||||||
|
|
||||||
|
| Element | Lage im Streambild 1920×1080 |
|
||||||
|
|---|---|
|
||||||
|
| Facecam | bündig oben links, **440×330**, Seitenverhältnis 4:3 |
|
||||||
|
| Chat-Overlay | ins Bild eingebrannt, beginnt bei **x = 1270** |
|
||||||
|
| Minimap | unten links, endet bei x ≈ 360 |
|
||||||
|
| Handy-/Kontakt-Overlay | rechts unten, ab x ≈ 1540 |
|
||||||
|
|
||||||
|
Die Kanten der Facecam sind über die gesamte Höhe bzw. Breite konstant
|
||||||
|
(rechte Kante x = 439 bei y = 120/200/280/360; untere Kante y = 391 bei
|
||||||
|
x = 40/140/240/340/420) — ein sauberes Rechteck, also ein fester Crop.
|
||||||
|
|
||||||
|
**Zwei Befunde, die das ursprünglich geplante Layout verändert haben:**
|
||||||
|
|
||||||
|
**1. Der Chat ist ins Streambild eingebrannt.** Ein zentrierter Gameplay-Crop
|
||||||
|
hätte mitten durch die Chat-Zeilen geschnitten — abgeschnittene Buchstaben am
|
||||||
|
rechten Rand. Das nutzbare Band liegt zwischen Facecam (endet bei 440) und Chat
|
||||||
|
(beginnt bei 1270), also **828 px breit**. Das ist schmaler als die 888 px, die
|
||||||
|
für ein zentrales Drittel nötig wären, und legt damit die Bandaufteilung fest.
|
||||||
|
|
||||||
|
**2. Die Facecam ist 4:3, das Zielband ist deutlich breiter.** Ein 4:3-Bild auf
|
||||||
|
ein 16:9-Band zu skalieren zieht das Gesicht um ein Drittel in die Breite.
|
||||||
|
Deshalb wird aus der Cam ein **Streifen mit der Zielproportion** geschnitten
|
||||||
|
(440×208 ab y = 100), nicht das ganze Bild verzerrt. Der Versatz y = 100 sitzt
|
||||||
|
so, dass Kopf und Kinn vollständig drin sind und oben etwas Luft bleibt.
|
||||||
|
|
||||||
|
Daraus folgt die Aufteilung **512 px Cam + 1408 px Gameplay = 1920**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i in.mp4 -filter_complex "
|
||||||
|
[0:v]crop=440:208:0:100,scale=1080:512,setsar=1[cam];
|
||||||
|
[0:v]crop=828:1080:440:0,scale=1080:1408,setsar=1[game];
|
||||||
|
[cam][game]vstack=inputs=2[v]
|
||||||
|
" -map "[v]" -map 0:a \
|
||||||
|
-c:v libx264 -crf 20 -preset veryfast -pix_fmt yuv420p \
|
||||||
|
-c:a aac -b:a 160k -movflags +faststart out.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Nachmessen und Vorschau rendern:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose run --rm toolkit calibrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Das legt drei Bilder in `data/clips/_calibration/` ab: das Einzelbild, dasselbe
|
||||||
|
Bild mit eingezeichneten Ausschnitten, und die fertige 9:16-Vorschau.
|
||||||
|
|
||||||
|
Für andere Spiele lässt sich pro Kategorie ein eigenes Profil in
|
||||||
|
`layout.yaml` hinterlegen.
|
||||||
|
|
||||||
|
### Was das Layout verbessern würde
|
||||||
|
|
||||||
|
Der Crop skaliert die Facecam von 440 px auf 1080 px Breite — Faktor 2,45. Das
|
||||||
|
ist sichtbar weich. **Die eigentliche Lösung liegt in OBS, nicht in ffmpeg:**
|
||||||
|
eine zweite Aufnahmespur oder eine dedizierte Vertical-Szene, in der Cam und
|
||||||
|
Gameplay in voller Auflösung nebeneinander vorliegen. Dann entfällt sowohl der
|
||||||
|
Qualitätsverlust als auch der ganze Filtergraph. Das ist eine Aufgabe für die
|
||||||
|
Zeit nach dem MVP — der Crop-Weg funktioniert vorher gut genug.
|
||||||
|
|
||||||
|
Kleinigkeit mit großer Wirkung: **GTA-Untertitel im Spiel ausschalten.** Sie
|
||||||
|
laufen über die volle Bildbreite und werden vom Gameplay-Crop rechts
|
||||||
|
abgeschnitten. Die eigenen Whisper-Untertitel liegen ohnehin darüber.
|
||||||
|
|
||||||
|
Bei Quelle = getrennte Vertical-Szene aus OBS entfällt der ganze Filtergraph.
|
||||||
|
|
||||||
|
## Untertitel
|
||||||
|
|
||||||
|
`whisper.cpp` (`whisper-cli`), Modell `ggml-small` (deutsch, reicht; `medium`
|
||||||
|
bei Bedarf), auf der extrahierten Audiospur (16 kHz Mono WAV). Kein Python,
|
||||||
|
keine GPU nötig — auf CPU braucht ein 30-Sekunden-Clip unter einer Minute.
|
||||||
|
Ausgabe als SRT, Umwandlung in ASS mit Karaoke-Timing
|
||||||
|
(wortweises Highlight — der Stil, der auf Shorts funktioniert), dann einbrennen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i out.mp4 -vf "ass=subs.ass" -c:a copy -c:v libx264 -crf 20 final.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Untertitel-Stil (Font, Größe, Position, Highlight-Farbe) liegt in
|
||||||
|
`config/subtitle-style.ass`. Sicherheitsabstand nach unten mindestens 320 px,
|
||||||
|
sonst verdeckt die TikTok-UI den Text.
|
||||||
|
|
||||||
|
## Ranking der Kandidaten
|
||||||
|
|
||||||
|
`score` aus: Spike-Höhe, Anzahl unterschiedlicher Chatter im Fenster,
|
||||||
|
Emote-Dichte, Lautstärkespitze im Audio (`ebur128`), Trigger-Art
|
||||||
|
(Hotkey > `!clip` > Spike), Menge erkannter Sprache im Transkript.
|
||||||
|
|
||||||
|
## Ausgabe
|
||||||
|
|
||||||
|
```
|
||||||
|
data/clips/<stream-datum>/<clip-id>/
|
||||||
|
raw.mp4 Quelle (OBS oder Twitch)
|
||||||
|
vertical.mp4 9:16 ohne Untertitel
|
||||||
|
final.mp4 9:16 mit eingebrannten Untertiteln <- Upload-Kandidat
|
||||||
|
subs.srt / subs.ass
|
||||||
|
transcript.txt
|
||||||
|
meta.json Score, Trigger, Chat-Ausschnitt, Titelvorschläge
|
||||||
|
thumb.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus ein Eintrag in `clips` mit `status='rendered'`. Das Dashboard zeigt die
|
||||||
|
Kandidaten des letzten Streams, sortiert nach Score, mit Vorschau und den
|
||||||
|
Knöpfen *Übernehmen* / *Verwerfen*.
|
||||||
|
|
||||||
|
## Ausdrücklich nicht in Ausbaustufe 1
|
||||||
|
|
||||||
|
Automatischer Upload zu TikTok/YouTube/Reels. Die Upload-APIs sind aufwendig
|
||||||
|
(die TikTok Content Posting API braucht eine App-Review), und der Nutzen ist
|
||||||
|
gering, solange die Auswahl ohnehin manuell erfolgt. Ausbaustufe 2.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- `!clip` im Chat, innerhalb von 3 Minuten liegt ein fertiges `final.mp4` vor.
|
||||||
|
- Untertitel sind zeitlich um weniger als 300 ms versetzt und bei normaler
|
||||||
|
Sprechlautstärke deutsch korrekt.
|
||||||
|
- Facecam-Crop sitzt bei drei verschiedenen OBS-Szenen richtig.
|
||||||
|
- Ein Clip läuft durch die Pipeline, während der Stream weiterläuft, ohne dass
|
||||||
|
die Encoding-Last Frames im Stream kostet (`-preset veryfast`, niedrige
|
||||||
|
Prozesspriorität, bei Bedarf NVENC).
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 6 — Community-Gedächtnis (`apps/community`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Bindung. Bei 10 Zuschauern der Unterschied zwischen
|
||||||
|
"Zuschauer" und "Community".
|
||||||
|
|
||||||
|
Läuft als Modul im `overlay-server`, nicht als eigener Prozess — es braucht
|
||||||
|
dieselbe Chat-Verbindung und denselben WS-Kanal zum Overlay.
|
||||||
|
|
||||||
|
## Datenlage
|
||||||
|
|
||||||
|
Alles Nötige liegt bereits in `users` und `user_streams` (siehe
|
||||||
|
[`../03-data-model.md`](../03-data-model.md)). Dieses Tool ist überwiegend
|
||||||
|
**Auswertung und Ausgabe**, kaum neue Datenerfassung — deshalb ist es billig,
|
||||||
|
sobald der Ingest steht.
|
||||||
|
|
||||||
|
## Die drei Ausgaben
|
||||||
|
|
||||||
|
### 1. Chat-Begrüßung
|
||||||
|
|
||||||
|
Ausgelöst durch die erste Nachricht eines Users im laufenden Stream:
|
||||||
|
|
||||||
|
| Fall | Bedingung | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| **Erstchatter** | `first-msg`-Tag oder `users.message_count = 0` | "Oh, @name schreibt zum ersten Mal — willkommen!" |
|
||||||
|
| **Rückkehrer** | letzter Besuch > 21 Tage her | "@name, lange nicht gesehen." |
|
||||||
|
| **Meilenstein** | `streams_chatted` in {5, 10, 25, 50, 100} | "Stream Nummer 25 für @name." |
|
||||||
|
| **Stammgast** | `streams_chatted >= 5` | individuelle Begrüßung aus `users.greeting` |
|
||||||
|
| sonst | — | nichts |
|
||||||
|
|
||||||
|
Regeln, damit es nicht nervt: höchstens eine Begrüßung alle 20 s, höchstens
|
||||||
|
zehn pro Stream, `muted_greeting` respektiert, und **keine Begrüßung, wenn Abdu
|
||||||
|
in den letzten 15 s selbst gesprochen hat** (der Ingest weiß über OBS bzw. über
|
||||||
|
eine Audiopegel-Schwelle, ob gerade geredet wird) — sonst redet der Bot dem
|
||||||
|
Streamer ins Wort.
|
||||||
|
|
||||||
|
### 2. Overlay-Hinweis für Abdu
|
||||||
|
|
||||||
|
Kleines, nur für Abdu sichtbares Panel (eigene Browser-Source in der
|
||||||
|
Studio-Ansicht oder zweiter Monitor). Wenn jemand schreibt:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ maxi_2k Stream 15 · Sub 3M │
|
||||||
|
│ zuletzt: vor 2 Tagen │
|
||||||
|
│ Notiz: studiert Maschinenbau, │
|
||||||
|
│ spielt selbst Elden Ring │
|
||||||
|
│ erste Nachricht: 04.03.2026 │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Das ist der eigentliche Wert.** Nicht der Bot, der etwas schreibt, sondern
|
||||||
|
Abdu, der weiß, mit wem er redet. Ein Bot, der "willkommen zurück" sagt, ist
|
||||||
|
Deko; ein Streamer, der "na, wie war die Klausur" sagt, bindet Menschen.
|
||||||
|
|
||||||
|
### 3. Notizen
|
||||||
|
|
||||||
|
Chat-Command für Abdu und Mods: `!notiz <name> <text>` hängt an `users.notes` an.
|
||||||
|
Im Dashboard editierbar. Das ist die einzige Datenquelle, die Handarbeit
|
||||||
|
braucht, und die mit dem höchsten Nutzen pro Aufwand.
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
Die DB enthält personenbezogene Daten (Chatverläufe, Notizen). Sie liegt lokal,
|
||||||
|
wird nicht geteilt und nicht in ein Repo committet (`data/` ist in `.gitignore`).
|
||||||
|
`!vergissmich` löscht auf Wunsch alle Daten eines Users bis auf die anonyme
|
||||||
|
Aggregatstatistik. Notizen enthalten nichts, was man der Person nicht ins
|
||||||
|
Gesicht sagen würde.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- Ein Testkonto, das zum ersten Mal schreibt, wird als Erstchatter erkannt und
|
||||||
|
genau einmal begrüßt.
|
||||||
|
- Nach einem Namenswechsel des Testkontos bleibt die Historie erhalten
|
||||||
|
(Verknüpfung über `user_id`, nicht `login`).
|
||||||
|
- Das Overlay-Panel zeigt den Hinweis innerhalb von 500 ms nach der Nachricht.
|
||||||
|
- Zwanzig Nachrichten in fünf Sekunden erzeugen höchstens eine Begrüßung.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 2 — Go-Live-Broadcaster (`apps/broadcaster`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Reichweite. Billigster Hebel im ganzen Repo — Leute, die schon
|
||||||
|
folgen, aber die Twitch-Benachrichtigung nicht sehen.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```
|
||||||
|
stream.online
|
||||||
|
-> 60 s warten (Titel/Kategorie/Thumbnail sind erst dann gesetzt)
|
||||||
|
-> GET /helix/channels (Titel, Kategorie)
|
||||||
|
-> OBS GetSourceScreenshot (Programm-Ausgabe, 1280x720)
|
||||||
|
-> Text aus Vorlage rendern
|
||||||
|
-> pro Plattform: Entwurf anlegen oder direkt senden
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plattformen
|
||||||
|
|
||||||
|
| Plattform | Weg | Aufwand | Auto-Post |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Discord** | Webhook, Embed mit Bild + `@everyone`/Rolle | trivial | ja |
|
||||||
|
| **Bluesky** | AT-Protocol, `com.atproto.repo.createRecord` mit Blob-Upload | klein | ja |
|
||||||
|
| **Mastodon** | `POST /api/v1/statuses` mit Media | klein | ja |
|
||||||
|
| **X/Twitter** | API v2, kostenpflichtig ab Basic-Tier | mittel | optional |
|
||||||
|
| **Instagram Story** | **kein offizieller Weg** — siehe unten | groß | nein |
|
||||||
|
|
||||||
|
### Instagram: die Wahrheit
|
||||||
|
|
||||||
|
Die Instagram Graph API kann Stories nur für **Business-/Creator-Konten**
|
||||||
|
veröffentlichen, die mit einer Facebook-Seite verknüpft sind, und braucht eine
|
||||||
|
App-Review mit `instagram_content_publish`. Für einen kleinen Kanal ist das
|
||||||
|
unverhältnismäßig, und inoffizielle Wege riskieren die Kontosperre.
|
||||||
|
|
||||||
|
**Lösung in Ausbaustufe 1:** Der Broadcaster erzeugt ein fertiges
|
||||||
|
**1080x1920-Story-Bild** (Screenshot + Titel + Kategorie + "LIVE"-Badge) und
|
||||||
|
legt es in `data/posts/<datum>/story.png` ab, plus eine Push-Nachricht an Abdus
|
||||||
|
Handy (Discord-DM oder ntfy.sh). Hochladen dauert dann fünf Sekunden manuell.
|
||||||
|
Das ist 90 % des Nutzens für 5 % des Aufwands.
|
||||||
|
|
||||||
|
## Sicherung gegen Fehlposts
|
||||||
|
|
||||||
|
- **Ein Post pro Stream.** `posts`-Tabelle wird auf `stream_id` geprüft.
|
||||||
|
- **Cooldown 6 h.** Wenn Twitch nach einem Verbindungsabbruch ein zweites
|
||||||
|
`stream.online` schickt, wird nicht erneut gepostet.
|
||||||
|
- **Kill-Switch** in `config/config.yaml`: `broadcaster.enabled: false`.
|
||||||
|
- Beim ersten Einrichten läuft alles im Modus `draft` — Posts landen nur in der
|
||||||
|
DB und in einem privaten Discord-Kanal zur Kontrolle.
|
||||||
|
|
||||||
|
## Vorlagen
|
||||||
|
|
||||||
|
`config/templates/golive.*.md`, mit Platzhaltern `{{title}}`, `{{category}}`,
|
||||||
|
`{{url}}`, `{{weekday}}`. Mehrere Varianten pro Plattform, zufällig gewählt —
|
||||||
|
identische Posts jede Woche werden von Algorithmen und Menschen gleichermaßen
|
||||||
|
ignoriert.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- Stream-Start erzeugt innerhalb von 90 s einen Discord-Post mit korrektem
|
||||||
|
Titel, Kategorie und aktuellem Screenshot.
|
||||||
|
- Ein simulierter doppelter `stream.online` erzeugt keinen zweiten Post.
|
||||||
|
- Story-Bild ist 1080x1920, Text ist auf dem Handy lesbar.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 8 — Punktesystem mit Bedeutung (`apps/loyalty`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Bindung. Läuft als Modul im `overlay-server`.
|
||||||
|
|
||||||
|
## Warum nicht Channel Points?
|
||||||
|
|
||||||
|
Twitch-Channel-Points funktionieren, haben aber zwei Grenzen: Man kann die
|
||||||
|
Vergabelogik nicht anpassen (keine Boni für Verhalten, das man belohnen will),
|
||||||
|
und Einlösungen sind an das Twitch-UI gebunden. Eigene Punkte laufen daneben.
|
||||||
|
|
||||||
|
**Beides parallel betreiben.** Channel Points bleiben für simple Sachen,
|
||||||
|
eigene Punkte für alles, was Logik braucht. `channel.channel_points_custom_reward_redemption.add`
|
||||||
|
wird ohnehin schon ingested, also können beide Systeme dieselben Aktionen
|
||||||
|
auslösen.
|
||||||
|
|
||||||
|
## Vergabe
|
||||||
|
|
||||||
|
| Anlass | Punkte |
|
||||||
|
|---|---|
|
||||||
|
| pro 10 Minuten Anwesenheit | 10 |
|
||||||
|
| ...davon aktiv im Chat | +5 |
|
||||||
|
| erste Nachricht des Streams | 25 |
|
||||||
|
| Anwesenheit in Streamminute 1–10 | +50 (belohnt pünktliches Erscheinen) |
|
||||||
|
| Follow | 200 (einmalig) |
|
||||||
|
| Sub / Resub | 500 |
|
||||||
|
| Raid rein | 300 an den Raider |
|
||||||
|
| Clip, der übernommen wird | 250 an den Auslöser |
|
||||||
|
|
||||||
|
Anwesenheit kommt aus `user_streams.minutes` (Helix-Chatter-Liste alle 5 min),
|
||||||
|
nicht aus dem Chat allein — sonst bekommen stille Zuschauer nie etwas.
|
||||||
|
|
||||||
|
## Shop
|
||||||
|
|
||||||
|
Der Punkt der Sache ist, dass Einlösungen **wirklich etwas tun**:
|
||||||
|
|
||||||
|
| Artikel | Kosten | Aktion | Freigabe |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Streamtitel für 10 min | 5.000 | `PATCH /helix/channels`, Timer setzt zurück | ja |
|
||||||
|
| Emote-Regen 30 s | 800 | Overlay-Effekt | nein |
|
||||||
|
| Sound aus dem Board | 300 | Overlay + Audio | nein |
|
||||||
|
| Challenge für Abdu | 3.000 | Karte im Overlay, Abdu bestätigt | ja |
|
||||||
|
| Nächstes Spiel bestimmen | 10.000 | Eintrag in die Warteschlange | ja |
|
||||||
|
| Facecam-Filter 60 s | 1.500 | obs-websocket Filter an | nein |
|
||||||
|
|
||||||
|
`requires_approval` ist bei allem gesetzt, was den Stream nach außen verändert
|
||||||
|
(Titel!) oder Abdu zu etwas verpflichtet. Titeländerung zusätzlich abgesichert:
|
||||||
|
Wortfilter, Längenbegrenzung, automatische Rückkehr zum Originaltitel per
|
||||||
|
Timer, und der Originaltitel wird vorher in `stream_segments` gesichert.
|
||||||
|
|
||||||
|
## Rückerstattung
|
||||||
|
|
||||||
|
Wenn eine Aktion fehlschlägt (OBS nicht erreichbar, Helix-Fehler, Abdu lehnt
|
||||||
|
ab), werden die Punkte automatisch zurückgebucht und der User im Chat
|
||||||
|
informiert. `redemptions.status = 'refunded'`.
|
||||||
|
|
||||||
|
## Bilanz-Integrität
|
||||||
|
|
||||||
|
Punkte werden nie direkt auf `users.points` geschrieben, sondern als Zeilen in
|
||||||
|
einer `points_ledger`-Tabelle geführt; `users.points` ist ein
|
||||||
|
Materialized-Summenfeld, das über einen Trigger aktualisiert wird. So ist jede
|
||||||
|
Punktzahl nachvollziehbar und ein Fehler in der Vergabelogik reparierbar, ohne
|
||||||
|
dass jemandem grundlos Punkte fehlen.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- Ein Zuschauer, der 30 min anwesend ist und dreimal schreibt, hat exakt die
|
||||||
|
erwartete Punktzahl.
|
||||||
|
- Titel-Einlösung ändert den Titel und setzt ihn nach 10 min zurück, auch wenn
|
||||||
|
der Server dazwischen neu startet (Timer wird persistiert).
|
||||||
|
- Fehlgeschlagene Einlösung bucht zurück.
|
||||||
|
- `points_ledger`-Summe stimmt mit `users.points` überein.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 3 — Raid- und Netzwerk-Tool (`apps/raid-scout`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Reichweite über Menschen statt über Algorithmen. In dieser
|
||||||
|
Kanalgröße die zuverlässigste Quelle für neue Stammzuschauer.
|
||||||
|
|
||||||
|
## Kandidaten finden
|
||||||
|
|
||||||
|
`GET /helix/streams?game_id=<aktuelle Kategorie>&first=100&language=de`,
|
||||||
|
seitenweise bis die Viewerzahl unter die Untergrenze fällt (Twitch sortiert
|
||||||
|
absteigend, also von hinten interessant). Filter:
|
||||||
|
|
||||||
|
- Viewer zwischen `min` und `max` (Default 5–30, konfigurierbar)
|
||||||
|
- Sprache `de`
|
||||||
|
- Uptime mindestens 30 min und höchstens 5 h — wer gerade erst angefangen hat,
|
||||||
|
verliert durch einen Raid nichts, wer gleich aufhört, kann nichts damit
|
||||||
|
anfangen
|
||||||
|
- nicht auf der Sperrliste (`config/raid-blocklist.yaml`)
|
||||||
|
|
||||||
|
## Bewertung
|
||||||
|
|
||||||
|
```
|
||||||
|
score = 0.35 * viewerNaehe (Ziel: aehnliche Groesse wie wir)
|
||||||
|
+ 0.25 * kategorieUeberlapp (gleiche Kategorie > gleiche Sparte)
|
||||||
|
+ 0.20 * beziehung (schon mal Kontakt gehabt? zurueckgeraidet?)
|
||||||
|
+ 0.10 * frische (lange nicht mehr geraidet)
|
||||||
|
+ 0.10 * uptimeFenster
|
||||||
|
```
|
||||||
|
|
||||||
|
`beziehung` kommt aus der `raids`-Tabelle: Wer uns geraidet hat und noch keinen
|
||||||
|
Raid zurückbekommen hat, steht ganz oben. **Das ist der eigentliche Kern des
|
||||||
|
Tools** — nicht das Finden von Fremden, sondern das Nicht-Vergessen von
|
||||||
|
Leuten, die schon Kontakt aufgenommen haben.
|
||||||
|
|
||||||
|
## Oberfläche
|
||||||
|
|
||||||
|
- **Chat-Command `!raidziel`** (nur Mods/Broadcaster): Bot postet die Top 3 mit
|
||||||
|
Login, Viewerzahl und Kategorie in den Chat.
|
||||||
|
- **Overlay-Panel**, das Abdu am Streamende einblenden kann: Top 5 als Karten,
|
||||||
|
Klick kopiert `/raid <login>` in die Zwischenablage. Bewusst **kein
|
||||||
|
automatisches Auslösen** — ein Raid ist eine soziale Geste, die einen
|
||||||
|
Menschen braucht.
|
||||||
|
- **Dashboard-Seite** mit der vollständigen Raid-Historie.
|
||||||
|
|
||||||
|
## Netzwerk-Gedächtnis
|
||||||
|
|
||||||
|
Für jeden Kanal, mit dem es je Kontakt gab:
|
||||||
|
|
||||||
|
| Feld | Quelle |
|
||||||
|
|---|---|
|
||||||
|
| Raids rein / raus, mit Datum und Viewerzahl | `channel.raid` EventSub |
|
||||||
|
| erwidert ja/nein | berechnet |
|
||||||
|
| Notizen | manuell im Dashboard |
|
||||||
|
| letzter Kontakt | berechnet |
|
||||||
|
|
||||||
|
Wöchentliche Erinnerung im Dashboard: "Diese 3 haben dich geraidet, du sie noch
|
||||||
|
nicht."
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- `!raidziel` liefert im laufenden Stream innerhalb von 3 s drei plausible
|
||||||
|
Kanäle in der aktuellen Kategorie.
|
||||||
|
- Ein eingehender Raid landet automatisch in `raids` mit `direction='in'`.
|
||||||
|
- Ein ausgehender Raid (manuell in Twitch ausgelöst) wird ebenfalls erfasst.
|
||||||
|
- Kanäle auf der Sperrliste tauchen nie auf.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 9 — Stream-Recap (`apps/recap`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Bindung. Gibt Leuten einen Grund, im Discord zu sein, und
|
||||||
|
hält den Kanal zwischen zwei Streams präsent.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
`stream.offline` → 10 Minuten warten (VOD-Verarbeitung, letzte Clips rendern)
|
||||||
|
→ Zahlen aus der DB ziehen → Discord-Post → optional Bluesky-Kurzfassung.
|
||||||
|
|
||||||
|
## Inhalt
|
||||||
|
|
||||||
|
```
|
||||||
|
🔴 Stream vom 09.09. · 3 h 42 min · Elden Ring, Just Chatting
|
||||||
|
|
||||||
|
Peak 17 Zuschauer · Schnitt 11
|
||||||
|
23 Leute im Chat, davon 4 zum ersten Mal
|
||||||
|
+6 Follower
|
||||||
|
|
||||||
|
Top-Chatter
|
||||||
|
1. maxi_2k 84 Nachrichten
|
||||||
|
2. lenaaa 61
|
||||||
|
3. der_olli 47
|
||||||
|
|
||||||
|
Willkommen im Chat 👋
|
||||||
|
@neuer1 @neuer2 @neuer3 @neuer4
|
||||||
|
|
||||||
|
Beste Momente
|
||||||
|
▸ "Malenia Versuch 41" clips.twitch.tv/...
|
||||||
|
▸ "Der Hund im Hintergrund" clips.twitch.tv/...
|
||||||
|
|
||||||
|
VOD-Kapitel
|
||||||
|
00:00 Start
|
||||||
|
00:14 Elden Ring
|
||||||
|
01:32 Pause
|
||||||
|
...
|
||||||
|
|
||||||
|
Nächster Stream: Mittwoch 20 Uhr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regeln, damit es gelesen wird
|
||||||
|
|
||||||
|
- **Namen vor Zahlen.** Der Teil, der Leute in den Discord bringt, ist die
|
||||||
|
Liste mit ihren Namen darin — nicht die Peak-Viewer-Zahl. Erstchatter werden
|
||||||
|
namentlich begrüßt, Top-Chatter genannt.
|
||||||
|
- **Keine schlechten Streams schönreden, aber auch nicht ausstellen.** Wenn Peak
|
||||||
|
unter der Hälfte des Schnitts der letzten fünf Streams liegt, entfallen die
|
||||||
|
Viewer-Zahlen und der Post konzentriert sich auf Chat und Momente. Öffentlich
|
||||||
|
schwache Zahlen zu posten schadet mehr, als es nützt; im Dashboard stehen sie
|
||||||
|
trotzdem.
|
||||||
|
- **Kein Post ohne Inhalt.** Streams unter 20 Minuten oder ohne Chat-Aktivität
|
||||||
|
erzeugen keinen Recap.
|
||||||
|
- **Clips einbetten, nicht verlinken.** Discord bettet Twitch-Clips ab; die
|
||||||
|
besten zwei nach `clips.score`.
|
||||||
|
|
||||||
|
## Vergleich zur Vorwoche
|
||||||
|
|
||||||
|
Zweite Zeile, klein: "Chat +18 % ggü. letzten 4 Streams". Nur bei mindestens
|
||||||
|
vier Vergleichsstreams, sonst weggelassen — Prozentangaben auf Basis von zwei
|
||||||
|
Datenpunkten sind Unsinn.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- Der Post erscheint innerhalb von 15 min nach Streamende.
|
||||||
|
- Alle Zahlen stimmen mit dem Dashboard überein.
|
||||||
|
- Ein Stream ohne Chat erzeugt keinen Post.
|
||||||
|
- Clip-Einbettungen funktionieren in Discord (nicht nur als nackte URL).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 5 — VOD-Kapitel (`apps/vod-chapters`)
|
||||||
|
|
||||||
|
**Wirkrichtung:** Steuerung und ein wenig Reichweite (YouTube-Zweitverwertung).
|
||||||
|
|
||||||
|
## Was aufgezeichnet wird
|
||||||
|
|
||||||
|
Während des Streams sammelt der Ingest ohnehin schon:
|
||||||
|
|
||||||
|
| Signal | Quelle | wird Kapitel? |
|
||||||
|
|---|---|---|
|
||||||
|
| Kategoriewechsel | EventSub `channel.update` | immer |
|
||||||
|
| Titelwechsel | EventSub `channel.update` | wenn Kategorie gleich bleibt |
|
||||||
|
| OBS-Szenenwechsel | obs-websocket | nur bei Szenen aus der Whitelist |
|
||||||
|
| Stream-Marker | `POST /helix/streams/markers` | immer |
|
||||||
|
| erstellter Clip | `clips` | als Kapitel-Kandidat |
|
||||||
|
|
||||||
|
OBS-Szenen sind das rauschendste Signal — ein Wechsel auf "BRB" und zurück sind
|
||||||
|
zwei Wechsel, die kein Kapitel sind. Deshalb Whitelist in `config/obs-scenes.yaml`
|
||||||
|
plus Mindestabstand von 5 Minuten zwischen Kapiteln; kürzere Segmente werden mit
|
||||||
|
dem vorherigen verschmolzen.
|
||||||
|
|
||||||
|
## Ausgabe
|
||||||
|
|
||||||
|
Nach `stream.offline`: VOD über `GET /helix/videos?user_id=&type=archive&first=1`
|
||||||
|
holen, Zeitstempel relativ zu `streams.started_at` umrechnen, Text erzeugen:
|
||||||
|
|
||||||
|
```
|
||||||
|
00:00 Start & Begrüßung
|
||||||
|
00:14 Elden Ring — Malenia, Versuch 40
|
||||||
|
01:32 Pause
|
||||||
|
01:47 Elden Ring — sie ist tot
|
||||||
|
02:55 Just Chatting — Community-Fragen
|
||||||
|
```
|
||||||
|
|
||||||
|
Format wählbar: Twitch-VOD-Beschreibung, YouTube-Kapitel (identisch, muss aber
|
||||||
|
bei `00:00` beginnen und mindestens drei Einträge mit je mindestens 10 s haben,
|
||||||
|
sonst zeigt YouTube keine Kapitel an), Discord-Nachricht.
|
||||||
|
|
||||||
|
Twitch erlaubt kein Setzen der VOD-Beschreibung über die API — der Text landet
|
||||||
|
deshalb in `data/vods/<datum>/chapters.txt` und wird per Push-Nachricht
|
||||||
|
zugestellt, plus als Block im Recap-Post. Einfügen dauert zehn Sekunden.
|
||||||
|
|
||||||
|
## Kapitel-Titel
|
||||||
|
|
||||||
|
Der Rohtitel ist der Twitch-Titel bzw. der Kategoriename. Optional (Ausbaustufe 2)
|
||||||
|
eine Verdichtung aus dem Chat: die häufigsten Substantive im Segment als
|
||||||
|
Zusatz. Erst sinnvoll, wenn genug Chat-Volumen da ist.
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- Ein Testtream mit zwei Kategoriewechseln und drei Szenenwechseln erzeugt
|
||||||
|
genau die erwarteten Kapitel.
|
||||||
|
- Zeitstempel weichen vom VOD um weniger als 5 s ab.
|
||||||
|
- Kein Kapitel unter 5 Minuten Länge.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "abdulive-toolkit",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Werkzeugkasten fuer den Twitch-Kanal abdulive_",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.12.0",
|
||||||
|
"scripts": {
|
||||||
|
"auth": "pnpm --filter @abdulive/twitch run auth",
|
||||||
|
"db:migrate": "pnpm --filter @abdulive/db run migrate",
|
||||||
|
"db:backup": "powershell -File scripts/backup.ps1",
|
||||||
|
"dev:ingest": "pnpm --filter @abdulive/ingest run dev",
|
||||||
|
"dev:overlay": "pnpm --filter @abdulive/overlay-server run dev",
|
||||||
|
"dev:dashboard": "pnpm --filter @abdulive/dashboard run dev",
|
||||||
|
"clipper:calibrate": "pnpm --filter @abdulive/clipper run calibrate",
|
||||||
|
"clipper:run": "pnpm --filter @abdulive/clipper run once",
|
||||||
|
"raid:scout": "pnpm --filter @abdulive/raid-scout run once",
|
||||||
|
"recap:run": "pnpm --filter @abdulive/recap run once",
|
||||||
|
"build": "pnpm -r run build",
|
||||||
|
"typecheck": "pnpm -r run typecheck",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "pnpm -r run test",
|
||||||
|
"start": "docker compose up -d",
|
||||||
|
"stop": "docker compose down",
|
||||||
|
"status": "docker compose ps",
|
||||||
|
"logs": "docker compose logs -f"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/core
|
||||||
|
|
||||||
|
Config-Laden, Logger, Event-Bus, Scheduler, Retry mit Backoff.
|
||||||
|
|
||||||
|
Spezifikation: [docs/01-architecture.md](../../docs/01-architecture.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/core — siehe docs/01-architecture.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/db
|
||||||
|
|
||||||
|
SQLite-Verbindung, Migrationen, Repositories. Einziger Ort mit SQL.
|
||||||
|
|
||||||
|
Spezifikation: [docs/03-data-model.md](../../docs/03-data-model.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/db",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/db — siehe docs/03-data-model.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/shared
|
||||||
|
|
||||||
|
Gemeinsame Typen, Event-Definitionen und Zod-Schemas. Haengt von nichts ab.
|
||||||
|
|
||||||
|
Spezifikation: [docs/01-architecture.md](../../docs/01-architecture.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/shared — siehe docs/01-architecture.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @abdulive/twitch
|
||||||
|
|
||||||
|
OAuth-Token-Store, Helix-Client mit Rate-Limit-Bucket, EventSub-WS, IRC-Client.
|
||||||
|
|
||||||
|
Spezifikation: [docs/02-twitch-integration.md](../../docs/02-twitch-integration.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@abdulive/twitch",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// @abdulive/twitch — siehe docs/02-twitch-integration.md
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
|
- 'apps/*'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# scripts
|
||||||
|
|
||||||
|
Skripte laufen entweder im Container (`docker compose run --rm toolkit <name>`)
|
||||||
|
oder auf dem Host, wo sie mit Docker sprechen.
|
||||||
|
|
||||||
|
| Skript | Wo | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `check-env` | Container | Prueft ffmpeg, whisper-cli, Sprachmodell, Volumes, OBS-Erreichbarkeit |
|
||||||
|
| `auth` | Container | OAuth-Flow fuer Broadcaster- und Bot-Token (braucht `--service-ports`) |
|
||||||
|
| `db:migrate` | Container | Migrationen anwenden |
|
||||||
|
| `calibrate` | Container | Facecam-Crop einmessen, schreibt `config/layout.yaml` |
|
||||||
|
| `backup.ps1` | Host | `VACUUM INTO` aus dem Volume `abdulive-db` nach `data/db/backup/`, 30 Generationen |
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user