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,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.
|
||||
Reference in new issue
Block a user